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:
@@ -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
|
||||
|
||||
@@ -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/<name>/ 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.
|
||||
|
||||
@@ -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}}…",
|
||||
|
||||
@@ -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, string>): 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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/<dirName>/ 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<TaskFrontmatter>, body: string): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function readImplementation(dirName: string, language: string | null): Promise<string | null> {
|
||||
const implPath = join(taskDir(dirName), implFileName(language));
|
||||
const file = Bun.file(implPath);
|
||||
async function readImplementation(dir: string, language: string | null): Promise<string | null> {
|
||||
const file = Bun.file(join(dir, implFileName(language)));
|
||||
return (await file.exists()) ? file.text() : null;
|
||||
}
|
||||
|
||||
@@ -170,21 +190,31 @@ export async function readCategoryOrder(): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTasks(): Promise<TaskSummary[]> {
|
||||
/**
|
||||
* 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<TaskSummary[]> {
|
||||
const roots = sources ?? (await capabilitySources('tasks'));
|
||||
|
||||
const byDirName = new Map<string, TaskSummary>();
|
||||
for (const source of roots) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(tasksRoot(), { withFileTypes: true });
|
||||
entries = await readdir(source.dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
continue; // most plugins ship no tasks at all — the ordinary case, not a fault
|
||||
}
|
||||
|
||||
const summaries: TaskSummary[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const file = Bun.file(taskFile(entry.name));
|
||||
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());
|
||||
summaries.push({
|
||||
byDirName.set(entry.name, {
|
||||
dirName: entry.name,
|
||||
name: fm.name || entry.name,
|
||||
description: fm.description,
|
||||
@@ -193,19 +223,30 @@ export async function listTasks(): Promise<TaskSummary[]> {
|
||||
inline: fm.inline,
|
||||
version: fm.version,
|
||||
trigger: fm.trigger ?? [],
|
||||
plugin: source.plugin,
|
||||
});
|
||||
}
|
||||
|
||||
summaries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return summaries;
|
||||
}
|
||||
|
||||
export async function getTaskByDirName(dirName: string): Promise<TaskRecord | null> {
|
||||
const file = Bun.file(taskFile(dirName));
|
||||
if (!(await file.exists())) return null;
|
||||
return [...byDirName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export async function getTaskByDirName(dirName: string, sources?: CapabilitySource[]): Promise<TaskRecord | null> {
|
||||
// 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(dirName, fm.language);
|
||||
return { ...fm, dirName, body, implementation, filePath: taskFile(dirName) };
|
||||
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<TaskSummary &
|
||||
inline: false,
|
||||
version: 1,
|
||||
trigger: [],
|
||||
plugin: null,
|
||||
filePath: taskFile(dirName),
|
||||
};
|
||||
}
|
||||
@@ -250,6 +292,7 @@ export async function updateTask(
|
||||
): Promise<TaskRecord | null> {
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<string[]> {
|
||||
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<CapabilitySource[]> {
|
||||
const sources: CapabilitySource[] = [{ dir: itemsDir(type as ItemType), plugin: null }];
|
||||
|
||||
let states: Awaited<ReturnType<typeof snapshotPlugins>>['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<ReturnType<typeof snapshotPlugins>>['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<string[]> {
|
||||
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 [];
|
||||
}
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
Reference in New Issue
Block a user