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} />}
+1
View File
@@ -682,6 +682,7 @@
"frontmatter": "Frontmatter",
"noMatches": "Keine Treffer",
"emptyFile": "Leere Datei",
"fromPlugin": "Gehört zum Plugin {{plugin}} — schreibgeschützt",
"createFailed": "{{kind}} konnte nicht erstellt werden",
"deleteFailed": "{{kind}} konnte nicht gelöscht werden",
"searchPlaceholder": "{{kind}} durchsuchen…",
+1
View File
@@ -682,6 +682,7 @@
"frontmatter": "Frontmatter",
"noMatches": "No matches",
"emptyFile": "Empty file",
"fromPlugin": "Ships with the {{plugin}} plugin — read-only",
"createFailed": "Could not create the {{kind}}",
"deleteFailed": "Could not delete the {{kind}}",
"searchPlaceholder": "Search {{kind}}…",
+1
View File
@@ -682,6 +682,7 @@
"frontmatter": "Frontmatter",
"noMatches": "Sin coincidencias",
"emptyFile": "Archivo vacío",
"fromPlugin": "Viene del plugin {{plugin}} — solo lectura",
"createFailed": "No se pudo crear el {{kind}}",
"deleteFailed": "No se pudo eliminar el {{kind}}",
"searchPlaceholder": "Buscar {{kind}}…",
+1
View File
@@ -682,6 +682,7 @@
"frontmatter": "Frontmatter",
"noMatches": "Aucun résultat",
"emptyFile": "Fichier vide",
"fromPlugin": "Fourni par le plugin {{plugin}} — lecture seule",
"createFailed": "Impossible de créer le {{kind}}",
"deleteFailed": "Impossible de supprimer le {{kind}}",
"searchPlaceholder": "Rechercher {{kind}}…",
+1
View File
@@ -682,6 +682,7 @@
"frontmatter": "Frontmatter",
"noMatches": "Sem correspondências",
"emptyFile": "Ficheiro vazio",
"fromPlugin": "Vem do plugin {{plugin}} — só de leitura",
"createFailed": "Não foi possível criar o {{kind}}",
"deleteFailed": "Não foi possível eliminar o {{kind}}",
"searchPlaceholder": "Pesquisar {{kind}}…",
+1
View File
@@ -682,6 +682,7 @@
"frontmatter": "Frontmatter",
"noMatches": "Inga träffar",
"emptyFile": "Tom fil",
"fromPlugin": "Följer med plugin-programmet {{plugin}} — skrivskyddad",
"createFailed": "Det gick inte att skapa {{kind}}",
"deleteFailed": "Det gick inte att ta bort {{kind}}",
"searchPlaceholder": "Sök {{kind}}…",