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
@@ -24,6 +24,8 @@ export type CapabilityDetail = CapabilitySummary & {
rawFrontmatter: string;
filePath: string;
chatSessionId: string | null;
/** Only tasks carry this today: the plugin that ships it, or null/absent when it is the owner's own. */
plugin?: string | null;
};
type CapabilityListProps = {
@@ -538,7 +540,17 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
<span className="text-sm font-medium text-duck-dark/70 flex-1">
{detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })}
</span>
{detail && (
{/*
A plugin's capability is read where it lives, in a git checkout that `plugins:update`
pulls. So there is nothing to offer here: an edit would be overwritten by the next update,
and the delete refuses server-side (409). Say whose it is instead of showing two controls
that cannot work.
*/}
{detail?.plugin ? (
<span className="text-xs text-duck-dark/40">
{t('capability.fromPlugin', { plugin: detail.plugin })}
</span>
) : detail ? (
<>
<button
onClick={() => setEditing(!editing)}
@@ -553,7 +565,7 @@ export const CapabilityPage = ({ kind, endpoint, queryKey, basePath }: Capabilit
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 hover:text-red-500" />
</button>
</>
)}
) : null}
</div>
<div className="overflow-y-auto flex-1 p-6">
{detail?.rawFrontmatter && <FrontmatterBlock yaml={detail.rawFrontmatter} />}