Files
platform/src/servers/capability-sources.ts
T
pastilhasandClaude Opus 5 6d673a1321 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
2026-08-24 04:20:18 +00:00

101 lines
4.4 KiB
TypeScript

import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { itemsDir, type ItemType } from './data-path';
import { snapshotPlugins } from './plugins/mount';
/**
* Where capabilities are read from — the owner's own store, plus every installed and enabled plugin's.
*
* ── Why plugins are read in place rather than copied in ──
*
* `capabilities/` is a git repository, and the point of it is that anybody can fork it: core's tasks and
* skills, plus their own on top. That only holds if the directory contains exactly two things, what core
* ships and what its owner wrote. Publishing a plugin's files into it broke that — a fork accumulated
* files it did not author, with nothing on disk saying which, so committing them carried another plugin's
* work and not committing them left `git status` permanently dirty.
*
* Copying was wrong a second way that has nothing to do with git. A copy goes STALE: `plugins:update` is
* a `git pull` in the plugin's directory, and a capability copied out of it keeps describing the previous
* version until something republishes. And a copy survives `plugins:disable`, so a disabled plugin's
* skill stayed in the agent's index — the plugin off, the agent still told it was there.
*
* Reading in place fixes all of that by construction, and needs no namespace to do it. Scoping the paths
* would have made provenance READABLE; separate directories make it STRUCTURAL, which is the stronger
* property and the cheaper one.
*
* See `docs/plugin-capabilities.md`.
*
* ── Enabled, not merely installed ──
*
* A disabled plugin contributes nothing. That is the whole meaning of disable — routes down, sidecar
* stopped — and a capability that outlived it was the one piece that did not comply.
*
* ── Cost ──
*
* This reads the database, so it is async, and it must not be called per turn. Its callers resolve it
* once per session (`skillsBlock`, `affordanceBlock`) or per request on surfaces that are already doing
* 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 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 {
({ states } = await snapshotPlugins());
} 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 sources;
}
for (const state of states) {
if (!state.install?.enabled) continue;
const dir = join(state.plugin.dir, type);
if (existsSync(dir)) sources.push({ dir, plugin: state.plugin.appName });
}
return sources;
}
/**
* The same list for a caller that only needs the paths.
*
* 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 async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise<string[]> {
return (await capabilitySources(type)).map((s) => s.dir);
}
/**
* Installed and enabled plugins, in the shape the notification catalogue wants.
*
* Notifications are the one capability whose namespace cannot be read off a path once the file stays in
* the plugin's own directory — see `notifications/catalogue.ts` → `loadPluginEntries`. So the source list
* carries the identity with it.
*/
export async function pluginCatalogueSources(): Promise<
{ appName: string; dir: string; manifest: { publisher: string } }[]
> {
try {
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 },
}));
} catch {
return [];
}
}