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:
2026-08-23 23:19:11 +00:00
co-authored by Claude Opus 5
parent 6572fa90f0
commit 8770f98dcd
15 changed files with 335 additions and 490 deletions
+8 -5
View File
@@ -552,11 +552,14 @@ function armIdle(session: PersistentSession): void {
}, IDLE_TIMEOUT_MS);
}
function createSession(
// Async only because the preamble is: resolving which plugins are installed and enabled, so their
// capabilities can be read where they live, reads the database. Once per session — see
// `capability-sources.ts`. Its single caller already awaits.
async function createSession(
params: ClaudeSpawnStreamingParams,
onEvent: (event: ChatEvent) => void,
onNotify: (kind: 'turn-ended' | 'input-needed') => void,
): PersistentSession {
): Promise<PersistentSession> {
const { sessionKey } = params;
const input = makeInputQueue();
const abort = new AbortController();
@@ -625,11 +628,11 @@ function createSession(
// Read here rather than at module load: a plugin installed an hour ago should be live on the next
// message, and this costs a few kilobytes once per session.
const affordances = affordanceBlock();
const affordances = await affordanceBlock();
// The skills INDEX — names, descriptions and paths, never bodies. See skills/load.ts for why this is
// the inverse of an affordance. Read here rather than at module load for the same reason: a skill
// written an hour ago should be offered on the next session.
const skills = skillsBlock();
const skills = await skillsBlock();
/*
And, for a commander, the two paths only this session can be told.
@@ -916,7 +919,7 @@ export async function spawnClaudeStreaming(
await applyModelChange(session, params.model);
await applyPlanModeChange(session, params.planMode);
} else {
session = createSession(params, onEvent, onNotify);
session = await createSession(params, onEvent, onNotify);
}
session.pushTurn(params.prompt, params.images);
}
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Channel, DeliveryResult, Notification } from './types';
import { loadCatalogue } from '../../notifications/catalogue';
import { pluginCatalogueSources } from '../../capability-sources';
import { discordChannel } from './discord';
import { apnsChannel } from './apns';
import { fcmChannel } from './fcm';
@@ -28,7 +29,7 @@ export async function dispatch(n: Notification): Promise<DeliveryResult[]> {
// Read ONCE, here — not per channel and not per device. `version` travels with the push as `mv`, and
// every copy of one notification must carry the same one; two channels reading disk either side of a
// plugin install would otherwise tell two devices different things about the same event.
const ctx = { catalogue: loadCatalogue() };
const ctx = { catalogue: loadCatalogue(undefined, await pluginCatalogueSources()) };
return Promise.all(
active.map(async (c) => {
+2 -1
View File
@@ -3,6 +3,7 @@ import { createSidecarConnector } from '../connect';
import { dispatch, configuredChannels } from './dispatch';
import { handleDeviceRoute } from './devices';
import { loadCatalogue } from '../../notifications/catalogue';
import { pluginCatalogueSources } from '../../capability-sources';
import { resolveNotifyUser } from './resolve-user';
import { closeApnsSessions } from './apns';
import type { Notification } from './types';
@@ -76,7 +77,7 @@ const server = Bun.serve({
// it would present as "the new plugin's notifications don't work" — the hardest kind of bug to place.
// It is a few kilobytes off local disk against a request the app makes about once per launch.
if (url.pathname === '/mapping' && req.method === 'GET') {
const catalogue = loadCatalogue();
const catalogue = loadCatalogue(undefined, await pluginCatalogueSources());
return Response.json(catalogue, {
// The client decides by `version`, so a cached copy would defeat the sync it exists to drive.
headers: { 'Cache-Control': 'no-store' },