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
101 lines
4.4 KiB
TypeScript
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 [];
|
|
}
|
|
}
|