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:
@@ -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} />}
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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}}…",
|
||||
|
||||
@@ -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}}…",
|
||||
|
||||
@@ -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}}…",
|
||||
|
||||
@@ -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}}…",
|
||||
|
||||
@@ -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}}…",
|
||||
|
||||
Reference in New Issue
Block a user