design: plugin capabilities are loaded in place, not copied

capabilities/ is meant to be forked — core's work plus the owner's, and nothing
else. Four publishers copy plugin files into it at install, so a fork
accumulates files it did not author with nothing on disk saying which. On this
machine skills/email-db/ and extensions/ are sitting untracked in that
repository right now, and there is no good answer: commit them and the fork
carries another plugin's work, leave them and git status is permanently noisy.

Copying is wrong a second way that has nothing to do with git. A copy goes
stale — plugins:update pulls new plugin code while the copied capability keeps
describing the old one — and a copy survives plugins:disable, so a disabled
plugin's skill stays in the agent's index.

So the platform reads them where they are: the capabilities directory, plus each
installed and enabled plugin's own. Nothing is copied, and nothing needs a
namespace to stay apart because separate directories already do that
structurally rather than by convention.

Four bugs stop existing: forks stay clean, capabilities update with the plugin,
disable actually disables, and uninstall needs no cleanup — so there is no list
of filenames to get wrong and delete the owner's work with.

Deletes all four publishers when built, including api/tasks/publish.ts from
earlier today, superseded before it shipped anywhere. app-store/assets stays —
those are images served over HTTP, and the copy is what makes them reachable.

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:11:19 +00:00
co-authored by Claude Opus 5
parent 99149443b7
commit 6572fa90f0
2 changed files with 96 additions and 0 deletions
+4
View File
@@ -537,6 +537,10 @@ explaining why it was safe.
- `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing
inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing
anything it already rules out
- `docs/plugin-capabilities.md` — **designed, not built**: a plugin's tasks, skills and affordances are
READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it
goes stale on update and survives a disable, and why separate directories beat namespaced paths —
a namespace makes provenance readable, separate directories make it structural
- `docs/filesystem-quotas.md` — **planned, not done**: the host-side half of per-user and per-team storage
limits. Why the kernel has to enforce it rather than the platform (members have `terminal` and can write
past anything we count), why `usrquota`+`grpquota` and not project quotas, and the one thing easiest to
+92
View File
@@ -0,0 +1,92 @@
# A plugin's capabilities are LOADED, never copied
**Status: designed, not built.** What exists today is the copying this replaces.
## 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.
**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, 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.