# 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// 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.