capabilities: read a plugin's where they live, stop copying them in
Four publishers copied a plugin's affordances, notifications and skills into capabilities/ at install. That directory is meant to be forked — core's work plus the owner's — so a fork accumulated files it did not author with nothing on disk saying which, and there was no good answer: commit them and carry another plugin's work, leave them and git status stays dirty. Copying was also wrong in two ways unrelated to git. A copy goes stale, because plugins:update pulls the plugin's directory and the copy keeps describing the old version. And a copy survived plugins:disable, so a disabled plugin's skill stayed in the agent's index — off, and still being announced. Now the loaders take a list of roots: the owner's store, plus each installed AND enabled plugin's own directory. capability-sources.ts resolves that list. No namespace is needed to keep them apart, because separate directories do it structurally rather than by convention. The owner's own win a skill name collision — their root is first and the merge keeps the first of each id — so a plugin cannot take over a name already in use. Affordances are deliberately NOT de-duplicated: several can be true at once. Notifications needed more than a root list, and it would have failed silently. Their codes were derived from the COPY'S PATH, <publisher>/<appName>/file.yaml. Read in place the path is just notifications/catalogue.yaml and carries no identity, so every plugin code would have vanished with nothing raised. loadPluginEntries takes the identity from the manifest instead, which is better provenance anyway — publisher and appName are validated at discovery, where a path was only ever a directory somebody chose. A yaml key still cannot claim another namespace, and that is pinned by test. createSession is async now, only because resolving enabled plugins reads the database. Once per session; its single caller already awaited. Deletes all four publishers including api/tasks/publish.ts from earlier today, superseded before it shipped. app-store/assets stays — images served over HTTP, where the copy is what makes them reachable. NOT done: tasks. That store is read-write, so reads must span sources while writes stay in the owner's directory, and a plugin's task has to be read-only in the UI. Next. Two things the design predicted as orphans turned out not to be, and both were checked before deleting rather than after. capabilities/extensions/tool-loader is written by the platform's own bootstrap every boot, not published by a plugin. And skills/email-db has no source to fall back on — the email plugin ships no skills directory, so that file exists in exactly one place and was hand-written. Neither was touched. 1422 pass, tsgo clean, officer restarts and serves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
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 async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise<string[]> {
|
||||
const roots = [itemsDir(type as ItemType)];
|
||||
|
||||
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 roots;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same list, for a caller that already holds a snapshot.
|
||||
*
|
||||
* Exists so a request doing several of these — the items rescan reads five types — pays for one database
|
||||
* read rather than five.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user