first half: the discovery and the two columns. no routes and no UI yet. PRESENCE IS THE DECLARATION. a plugin with commanders/<name>/COMMANDER.md offers one, and manifest.ts says nothing — same as tasks/ and skills/, and for the same reason: the directory already says it, and a second place to say it is a second place for the two to disagree. read in place through capabilitySources, so only an installed AND ENABLED plugin offers anything and plugins:update changes a template without republishing. A TEMPLATE IS NOT A COMMANDER. what a plugin ships is an OFFER: nothing runs, nothing is scheduled, no directory exists until an owner creates one from it. that is the shape asked for — a plugin may propose recurring work, only a person may start it. `plugin` IS A COLUMN, not a reserved category name. provenance should be structural: a category called "av-club" is a string an owner can rename, merge or delete, and the day they do, a plugin's commanders quietly become ordinary ones with nothing on disk saying otherwise. it also survives an uninstall, which is the point — the commander is still there and the UI can say where it came from. `plugin_config` holds the owner's answers because the form has to be RE-OPENABLE. somebody changing which directory is watched should see the one it is watching, not an empty form. the watcher script is generated from this, so this is the source of truth rather than the script. inputs reuse TASK.md's vocabulary exactly, so the existing form renderer draws them and the existing validation applies. "which folder, which extensions, what output" is the same question a task asks. `missingInputs` is the configure-before-running rule, and it is checked at ARM time rather than at creation. a half-configured commander that exists and is visible is useful; an ARMED one is what would run somebody's disk against a blank setting. columns verified live after db:push: plugin text, plugin_config jsonb. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
103 lines
4.4 KiB
TypeScript
103 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' | 'commanders',
|
|
): 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 [];
|
|
}
|
|
}
|