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
This commit is contained in:
2026-08-24 04:20:18 +00:00
co-authored by Claude Opus 5
parent 50e96e0d77
commit 6d673a1321
14 changed files with 259 additions and 64 deletions
+23 -6
View File
@@ -1,6 +1,7 @@
# A plugin's capabilities are LOADED, never copied
**Status: designed, not built.** What exists today is the copying this replaces.
**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
@@ -32,8 +33,8 @@ plugins/<name>/ the plugin's own repository, cloned on instal
```
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*.
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:
@@ -64,6 +65,21 @@ 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.
@@ -87,6 +103,7 @@ what happens today and needs no change. Scoping them is a separate question and
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, and the loader has to break that tie — first-come, as the items store
has always done, or by qualifying with the plugin name at read time. Worth settling when a second plugin
ships a task; not before.
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.