fix: register background tools, session replay, background persistence, and tests/docs for Task 5 spec-review
This commit is contained in:
26
README.md
26
README.md
@@ -32,7 +32,31 @@ pi install https://gitea.rwiesner.com/pi/pi-subagents
|
|||||||
- selected model
|
- selected model
|
||||||
- optional working directory
|
- optional working directory
|
||||||
|
|
||||||
Child runs are normal Pi sessions. This package does not add built-in role behavior, markdown-discovered subagents, per-agent tool restrictions, or appended role prompts.
|
Child runs are normal Pi sessions.
|
||||||
|
|
||||||
|
## Presets
|
||||||
|
|
||||||
|
Subagent presets are discovered from markdown files. Locations:
|
||||||
|
|
||||||
|
- global: `~/.pi/agent/subagents/*.md`
|
||||||
|
- nearest project: `.pi/subagents/*.md` (nearest ancestor directory)
|
||||||
|
|
||||||
|
Project presets override global presets by name. No built-in presets or roles are bundled with this package.
|
||||||
|
|
||||||
|
Preset frontmatter fields:
|
||||||
|
|
||||||
|
- `name` (required)
|
||||||
|
- `description` (required)
|
||||||
|
- `model` (optional; provider/id)
|
||||||
|
- `tools` (optional; comma-separated list or array)
|
||||||
|
|
||||||
|
Preset body becomes the child session system prompt. The `tools` field maps directly to Pi CLI `--tools` and limits which built-in tools are available to the child run.
|
||||||
|
|
||||||
|
## Tools provided
|
||||||
|
|
||||||
|
- `subagent` — foreground single or parallel runs using named presets
|
||||||
|
- `background_agent` — detached process-backed run that returns immediately with a handle
|
||||||
|
- `background_agent_status` — query background run counts and status
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
41
index.ts
41
index.ts
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { createRunArtifacts } from "./src/artifacts.ts";
|
import { createRunArtifacts } from "./src/artifacts.ts";
|
||||||
import { loadSubagentsConfig } from "./src/config.ts";
|
import { loadSubagentsConfig } from "./src/config.ts";
|
||||||
import { monitorRun } from "./src/monitor.ts";
|
import { monitorRun as defaultMonitorRun } from "./src/monitor.ts";
|
||||||
import { listAvailableModelReferences } from "./src/models.ts";
|
import { listAvailableModelReferences } from "./src/models.ts";
|
||||||
import { createProcessSingleRunner } from "./src/process-runner.ts";
|
import { createProcessSingleRunner } from "./src/process-runner.ts";
|
||||||
import { createConfiguredRunSingleTask } from "./src/runner.ts";
|
import { createConfiguredRunSingleTask } from "./src/runner.ts";
|
||||||
@@ -18,10 +18,17 @@ import {
|
|||||||
isInsideTmux,
|
isInsideTmux,
|
||||||
} from "./src/tmux.ts";
|
} from "./src/tmux.ts";
|
||||||
|
|
||||||
|
// background imports
|
||||||
|
import { createBackgroundRegistry } from "./src/background-registry.ts";
|
||||||
|
import { createBackgroundAgentTool } from "./src/background-tool.ts";
|
||||||
|
import { createBackgroundStatusTool } from "./src/background-status-tool.ts";
|
||||||
|
import { createBackgroundAgentSchema } from "./src/background-schema.ts";
|
||||||
|
import { discoverSubagentPresets } from "./src/presets.ts";
|
||||||
|
|
||||||
const packageRoot = dirname(fileURLToPath(import.meta.url));
|
const packageRoot = dirname(fileURLToPath(import.meta.url));
|
||||||
const wrapperPath = join(packageRoot, "src", "wrapper", "cli.mjs");
|
const wrapperPath = join(packageRoot, "src", "wrapper", "cli.mjs");
|
||||||
|
|
||||||
export default function subagentsExtension(pi: ExtensionAPI) {
|
export default function subagentsExtension(pi: ExtensionAPI, deps: any = {}) {
|
||||||
if (process.env.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR === "agent") {
|
if (process.env.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR === "agent") {
|
||||||
pi.registerProvider("github-copilot", {
|
pi.registerProvider("github-copilot", {
|
||||||
headers: { "X-Initiator": "agent" },
|
headers: { "X-Initiator": "agent" },
|
||||||
@@ -37,6 +44,8 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
|
|
||||||
let lastRegisteredModelsKey: string | undefined;
|
let lastRegisteredModelsKey: string | undefined;
|
||||||
|
|
||||||
|
const monitorRun = deps.monitorRun ?? defaultMonitorRun;
|
||||||
|
|
||||||
const tmuxRunner = createTmuxSingleRunner({
|
const tmuxRunner = createTmuxSingleRunner({
|
||||||
assertInsideTmux() {
|
assertInsideTmux() {
|
||||||
if (!isInsideTmux()) throw new Error('tmux runner requires pi to be running inside tmux.');
|
if (!isInsideTmux()) throw new Error('tmux runner requires pi to be running inside tmux.');
|
||||||
@@ -68,11 +77,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// background registry and helpers
|
// background registry and helpers
|
||||||
const registry = (typeof createBackgroundRegistry === 'function') ? createBackgroundRegistry() : undefined;
|
const registry = createBackgroundRegistry();
|
||||||
let latestUi: { notify(message: string, type?: "info" | "warning" | "error"): void; setStatus(key: string, text: string | undefined): void } | undefined;
|
let latestUi: { notify(message: string, type?: "info" | "warning" | "error"): void; setStatus(key: string, text: string | undefined): void } | undefined;
|
||||||
|
|
||||||
function renderCounts() {
|
function renderCounts() {
|
||||||
if (!registry) return undefined;
|
|
||||||
const counts = registry.getCounts();
|
const counts = registry.getCounts();
|
||||||
return counts.total === 0 ? undefined : `bg: ${counts.running} running / ${counts.total} total`;
|
return counts.total === 0 ? undefined : `bg: ${counts.running} running / ${counts.total} total`;
|
||||||
}
|
}
|
||||||
@@ -82,7 +90,6 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function watchBackgroundRun(runId: string) {
|
async function watchBackgroundRun(runId: string) {
|
||||||
if (!registry) return;
|
|
||||||
const run = registry.getRun(runId);
|
const run = registry.getRun(runId);
|
||||||
if (!run || !run.paths || !run.paths.eventsPath || !run.paths.resultPath) return;
|
if (!run || !run.paths || !run.paths.eventsPath || !run.paths.resultPath) return;
|
||||||
try {
|
try {
|
||||||
@@ -147,8 +154,12 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
discoverSubagentPresets,
|
discoverSubagentPresets,
|
||||||
launchDetachedTask: processRunner.launchDetachedTask,
|
launchDetachedTask: processRunner.launchDetachedTask,
|
||||||
registerBackgroundRun(entry: any) {
|
registerBackgroundRun(entry: any) {
|
||||||
if (!registry) return { running: 0, completed: 0, failed: 0, aborted: 0, total: 0 };
|
|
||||||
registry.recordLaunch({ runId: entry.runId, preset: entry.preset, task: entry.task, requestedModel: entry.requestedModel, resolvedModel: entry.resolvedModel, paths: entry.paths, meta: entry.meta });
|
registry.recordLaunch({ runId: entry.runId, preset: entry.preset, task: entry.task, requestedModel: entry.requestedModel, resolvedModel: entry.resolvedModel, paths: entry.paths, meta: entry.meta });
|
||||||
|
const run = registry.getRun(entry.runId);
|
||||||
|
try {
|
||||||
|
// persist initial run record so session manager can rebuild later
|
||||||
|
pi.appendEntry("pi-subagents:bg-run", { run });
|
||||||
|
} catch (e) {}
|
||||||
return registry.getCounts();
|
return registry.getCounts();
|
||||||
},
|
},
|
||||||
watchBackgroundRun(runId: string) {
|
watchBackgroundRun(runId: string) {
|
||||||
@@ -167,21 +178,27 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
// replay persisted runs if session manager provides entries
|
// replay persisted runs if session manager provides entries
|
||||||
try {
|
try {
|
||||||
const entries = ctx.sessionManager?.getEntries?.() ?? [];
|
const entries = ctx.sessionManager?.getEntries?.() ?? [];
|
||||||
const bgEntries = entries.filter((e: any) => e.type === "pi-subagents:bg-run" || e.type === "pi-subagents:bg-update");
|
// clear existing registry and rebuild from session entries
|
||||||
// convert to registry runs if possible
|
registry.replay([]);
|
||||||
const runs = bgEntries.map((be: any) => be.data?.run).filter(Boolean);
|
for (const e of entries) {
|
||||||
if (registry && runs.length) registry.replay(runs);
|
const type = (e.type ?? e.customType) as string | undefined;
|
||||||
|
if (type === "pi-subagents:bg-run") {
|
||||||
|
const run = e.data?.run ?? e.data;
|
||||||
|
if (run && run.runId) registry.recordLaunch(run as any);
|
||||||
|
} else if (type === "pi-subagents:bg-update") {
|
||||||
|
const data = e.data;
|
||||||
|
if (data && data.runId) registry.recordUpdate(data.runId, data as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
updateStatus();
|
updateStatus();
|
||||||
|
|
||||||
// reattach watchers for running runs
|
// reattach watchers for running runs
|
||||||
try {
|
try {
|
||||||
if (registry) {
|
|
||||||
for (const r of registry.getSnapshot({ includeCompleted: true })) {
|
for (const r of registry.getSnapshot({ includeCompleted: true })) {
|
||||||
if (r.status === "running") void watchBackgroundRun(r.runId);
|
if (r.status === "running") void watchBackgroundRun(r.runId);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
syncSubagentTool(ctx);
|
syncSubagentTool(ctx);
|
||||||
|
|||||||
@@ -15,11 +15,15 @@ test("status tool shows active runs by default and can query single run", async
|
|||||||
assert.equal(resDefault.details.runs.length, 1);
|
assert.equal(resDefault.details.runs.length, 1);
|
||||||
assert.equal(resDefault.details.runs[0].runId, "r1");
|
assert.equal(resDefault.details.runs[0].runId, "r1");
|
||||||
assert.match(resDefault.content[0].text, /Active runs: 1/);
|
assert.match(resDefault.content[0].text, /Active runs: 1/);
|
||||||
|
// assert counts appear in details and text
|
||||||
|
assert.deepEqual(resDefault.details.counts, { running: 1, completed: 1, failed: 0, aborted: 0, total: 2 });
|
||||||
|
assert.match(resDefault.content[0].text, /running=1\s+completed=1\s+failed=0\s+aborted=0\s+total=2/);
|
||||||
|
|
||||||
const resSingle: any = await tool.execute("id", { runId: "r2" }, undefined, undefined, undefined);
|
const resSingle: any = await tool.execute("id", { runId: "r2" }, undefined, undefined, undefined);
|
||||||
assert.equal(resSingle.details.runs.length, 1);
|
assert.equal(resSingle.details.runs.length, 1);
|
||||||
assert.equal(resSingle.details.runs[0].runId, "r2");
|
assert.equal(resSingle.details.runs[0].runId, "r2");
|
||||||
assert.match(resSingle.content[0].text, /status=completed/);
|
assert.match(resSingle.content[0].text, /status=completed/);
|
||||||
|
assert.deepEqual(resSingle.details.counts, { running: 1, completed: 1, failed: 0, aborted: 0, total: 2 });
|
||||||
|
|
||||||
const resNotFound: any = await tool.execute("id", { runId: "nope" }, undefined, undefined, undefined);
|
const resNotFound: any = await tool.execute("id", { runId: "nope" }, undefined, undefined, undefined);
|
||||||
assert.equal(resNotFound.details.runs.length, 0);
|
assert.equal(resNotFound.details.runs.length, 0);
|
||||||
|
|||||||
@@ -34,8 +34,13 @@ test("the extension entrypoint registers the subagent tool with the currently av
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(registeredTools.length, 1);
|
// expect three tools: subagent, background_agent_status, background_agent
|
||||||
|
assert.equal(registeredTools.length, 3);
|
||||||
assert.equal(registeredTools[0]?.name, "subagent");
|
assert.equal(registeredTools[0]?.name, "subagent");
|
||||||
|
assert.equal(registeredTools[1]?.name, "background_agent_status");
|
||||||
|
assert.equal(registeredTools[2]?.name, "background_agent");
|
||||||
|
|
||||||
|
// subagent schema checks (same as before)
|
||||||
const params = registeredTools[0]?.parameters;
|
const params = registeredTools[0]?.parameters;
|
||||||
const branches = params?.anyOf ?? params?.oneOf ?? [params];
|
const branches = params?.anyOf ?? params?.oneOf ?? [params];
|
||||||
// ensure union branches exist: single-mode and parallel-mode
|
// ensure union branches exist: single-mode and parallel-mode
|
||||||
@@ -57,6 +62,13 @@ test("the extension entrypoint registers the subagent tool with the currently av
|
|||||||
"anthropic/claude-sonnet-4-5",
|
"anthropic/claude-sonnet-4-5",
|
||||||
"openai/gpt-5",
|
"openai/gpt-5",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// background_agent schema should include dynamic model enum
|
||||||
|
const bgAgent = registeredTools.find((t: any) => t.name === "background_agent");
|
||||||
|
assert(bgAgent, "background_agent registered");
|
||||||
|
const bgParams = bgAgent.parameters;
|
||||||
|
assert.equal(bgParams.properties.model.enum[0], "anthropic/claude-sonnet-4-5");
|
||||||
|
assert.equal(bgParams.properties.model.enum[1], "openai/gpt-5");
|
||||||
} finally {
|
} finally {
|
||||||
if (original === undefined) delete process.env.PI_SUBAGENTS_CHILD;
|
if (original === undefined) delete process.env.PI_SUBAGENTS_CHILD;
|
||||||
else process.env.PI_SUBAGENTS_CHILD = original;
|
else process.env.PI_SUBAGENTS_CHILD = original;
|
||||||
@@ -97,7 +109,7 @@ test("before_agent_start re-applies subagent registration when available models
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(registeredTools.length, 1);
|
assert.equal(registeredTools.length, 3);
|
||||||
{
|
{
|
||||||
const params = registeredTools[0]?.parameters;
|
const params = registeredTools[0]?.parameters;
|
||||||
const branches = params?.anyOf ?? params?.oneOf ?? [params];
|
const branches = params?.anyOf ?? params?.oneOf ?? [params];
|
||||||
@@ -121,9 +133,9 @@ test("before_agent_start re-applies subagent registration when available models
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(registeredTools.length, 2);
|
assert.equal(registeredTools.length, 6);
|
||||||
{
|
{
|
||||||
const params = registeredTools[1]?.parameters;
|
const params = registeredTools[3]?.parameters;
|
||||||
const branches = params?.anyOf ?? params?.oneOf ?? [params];
|
const branches = params?.anyOf ?? params?.oneOf ?? [params];
|
||||||
const parallelBranch = branches.find((b: any) => b.properties && "tasks" in b.properties);
|
const parallelBranch = branches.find((b: any) => b.properties && "tasks" in b.properties);
|
||||||
assert(parallelBranch, "parallel branch present");
|
assert(parallelBranch, "parallel branch present");
|
||||||
@@ -270,7 +282,7 @@ test("does not re-register the subagent tool when models list unchanged, but re-
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(registerToolCalls, 1);
|
assert.equal(registerToolCalls, 3);
|
||||||
|
|
||||||
// Second registration with the same models — should not increase count
|
// Second registration with the same models — should not increase count
|
||||||
await handlers.before_agent_start?.(
|
await handlers.before_agent_start?.(
|
||||||
@@ -285,7 +297,7 @@ test("does not re-register the subagent tool when models list unchanged, but re-
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(registerToolCalls, 1);
|
assert.equal(registerToolCalls, 3);
|
||||||
|
|
||||||
// Third call with changed model list — should re-register
|
// Third call with changed model list — should re-register
|
||||||
await handlers.session_start?.(
|
await handlers.session_start?.(
|
||||||
@@ -299,7 +311,7 @@ test("does not re-register the subagent tool when models list unchanged, but re-
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(registerToolCalls, 2);
|
assert.equal(registerToolCalls, 6);
|
||||||
} finally {
|
} finally {
|
||||||
if (original === undefined) delete process.env.PI_SUBAGENTS_CHILD;
|
if (original === undefined) delete process.env.PI_SUBAGENTS_CHILD;
|
||||||
else process.env.PI_SUBAGENTS_CHILD = original;
|
else process.env.PI_SUBAGENTS_CHILD = original;
|
||||||
@@ -343,7 +355,7 @@ test("same model set in different orders should NOT trigger re-registration", as
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(registerToolCalls, 1);
|
assert.equal(registerToolCalls, 3);
|
||||||
|
|
||||||
// Same models but reversed order — should NOT re-register
|
// Same models but reversed order — should NOT re-register
|
||||||
await handlers.before_agent_start?.(
|
await handlers.before_agent_start?.(
|
||||||
@@ -358,7 +370,7 @@ test("same model set in different orders should NOT trigger re-registration", as
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(registerToolCalls, 1);
|
assert.equal(registerToolCalls, 3);
|
||||||
} finally {
|
} finally {
|
||||||
if (original === undefined) delete process.env.PI_SUBAGENTS_CHILD;
|
if (original === undefined) delete process.env.PI_SUBAGENTS_CHILD;
|
||||||
else process.env.PI_SUBAGENTS_CHILD = original;
|
else process.env.PI_SUBAGENTS_CHILD = original;
|
||||||
@@ -411,10 +423,9 @@ test("empty model list should NOT register the tool, but a later non-empty list
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(registerToolCalls, 1);
|
assert.equal(registerToolCalls, 3);
|
||||||
} finally {
|
} finally {
|
||||||
if (original === undefined) delete process.env.PI_SUBAGENTS_CHILD;
|
if (original === undefined) delete process.env.PI_SUBAGENTS_CHILD;
|
||||||
else process.env.PI_SUBAGENTS_CHILD = original;
|
else process.env.PI_SUBAGENTS_CHILD = original;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user