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
+18 -2
View File
@@ -1,6 +1,7 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { OFFICER_ITEMS_DIR } from '../data-path';
import { capabilityRoots } from '../capability-sources';
// What the platform tells an agent about the surface it is speaking through.
//
@@ -107,6 +108,21 @@ export function compileAffordances(affordances: Affordance[]): string | null {
}
/** Read and compile in one step, for callers that only want the string. */
export function affordanceBlock(root: string = AFFORDANCES_DIR): string | null {
return compileAffordances(loadAffordances(root));
/**
* Every affordance in force: the owner's own, plus each installed and enabled plugin's.
*
* Read in place rather than copied in — see `capability-sources.ts`. Unlike skills these are NOT
* de-duplicated by name: an affordance is a paragraph of guidance, several can be true at once, and two
* plugins describing the same surface differently is information rather than a collision.
*/
export function loadAffordancesAcross(roots: string[]): Affordance[] {
return roots.flatMap((root) => loadAffordances(root));
}
/**
* Read and compile in one step. Async because resolving which plugins are enabled reads the database;
* called once per session, which is what makes that affordable.
*/
export async function affordanceBlock(): Promise<string | null> {
return compileAffordances(loadAffordancesAcross(await capabilityRoots('affordances')));
}
-80
View File
@@ -1,80 +0,0 @@
import { describe, expect, test, afterEach } from 'bun:test';
import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { affordanceDirFor, affordanceSourceFor, publishAffordances, unpublishAffordances } from './publish';
import { AFFORDANCES_DIR } from './load';
import type { DiscoveredPlugin } from '../plugins/manifest';
// The failure that matters is not a copy going wrong — it is uninstall taking somebody else's files with
// it. Every first-party plugin publishes under one `officerdev/` directory, so removing the wrong level
// would strip them all and nobody would notice until an agent stopped knowing something.
const root = join(tmpdir(), `aff-pub-${process.pid}`);
const plugin = (appName: string, publisher = 'officerdev'): DiscoveredPlugin =>
({ appName, dir: join(root, appName), manifest: { publisher } }) as DiscoveredPlugin;
const withAffordance = (appName: string, publisher = 'officerdev') => {
const p = plugin(appName, publisher);
mkdirSync(affordanceSourceFor(p), { recursive: true });
writeFileSync(join(affordanceSourceFor(p), `${appName}.md`), `---\nname: ${appName}\n---\n\nbody\n`);
return p;
};
afterEach(() => {
rmSync(root, { recursive: true, force: true });
for (const pub of ['officerdev', 'alice']) rmSync(join(AFFORDANCES_DIR, pub), { recursive: true, force: true });
});
describe('publishing', () => {
test('lands under <publisher>/<appName>, mirroring mountPrefix', () => {
const p = withAffordance('test-gitea');
expect(affordanceDirFor(p)).toBe(join(AFFORDANCES_DIR, 'officerdev', 'test-gitea'));
});
test('copies what the plugin ships and counts it', async () => {
expect(await publishAffordances(withAffordance('test-gitea'))).toBe(1);
});
test('a plugin shipping none is not a failure — most ship none', async () => {
const bare = plugin('test-bare');
mkdirSync(bare.dir, { recursive: true });
expect(await publishAffordances(bare)).toBe(0);
});
test('re-publishing overwrites rather than erroring, so re-install works', async () => {
const p = withAffordance('test-gitea');
expect(await publishAffordances(p)).toBe(1);
expect(await publishAffordances(p)).toBe(1);
});
test('two publishers with the same plugin name do not collide', async () => {
await publishAffordances(withAffordance('test-gitea', 'officerdev'));
await publishAffordances(withAffordance('test-gitea', 'alice'));
expect(existsSync(join(AFFORDANCES_DIR, 'officerdev', 'test-gitea', 'test-gitea.md'))).toBe(true);
expect(existsSync(join(AFFORDANCES_DIR, 'alice', 'test-gitea', 'test-gitea.md'))).toBe(true);
});
});
describe('unpublishing', () => {
test('removes the plugin', async () => {
const p = withAffordance('test-gitea');
await publishAffordances(p);
await unpublishAffordances(p);
expect(existsSync(affordanceDirFor(p))).toBe(false);
});
test('leaves the PUBLISHER alone — the thing that would silently strip every sibling', async () => {
const a = withAffordance('test-gitea');
const b = withAffordance('test-music');
await publishAffordances(a);
await publishAffordances(b);
await unpublishAffordances(a);
expect(existsSync(affordanceDirFor(b))).toBe(true);
});
test('is safe when nothing was ever published', async () => {
await unpublishAffordances(plugin('test-never'));
});
});
-62
View File
@@ -1,62 +0,0 @@
import { cp, readdir, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { AFFORDANCES_DIR } from './load';
import type { DiscoveredPlugin } from '../plugins/manifest';
// Copying a plugin's affordances into the store the agent reads.
//
// A plugin is a new SURFACE arriving at runtime, which is the case hand-written prose could never cover:
// install gitea and no agent learns that `tea` is authenticated, or that `git` is the better tool for
// what git already does. The plugin knows; nothing was carrying that across.
//
// Same shape as `publishAssets` — a directory in the plugin repository, copied to a platform-managed
// place on install, removed on uninstall. Deliberately so: two mechanisms for "the plugin ships some
// files" would drift, and this one is already understood.
//
// ── Why the destination is namespaced ──
//
// `<publisher>/<appName>/`, mirroring `mountPrefix`. Two plugins called `gitea` from different publishers
// must not overwrite each other's affordances, and an affordance in the tree should say whose it is
// without opening it. `core` occupies the appName slot for the platform's own.
/** Where a plugin's affordances land. `officerdev/gitea/`, beside `officer/core/`. */
export const affordanceDirFor = (plugin: DiscoveredPlugin): string =>
join(AFFORDANCES_DIR, plugin.manifest.publisher, plugin.appName);
/** What the plugin ships, if anything. */
export const affordanceSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'affordances');
/**
* Publish a plugin's affordances. Returns the number of files, or 0 when it ships none — which is the
* ordinary case and not a failure.
*
* Idempotent by overwriting, so re-running an install republishes rather than erroring. It does NOT
* remove files the plugin has since dropped; that is what uninstall is for, and deleting on every
* install would make a half-finished copy destructive.
*/
export async function publishAffordances(plugin: DiscoveredPlugin): Promise<number> {
const source = affordanceSourceFor(plugin);
if (!existsSync(source)) return 0;
const dest = affordanceDirFor(plugin);
await cp(source, dest, { recursive: true, force: true });
try {
return ((await readdir(dest, { recursive: true })) as string[]).filter((f) => f.endsWith('.md')).length;
} catch {
return 0;
}
}
/**
* Drop a plugin's affordances.
*
* Removes the plugin's own directory, never the publisher's: `officerdev/` holds every first-party
* plugin's, and taking it out because one was uninstalled would silently strip the rest.
*
* Safe to call when nothing was ever published.
*/
export async function unpublishAffordances(plugin: DiscoveredPlugin): Promise<void> {
await rm(affordanceDirFor(plugin), { recursive: true, force: true });
}
-97
View File
@@ -1,97 +0,0 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { itemsDir } from '@@/data-path';
import { publishTasks, taskSourceFor, unpublishTasks } from './publish';
import type { DiscoveredPlugin } from '../../plugins/manifest';
/**
* A plugin shipping a task is how it reaches the file browser's context menu, so these are the two
* properties that matter: it arrives on install, and uninstalling takes back only what the plugin brought.
*
* The second is the one worth a test. `tasks/` is the OWNER'S directory — their own work lives beside
* whatever a plugin published, with nothing on disk marking which is which. A `rm -rf` of the wrong scope
* here destroys work no plugin ever created, which is not a failure a type checker can catch.
*
* Everything is prefixed `zz-publish-test-` and removed afterwards, because these write into the real
* items store rather than a fixture — `itemsDir` is derived from the install layout and cannot be pointed
* elsewhere for a test.
*/
const PREFIX = 'zz-publish-test-';
const made: string[] = [];
const plugin = (dir: string): DiscoveredPlugin => ({ appName: `${PREFIX}plugin`, dir }) as DiscoveredPlugin;
const seedPluginWithTasks = (names: string[]): string => {
const dir = mkdtempSync(join(tmpdir(), 'zz-plugin-'));
made.push(dir);
for (const name of names) {
const t = join(dir, 'tasks', name);
mkdirSync(t, { recursive: true });
writeFileSync(join(t, 'TASK.md'), `---\nname: ${name}\n---\n`);
}
return dir;
};
afterEach(() => {
for (const d of made.splice(0)) rmSync(d, { recursive: true, force: true });
// Anything this file could have written into the real store, by name.
for (const n of [`${PREFIX}one`, `${PREFIX}two`, `${PREFIX}owners-own`]) {
rmSync(join(itemsDir('tasks'), n), { recursive: true, force: true });
}
});
describe('publishTasks', () => {
test('a plugin with no tasks directory publishes nothing and does not fail', async () => {
const dir = mkdtempSync(join(tmpdir(), 'zz-plugin-'));
made.push(dir);
expect(await publishTasks(plugin(dir))).toBe(0);
});
test('tasks arrive in the items store, contents and all', async () => {
const dir = seedPluginWithTasks([`${PREFIX}one`, `${PREFIX}two`]);
expect(await publishTasks(plugin(dir))).toBe(2);
const landed = join(itemsDir('tasks'), `${PREFIX}one`, 'TASK.md');
expect(existsSync(landed)).toBe(true);
expect(readFileSync(landed, 'utf8')).toContain(`name: ${PREFIX}one`);
});
test('publishing twice overwrites rather than failing', async () => {
const dir = seedPluginWithTasks([`${PREFIX}one`]);
await publishTasks(plugin(dir));
expect(await publishTasks(plugin(dir))).toBe(1);
});
});
describe('unpublishTasks leaves the owner alone', () => {
// The whole reason this file exists.
test("removes only what the plugin shipped, never the owner's own task", async () => {
const ownersOwn = join(itemsDir('tasks'), `${PREFIX}owners-own`);
mkdirSync(ownersOwn, { recursive: true });
writeFileSync(join(ownersOwn, 'TASK.md'), '---\nname: hand written\n---\n');
const dir = seedPluginWithTasks([`${PREFIX}one`]);
await publishTasks(plugin(dir));
await unpublishTasks(plugin(dir));
expect(existsSync(join(itemsDir('tasks'), `${PREFIX}one`))).toBe(false);
expect(existsSync(join(ownersOwn, 'TASK.md'))).toBe(true);
});
test('a plugin whose source is already gone removes nothing', async () => {
const ownersOwn = join(itemsDir('tasks'), `${PREFIX}owners-own`);
mkdirSync(ownersOwn, { recursive: true });
// The directory the plugin was cloned into no longer exists — deleted before uninstall ran.
await unpublishTasks(plugin(join(tmpdir(), 'zz-plugin-does-not-exist')));
expect(existsSync(ownersOwn)).toBe(true);
});
test('the source path is the plugin directory, not the store', () => {
expect(taskSourceFor(plugin('/somewhere/plugins/x'))).toBe('/somewhere/plugins/x/tasks');
});
});
-73
View File
@@ -1,73 +0,0 @@
import { cp, readdir, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { itemsDir } from '@@/data-path';
import type { DiscoveredPlugin } from '../../plugins/manifest';
// Publishing a plugin's tasks, the fourth of these and the same shape as assets, affordances,
// notification catalogues and skills.
//
// ── Why a plugin needs to ship a task at all ──
//
// A task is how something reaches the FILE BROWSER'S CONTEXT MENU. `TriggerConfig` is
// `{ type: 'file'; extensions: string[] } | { type: 'directory' }`, and `matchesTrigger` in
// `officerdev/src/apps/FileBrowser/useTasks.ts` is what decides which entries appear on a right-click. So
// a plugin that wants to offer "transcribe this" on an mp3 does not need a platform change, a menu
// registry or a new frontend hook — it needs a task with the right trigger, and that already works.
//
// What was missing was only the delivery: every other publishable thing had a publisher and tasks did
// not, so the capability had to be installed by hand alongside the plugin and removed by hand after.
//
// ── Flat, not namespaced by publisher ──
//
// Same trade as skills, for the same reason and with the same limit. A task's directory name is its
// identity everywhere — `taskDirName` in a job row, the path a script runner materialises, the thing the
// owner sees in a run history. Wrapping that in `officerdev/whisper/` would put a publisher in every one
// of those places for the benefit of a collision nobody has had. Collisions resolve first-come, which is
// the rule the items store has always used.
//
// That is acceptable because a task claims no authority — it is an entry in a menu, gated by the same
// permission the tasks surface already carries. It is NOT acceptable for affordances, which is why those
// are namespaced: an affordance that could claim `officer/core:` would be claiming the platform's voice.
const tasksDir = () => itemsDir('tasks');
/** What the plugin ships, if anything. */
export const taskSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'tasks');
/** Publish a plugin's tasks. Returns the number of directories, or 0 when it ships none. */
export async function publishTasks(plugin: DiscoveredPlugin): Promise<number> {
const source = taskSourceFor(plugin);
if (!existsSync(source)) return 0;
await cp(source, tasksDir(), { recursive: true, force: true });
try {
return (await readdir(source, { withFileTypes: true })).filter((e) => e.isDirectory()).length;
} catch {
return 0;
}
}
/**
* Drop a plugin's tasks.
*
* Removes only the directories THIS plugin shipped, read from its own source tree — never everything
* under `tasks/`, which holds the owner's own work. A task the owner wrote by hand has no marker saying
* so, so the plugin's source tree is the only honest record of what came from the plugin.
*
* The consequence is worth stating: if the owner EDITS a task a plugin shipped, uninstalling still
* removes it, because it is still one of the names the plugin published. That matches skills and is the
* behaviour to keep — the alternative is orphans nobody can attribute.
*/
export async function unpublishTasks(plugin: DiscoveredPlugin): Promise<void> {
const source = taskSourceFor(plugin);
if (!existsSync(source)) return;
let dirs: string[];
try {
dirs = (await readdir(source, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
} catch {
return;
}
for (const dir of dirs) await rm(join(tasksDir(), dir), { recursive: true, force: true });
}
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadSkillsAcross } from './skills/load';
import { loadAffordancesAcross } from './affordances/load';
import { loadPluginEntries } from './notifications/catalogue';
/**
* A plugin's capabilities are READ where they live rather than copied into `capabilities/`
* (docs/plugin-capabilities.md). These pin the three properties that were not obvious.
*
* `capabilityRoots` itself is not tested here — it reads the real database and the real plugin
* directory, so a test of it would assert whatever this machine happens to have installed. What is worth
* pinning is what the loaders do with a list once they have one, and that is pure.
*/
const seed = (files: Record<string, string>): string => {
const dir = mkdtempSync(join(tmpdir(), 'zz-caps-'));
for (const [rel, body] of Object.entries(files)) {
const abs = join(dir, rel);
mkdirSync(join(abs, '..'), { recursive: true });
writeFileSync(abs, body);
}
return dir;
};
const skill = (name: string, desc: string) => `---\nname: ${name}\ndescription: ${desc}\n---\n\nbody\n`;
describe('skills merge across sources', () => {
test('a plugin directory contributes its skills', () => {
const owner = seed({ 'mine/SKILL.md': skill('Mine', 'the owner wrote this') });
const plugin = seed({ 'theirs/SKILL.md': skill('Theirs', 'a plugin shipped this') });
try {
const ids = loadSkillsAcross([owner, plugin]).map((s) => s.id).sort();
expect(ids).toEqual(['mine', 'theirs']);
} finally {
rmSync(owner, { recursive: true, force: true });
rmSync(plugin, { recursive: true, force: true });
}
});
// Precedence, and the reason for it: a plugin must not be able to take over the meaning of a name its
// owner is already using, just by picking the same directory name.
test("the owner's own wins a name collision", () => {
const owner = seed({ 'deploy/SKILL.md': skill('Deploy', 'the owner version') });
const plugin = seed({ 'deploy/SKILL.md': skill('Deploy', 'the plugin version') });
try {
const found = loadSkillsAcross([owner, plugin]);
expect(found).toHaveLength(1);
expect(found[0]!.description).toBe('the owner version');
} finally {
rmSync(owner, { recursive: true, force: true });
rmSync(plugin, { recursive: true, force: true });
}
});
// Which is what makes disable mean disable: the caller simply stops passing that root.
test('dropping a source drops its skills', () => {
const owner = seed({ 'mine/SKILL.md': skill('Mine', 'kept') });
const plugin = seed({ 'theirs/SKILL.md': skill('Theirs', 'gone when disabled') });
try {
expect(loadSkillsAcross([owner, plugin])).toHaveLength(2);
expect(loadSkillsAcross([owner])).toHaveLength(1);
} finally {
rmSync(owner, { recursive: true, force: true });
rmSync(plugin, { recursive: true, force: true });
}
});
test('a source that does not exist is skipped, not fatal', () => {
const owner = seed({ 'mine/SKILL.md': skill('Mine', 'kept') });
try {
expect(loadSkillsAcross([owner, join(tmpdir(), 'zz-caps-nope')])).toHaveLength(1);
} finally {
rmSync(owner, { recursive: true, force: true });
}
});
});
describe('affordances are not de-duplicated', () => {
// Unlike skills: an affordance is guidance, several can be true at once, and two plugins describing the
// same surface differently is information rather than a collision.
test('both sources contribute even with the same file name', () => {
const a = seed({ 'x/one.md': '---\nname: A\ndescription: from a\n---\nbody a\n' });
const b = seed({ 'y/one.md': '---\nname: B\ndescription: from b\n---\nbody b\n' });
try {
expect(loadAffordancesAcross([a, b])).toHaveLength(2);
} finally {
rmSync(a, { recursive: true, force: true });
rmSync(b, { recursive: true, force: true });
}
});
});
describe('notification codes come from the manifest, not the path', () => {
// The whole reason loadPluginEntries exists. Copied to capabilities/ the path carried
// <publisher>/<appName>; read in place it carries nothing, so the identity has to travel separately.
test('a plugin in its own directory still gets a namespaced code', () => {
const dir = seed({ 'notifications/catalogue.yaml': "nowPlaying:\n title: 'Now playing'\n" });
try {
const entries = loadPluginEntries({ appName: 'music', dir, manifest: { publisher: 'officerdev' } });
expect(Object.keys(entries)).toEqual(['officerdev/music:nowPlaying']);
expect(entries['officerdev/music:nowPlaying']!.thread).toBe('officerdev/music');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// The property that must survive the move: a plugin cannot claim the platform's voice by writing a
// core-looking key into its own yaml.
test('a yaml key cannot claim another namespace', () => {
const dir = seed({ 'notifications/c.yaml': "officer/core:test:\n title: 'Nice try'\n" });
try {
const keys = Object.keys(loadPluginEntries({ appName: 'x', dir, manifest: { publisher: 'someone' } }));
expect(keys).toEqual(['someone/x:officer/core:test']);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+97
View File
@@ -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 [];
}
}
+59 -2
View File
@@ -44,6 +44,8 @@ export type NotificationEntry = {
thread?: string;
};
export type PluginCatalogueSource = { appName: string; dir: string; manifest: { publisher: string } };
export type Catalogue = {
/**
* Changes whenever the aggregate does. A CONTENT HASH, not a counter.
@@ -195,6 +197,58 @@ export function loadPublishedEntries(root: string = NOTIFICATIONS_DIR): Record<s
return entries;
}
/**
* A single plugin's catalogue, read from its OWN directory.
*
* Separate from `loadPublishedEntries` because the namespace has to come from somewhere else. That
* function derives `<publisher>/<appName>` from the path, which worked while installing COPIED a
* plugin's yaml to `capabilities/notifications/<publisher>/<appName>/`. Read in place the path is just
* `notifications/catalogue.yaml` and carries no identity at all — so the codes would silently vanish,
* which is the failure this exists to prevent.
*
* The manifest supplies it instead, and that is strictly better provenance: `publisher` and `appName` are
* validated at discovery, where the path was only ever a directory somebody chose. The property that
* matters is unchanged — the platform imposes the namespace and the yaml cannot claim one. Writing
* `officer/core:test` as a key still produces `<you>/<yourplugin>:officer/core:test`.
*/
export function loadPluginEntries(plugin: {
appName: string;
dir: string;
manifest: { publisher: string };
}): Record<string, NotificationEntry> {
const root = join(plugin.dir, 'notifications');
let files: string[];
try {
files = (readdirSync(root, { recursive: true }) as string[]).filter((f) => /\.ya?ml$/.test(f));
} catch {
return {}; // shipping none is the ordinary case
}
const { publisher } = plugin.manifest;
const entries: Record<string, NotificationEntry> = {};
for (const rel of files.sort()) {
let doc: unknown;
try {
doc = YAML.parse(readFileSync(join(root, rel), 'utf8'));
} catch {
continue;
}
if (doc === null || typeof doc !== 'object') continue;
for (const [key, value] of Object.entries(doc as Record<string, unknown>)) {
const entry = coerce(value);
if (!entry) continue;
entries[`${publisher}/${plugin.appName}:${key}`] = {
...entry,
thread: entry.thread ?? `${publisher}/${plugin.appName}`,
};
}
}
return entries;
}
/**
* The whole catalogue: the platform's own plus everything installed plugins publish.
*
@@ -202,8 +256,11 @@ export function loadPublishedEntries(root: string = NOTIFICATIONS_DIR): Record<s
* from its install path — so this only matters if the store is edited by hand, and the platform's own
* notifications are the ones that must keep working when it is.
*/
export function loadCatalogue(root: string = NOTIFICATIONS_DIR): Catalogue {
const entries = { ...loadPublishedEntries(root), ...CORE_ENTRIES };
export function loadCatalogue(root: string = NOTIFICATIONS_DIR, plugins: PluginCatalogueSource[] = []): Catalogue {
// Plugin entries first so the owner's own store, then core, still win a collision — the same precedence
// as before, with plugins now read from their own directories rather than copies.
const fromPlugins = Object.assign({}, ...plugins.map(loadPluginEntries));
const entries = { ...fromPlugins, ...loadPublishedEntries(root), ...CORE_ENTRIES };
// Sorted so the hash depends on content, not on the order the filesystem happened to return.
const canonical = JSON.stringify(
-58
View File
@@ -1,58 +0,0 @@
import { cp, readdir, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { NOTIFICATIONS_DIR } from './catalogue';
import type { DiscoveredPlugin } from '../plugins/manifest';
// Copying a plugin's notification catalogue into the store the sidecar reads.
//
// Deliberately the same shape as `publishAffordances` — a directory in the plugin repository, copied to a
// platform-managed place on install, removed on uninstall. Two mechanisms for "the plugin ships some
// files" would drift, and that one is already understood.
//
// The destination namespace is what makes codes safe: `<publisher>/<appName>/`, mirroring `mountPrefix`,
// so a plugin's codes are stamped with its provenance by WHERE IT LANDS rather than by anything it
// declares. See `catalogue.ts`.
/** Where a plugin's catalogue lands. `officerdev/music/`, beside `officer/core`'s built-in entries. */
export const notificationDirFor = (plugin: DiscoveredPlugin): string =>
join(NOTIFICATIONS_DIR, plugin.manifest.publisher, plugin.appName);
/** What the plugin ships, if anything. */
export const notificationSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'notifications');
/**
* Publish a plugin's catalogue. Returns the number of files, or 0 when it ships none — the ordinary case.
*
* Idempotent by overwriting, so re-running an install republishes rather than erroring. It does NOT remove
* files the plugin has since dropped; that is uninstall's job, and deleting on every install would make a
* half-finished copy destructive.
*/
export async function publishNotifications(plugin: DiscoveredPlugin): Promise<number> {
const source = notificationSourceFor(plugin);
if (!existsSync(source)) return 0;
const dest = notificationDirFor(plugin);
await cp(source, dest, { recursive: true, force: true });
try {
return ((await readdir(dest, { recursive: true })) as string[]).filter((f) => /\.ya?ml$/.test(f)).length;
} catch {
return 0;
}
}
/**
* Drop a plugin's catalogue.
*
* Removes the plugin's own directory, never the publisher's: `officerdev/` holds every first-party
* plugin's, and taking it out because one was uninstalled would strip the rest.
*
* The codes then stop resolving, which is a defined state rather than a broken one — a device holding a
* queued push for a departed plugin renders the unknown-code fallback. See docs/push-notifications.md.
*
* Safe to call when nothing was ever published.
*/
export async function unpublishNotifications(plugin: DiscoveredPlugin): Promise<void> {
await rm(notificationDirFor(plugin), { recursive: true, force: true });
}
-55
View File
@@ -17,10 +17,6 @@ import { discoverPlugins } from './discover';
import { fetchPlugin } from './fetch';
import { revokeGrantsForPermissions } from 'officerdb';
import { publishAssets, unpublishAssets } from '../app-store/assets';
import { publishAffordances, unpublishAffordances } from '../affordances/publish';
import { publishNotifications, unpublishNotifications } from '../notifications/publish';
import { publishSkills, unpublishSkills } from '../skills/publish';
import { publishTasks, unpublishTasks } from '../api/tasks/publish';
import { refreshPluginMounts, snapshotPlugins } from './mount';
import { publishExtension, unpublishExtension, extensionFileName } from './zip';
import { generatePluginSchemas, pushPluginSchema } from './schema';
@@ -403,53 +399,6 @@ export async function installPlugin(
await step(steps, onStep, `extension: published to /plugins/${appName}/${file}`);
}
// What this plugin needs an AGENT to know about itself. A plugin is a new surface arriving at
// runtime, and nothing else carries that across: install gitea and no agent learns that `tea` is
// authenticated unless the plugin says so. Same shape as the assets above — a directory in the
// repository, copied to a platform-managed place.
//
// Not fatal. An affordance that failed to copy costs an agent some knowledge; refusing the install
// over it would cost the owner the plugin.
try {
const published = await publishAffordances(plugin);
if (published) {
await step(steps, onStep, `affordances: ${published} published to capabilities/affordances/`);
}
} catch (err) {
await step(steps, onStep, `affordances: NOT published — ${err instanceof Error ? err.message : err}`);
}
// Same bargain for the notification catalogue: a plugin that cannot publish its codes still installs,
// and its pushes render the unknown-code fallback on the device rather than nothing at all.
try {
const published = await publishNotifications(plugin);
if (published) {
await step(steps, onStep, `notifications: ${published} published to capabilities/notifications/`);
}
} catch (err) {
await step(steps, onStep, `notifications: NOT published — ${err instanceof Error ? err.message : err}`);
}
// Same bargain again: a plugin that cannot publish its skills still installs. The agent simply does
// not learn the reference exists, which is the state every plugin was in before this.
try {
const published = await publishSkills(plugin);
if (published) await step(steps, onStep, `skills: ${published} published to capabilities/skills/`);
} catch (err) {
await step(steps, onStep, `skills: NOT published — ${err instanceof Error ? err.message : err}`);
}
// And the same for tasks, which is how a plugin reaches the FILE BROWSER'S CONTEXT MENU. A task with a
// file trigger appears on a right-click with no platform change at all — `matchesTrigger` in
// officerdev/src/apps/FileBrowser/useTasks.ts already does that work. What was missing was only the
// delivery, so the capability had to be installed by hand beside the plugin and removed by hand after.
try {
const published = await publishTasks(plugin);
if (published) await step(steps, onStep, `tasks: ${published} published to capabilities/tasks/`);
} catch (err) {
await step(steps, onStep, `tasks: NOT published — ${err instanceof Error ? err.message : err}`);
}
// The tile, for the person who just installed it. `plugin.web` because `pluginDockManifests` only
// offers a tile for a plugin that has a frontend — a backend-only plugin has no screen to pin.
if (plugin.web && forUserId !== undefined) {
@@ -547,12 +496,8 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?:
// Same reasoning: a copy, with the original still in the plugin's directory. Removed so an agent stops
// being told about a surface that is no longer there — an affordance for an uninstalled plugin is
// worse than none, because it reads as true.
if (plugin) await unpublishAffordances(plugin).catch(() => {});
// And its notification codes, which stop resolving rather than resolving to something stale. A device
// still holding a queued push renders the unknown-code fallback, which is a defined state.
if (plugin) await unpublishNotifications(plugin).catch(() => {});
if (plugin) await unpublishSkills(plugin).catch(() => {});
if (plugin) await unpublishTasks(plugin).catch(() => {});
await step(steps, onStep, 'tables and data: untouched (published assets, affordances, notifications, skills and tasks removed)');
return { ok: true, appName, steps };
}
+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' },
+28 -2
View File
@@ -1,6 +1,7 @@
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { OFFICER_ITEMS_DIR } from '../data-path';
import { capabilityRoots } from '../capability-sources';
// Skills: reference the agent reads WHEN IT NEEDS IT, not on every turn.
//
@@ -104,6 +105,31 @@ export function compileSkills(skills: Skill[]): string | null {
}
/** Read and compile in one step, for callers that only want the string. */
export function skillsBlock(root: string = SKILLS_DIR): string | null {
return compileSkills(loadSkills(root));
/**
* Every skill on offer: the owner's own, plus each installed and enabled plugin's own directory.
*
* A plugin's skills are read WHERE THEY LIVE rather than copied into `capabilities/` — see
* `capability-sources.ts` for why, and `docs/plugin-capabilities.md` for the whole argument.
*
* **The owner's own win a name collision**, because `capabilityRoots` puts their directory first and this
* keeps the first of each id. That is the right precedence: a skill somebody wrote deliberately should
* not be displaced by a plugin that happened to pick the same directory name, and a plugin cannot
* silently take over the meaning of a name already in use.
*/
export function loadSkillsAcross(roots: string[]): Skill[] {
const byId = new Map<string, Skill>();
for (const root of roots) {
for (const skill of loadSkills(root)) if (!byId.has(skill.id)) byId.set(skill.id, skill);
}
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
}
/**
* Read and compile in one step, for callers that only want the string.
*
* Async because resolving which plugins are enabled reads the database. Called once per session, which is
* what makes that affordable — see the note in `capability-sources.ts`.
*/
export async function skillsBlock(): Promise<string | null> {
return compileSkills(loadSkillsAcross(await capabilityRoots('skills')));
}
-52
View File
@@ -1,52 +0,0 @@
import { cp, readdir, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { SKILLS_DIR } from './load';
import type { DiscoveredPlugin } from '../plugins/manifest';
// Publishing a plugin's skills, the same shape as affordances and notification catalogues.
//
// Deliberately NOT namespaced by publisher, unlike those two. A skill's directory name is what appears in
// the index and what the agent reads a path to, and `officerdev/gitea/SKILL.md` in a prompt is noise for
// a human and for a model. Collisions are handled by the loader taking whichever sorts first, which is
// the same rule the items store has always used — one flat namespace, first come.
//
// That trade is only acceptable because a skill is REFERENCE. An affordance's namespace matters because a
// plugin must not be able to claim `officer/core:`; a skill has no authority to usurp.
export const skillDirFor = (plugin: DiscoveredPlugin): string => join(SKILLS_DIR, plugin.appName);
/** What the plugin ships, if anything. */
export const skillSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'skills');
/** Publish a plugin's skills. Returns the number of directories, or 0 when it ships none. */
export async function publishSkills(plugin: DiscoveredPlugin): Promise<number> {
const source = skillSourceFor(plugin);
if (!existsSync(source)) return 0;
await cp(source, SKILLS_DIR, { recursive: true, force: true });
try {
return (await readdir(source, { withFileTypes: true })).filter((e) => e.isDirectory()).length;
} catch {
return 0;
}
}
/**
* Drop a plugin's skills.
*
* Removes only the directories THIS plugin shipped, read from its own source tree — not everything under
* `skills/`, which holds the platform's own and every other plugin's.
*/
export async function unpublishSkills(plugin: DiscoveredPlugin): Promise<void> {
const source = skillSourceFor(plugin);
if (!existsSync(source)) return;
let dirs: string[];
try {
dirs = (await readdir(source, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
} catch {
return;
}
for (const dir of dirs) await rm(join(SKILLS_DIR, dir), { recursive: true, force: true });
}