diff --git a/index.ts b/index.ts index c6c5e32..7d841e2 100644 --- a/index.ts +++ b/index.ts @@ -67,6 +67,46 @@ export default function subagentsExtension(pi: ExtensionAPI) { monitorRun, }); + // background registry and helpers + const registry = (typeof createBackgroundRegistry === 'function') ? createBackgroundRegistry() : undefined; + let latestUi: { notify(message: string, type?: "info" | "warning" | "error"): void; setStatus(key: string, text: string | undefined): void } | undefined; + + function renderCounts() { + if (!registry) return undefined; + const counts = registry.getCounts(); + return counts.total === 0 ? undefined : `bg: ${counts.running} running / ${counts.total} total`; + } + + function updateStatus() { + latestUi?.setStatus("pi-subagents", renderCounts()); + } + + async function watchBackgroundRun(runId: string) { + if (!registry) return; + const run = registry.getRun(runId); + if (!run || !run.paths || !run.paths.eventsPath || !run.paths.resultPath) return; + try { + const result = await monitorRun({ eventsPath: run.paths.eventsPath, resultPath: run.paths.resultPath }); + const status = result.stopReason === "aborted" ? "aborted" : result.exitCode === 0 ? "completed" : "failed"; + registry.recordUpdate(runId, { status, exitCode: result.exitCode, finalText: result.finalText, stopReason: result.stopReason }); + try { + pi.appendEntry("pi-subagents:bg-update", { runId, status, finalText: result.finalText, exitCode: result.exitCode }); + } catch (e) {} + updateStatus(); + latestUi?.notify(`Background agent ${run.preset} finished: ${result.finalText || status}`, status === "completed" ? "info" : "error"); + try { + pi.sendMessage({ + customType: "pi-subagents:bg-complete", + content: `Background agent ${run.preset} completed (${runId}): ${result.finalText || status}`, + display: true, + details: { runId, status }, + }, { triggerTurn: false }); + } catch (e) {} + } catch (e) { + // monitorRun errors are non-fatal here + } + } + const runSingleTask = createConfiguredRunSingleTask({ loadConfig: (cwd) => loadSubagentsConfig(cwd), processRunner, @@ -98,6 +138,24 @@ export default function subagentsExtension(pi: ExtensionAPI) { runSingleTask, }), ); + + // also register background tools when models available + try { + pi.registerTool(createBackgroundStatusTool({ registry })); + pi.registerTool(createBackgroundAgentTool({ + parameters: createBackgroundAgentSchema(availableModels), + discoverSubagentPresets, + launchDetachedTask: processRunner.launchDetachedTask, + 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 }); + return registry.getCounts(); + }, + watchBackgroundRun(runId: string) { + void watchBackgroundRun(runId); + }, + })); + } catch (e) {} }; const syncSubagentTool = (ctx: { modelRegistry: { getAvailable(): Array<{ provider: string; id: string }> } }) => { @@ -105,10 +163,32 @@ export default function subagentsExtension(pi: ExtensionAPI) { }; pi.on("session_start", (_event, ctx) => { + latestUi = ctx.ui; + // replay persisted runs if session manager provides entries + try { + const entries = ctx.sessionManager?.getEntries?.() ?? []; + const bgEntries = entries.filter((e: any) => e.type === "pi-subagents:bg-run" || e.type === "pi-subagents:bg-update"); + // convert to registry runs if possible + const runs = bgEntries.map((be: any) => be.data?.run).filter(Boolean); + if (registry && runs.length) registry.replay(runs); + } catch (e) {} + + updateStatus(); + + // reattach watchers for running runs + try { + if (registry) { + for (const r of registry.getSnapshot({ includeCompleted: true })) { + if (r.status === "running") void watchBackgroundRun(r.runId); + } + } + } catch (e) {} + syncSubagentTool(ctx); }); pi.on("before_agent_start", (_event, ctx) => { + latestUi = ctx.ui; syncSubagentTool(ctx); }); } diff --git a/src/background-status-tool.ts b/src/background-status-tool.ts index 94455d8..9faa725 100644 --- a/src/background-status-tool.ts +++ b/src/background-status-tool.ts @@ -17,11 +17,13 @@ export function createBackgroundStatusTool(deps: { registry: BackgroundRegistry // when querying a single run by id, allow completed runs regardless of includeCompleted flag const runs = runId ? registry.getSnapshot({ runId, includeCompleted: true }) : registry.getSnapshot({ includeCompleted }); + const counts = registry.getCounts(); + if (runId) { if (runs.length === 0) { return { content: [{ type: "text" as const, text: `No run found for runId: ${runId}` }], - details: { runs: [] }, + details: { runs: [], counts }, isError: false, }; } @@ -29,17 +31,17 @@ export function createBackgroundStatusTool(deps: { registry: BackgroundRegistry const text = `Run ${r.runId}: status=${r.status}, task=${r.task}, final="${r.finalText ?? "(no output)"}"`; return { content: [{ type: "text" as const, text }], - details: { runs }, + details: { runs, counts }, isError: false, }; } // default: active runs only (unless includeCompleted) const active = runs; - const text = `Active runs: ${active.length}`; + const text = `Active runs: ${active.length}. Counts: running=${counts.running} completed=${counts.completed} failed=${counts.failed} aborted=${counts.aborted} total=${counts.total}`; return { content: [{ type: "text" as const, text }], - details: { runs: active }, + details: { runs: active, counts }, isError: false, }; }, diff --git a/src/background-tool.test.ts b/src/background-tool.test.ts new file mode 100644 index 0000000..279b79f --- /dev/null +++ b/src/background-tool.test.ts @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createBackgroundAgentTool } from "./background-tool.ts"; + +test("background_agent returns immediately with handle metadata and counts", async () => { + const appended: Array<{ type: string; data: any }> = []; + let watchCalled = false; + + const tool = createBackgroundAgentTool({ + discoverSubagentPresets: () => ({ + presets: [ + { + name: "repo-scout", + description: "Scout", + model: "openai/gpt-5", + systemPrompt: "Scout", + source: "global", + filePath: "/tmp/repo-scout.md", + }, + ], + projectPresetsDir: null, + }), + launchDetachedTask: async () => ({ + runId: "run-1", + task: "inspect auth", + requestedModel: "openai/gpt-5", + resolvedModel: "openai/gpt-5", + resultPath: "/repo/.pi/subagents/runs/run-1/result.json", + eventsPath: "/repo/.pi/subagents/runs/run-1/events.jsonl", + stdoutPath: "/repo/.pi/subagents/runs/run-1/stdout.log", + stderrPath: "/repo/.pi/subagents/runs/run-1/stderr.log", + transcriptPath: "/repo/.pi/subagents/runs/run-1/transcript.log", + sessionPath: "/repo/.pi/subagents/runs/run-1/child-session.jsonl", + }), + registerBackgroundRun(run: any) { + appended.push({ type: "run", data: run }); + return { running: 1, completed: 0, failed: 0, aborted: 0, total: 1 }; + }, + watchBackgroundRun() { + watchCalled = true; + }, + } as any); + + const result: any = await tool.execute( + "tool-1", + { preset: "repo-scout", task: "inspect auth" }, + undefined, + undefined, + { + cwd: "/repo", + modelRegistry: { getAvailable: () => [{ provider: "openai", id: "gpt-5" }] }, + hasUI: false, + } as any, + ); + + assert.equal(result.isError, false); + assert.equal(result.details.runId, "run-1"); + assert.deepEqual(result.details.counts, { running: 1, completed: 0, failed: 0, aborted: 0, total: 1 }); + assert.equal(watchCalled, true); + assert.equal(appended.length, 1); +}); diff --git a/src/background-tool.ts b/src/background-tool.ts new file mode 100644 index 0000000..279c736 --- /dev/null +++ b/src/background-tool.ts @@ -0,0 +1,65 @@ +import { Text } from "@mariozechner/pi-tui"; +import { discoverSubagentPresets } from "./presets.ts"; +import { normalizeAvailableModelReference, listAvailableModelReferences } from "./models.ts"; +import { BackgroundAgentSchema } from "./background-schema.ts"; + +export function createBackgroundAgentTool(deps: any = {}) { + return { + name: "background_agent", + label: "Background Agent", + description: "Spawn a detached background child session and return its run handle immediately.", + parameters: deps.parameters ?? BackgroundAgentSchema, + async execute(_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, ctx: any) { + const discovery = (deps.discoverSubagentPresets ?? discoverSubagentPresets)(ctx.cwd); + const presets = discovery.presets; + + if (typeof params.preset !== "string" || params.preset.trim() === "") { + return { content: [{ type: "text" as const, text: "Missing preset" }], isError: true }; + } + + const preset = presets.find((p: any) => p.name === params.preset); + if (!preset) { + const names = presets.map((p: any) => p.name).join(", ") || "(none)"; + return { content: [{ type: "text" as const, text: `Unknown preset "${params.preset}". Available presets: ${names}` }], isError: true }; + } + + const available = (deps.listAvailableModelReferences ?? listAvailableModelReferences)(ctx.modelRegistry); + const availableText = available.join(", ") || "(none)"; + const normalizedModel = (deps.normalizeAvailableModelReference ?? normalizeAvailableModelReference)(params.model ?? preset.model, available); + if (!normalizedModel) { + return { content: [{ type: "text" as const, text: `Background agent requires a model chosen from the available models: ${availableText}` }], isError: true }; + } + + const launch = await deps.launchDetachedTask({ + cwd: params.cwd ?? ctx.cwd, + meta: { + mode: "background", + preset: preset.name, + presetSource: preset.source, + task: params.task, + cwd: params.cwd ?? ctx.cwd, + requestedModel: normalizedModel, + resolvedModel: normalizedModel, + systemPrompt: preset.systemPrompt, + tools: preset.tools, + }, + }); + + const counts = deps.registerBackgroundRun({ runId: launch.runId, preset: preset.name, task: params.task, requestedModel: normalizedModel, resolvedModel: normalizedModel, paths: { resultPath: launch.resultPath, eventsPath: launch.eventsPath } }); + deps.watchBackgroundRun(launch.runId); + + return { + content: [{ type: "text" as const, text: `Background agent started: ${launch.runId}` }], + details: { runId: launch.runId, counts }, + isError: false, + }; + }, + renderCall() { + return new Text("background_agent", 0, 0); + }, + renderResult(result: { content: Array<{ type: string; text?: string }> }) { + const first = result.content[0]; + return new Text(first?.type === "text" ? first.text ?? "" : "", 0, 0); + }, + }; +} diff --git a/src/process-runner.ts b/src/process-runner.ts index 1f4cbf8..767c9e0 100644 --- a/src/process-runner.ts +++ b/src/process-runner.ts @@ -35,10 +35,13 @@ export function createProcessSingleRunner(deps: { buildWrapperSpawn: (metaPath: string) => { command: string; args: string[]; env?: NodeJS.ProcessEnv }; spawnChild?: typeof spawn; monitorRun: (input: { eventsPath: string; resultPath: string; onEvent?: (event: any) => void }) => Promise; + // new detached launcher helper + createDetachedLauncher?: (deps: any) => any; }): RunSingleTask { const spawnChild = deps.spawnChild ?? spawn; - return async function runSingleTask(input) { + // keep existing behavior + const runSingleTaskImpl = async function runSingleTask(input: any) { const artifacts = await deps.createArtifacts(input.cwd, input.meta); const spawnSpec = deps.buildWrapperSpawn(artifacts.metaPath); @@ -79,4 +82,62 @@ export function createProcessSingleRunner(deps: { eventsPath: artifacts.eventsPath, }; }; + + // create detached launcher that returns immediately + function createDetachedLauncher() { + return async function launchDetachedTask(input: { cwd: string; meta: Record; spawnChild?: typeof spawn }) { + const artifacts = await deps.createArtifacts(input.cwd, input.meta); + const spawnSpec = deps.buildWrapperSpawn(artifacts.metaPath); + + const useSpawn = input.spawnChild ?? spawnChild; + + try { + const child = useSpawn(spawnSpec.command, spawnSpec.args, { + cwd: input.cwd, + env: { ...process.env, ...(spawnSpec.env ?? {}) }, + stdio: ["ignore", "ignore", "ignore"], + }); + + child.once("error", (error) => { + void (async () => { + const r = makeLaunchFailureResult(artifacts, input.meta, input.cwd, error); + await writeFile(artifacts.resultPath, JSON.stringify(r, null, 2), "utf8"); + })(); + }); + } catch (error) { + const r = makeLaunchFailureResult(artifacts, input.meta, input.cwd, error); + await writeFile(artifacts.resultPath, JSON.stringify(r, null, 2), "utf8"); + return { + runId: artifacts.runId, + sessionPath: artifacts.sessionPath, + stdoutPath: artifacts.stdoutPath, + stderrPath: artifacts.stderrPath, + transcriptPath: artifacts.transcriptPath, + resultPath: artifacts.resultPath, + eventsPath: artifacts.eventsPath, + ...input.meta, + exitCode: 1, + stopReason: "error", + }; + } + + return { + runId: artifacts.runId, + sessionPath: artifacts.sessionPath, + stdoutPath: artifacts.stdoutPath, + stderrPath: artifacts.stderrPath, + transcriptPath: artifacts.transcriptPath, + resultPath: artifacts.resultPath, + eventsPath: artifacts.eventsPath, + ...input.meta, + }; + }; + } + + const detached = createDetachedLauncher(); + + // expose both runSingleTask and detached launcher + const fn: any = runSingleTaskImpl as any; + (fn as any).launchDetachedTask = detached; + return fn as RunSingleTask; }