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
110 lines
6.7 KiB
Markdown
110 lines
6.7 KiB
Markdown
# A plugin's capabilities are LOADED, never copied
|
|
|
|
**Status: built.** Skills, affordances and notification catalogues landed in `2026.08.23.06`; tasks — the
|
|
read-write one, and the last piece — landed on 2026-08-24. The four publishers are gone.
|
|
|
|
## The problem, as it is on disk right now
|
|
|
|
`capabilities/` is a git repository. The intent is that anyone can fork it, keep their own tasks, skills
|
|
and affordances in it, and pull core's on top. That only works if the directory contains exactly two
|
|
things: what core ships, and what the owner wrote.
|
|
|
|
It does not. Four publishers copy a plugin's files into it at install time — assets, affordances,
|
|
notification catalogues and skills — so a fork accumulates files it did not author. On this machine today
|
|
`skills/email-db/` and `extensions/` sit untracked in that repository, published there by plugins. Commit
|
|
them and the fork carries another plugin's work; leave them and `git status` is permanently noisy. There
|
|
is no third answer, because nothing on disk says which files came from where.
|
|
|
|
Copying is also wrong in a second way that has nothing to do with git. **A copy goes stale.**
|
|
`plugins:update` pulls new plugin code and the copied capabilities keep describing the old version until
|
|
something republishes them. And a copy survives `plugins:disable`, so a disabled plugin's skill stays in
|
|
the agent's index — the plugin is off and the agent still believes it is there.
|
|
|
|
## The design
|
|
|
|
**A plugin keeps its capabilities in its own directory, and the platform reads them where they are.**
|
|
|
|
```
|
|
capabilities/ the owner's fork: core's, plus their own. Nothing else, ever.
|
|
tasks/ skills/ affordances/ notifications/
|
|
|
|
plugins/<name>/ the plugin's own repository, cloned on install
|
|
tasks/ skills/ affordances/ notifications/
|
|
```
|
|
|
|
Nothing is copied. Nothing needs a namespace to keep the two apart, because they are already apart —
|
|
which is the whole reason this beats scoping the paths. A namespace makes provenance _readable_; separate
|
|
directories make it _structural_.
|
|
|
|
Four consequences, and each of them is a bug that stops existing:
|
|
|
|
- **A fork stays clean.** `capabilities/` contains only what its owner is responsible for, so pulling from
|
|
upstream merges without touching anything a plugin put there.
|
|
- **Capabilities update with the plugin.** `plugins:update` is a `git pull` in the plugin's directory, and
|
|
the capabilities are in that directory. There is nothing left to go stale.
|
|
- **Disable actually disables.** The load list is built from plugins that are installed AND enabled, so a
|
|
disabled plugin contributes nothing. Today its copies remain.
|
|
- **Uninstall needs no cleanup step.** The directory goes and its capabilities go with it. No list of
|
|
filenames to be careful with, and no way to delete the owner's own work by getting that list wrong.
|
|
|
|
## What has to change
|
|
|
|
**The loaders take a list of roots.** `loadSkills(root)` and `loadAffordances(root)` already take one with
|
|
a default, so this is a small change rather than a rewrite. Notifications the same.
|
|
|
|
**The list is resolved per session, not at module load.** `affordanceBlock()` and `skillsBlock()` are
|
|
called once per session in `sidecar/claude/claude-manager.ts` — deliberately, so a capability written an
|
|
hour ago is offered on the next session. That is also why they can afford to ask the database which
|
|
plugins are installed: once per session, not once per turn.
|
|
|
|
**Tasks are the awkward one, because that store is read-write.** `api/tasks/task-files.ts` creates, edits
|
|
and deletes as well as reads. Reading must span every source; writing must only ever touch the owner's
|
|
directory. A plugin's task is read-only by nature and the UI has to know, or it will offer an Edit that
|
|
silently writes into a directory the next `plugins:update` overwrites.
|
|
|
|
A task's directory name is also stored data — `taskDirName` in `pipeline_jobs` — so lookup by name has to
|
|
search every source rather than assuming one root.
|
|
|
|
**How it went in, since it turned out to be one function rather than several.** Every path that runs a
|
|
task — the file browser's context menu, both runner sockets, the job queue, the pipeline executor —
|
|
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 becomes runnable everywhere at once with its identity
|
|
unchanged: still a directory name, still what `pipeline_jobs` stores.
|
|
|
|
The record carries `plugin` — the app name, or null for the owner's own. 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 replaces the edit and delete controls with a line saying whose it is. Without the
|
|
delete guard the old code 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.
|
|
|
|
`getTaskByDirName` also refuses a name containing a separator. It always joined a caller-supplied string
|
|
onto a path; searching several roots is the moment that stops being one directory nobody could escape.
|
|
|
|
**The four publishers are deleted**, along with their install and uninstall wiring:
|
|
`affordances/publish.ts`, `notifications/publish.ts`, `skills/publish.ts`, `api/tasks/publish.ts`. The
|
|
last was written on 2026-08-23 and is superseded before it ever shipped.
|
|
|
|
`app-store/assets` is a different case and stays. Those are images served over HTTP from a
|
|
platform-managed path, not capabilities read from disk, and the copy is what makes them reachable.
|
|
|
|
## The migration
|
|
|
|
One-off, and it is deletion rather than movement. The copies already in `capabilities/` have nothing to
|
|
remove them once the publishers are gone — `skills/email-db/` and `extensions/` on this machine, and
|
|
whatever the equivalent is per host. They are copies of files that still exist in each plugin's own
|
|
directory, so deleting them loses nothing.
|
|
|
|
Check each host rather than assuming this one is representative.
|
|
|
|
## What this does not decide
|
|
|
|
**Where the owner's own capabilities live inside their fork.** Flat at the top of each type directory is
|
|
what happens today and needs no change. Scoping them is a separate question and this design does not
|
|
force it either way.
|
|
|
|
**Whether a plugin's capabilities should be namespaced among themselves.** Two plugins can still ship a
|
|
task with the same directory name. The tie-break shipped as first-come with the owner's store searched
|
|
first, which settles the case that matters — a plugin cannot take over a name its owner is already
|
|
using — and leaves two plugins colliding with each other resolved by discovery order, which is arbitrary.
|
|
Worth revisiting when a second plugin ships a task; not before.
|