plugins: a plugin's tasks are read where they live

the last piece of docs/plugin-capabilities.md. skills, affordances and
notification catalogues already loaded from each installed and enabled
plugin's own directory; tasks did not, so the transcribe plugin shipped a
task nothing could see — right-clicking an .m4a offered no Run Task at all.

it turned out to be one function rather than several. every path that runs
a task resolves it through getTaskByDirName and then reads implementation
off the record, so the sources go in there and in listTasks and a plugin's
task is runnable everywhere at once, identity unchanged: still a directory
name, still what pipeline_jobs stores.

the record carries `plugin` and that one field does the rest. updateTask
and deleteTask throw on it, DELETE /api/tasks/:name answers 409 naming the
plugin, and the capability screen shows whose it is instead of an edit and
a delete that cannot work. without the delete guard it was quietly a no-op:
rm -rf on a path in the owner's store that does not exist, then ok: true,
and the task still in the menu.

owner's store first, so a plugin cannot take over a name already in use.

getTaskByDirName now refuses a name containing a separator. it always
joined a caller-supplied string onto a path — from a websocket message and
a job row — and searching several roots is the moment that stops being one
directory nobody could escape.

capabilityRootsFrom is gone; it was dead. capabilitySources replaces it and
carries the provenance, with capabilityRoots the thin map over it that the
loaders that only want paths still use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-24 04:20:18 +00:00
co-authored by Claude Opus 5
parent 50e96e0d77
commit 6d673a1321
14 changed files with 259 additions and 64 deletions
+1 -1
View File
@@ -541,7 +541,7 @@ explaining why it was safe.
its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem, its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem,
a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its
conversation. The CLAUDE.md in its own working directory is the living document, for free conversation. The CLAUDE.md in its own working directory is the living document, for free
- `docs/plugin-capabilities.md` — **designed, not built**: a plugin's tasks, skills and affordances are - `docs/plugin-capabilities.md` — **built**: a plugin's tasks, skills and affordances are
READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it
goes stale on update and survives a disable, and why separate directories beat namespaced paths — goes stale on update and survives a disable, and why separate directories beat namespaced paths —
a namespace makes provenance readable, separate directories make it structural a namespace makes provenance readable, separate directories make it structural
+23 -6
View File
@@ -1,6 +1,7 @@
# A plugin's capabilities are LOADED, never copied # A plugin's capabilities are LOADED, never copied
**Status: designed, not built.** What exists today is the copying this replaces. **Status: built.** Skills, affordances and notification catalogues landed in `2026.08.23.06`; tasks — the
read-write one, and the last piece — landed on 2026-08-24. The four publishers are gone.
## The problem, as it is on disk right now ## The problem, as it is on disk right now
@@ -32,8 +33,8 @@ plugins/<name>/ the plugin's own repository, cloned on instal
``` ```
Nothing is copied. Nothing needs a namespace to keep the two apart, because they are already apart — Nothing is copied. Nothing needs a namespace to keep the two apart, because they are already apart —
which is the whole reason this beats scoping the paths. A namespace makes provenance *readable*; separate which is the whole reason this beats scoping the paths. A namespace makes provenance _readable_; separate
directories make it *structural*. directories make it _structural_.
Four consequences, and each of them is a bug that stops existing: Four consequences, and each of them is a bug that stops existing:
@@ -64,6 +65,21 @@ silently writes into a directory the next `plugins:update` overwrites.
A task's directory name is also stored data — `taskDirName` in `pipeline_jobs` — so lookup by name has to A task's directory name is also stored data — `taskDirName` in `pipeline_jobs` — so lookup by name has to
search every source rather than assuming one root. search every source rather than assuming one root.
**How it went in, since it turned out to be one function rather than several.** Every path that runs a
task — the file browser's context menu, both runner sockets, the job queue, the pipeline executor —
resolves it through `getTaskByDirName` and then reads `implementation` off the record. So the sources go
in there and in `listTasks`, and a plugin's task becomes runnable everywhere at once with its identity
unchanged: still a directory name, still what `pipeline_jobs` stores.
The record carries `plugin` — the app name, or null for the owner's own. That one field does the rest:
`updateTask` and `deleteTask` throw on it, `DELETE /api/tasks/:name` answers 409 naming the plugin, and
the capability screen replaces the edit and delete controls with a line saying whose it is. Without the
delete guard the old code was quietly a no-op — `rm -rf` on a path in the owner's store that does not
exist, then `{ ok: true }`, and the task still in the menu.
`getTaskByDirName` also refuses a name containing a separator. It always joined a caller-supplied string
onto a path; searching several roots is the moment that stops being one directory nobody could escape.
**The four publishers are deleted**, along with their install and uninstall wiring: **The four publishers are deleted**, along with their install and uninstall wiring:
`affordances/publish.ts`, `notifications/publish.ts`, `skills/publish.ts`, `api/tasks/publish.ts`. The `affordances/publish.ts`, `notifications/publish.ts`, `skills/publish.ts`, `api/tasks/publish.ts`. The
last was written on 2026-08-23 and is superseded before it ever shipped. last was written on 2026-08-23 and is superseded before it ever shipped.
@@ -87,6 +103,7 @@ what happens today and needs no change. Scoping them is a separate question and
force it either way. force it either way.
**Whether a plugin's capabilities should be namespaced among themselves.** Two plugins can still ship a **Whether a plugin's capabilities should be namespaced among themselves.** Two plugins can still ship a
task with the same directory name, and the loader has to break that tie — first-come, as the items store task with the same directory name. The tie-break shipped as first-come with the owner's store searched
has always done, or by qualifying with the plugin name at read time. Worth settling when a second plugin first, which settles the case that matters — a plugin cannot take over a name its owner is already
ships a task; not before. using — and leaves two plugins colliding with each other resolved by discovery order, which is arbitrary.
Worth revisiting when a second plugin ships a task; not before.
@@ -24,6 +24,8 @@ export type CapabilityDetail = CapabilitySummary & {
rawFrontmatter: string; rawFrontmatter: string;
filePath: string; filePath: string;
chatSessionId: string | null; chatSessionId: string | null;
/** Only tasks carry this today: the plugin that ships it, or null/absent when it is the owner's own. */
plugin?: string | null;
}; };
type CapabilityListProps = { type CapabilityListProps = {
@@ -538,7 +540,17 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
<span className="text-sm font-medium text-duck-dark/70 flex-1"> <span className="text-sm font-medium text-duck-dark/70 flex-1">
{detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })} {detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })}
</span> </span>
{detail && ( {/*
A plugin's capability is read where it lives, in a git checkout that `plugins:update`
pulls. So there is nothing to offer here: an edit would be overwritten by the next update,
and the delete refuses server-side (409). Say whose it is instead of showing two controls
that cannot work.
*/}
{detail?.plugin ? (
<span className="text-xs text-duck-dark/40">
{t('capability.fromPlugin', { plugin: detail.plugin })}
</span>
) : detail ? (
<> <>
<button <button
onClick={() => setEditing(!editing)} onClick={() => setEditing(!editing)}
@@ -553,7 +565,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 hover:text-red-500" /> <Trash2 className="h-3.5 w-3.5 text-duck-dark/50 hover:text-red-500" />
</button> </button>
</> </>
)} ) : null}
</div> </div>
<div className="overflow-y-auto flex-1 p-6"> <div className="overflow-y-auto flex-1 p-6">
{detail?.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />} {detail?.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}
+1
View File
@@ -682,6 +682,7 @@
"frontmatter": "Frontmatter", "frontmatter": "Frontmatter",
"noMatches": "Keine Treffer", "noMatches": "Keine Treffer",
"emptyFile": "Leere Datei", "emptyFile": "Leere Datei",
"fromPlugin": "Gehört zum Plugin {{plugin}} — schreibgeschützt",
"createFailed": "{{kind}} konnte nicht erstellt werden", "createFailed": "{{kind}} konnte nicht erstellt werden",
"deleteFailed": "{{kind}} konnte nicht gelöscht werden", "deleteFailed": "{{kind}} konnte nicht gelöscht werden",
"searchPlaceholder": "{{kind}} durchsuchen…", "searchPlaceholder": "{{kind}} durchsuchen…",
+1
View File
@@ -682,6 +682,7 @@
"frontmatter": "Frontmatter", "frontmatter": "Frontmatter",
"noMatches": "No matches", "noMatches": "No matches",
"emptyFile": "Empty file", "emptyFile": "Empty file",
"fromPlugin": "Ships with the {{plugin}} plugin — read-only",
"createFailed": "Could not create the {{kind}}", "createFailed": "Could not create the {{kind}}",
"deleteFailed": "Could not delete the {{kind}}", "deleteFailed": "Could not delete the {{kind}}",
"searchPlaceholder": "Search {{kind}}…", "searchPlaceholder": "Search {{kind}}…",
+1
View File
@@ -682,6 +682,7 @@
"frontmatter": "Frontmatter", "frontmatter": "Frontmatter",
"noMatches": "Sin coincidencias", "noMatches": "Sin coincidencias",
"emptyFile": "Archivo vacío", "emptyFile": "Archivo vacío",
"fromPlugin": "Viene del plugin {{plugin}} — solo lectura",
"createFailed": "No se pudo crear el {{kind}}", "createFailed": "No se pudo crear el {{kind}}",
"deleteFailed": "No se pudo eliminar el {{kind}}", "deleteFailed": "No se pudo eliminar el {{kind}}",
"searchPlaceholder": "Buscar {{kind}}…", "searchPlaceholder": "Buscar {{kind}}…",
+1
View File
@@ -682,6 +682,7 @@
"frontmatter": "Frontmatter", "frontmatter": "Frontmatter",
"noMatches": "Aucun résultat", "noMatches": "Aucun résultat",
"emptyFile": "Fichier vide", "emptyFile": "Fichier vide",
"fromPlugin": "Fourni par le plugin {{plugin}} — lecture seule",
"createFailed": "Impossible de créer le {{kind}}", "createFailed": "Impossible de créer le {{kind}}",
"deleteFailed": "Impossible de supprimer le {{kind}}", "deleteFailed": "Impossible de supprimer le {{kind}}",
"searchPlaceholder": "Rechercher {{kind}}…", "searchPlaceholder": "Rechercher {{kind}}…",
+1
View File
@@ -682,6 +682,7 @@
"frontmatter": "Frontmatter", "frontmatter": "Frontmatter",
"noMatches": "Sem correspondências", "noMatches": "Sem correspondências",
"emptyFile": "Ficheiro vazio", "emptyFile": "Ficheiro vazio",
"fromPlugin": "Vem do plugin {{plugin}} — só de leitura",
"createFailed": "Não foi possível criar o {{kind}}", "createFailed": "Não foi possível criar o {{kind}}",
"deleteFailed": "Não foi possível eliminar o {{kind}}", "deleteFailed": "Não foi possível eliminar o {{kind}}",
"searchPlaceholder": "Pesquisar {{kind}}…", "searchPlaceholder": "Pesquisar {{kind}}…",
+1
View File
@@ -682,6 +682,7 @@
"frontmatter": "Frontmatter", "frontmatter": "Frontmatter",
"noMatches": "Inga träffar", "noMatches": "Inga träffar",
"emptyFile": "Tom fil", "emptyFile": "Tom fil",
"fromPlugin": "Följer med plugin-programmet {{plugin}} — skrivskyddad",
"createFailed": "Det gick inte att skapa {{kind}}", "createFailed": "Det gick inte att skapa {{kind}}",
"deleteFailed": "Det gick inte att ta bort {{kind}}", "deleteFailed": "Det gick inte att ta bort {{kind}}",
"searchPlaceholder": "Sök {{kind}}…", "searchPlaceholder": "Sök {{kind}}…",
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { listTasks, getTaskByDirName } from './task-files';
/**
* A plugin's tasks are read where they live (docs/plugin-capabilities.md), and the whole reason it lands
* in `task-files.ts` is that every runner resolves a task through these two functions. So these pin the
* properties the file browser's context menu depends on — a plugin's task is listed, is resolvable by the
* same directory name a menu click sends back, and carries its implementation.
*
* Sources are injected: resolving them for real reads the database and the plugin directory, which would
* make this assert whatever this machine happens to have installed.
*/
const seed = (files: Record<string, string>): string => {
const dir = mkdtempSync(join(tmpdir(), 'zz-tasks-'));
for (const [rel, body] of Object.entries(files)) {
const abs = join(dir, rel);
mkdirSync(join(abs, '..'), { recursive: true });
writeFileSync(abs, body);
}
return dir;
};
const taskMd = (name: string, extra = '') =>
`---\nname: ${name}\nmode: script\nlanguage: typescript\n${extra}---\n\nbody\n`;
describe('tasks across sources', () => {
test("a plugin's task is listed and carries its provenance", async () => {
const owner = seed({ 'mine/TASK.md': taskMd('Mine') });
const plugin = seed({ 'transcribe-audio-file/TASK.md': taskMd('Transcribe Audio File') });
try {
const found = await listTasks([
{ dir: owner, plugin: null },
{ dir: plugin, plugin: 'transcribe' },
]);
expect(found.map((t) => [t.dirName, t.plugin])).toEqual([
['mine', null],
['transcribe-audio-file', 'transcribe'],
]);
} finally {
rmSync(owner, { recursive: true, force: true });
rmSync(plugin, { recursive: true, force: true });
}
});
// The context menu sends back a directory name and nothing else, so resolution has to find the plugin's
// copy by that name alone — otherwise the task is listed and then "not found" the moment it is clicked.
test("a plugin's task resolves by directory name, with its implementation", async () => {
const owner = seed({ 'mine/TASK.md': taskMd('Mine') });
const plugin = seed({
'transcribe-audio-file/TASK.md': taskMd('Transcribe Audio File'),
'transcribe-audio-file/index.ts': 'export const ok = true;\n',
});
try {
const sources = [
{ dir: owner, plugin: null },
{ dir: plugin, plugin: 'transcribe' },
];
const task = await getTaskByDirName('transcribe-audio-file', sources);
expect(task?.plugin).toBe('transcribe');
expect(task?.implementation).toBe('export const ok = true;\n');
} finally {
rmSync(owner, { recursive: true, force: true });
rmSync(plugin, { recursive: true, force: true });
}
});
// Same precedence as skills: a plugin cannot take over a name its owner is already using.
test("the owner's own wins a name collision", async () => {
const owner = seed({ 'deploy/TASK.md': taskMd('Deploy (owner)') });
const plugin = seed({ 'deploy/TASK.md': taskMd('Deploy (plugin)') });
try {
const sources = [
{ dir: owner, plugin: null },
{ dir: plugin, plugin: 'somebody' },
];
expect(await listTasks(sources)).toHaveLength(1);
const task = await getTaskByDirName('deploy', sources);
expect(task?.name).toBe('Deploy (owner)');
expect(task?.plugin).toBeNull();
} finally {
rmSync(owner, { recursive: true, force: true });
rmSync(plugin, { recursive: true, force: true });
}
});
// A directory name arrives from a websocket message and a job row before it is joined onto a path.
test('a traversing directory name resolves to nothing', async () => {
const owner = seed({ 'mine/TASK.md': taskMd('Mine') });
try {
const sources = [{ dir: join(owner, 'nested'), plugin: null }];
expect(await getTaskByDirName('../mine', sources)).toBeNull();
} finally {
rmSync(owner, { recursive: true, force: true });
}
});
});
+68 -17
View File
@@ -1,10 +1,28 @@
import { readdir, mkdir, rm } from 'node:fs/promises'; import { readdir, mkdir, rm } from 'node:fs/promises';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { itemsDir, OFFICER_ITEMS_DIR } from '../../data-path'; import { itemsDir, OFFICER_ITEMS_DIR } from '../../data-path';
import { capabilitySources, type CapabilitySource } from '../../capability-sources';
// File-backed task store. Every task is a directory under $OFFICER_ITEMS_DIR/tasks/<dirName>/ with a // File-backed task store. Every task is a directory under $OFFICER_ITEMS_DIR/tasks/<dirName>/ with a
// TASK.md (metadata + prose body) and, for script-mode tasks, a sibling implementation file. There are // TASK.md (metadata + prose body) and, for script-mode tasks, a sibling implementation file. There are
// no scope tiers and no database — the directory name is the task's identity. // no scope tiers and no database — the directory name is the task's identity.
//
// ── Plus every enabled plugin's own tasks/ directory ──
//
// Read where they live, never copied in — the same rule as skills and affordances, and the same reasons
// (`capability-sources.ts`, `docs/plugin-capabilities.md`). It lands here rather than in each caller
// because EVERY execution path goes through `getTaskByDirName`: the context menu, the runner sockets,
// the job queue and the pipeline executor all resolve a task by its directory name and then read
// `implementation` off the record. Teaching this one function about plugin sources makes a plugin's task
// runnable everywhere at once, and leaves the identity — the directory name — exactly as it was.
//
// The owner's own store is searched FIRST, so a name the owner is already using cannot be taken over by
// a plugin that happened to pick the same directory name.
//
// Writing is a different matter: create, update and delete only ever touch the owner's store, and refuse
// a task that belongs to a plugin. A plugin's task lives in a git checkout that `plugins:update` pulls —
// editing it in place would be overwritten by the next update, and deleting it would make the plugin's
// own files disappear from a screen that says nothing about plugins.
const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML; const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML;
@@ -34,6 +52,8 @@ export type TaskRecord = TaskFrontmatter & {
body: string; body: string;
implementation: string | null; implementation: string | null;
filePath: string; filePath: string;
/** The plugin that ships it, or null when it is the owner's own. Read-only when set. */
plugin: string | null;
}; };
export type TaskSummary = { export type TaskSummary = {
@@ -45,6 +65,7 @@ export type TaskSummary = {
inline: boolean | 'ask'; inline: boolean | 'ask';
version: number; version: number;
trigger: unknown; trigger: unknown;
plugin: string | null;
}; };
// Frontmatter keys, in write order. These mirror the old `tasks` table columns 1:1. // Frontmatter keys, in write order. These mirror the old `tasks` table columns 1:1.
@@ -145,9 +166,8 @@ function buildTaskMd(fm: Partial<TaskFrontmatter>, body: string): string {
return lines.join('\n'); return lines.join('\n');
} }
async function readImplementation(dirName: string, language: string | null): Promise<string | null> { async function readImplementation(dir: string, language: string | null): Promise<string | null> {
const implPath = join(taskDir(dirName), implFileName(language)); const file = Bun.file(join(dir, implFileName(language)));
const file = Bun.file(implPath);
return (await file.exists()) ? file.text() : null; return (await file.exists()) ? file.text() : null;
} }
@@ -170,21 +190,31 @@ export async function readCategoryOrder(): Promise<string[]> {
} }
} }
export async function listTasks(): Promise<TaskSummary[]> { /**
* Every task on offer, the owner's own first.
*
* `sources` is injectable so this can be tested against temporary directories — resolving them for real
* reads the database and the plugin directory, which would make a test assert whatever this machine
* happens to have installed.
*/
export async function listTasks(sources?: CapabilitySource[]): Promise<TaskSummary[]> {
const roots = sources ?? (await capabilitySources('tasks'));
const byDirName = new Map<string, TaskSummary>();
for (const source of roots) {
let entries; let entries;
try { try {
entries = await readdir(tasksRoot(), { withFileTypes: true }); entries = await readdir(source.dir, { withFileTypes: true });
} catch { } catch {
return []; continue; // most plugins ship no tasks at all — the ordinary case, not a fault
} }
const summaries: TaskSummary[] = [];
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory() || byDirName.has(entry.name)) continue;
const file = Bun.file(taskFile(entry.name)); const file = Bun.file(join(source.dir, entry.name, 'TASK.md'));
if (!(await file.exists())) continue; if (!(await file.exists())) continue;
const { fm } = parseTaskMd(await file.text()); const { fm } = parseTaskMd(await file.text());
summaries.push({ byDirName.set(entry.name, {
dirName: entry.name, dirName: entry.name,
name: fm.name || entry.name, name: fm.name || entry.name,
description: fm.description, description: fm.description,
@@ -193,19 +223,30 @@ export async function listTasks(): Promise<TaskSummary[]> {
inline: fm.inline, inline: fm.inline,
version: fm.version, version: fm.version,
trigger: fm.trigger ?? [], trigger: fm.trigger ?? [],
plugin: source.plugin,
}); });
} }
}
summaries.sort((a, b) => a.name.localeCompare(b.name)); return [...byDirName.values()].sort((a, b) => a.name.localeCompare(b.name));
return summaries;
} }
export async function getTaskByDirName(dirName: string): Promise<TaskRecord | null> { export async function getTaskByDirName(dirName: string, sources?: CapabilitySource[]): Promise<TaskRecord | null> {
const file = Bun.file(taskFile(dirName)); // A directory name reaches this from a websocket message and a job row, so it is caller-supplied and
if (!(await file.exists())) return null; // gets joined onto a path. Traversal would otherwise read any TASK.md on the disk.
if (!dirName || dirName.includes('/') || dirName.includes('\\') || dirName === '.' || dirName === '..') return null;
const roots = sources ?? (await capabilitySources('tasks'));
for (const source of roots) {
const dir = join(source.dir, dirName);
const filePath = join(dir, 'TASK.md');
const file = Bun.file(filePath);
if (!(await file.exists())) continue;
const { fm, body } = parseTaskMd(await file.text()); const { fm, body } = parseTaskMd(await file.text());
const implementation = await readImplementation(dirName, fm.language); const implementation = await readImplementation(dir, fm.language);
return { ...fm, dirName, body, implementation, filePath: taskFile(dirName) }; return { ...fm, dirName, body, implementation, filePath, plugin: source.plugin };
}
return null;
} }
type CreateTaskInput = { type CreateTaskInput = {
@@ -240,6 +281,7 @@ export async function createTask(input: CreateTaskInput): Promise<TaskSummary &
inline: false, inline: false,
version: 1, version: 1,
trigger: [], trigger: [],
plugin: null,
filePath: taskFile(dirName), filePath: taskFile(dirName),
}; };
} }
@@ -250,6 +292,7 @@ export async function updateTask(
): Promise<TaskRecord | null> { ): Promise<TaskRecord | null> {
const existing = await getTaskByDirName(dirName); const existing = await getTaskByDirName(dirName);
if (!existing) return null; if (!existing) return null;
if (existing.plugin) throw new Error(`${dirName} belongs to the ${existing.plugin} plugin and is read-only`);
const merged: TaskFrontmatter = { ...existing, ...patch }; const merged: TaskFrontmatter = { ...existing, ...patch };
const body = patch.body ?? existing.body; const body = patch.body ?? existing.body;
@@ -262,7 +305,15 @@ export async function updateTask(
return getTaskByDirName(dirName); return getTaskByDirName(dirName);
} }
/**
* Only ever the owner's own copy.
*
* Without the guard this was quietly a no-op on a plugin's task — `rm -rf` on a path in the owner's store
* that does not exist, followed by a cheerful `{ ok: true }` and a task still in the menu.
*/
export async function deleteTask(dirName: string): Promise<void> { export async function deleteTask(dirName: string): Promise<void> {
const existing = await getTaskByDirName(dirName);
if (existing?.plugin) throw new Error(`${dirName} belongs to the ${existing.plugin} plugin and cannot be deleted`);
await rm(taskDir(dirName), { recursive: true, force: true }); await rm(taskDir(dirName), { recursive: true, force: true });
} }
+4
View File
@@ -15,6 +15,7 @@ tasksRouter.get('/', async (ctx) => {
category: row.category, category: row.category,
triggers: (row.trigger as TriggerConfig[]) ?? [], triggers: (row.trigger as TriggerConfig[]) ?? [],
mode: row.mode ?? 'agentic', mode: row.mode ?? 'agentic',
plugin: row.plugin,
})); }));
return ctx.json(tasks); return ctx.json(tasks);
@@ -47,6 +48,7 @@ tasksRouter.get('/:name', async (ctx) => {
config: task.config, config: task.config,
version: task.version, version: task.version,
filePath: task.filePath, filePath: task.filePath,
plugin: task.plugin,
}); });
}); });
@@ -74,6 +76,8 @@ tasksRouter.delete('/:name', async (ctx) => {
const task = await getTaskByDirName(name); const task = await getTaskByDirName(name);
if (!task) return ctx.text('Not found', 404); if (!task) return ctx.text('Not found', 404);
// A plugin's task is its own file in its own checkout. Uninstalling the plugin is how it goes away.
if (task.plugin) return ctx.text(`${name} belongs to the ${task.plugin} plugin — uninstall it instead`, 409);
await deleteTask(task.dirName); await deleteTask(task.dirName);
return ctx.json({ ok: true }); return ctx.json({ ok: true });
+23 -20
View File
@@ -37,8 +37,14 @@ import { snapshotPlugins } from './plugins/mount';
* database work. A missing directory is skipped rather than erroring: most plugins ship no capabilities * database work. A missing directory is skipped rather than erroring: most plugins ship no capabilities
* at all, and that is the ordinary case rather than a fault. * at all, and that is the ordinary case rather than a fault.
*/ */
export async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise<string[]> { export type CapabilitySource = {
const roots = [itemsDir(type as ItemType)]; dir: string;
/** The plugin that ships it, or null for the owner's own store. */
plugin: string | null;
};
export async function capabilitySources(type: ItemType | 'affordances' | 'notifications'): Promise<CapabilitySource[]> {
const sources: CapabilitySource[] = [{ dir: itemsDir(type as ItemType), plugin: null }];
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = []; let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
try { try {
@@ -46,34 +52,27 @@ export async function capabilityRoots(type: ItemType | 'affordances' | 'notifica
} catch { } catch {
// The owner's own capabilities still load. A database that cannot be read is a much louder problem // The owner's own capabilities still load. A database that cannot be read is a much louder problem
// somewhere else, and losing the agent's whole skill index to it would only obscure that. // somewhere else, and losing the agent's whole skill index to it would only obscure that.
return roots; return sources;
} }
for (const state of states) { for (const state of states) {
if (!state.install?.enabled) continue; if (!state.install?.enabled) continue;
const dir = join(state.plugin.dir, type); const dir = join(state.plugin.dir, type);
if (existsSync(dir)) roots.push(dir); if (existsSync(dir)) sources.push({ dir, plugin: state.plugin.appName });
} }
return roots; return sources;
} }
/** /**
* The same list, for a caller that already holds a snapshot. * The same list for a caller that only needs the paths.
* *
* Exists so a request doing several of these — the items rescan reads five types — pays for one database * Which is most of them: a skill or an affordance is read, compiled into a preamble and never written
* read rather than five. * to, so where it came from changes nothing. Tasks are the exception — one can be deleted, and deleting
* a plugin's file out of the owner's screen is not a thing the owner asked for — so `task-files.ts`
* takes the sources and keeps the provenance.
*/ */
export function capabilityRootsFrom( export async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise<string[]> {
states: Awaited<ReturnType<typeof snapshotPlugins>>['states'], return (await capabilitySources(type)).map((s) => s.dir);
type: ItemType | 'affordances' | 'notifications',
): string[] {
const roots = [itemsDir(type as ItemType)];
for (const state of states) {
if (!state.install?.enabled) continue;
const dir = join(state.plugin.dir, type);
if (existsSync(dir)) roots.push(dir);
}
return roots;
} }
/** /**
@@ -90,7 +89,11 @@ export async function pluginCatalogueSources(): Promise<
const { states } = await snapshotPlugins(); const { states } = await snapshotPlugins();
return states return states
.filter((s) => s.install?.enabled) .filter((s) => s.install?.enabled)
.map((s) => ({ appName: s.plugin.appName, dir: s.plugin.dir, manifest: { publisher: s.plugin.manifest.publisher } })); .map((s) => ({
appName: s.plugin.appName,
dir: s.plugin.dir,
manifest: { publisher: s.plugin.manifest.publisher },
}));
} catch { } catch {
return []; return [];
} }
@@ -14,6 +14,8 @@ export type TaskSummary = {
triggers: TriggerConfig[]; triggers: TriggerConfig[];
mode: 'script' | 'agentic' | 'pipeline'; mode: 'script' | 'agentic' | 'pipeline';
userId: number | null; userId: number | null;
/** The plugin that ships it, or null when it is the owner's own. */
plugin?: string | null;
}; };
export type TaskGroup = { category: string; tasks: TaskSummary[] }; export type TaskGroup = { category: string; tasks: TaskSummary[] };