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
+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
* at all, and that is the ordinary case rather than a fault.
*/
export async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise<string[]> {
const roots = [itemsDir(type as ItemType)];
export type CapabilitySource = {
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'] = [];
try {
@@ -46,34 +52,27 @@ export async function capabilityRoots(type: ItemType | 'affordances' | 'notifica
} catch {
// 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.
return roots;
return sources;
}
for (const state of states) {
if (!state.install?.enabled) continue;
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
* read rather than five.
* Which is most of them: a skill or an affordance is read, compiled into a preamble and never written
* 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(
states: Awaited<ReturnType<typeof snapshotPlugins>>['states'],
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;
export async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise<string[]> {
return (await capabilitySources(type)).map((s) => s.dir);
}
/**
@@ -90,7 +89,11 @@ export async function pluginCatalogueSources(): Promise<
const { states } = await snapshotPlugins();
return states
.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 {
return [];
}