Files
platform/docs/plugin-capabilities.md
T
pastilhasandClaude Opus 5 6d673a1321 plugins: a plugin's tasks are read where they live
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
2026-08-24 04:20:18 +00:00

6.7 KiB

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.