From 6d673a1321ecb852616be457d1af885e6f1a6120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 24 Aug 2026 04:20:18 +0000 Subject: [PATCH] plugins: a plugin's tasks are read where they live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- CLAUDE.md | 2 +- docs/plugin-capabilities.md | 29 ++++- .../Screens/Dashboard/CapabilityPage.tsx | 16 ++- src/apps/officer-web/locales/de-DE.json | 1 + src/apps/officer-web/locales/en-GB.json | 1 + src/apps/officer-web/locales/es-ES.json | 1 + src/apps/officer-web/locales/fr-FR.json | 1 + src/apps/officer-web/locales/pt-PT.json | 1 + src/apps/officer-web/locales/sv-SE.json | 1 + src/servers/api/tasks/task-files.test.ts | 100 +++++++++++++++ src/servers/api/tasks/task-files.ts | 121 +++++++++++++----- src/servers/api/tasks/tasks.ts | 4 + src/servers/capability-sources.ts | 43 ++++--- .../src/apps/FileBrowser/useTasks.ts | 2 + 14 files changed, 259 insertions(+), 64 deletions(-) create mode 100644 src/servers/api/tasks/task-files.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 40f20f09..df0fb8e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -541,7 +541,7 @@ explaining why it was safe. its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem, a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its conversation. The CLAUDE.md in its own working directory is the living document, for free -- `docs/plugin-capabilities.md` — **designed, not built**: a plugin's tasks, skills and affordances are +- `docs/plugin-capabilities.md` — **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 diff --git a/docs/plugin-capabilities.md b/docs/plugin-capabilities.md index fe28945c..bda55f3b 100644 --- a/docs/plugin-capabilities.md +++ b/docs/plugin-capabilities.md @@ -1,6 +1,7 @@ # A plugin's capabilities are LOADED, never copied -**Status: designed, not built.** What exists today is the copying this replaces. +**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 @@ -32,8 +33,8 @@ plugins// the plugin's own repository, cloned on instal ``` 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*. +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: @@ -64,6 +65,21 @@ 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. @@ -87,6 +103,7 @@ what happens today and needs no change. Scoping them is a separate question and 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. +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. diff --git a/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx b/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx index c803051d..92430b38 100644 --- a/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx +++ b/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx @@ -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 {detail?.name ?? t('capability.selectOne', { kind: kind.toLowerCase() })} - {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 ? ( + + {t('capability.fromPlugin', { plugin: detail.plugin })} + + ) : detail ? ( <> - )} + ) : null}
{detail?.rawFrontmatter && } diff --git a/src/apps/officer-web/locales/de-DE.json b/src/apps/officer-web/locales/de-DE.json index 3a8197b8..58b983d8 100644 --- a/src/apps/officer-web/locales/de-DE.json +++ b/src/apps/officer-web/locales/de-DE.json @@ -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…", diff --git a/src/apps/officer-web/locales/en-GB.json b/src/apps/officer-web/locales/en-GB.json index 9efa42df..54a6e18b 100644 --- a/src/apps/officer-web/locales/en-GB.json +++ b/src/apps/officer-web/locales/en-GB.json @@ -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}}…", diff --git a/src/apps/officer-web/locales/es-ES.json b/src/apps/officer-web/locales/es-ES.json index c0662222..e4e923a2 100644 --- a/src/apps/officer-web/locales/es-ES.json +++ b/src/apps/officer-web/locales/es-ES.json @@ -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}}…", diff --git a/src/apps/officer-web/locales/fr-FR.json b/src/apps/officer-web/locales/fr-FR.json index c8a9b531..72449952 100644 --- a/src/apps/officer-web/locales/fr-FR.json +++ b/src/apps/officer-web/locales/fr-FR.json @@ -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}}…", diff --git a/src/apps/officer-web/locales/pt-PT.json b/src/apps/officer-web/locales/pt-PT.json index a1d147d1..87790a03 100644 --- a/src/apps/officer-web/locales/pt-PT.json +++ b/src/apps/officer-web/locales/pt-PT.json @@ -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}}…", diff --git a/src/apps/officer-web/locales/sv-SE.json b/src/apps/officer-web/locales/sv-SE.json index 63908e5a..a0920077 100644 --- a/src/apps/officer-web/locales/sv-SE.json +++ b/src/apps/officer-web/locales/sv-SE.json @@ -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}}…", diff --git a/src/servers/api/tasks/task-files.test.ts b/src/servers/api/tasks/task-files.test.ts new file mode 100644 index 00000000..badc95cb --- /dev/null +++ b/src/servers/api/tasks/task-files.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { listTasks, getTaskByDirName } from './task-files'; + +/** + * A plugin's tasks are read where they live (docs/plugin-capabilities.md), and the whole reason it lands + * in `task-files.ts` is that every runner resolves a task through these two functions. So these pin the + * properties the file browser's context menu depends on — a plugin's task is listed, is resolvable by the + * same directory name a menu click sends back, and carries its implementation. + * + * Sources are injected: resolving them for real reads the database and the plugin directory, which would + * make this assert whatever this machine happens to have installed. + */ + +const seed = (files: Record): string => { + const dir = mkdtempSync(join(tmpdir(), 'zz-tasks-')); + for (const [rel, body] of Object.entries(files)) { + const abs = join(dir, rel); + mkdirSync(join(abs, '..'), { recursive: true }); + writeFileSync(abs, body); + } + return dir; +}; + +const taskMd = (name: string, extra = '') => + `---\nname: ${name}\nmode: script\nlanguage: typescript\n${extra}---\n\nbody\n`; + +describe('tasks across sources', () => { + test("a plugin's task is listed and carries its provenance", async () => { + const owner = seed({ 'mine/TASK.md': taskMd('Mine') }); + const plugin = seed({ 'transcribe-audio-file/TASK.md': taskMd('Transcribe Audio File') }); + try { + const found = await listTasks([ + { dir: owner, plugin: null }, + { dir: plugin, plugin: 'transcribe' }, + ]); + expect(found.map((t) => [t.dirName, t.plugin])).toEqual([ + ['mine', null], + ['transcribe-audio-file', 'transcribe'], + ]); + } finally { + rmSync(owner, { recursive: true, force: true }); + rmSync(plugin, { recursive: true, force: true }); + } + }); + + // The context menu sends back a directory name and nothing else, so resolution has to find the plugin's + // copy by that name alone — otherwise the task is listed and then "not found" the moment it is clicked. + test("a plugin's task resolves by directory name, with its implementation", async () => { + const owner = seed({ 'mine/TASK.md': taskMd('Mine') }); + const plugin = seed({ + 'transcribe-audio-file/TASK.md': taskMd('Transcribe Audio File'), + 'transcribe-audio-file/index.ts': 'export const ok = true;\n', + }); + try { + const sources = [ + { dir: owner, plugin: null }, + { dir: plugin, plugin: 'transcribe' }, + ]; + const task = await getTaskByDirName('transcribe-audio-file', sources); + expect(task?.plugin).toBe('transcribe'); + expect(task?.implementation).toBe('export const ok = true;\n'); + } finally { + rmSync(owner, { recursive: true, force: true }); + rmSync(plugin, { recursive: true, force: true }); + } + }); + + // Same precedence as skills: a plugin cannot take over a name its owner is already using. + test("the owner's own wins a name collision", async () => { + const owner = seed({ 'deploy/TASK.md': taskMd('Deploy (owner)') }); + const plugin = seed({ 'deploy/TASK.md': taskMd('Deploy (plugin)') }); + try { + const sources = [ + { dir: owner, plugin: null }, + { dir: plugin, plugin: 'somebody' }, + ]; + expect(await listTasks(sources)).toHaveLength(1); + const task = await getTaskByDirName('deploy', sources); + expect(task?.name).toBe('Deploy (owner)'); + expect(task?.plugin).toBeNull(); + } finally { + rmSync(owner, { recursive: true, force: true }); + rmSync(plugin, { recursive: true, force: true }); + } + }); + + // A directory name arrives from a websocket message and a job row before it is joined onto a path. + test('a traversing directory name resolves to nothing', async () => { + const owner = seed({ 'mine/TASK.md': taskMd('Mine') }); + try { + const sources = [{ dir: join(owner, 'nested'), plugin: null }]; + expect(await getTaskByDirName('../mine', sources)).toBeNull(); + } finally { + rmSync(owner, { recursive: true, force: true }); + } + }); +}); diff --git a/src/servers/api/tasks/task-files.ts b/src/servers/api/tasks/task-files.ts index 5a7b271b..2f6dbf24 100644 --- a/src/servers/api/tasks/task-files.ts +++ b/src/servers/api/tasks/task-files.ts @@ -1,10 +1,28 @@ import { readdir, mkdir, rm } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { itemsDir, OFFICER_ITEMS_DIR } from '../../data-path'; +import { capabilitySources, type CapabilitySource } from '../../capability-sources'; // File-backed task store. Every task is a directory under $OFFICER_ITEMS_DIR/tasks// with a // TASK.md (metadata + prose body) and, for script-mode tasks, a sibling implementation file. There are // no scope tiers and no database — the directory name is the task's identity. +// +// ── Plus every enabled plugin's own tasks/ directory ── +// +// Read where they live, never copied in — the same rule as skills and affordances, and the same reasons +// (`capability-sources.ts`, `docs/plugin-capabilities.md`). It lands here rather than in each caller +// because EVERY execution path goes through `getTaskByDirName`: the context menu, the runner sockets, +// the job queue and the pipeline executor all resolve a task by its directory name and then read +// `implementation` off the record. Teaching this one function about plugin sources makes a plugin's task +// runnable everywhere at once, and leaves the identity — the directory name — exactly as it was. +// +// The owner's own store is searched FIRST, so a name the owner is already using cannot be taken over by +// a plugin that happened to pick the same directory name. +// +// Writing is a different matter: create, update and delete only ever touch the owner's store, and refuse +// a task that belongs to a plugin. A plugin's task lives in a git checkout that `plugins:update` pulls — +// editing it in place would be overwritten by the next update, and deleting it would make the plugin's +// own files disappear from a screen that says nothing about plugins. const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML; @@ -34,6 +52,8 @@ export type TaskRecord = TaskFrontmatter & { body: string; implementation: string | null; filePath: string; + /** The plugin that ships it, or null when it is the owner's own. Read-only when set. */ + plugin: string | null; }; export type TaskSummary = { @@ -45,6 +65,7 @@ export type TaskSummary = { inline: boolean | 'ask'; version: number; trigger: unknown; + plugin: string | null; }; // Frontmatter keys, in write order. These mirror the old `tasks` table columns 1:1. @@ -145,9 +166,8 @@ function buildTaskMd(fm: Partial, body: string): string { return lines.join('\n'); } -async function readImplementation(dirName: string, language: string | null): Promise { - const implPath = join(taskDir(dirName), implFileName(language)); - const file = Bun.file(implPath); +async function readImplementation(dir: string, language: string | null): Promise { + const file = Bun.file(join(dir, implFileName(language))); return (await file.exists()) ? file.text() : null; } @@ -170,42 +190,63 @@ export async function readCategoryOrder(): Promise { } } -export async function listTasks(): Promise { - let entries; - try { - entries = await readdir(tasksRoot(), { withFileTypes: true }); - } catch { - return []; +/** + * Every task on offer, the owner's own first. + * + * `sources` is injectable so this can be tested against temporary directories — resolving them for real + * reads the database and the plugin directory, which would make a test assert whatever this machine + * happens to have installed. + */ +export async function listTasks(sources?: CapabilitySource[]): Promise { + const roots = sources ?? (await capabilitySources('tasks')); + + const byDirName = new Map(); + for (const source of roots) { + let entries; + try { + entries = await readdir(source.dir, { withFileTypes: true }); + } catch { + continue; // most plugins ship no tasks at all — the ordinary case, not a fault + } + + for (const entry of entries) { + if (!entry.isDirectory() || byDirName.has(entry.name)) continue; + const file = Bun.file(join(source.dir, entry.name, 'TASK.md')); + if (!(await file.exists())) continue; + const { fm } = parseTaskMd(await file.text()); + byDirName.set(entry.name, { + dirName: entry.name, + name: fm.name || entry.name, + description: fm.description, + category: fm.category, + mode: fm.mode, + inline: fm.inline, + version: fm.version, + trigger: fm.trigger ?? [], + plugin: source.plugin, + }); + } } - const summaries: TaskSummary[] = []; - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const file = Bun.file(taskFile(entry.name)); - if (!(await file.exists())) continue; - const { fm } = parseTaskMd(await file.text()); - summaries.push({ - dirName: entry.name, - name: fm.name || entry.name, - description: fm.description, - category: fm.category, - mode: fm.mode, - inline: fm.inline, - version: fm.version, - trigger: fm.trigger ?? [], - }); - } - - summaries.sort((a, b) => a.name.localeCompare(b.name)); - return summaries; + return [...byDirName.values()].sort((a, b) => a.name.localeCompare(b.name)); } -export async function getTaskByDirName(dirName: string): Promise { - const file = Bun.file(taskFile(dirName)); - if (!(await file.exists())) return null; - const { fm, body } = parseTaskMd(await file.text()); - const implementation = await readImplementation(dirName, fm.language); - return { ...fm, dirName, body, implementation, filePath: taskFile(dirName) }; +export async function getTaskByDirName(dirName: string, sources?: CapabilitySource[]): Promise { + // A directory name reaches this from a websocket message and a job row, so it is caller-supplied and + // gets joined onto a path. Traversal would otherwise read any TASK.md on the disk. + if (!dirName || dirName.includes('/') || dirName.includes('\\') || dirName === '.' || dirName === '..') return null; + + const roots = sources ?? (await capabilitySources('tasks')); + for (const source of roots) { + const dir = join(source.dir, dirName); + const filePath = join(dir, 'TASK.md'); + const file = Bun.file(filePath); + if (!(await file.exists())) continue; + const { fm, body } = parseTaskMd(await file.text()); + const implementation = await readImplementation(dir, fm.language); + return { ...fm, dirName, body, implementation, filePath, plugin: source.plugin }; + } + return null; } type CreateTaskInput = { @@ -240,6 +281,7 @@ export async function createTask(input: CreateTaskInput): Promise { const existing = await getTaskByDirName(dirName); if (!existing) return null; + if (existing.plugin) throw new Error(`${dirName} belongs to the ${existing.plugin} plugin and is read-only`); const merged: TaskFrontmatter = { ...existing, ...patch }; const body = patch.body ?? existing.body; @@ -262,7 +305,15 @@ export async function updateTask( return getTaskByDirName(dirName); } +/** + * Only ever the owner's own copy. + * + * Without the guard this was quietly a no-op on a plugin's task — `rm -rf` on a path in the owner's store + * that does not exist, followed by a cheerful `{ ok: true }` and a task still in the menu. + */ export async function deleteTask(dirName: string): Promise { + const existing = await getTaskByDirName(dirName); + if (existing?.plugin) throw new Error(`${dirName} belongs to the ${existing.plugin} plugin and cannot be deleted`); await rm(taskDir(dirName), { recursive: true, force: true }); } diff --git a/src/servers/api/tasks/tasks.ts b/src/servers/api/tasks/tasks.ts index 14584eb9..b2376027 100644 --- a/src/servers/api/tasks/tasks.ts +++ b/src/servers/api/tasks/tasks.ts @@ -15,6 +15,7 @@ tasksRouter.get('/', async (ctx) => { category: row.category, triggers: (row.trigger as TriggerConfig[]) ?? [], mode: row.mode ?? 'agentic', + plugin: row.plugin, })); return ctx.json(tasks); @@ -47,6 +48,7 @@ tasksRouter.get('/:name', async (ctx) => { config: task.config, version: task.version, filePath: task.filePath, + plugin: task.plugin, }); }); @@ -74,6 +76,8 @@ tasksRouter.delete('/:name', async (ctx) => { const task = await getTaskByDirName(name); if (!task) return ctx.text('Not found', 404); + // A plugin's task is its own file in its own checkout. Uninstalling the plugin is how it goes away. + if (task.plugin) return ctx.text(`${name} belongs to the ${task.plugin} plugin — uninstall it instead`, 409); await deleteTask(task.dirName); return ctx.json({ ok: true }); diff --git a/src/servers/capability-sources.ts b/src/servers/capability-sources.ts index 6808e0d1..878c8dea 100644 --- a/src/servers/capability-sources.ts +++ b/src/servers/capability-sources.ts @@ -37,8 +37,14 @@ import { snapshotPlugins } from './plugins/mount'; * database work. A missing directory is skipped rather than erroring: most plugins ship no capabilities * at all, and that is the ordinary case rather than a fault. */ -export async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise { - const roots = [itemsDir(type as ItemType)]; +export type CapabilitySource = { + dir: string; + /** The plugin that ships it, or null for the owner's own store. */ + plugin: string | null; +}; + +export async function capabilitySources(type: ItemType | 'affordances' | 'notifications'): Promise { + const sources: CapabilitySource[] = [{ dir: itemsDir(type as ItemType), plugin: null }]; let states: Awaited>['states'] = []; try { @@ -46,34 +52,27 @@ export async function capabilityRoots(type: ItemType | 'affordances' | 'notifica } catch { // The owner's own capabilities still load. A database that cannot be read is a much louder problem // somewhere else, and losing the agent's whole skill index to it would only obscure that. - return roots; + return sources; } for (const state of states) { if (!state.install?.enabled) continue; const dir = join(state.plugin.dir, type); - if (existsSync(dir)) roots.push(dir); + if (existsSync(dir)) sources.push({ dir, plugin: state.plugin.appName }); } - return roots; + return sources; } /** - * The same list, for a caller that already holds a snapshot. + * The same list for a caller that only needs the paths. * - * Exists so a request doing several of these — the items rescan reads five types — pays for one database - * read rather than five. + * Which is most of them: a skill or an affordance is read, compiled into a preamble and never written + * to, so where it came from changes nothing. Tasks are the exception — one can be deleted, and deleting + * a plugin's file out of the owner's screen is not a thing the owner asked for — so `task-files.ts` + * takes the sources and keeps the provenance. */ -export function capabilityRootsFrom( - states: Awaited>['states'], - type: ItemType | 'affordances' | 'notifications', -): string[] { - const roots = [itemsDir(type as ItemType)]; - for (const state of states) { - if (!state.install?.enabled) continue; - const dir = join(state.plugin.dir, type); - if (existsSync(dir)) roots.push(dir); - } - return roots; +export async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise { + return (await capabilitySources(type)).map((s) => s.dir); } /** @@ -90,7 +89,11 @@ export async function pluginCatalogueSources(): Promise< const { states } = await snapshotPlugins(); return states .filter((s) => s.install?.enabled) - .map((s) => ({ appName: s.plugin.appName, dir: s.plugin.dir, manifest: { publisher: s.plugin.manifest.publisher } })); + .map((s) => ({ + appName: s.plugin.appName, + dir: s.plugin.dir, + manifest: { publisher: s.plugin.manifest.publisher }, + })); } catch { return []; } diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts index 785009bf..fa4aca48 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts @@ -14,6 +14,8 @@ export type TaskSummary = { triggers: TriggerConfig[]; mode: 'script' | 'agentic' | 'pipeline'; userId: number | null; + /** The plugin that ships it, or null when it is the owner's own. */ + plugin?: string | null; }; export type TaskGroup = { category: string; tasks: TaskSummary[] };