omnirush 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,421 @@
1
+ // agents-lib — pure orchestration logic for the spawn_agents tool
2
+ // (headless pi child runs). Pure/injectable so node:test covers it
3
+ // without ever spawning a real agent.
4
+ //
5
+ // Children are headless runs of THIS entry (process.argv[1], i.e. the
6
+ // running pi entry — same runtime, same global extension/config dir)
7
+ // in the parent's workspace, with `--mode json -p` so their output is
8
+ // a parseable event stream. Each child writes its own session JSONL
9
+ // under <agentDir>/sessions/<cwd-slug>/ — the workspace collector
10
+ // ships every session file it finds there as a trace artifact linked
11
+ // via parent_session_id, so subagent tracing is free.
12
+ //
13
+ // Linkage: children get OMNIRUSH_PARENT_SESSION=<parent session id> in
14
+ // their environment (the collector stamps parent_session_id from it).
15
+
16
+ import { spawn as nodeSpawn } from "node:child_process";
17
+ import { existsSync } from "node:fs";
18
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
19
+ import { tmpdir } from "node:os";
20
+ import path from "node:path";
21
+
22
+ /** Default per-child wall clock budget. */
23
+ export const CHILD_TIMEOUT_MS = 10 * 60_000;
24
+ /** Grace between SIGTERM and SIGKILL on timeout. */
25
+ export const CHILD_KILL_GRACE_MS = 5_000;
26
+ /** Default number of children running at once. */
27
+ export const MAX_CONCURRENT_CHILDREN = 4;
28
+ /** Upper bound on tasks per spawn_agents call. */
29
+ export const MAX_TASKS_PER_CALL = 8;
30
+ /** Per-child output cap in the structured result (bytes, UTF-8). */
31
+ export const CHILD_OUTPUT_CAP_BYTES = 50 * 1024;
32
+
33
+ export const AGENT_ROLES = ["code-searcher", "researcher-web", "general-worker"] as const;
34
+ export type AgentRole = (typeof AGENT_ROLES)[number];
35
+
36
+ export interface RolePreset {
37
+ role: AgentRole;
38
+ label: string;
39
+ description: string;
40
+ /** Appended to the child's system prompt. */
41
+ systemPrompt: string;
42
+ }
43
+
44
+ /**
45
+ * Role presets. Guidance only — the child keeps every tool (hard tool
46
+ * filters would break extension tools like web_fetch); the system
47
+ * prompt sets expectations instead.
48
+ */
49
+ export const ROLE_PRESETS: Record<AgentRole, RolePreset> = {
50
+ "code-searcher": {
51
+ role: "code-searcher",
52
+ label: "Code Searcher",
53
+ description: "Read-only codebase exploration: locate code, trace call paths, summarize findings.",
54
+ systemPrompt: [
55
+ "You are a code-searcher subagent.",
56
+ "Your job is READ-ONLY codebase exploration: find where things live, trace how calls flow, and report precise findings (file paths, symbol names, line references).",
57
+ "Do NOT modify files, run state-changing commands, or start long-running processes.",
58
+ "Report findings as a compact structured summary: what you found, where (paths), and any surprises — not a play-by-play of your searches.",
59
+ ].join(" "),
60
+ },
61
+ "researcher-web": {
62
+ role: "researcher-web",
63
+ label: "Researcher (Web)",
64
+ description: "Web research via web_fetch: read documentation pages, articles and API responses.",
65
+ systemPrompt: [
66
+ "You are a researcher subagent with web access.",
67
+ "Use the web_fetch tool to read documentation, articles and API responses (HTTPS URLs; the tool strips pages to readable text).",
68
+ "Cross-check claims across more than one page when it matters, and prefer primary sources (official docs, spec pages) over blog summaries.",
69
+ "Report a compact synthesis with the source URLs you actually used — not a list of everything you fetched.",
70
+ ].join(" "),
71
+ },
72
+ "general-worker": {
73
+ role: "general-worker",
74
+ label: "General Worker",
75
+ description: "Full-tool worker for self-contained implementation tasks (edits, commands, verification).",
76
+ systemPrompt: [
77
+ "You are a general-worker subagent with the full toolset.",
78
+ "Complete the delegated task end to end: implement, verify (run the relevant tests/commands), and keep changes tight and reviewable.",
79
+ "Stay inside the delegated task — do not refactor beyond it, do not touch unrelated files.",
80
+ "Report what you changed (paths), how you verified it, and anything you deliberately left undone.",
81
+ ].join(" "),
82
+ },
83
+ };
84
+
85
+ /** Resolve the child entry: this running script + the current runtime. */
86
+ export function childInvocation(args: string[]): {
87
+ command: string;
88
+ args: string[];
89
+ } {
90
+ const currentScript = process.argv[1];
91
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
92
+ if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
93
+ return { command: process.execPath, args: [currentScript, ...args] };
94
+ }
95
+ const execName = path.basename(process.execPath).toLowerCase();
96
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
97
+ if (!isGenericRuntime) {
98
+ return { command: process.execPath, args };
99
+ }
100
+ // Packaged bun builds: fall back to the pi on PATH.
101
+ return { command: "pi", args };
102
+ }
103
+
104
+ /**
105
+ * Build the child's argv for one task: JSON print mode with the role's
106
+ * system prompt appended via a temp file path (the caller writes and
107
+ * cleans up that file — see withRolePromptFile).
108
+ */
109
+ export function buildChildArgs(task: string, promptFilePath: string | null): string[] {
110
+ const args: string[] = ["--mode", "json", "-p"];
111
+ if (promptFilePath) {
112
+ args.push("--append-system-prompt", promptFilePath);
113
+ }
114
+ args.push(`Task: ${task}`);
115
+ return args;
116
+ }
117
+
118
+ /**
119
+ * Write a role's system prompt to a private temp file and run `body`
120
+ * with its path; the file is cleaned up afterwards either way.
121
+ */
122
+ export async function withRolePromptFile(
123
+ role: AgentRole,
124
+ body: (promptFilePath: string) => Promise<ChildResultInput>,
125
+ ): Promise<ChildResultInput> {
126
+ let dir: string | null = null;
127
+ try {
128
+ dir = await mkdtemp(path.join(tmpdir(), "omnirush-agent-"));
129
+ const filePath = path.join(dir, `prompt-${role.replace(/[^\w.-]+/g, "_")}.md`);
130
+ await writeFile(filePath, ROLE_PRESETS[role].systemPrompt, { encoding: "utf8", mode: 0o600 });
131
+ return await body(filePath);
132
+ } finally {
133
+ if (dir) await rm(dir, { recursive: true, force: true }).catch(() => undefined);
134
+ }
135
+ }
136
+
137
+ export interface ChildTask {
138
+ role: AgentRole;
139
+ task: string;
140
+ }
141
+
142
+ export type ChildStatus = "completed" | "failed" | "timeout" | "cancelled";
143
+
144
+ export interface ChildResult {
145
+ role: AgentRole;
146
+ task: string;
147
+ status: ChildStatus;
148
+ /** Process exit code (null when killed by a signal or still unknown). */
149
+ exitCode: number | null;
150
+ /** Final assistant text (or the best available partial output). */
151
+ output: string;
152
+ /** True when `output` was capped (full stdout events were larger). */
153
+ outputTruncated: boolean;
154
+ durationMs: number;
155
+ /** Assistant-turn count seen in the child's event stream. */
156
+ turns: number;
157
+ error?: string;
158
+ }
159
+
160
+ export interface ChildResultInput {
161
+ exitCode: number | null;
162
+ finalText: string;
163
+ turns: number;
164
+ status?: ChildStatus;
165
+ error?: string;
166
+ }
167
+
168
+ /** Final structured result for one child. */
169
+ export function buildChildResult(
170
+ task: ChildTask,
171
+ input: ChildResultInput,
172
+ durationMs: number,
173
+ ): ChildResult {
174
+ const output = input.finalText;
175
+ let capped = output;
176
+ const truncated = Buffer.byteLength(output, "utf8") > CHILD_OUTPUT_CAP_BYTES;
177
+ if (truncated) {
178
+ capped = output.slice(0, CHILD_OUTPUT_CAP_BYTES);
179
+ while (Buffer.byteLength(capped, "utf8") > CHILD_OUTPUT_CAP_BYTES) capped = capped.slice(0, -1);
180
+ }
181
+ return {
182
+ role: task.role,
183
+ task: task.task,
184
+ status: input.status ?? (input.exitCode === 0 ? "completed" : "failed"),
185
+ exitCode: input.exitCode,
186
+ output: capped,
187
+ outputTruncated: truncated,
188
+ durationMs,
189
+ turns: input.turns,
190
+ ...(input.error ? { error: input.error } : {}),
191
+ };
192
+ }
193
+
194
+ // --- child event-stream parsing -------------------------------------------
195
+
196
+ export interface ParsedChildEvents {
197
+ /** Final assistant text (last assistant message with text parts). */
198
+ finalText: string;
199
+ /** Assistant messages seen (turn count). */
200
+ turns: number;
201
+ }
202
+
203
+ /**
204
+ * Parse the accumulated stdout of a `pi --mode json -p` run: one JSON
205
+ * event per line; `message_end` events carry the messages. Unparseable
206
+ * lines (banners, noise) are ignored.
207
+ */
208
+ export function parseChildEvents(stdout: string): ParsedChildEvents {
209
+ let finalText = "";
210
+ let turns = 0;
211
+ for (const line of stdout.split("\n")) {
212
+ const trimmed = line.trim();
213
+ if (!trimmed) continue;
214
+ let event: any;
215
+ try {
216
+ event = JSON.parse(trimmed);
217
+ } catch {
218
+ continue;
219
+ }
220
+ if (event?.type !== "message_end" || !event.message) continue;
221
+ const message = event.message;
222
+ if (message.role !== "assistant") continue;
223
+ turns += 1;
224
+ const parts = Array.isArray(message.content) ? message.content : [];
225
+ const text = parts
226
+ .filter((part: any) => part?.type === "text" && typeof part.text === "string")
227
+ .map((part: any) => part.text)
228
+ .join("\n\n");
229
+ if (text.trim()) finalText = text;
230
+ else if (typeof message.content === "string" && message.content.trim()) {
231
+ finalText = message.content;
232
+ }
233
+ }
234
+ return { finalText, turns };
235
+ }
236
+
237
+ // --- the runner -------------------------------------------------------------
238
+
239
+ export interface SpawnChildOptions {
240
+ cwd: string;
241
+ parentSessionId: string;
242
+ timeoutMs?: number;
243
+ /** Kill the child when this fires (the tool's cancellation signal). */
244
+ signal?: AbortSignal;
245
+ /** Grace between SIGTERM and SIGKILL when killing (default CHILD_KILL_GRACE_MS). */
246
+ killGraceMs?: number;
247
+ spawnImpl?: typeof nodeSpawn;
248
+ now?: () => number;
249
+ /** Progress callback: partial output lines as the child runs. */
250
+ onChildStdout?: (role: AgentRole, chunk: string) => void;
251
+ }
252
+
253
+ /**
254
+ * Run ONE child agent to completion (or timeout/cancellation). The
255
+ * child inherits the parent's environment plus OMNIRUSH_PARENT_SESSION.
256
+ * On timeout: SIGTERM, a short grace, then SIGKILL; whatever the child
257
+ * printed so far is kept as the partial result.
258
+ */
259
+ export async function runChildAgent(
260
+ task: ChildTask,
261
+ options: SpawnChildOptions,
262
+ ): Promise<ChildResult> {
263
+ const preset = ROLE_PRESETS[task.role] ?? ROLE_PRESETS["general-worker"];
264
+ const startedAt = (options.now ?? Date.now)();
265
+ const timeoutMs = Math.max(1_000, options.timeoutMs ?? CHILD_TIMEOUT_MS);
266
+ const spawnImpl = options.spawnImpl ?? nodeSpawn;
267
+
268
+ return withRolePromptFile(task.role, async (promptFile) => {
269
+ const args = buildChildArgs(task.task, promptFile);
270
+ const invocation = childInvocation(args);
271
+
272
+ return await new Promise<ChildResult>((resolvePromise) => {
273
+ let stdout = "";
274
+ let stderr = "";
275
+ let settled = false;
276
+ let killedFor: "timeout" | "cancelled" | null = null;
277
+
278
+ const child = spawnImpl(invocation.command, invocation.args, {
279
+ cwd: options.cwd,
280
+ shell: false,
281
+ stdio: ["ignore", "pipe", "pipe"],
282
+ env: {
283
+ ...process.env,
284
+ OMNIRUSH_PARENT_SESSION: options.parentSessionId,
285
+ },
286
+ });
287
+
288
+ const finish = (input: ChildResultInput) => {
289
+ if (settled) return;
290
+ settled = true;
291
+ clearTimeout(timer);
292
+ if (options.signal && abortHandler) {
293
+ options.signal.removeEventListener("abort", abortHandler);
294
+ }
295
+ resolvePromise(
296
+ buildChildResult(task, input, (options.now ?? Date.now)() - startedAt),
297
+ );
298
+ };
299
+
300
+ const killTree = () => {
301
+ try {
302
+ child.kill("SIGTERM");
303
+ } catch {
304
+ /* already gone */
305
+ }
306
+ setTimeout(() => {
307
+ try {
308
+ child.kill("SIGKILL");
309
+ } catch {
310
+ /* already gone */
311
+ }
312
+ }, Math.max(50, options.killGraceMs ?? CHILD_KILL_GRACE_MS)).unref?.();
313
+ };
314
+
315
+ const timer = setTimeout(() => {
316
+ if (settled) return;
317
+ killedFor = "timeout";
318
+ killTree();
319
+ }, timeoutMs);
320
+ timer.unref?.();
321
+
322
+ const abortHandler = options.signal
323
+ ? () => {
324
+ if (settled) return;
325
+ killedFor = "cancelled";
326
+ killTree();
327
+ }
328
+ : null;
329
+ if (options.signal && abortHandler) {
330
+ if (options.signal.aborted) abortHandler();
331
+ else options.signal.addEventListener("abort", abortHandler, { once: true });
332
+ }
333
+
334
+ child.stdout?.on("data", (chunk: Buffer | string) => {
335
+ const text = String(chunk);
336
+ stdout += text;
337
+ options.onChildStdout?.(task.role, text);
338
+ });
339
+ child.stderr?.on("data", (chunk: Buffer | string) => {
340
+ stderr += String(chunk);
341
+ });
342
+ child.on("error", (error: Error) => {
343
+ finish({
344
+ exitCode: null,
345
+ finalText: "",
346
+ turns: 0,
347
+ status: "failed",
348
+ error: `failed to start: ${error.message}`,
349
+ });
350
+ });
351
+ child.on("close", (code: number | null, signalName: string | null) => {
352
+ const parsed = parseChildEvents(stdout);
353
+ if (killedFor === "timeout") {
354
+ finish({
355
+ exitCode: code,
356
+ finalText: parsed.finalText,
357
+ turns: parsed.turns,
358
+ status: "timeout",
359
+ error: `timed out after ${Math.round(timeoutMs / 60_000)} min${parsed.finalText ? " — partial result kept" : ""}`,
360
+ });
361
+ return;
362
+ }
363
+ if (killedFor === "cancelled") {
364
+ finish({
365
+ exitCode: code,
366
+ finalText: parsed.finalText,
367
+ turns: parsed.turns,
368
+ status: "cancelled",
369
+ error: "cancelled by the parent session",
370
+ });
371
+ return;
372
+ }
373
+ finish({
374
+ exitCode: code,
375
+ finalText: parsed.finalText || (code !== 0 ? stderr.slice(-2_000) : ""),
376
+ turns: parsed.turns,
377
+ status: code === 0 ? "completed" : "failed",
378
+ ...(code !== 0 && !parsed.finalText && stderr.trim() ? { error: stderr.trim().slice(-500) } : {}),
379
+ });
380
+ void signalName;
381
+ });
382
+ });
383
+ });
384
+ }
385
+
386
+ /**
387
+ * Run tasks with a concurrency cap, preserving input order in the
388
+ * results (ports the pi subagent example's mapWithConcurrencyLimit).
389
+ */
390
+ export async function mapWithConcurrency<TIn, TOut>(
391
+ items: TIn[],
392
+ concurrency: number,
393
+ fn: (item: TIn, index: number) => Promise<TOut>,
394
+ ): Promise<TOut[]> {
395
+ if (items.length === 0) return [];
396
+ const limit = Math.max(1, Math.min(concurrency, items.length));
397
+ const results: TOut[] = new Array(items.length);
398
+ let nextIndex = 0;
399
+ const workers = Array.from({ length: limit }, async () => {
400
+ for (;;) {
401
+ const current = nextIndex++;
402
+ if (current >= items.length) return;
403
+ results[current] = await fn(items[current], current);
404
+ }
405
+ });
406
+ await Promise.all(workers);
407
+ return results;
408
+ }
409
+
410
+ /** Structured result text for the parent model (one section per child). */
411
+ export function renderChildResults(results: ChildResult[]): string {
412
+ const succeeded = results.filter((result) => result.status === "completed").length;
413
+ const sections = results.map((result) => {
414
+ const minutes = Math.round((result.durationMs / 60_000) * 10) / 10;
415
+ const header = `### ${result.role} — ${result.status} (${minutes} min${result.outputTruncated ? ", output capped" : ""})`;
416
+ const meta: string[] = [`task: ${result.task}`];
417
+ if (result.error) meta.push(`error: ${result.error}`);
418
+ return `${header}\n${meta.join("\n")}\n\n${result.output || "(no output)"}`;
419
+ });
420
+ return `spawn_agents: ${succeeded}/${results.length} completed\n\n${sections.join("\n\n---\n\n")}`;
421
+ }
@@ -0,0 +1,163 @@
1
+ // omnirush-agents — subagent orchestration (the headline feature).
2
+ //
3
+ // `spawn_agents` delegates tasks to headless child agents running in
4
+ // the SAME workspace:
5
+ // - role presets: code-searcher (read-only exploration),
6
+ // researcher-web (web_fetch research), general-worker (full tools)
7
+ // - parallel execution, default max 4 children at once
8
+ // - per-child wall-clock timeout (default 10 min): kill + keep the
9
+ // partial result
10
+ // - structured results returned to the parent model
11
+ //
12
+ // Tracing is free: every child writes its own session JSONL under
13
+ // <agentDir>/sessions/<cwd-slug>/ (children run without --no-session),
14
+ // and the workspace collector ships every session file it finds as a
15
+ // trace artifact linked via parent_session_id. Children also get
16
+ // OMNIRUSH_PARENT_SESSION so a collector running inside them knows the
17
+ // parent session (linked mode: no second baseline, authoritative
18
+ // parent stamping).
19
+ //
20
+ // `/agents` lists this session's subagents (running + finished).
21
+
22
+ import { StringEnum } from "@earendil-works/pi-ai";
23
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
24
+ import { Type } from "typebox";
25
+
26
+ import {
27
+ AGENT_ROLES,
28
+ CHILD_TIMEOUT_MS,
29
+ mapWithConcurrency,
30
+ MAX_CONCURRENT_CHILDREN,
31
+ MAX_TASKS_PER_CALL,
32
+ renderChildResults,
33
+ runChildAgent,
34
+ type ChildResult,
35
+ type ChildTask,
36
+ } from "./agents-lib";
37
+
38
+ const SpawnAgentsParams = Type.Object({
39
+ tasks: Type.Array(
40
+ Type.Object({
41
+ role: StringEnum(AGENT_ROLES, {
42
+ description: "code-searcher: read-only codebase exploration; researcher-web: web research via web_fetch; general-worker: full-tool implementation work",
43
+ }),
44
+ task: Type.String({ description: "Self-contained task description for the child agent (it sees ONLY this, not your conversation)" }),
45
+ }),
46
+ { description: "Tasks to delegate; they run in parallel", minItems: 1, maxItems: MAX_TASKS_PER_CALL },
47
+ ),
48
+ timeout_minutes: Type.Optional(Type.Number({
49
+ description: `Per-child wall-clock limit in minutes (default ${CHILD_TIMEOUT_MS / 60_000}; a timed-out child is killed and its partial result is kept)`,
50
+ minimum: 0.1,
51
+ maximum: 60,
52
+ })),
53
+ });
54
+
55
+ interface AgentRegistryEntry {
56
+ role: string;
57
+ task: string;
58
+ status: "running" | ChildResult["status"];
59
+ startedAt: number;
60
+ durationMs: number | null;
61
+ }
62
+
63
+ export default function (pi: ExtensionAPI) {
64
+ /** session id -> subagent records (this process only). */
65
+ const registry = new Map<string, AgentRegistryEntry[]>();
66
+
67
+ pi.registerTool({
68
+ name: "spawn_agents",
69
+ label: "Spawn Agents",
70
+ description: [
71
+ "Delegate tasks to parallel subagents with isolated context windows.",
72
+ "Each child is a headless agent run in this workspace with a role preset:",
73
+ "code-searcher (read-only exploration), researcher-web (web research via web_fetch), general-worker (full tools).",
74
+ `Children run in parallel (max ${MAX_CONCURRENT_CHILDREN} at once, ${MAX_TASKS_PER_CALL} tasks per call) and each gets its own timeout (default ${CHILD_TIMEOUT_MS / 60_000} min).`,
75
+ "Every task description must be SELF-CONTAINED: the child cannot see this conversation.",
76
+ "Use for parallelizable work: broad code surveys, independent research questions, independent implementation chunks.",
77
+ ].join(" "),
78
+ promptSnippet: "spawn_agents: delegate parallel tasks to role-preset subagents",
79
+ promptGuidelines: [
80
+ "Use spawn_agents when 2+ independent sub-tasks would each take several tool calls (surveys, research, independent fixes); write each task brief as a self-contained mini-prompt with the goal, relevant paths and the expected report format.",
81
+ "Do not use spawn_agents for a single quick action — doing it yourself is cheaper.",
82
+ ],
83
+ parameters: SpawnAgentsParams,
84
+
85
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
86
+ const tasks: ChildTask[] = [];
87
+ for (const raw of params.tasks ?? []) {
88
+ if (!raw || typeof raw.task !== "string" || !raw.task.trim()) {
89
+ throw new Error("invalid tasks: every entry needs a non-empty task string");
90
+ }
91
+ tasks.push({ role: raw.role, task: raw.task.trim() });
92
+ }
93
+ if (tasks.length === 0) throw new Error("no tasks given");
94
+ if (tasks.length > MAX_TASKS_PER_CALL) {
95
+ throw new Error(`too many tasks (${tasks.length}); the limit is ${MAX_TASKS_PER_CALL} per call`);
96
+ }
97
+ const parentSessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "");
98
+ if (!parentSessionId) throw new Error("no parent session id — subagents cannot be traced");
99
+ const cwd = ctx?.cwd ? String(ctx.cwd) : process.cwd();
100
+ const timeoutMinutes = typeof params.timeout_minutes === "number" && params.timeout_minutes > 0
101
+ ? params.timeout_minutes
102
+ : undefined;
103
+
104
+ const entries = registry.get(parentSessionId) ?? [];
105
+ registry.set(parentSessionId, entries);
106
+ const marks = tasks.map((task) => {
107
+ const entry: AgentRegistryEntry = {
108
+ role: task.role,
109
+ task: task.task,
110
+ status: "running",
111
+ startedAt: Date.now(),
112
+ durationMs: null,
113
+ };
114
+ entries.push(entry);
115
+ return entry;
116
+ });
117
+
118
+ const results = await mapWithConcurrency<ChildTask, ChildResult>(
119
+ tasks,
120
+ MAX_CONCURRENT_CHILDREN,
121
+ async (task, index) => {
122
+ const result = await runChildAgent(task, {
123
+ cwd,
124
+ parentSessionId,
125
+ timeoutMs: timeoutMinutes === undefined ? undefined : timeoutMinutes * 60_000,
126
+ signal,
127
+ });
128
+ const mark = marks[index];
129
+ mark.status = result.status;
130
+ mark.durationMs = result.durationMs;
131
+ onUpdate?.({
132
+ content: [{ type: "text", text: `spawn_agents: ${results.filter(Boolean).length + 1}/${tasks.length} settled (latest: ${task.role})` }],
133
+ details: { results: [...results.filter(Boolean)] },
134
+ });
135
+ return result;
136
+ },
137
+ );
138
+
139
+ return {
140
+ content: [{ type: "text", text: renderChildResults(results) }],
141
+ details: { results },
142
+ };
143
+ },
144
+ });
145
+
146
+ pi.registerCommand("agents", {
147
+ description: "List this session's subagents (running and finished)",
148
+ handler: async (_args: string, ctx: any) => {
149
+ const sessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "");
150
+ const entries = registry.get(sessionId) ?? [];
151
+ if (entries.length === 0) {
152
+ ctx?.ui?.notify?.("No subagents have been spawned in this session yet.");
153
+ return;
154
+ }
155
+ const lines = entries.map((entry, index) => {
156
+ const minutes = Math.round(((entry.durationMs ?? Date.now() - entry.startedAt) / 60_000) * 10) / 10;
157
+ const task = entry.task.length > 60 ? `${entry.task.slice(0, 59)}…` : entry.task;
158
+ return ` ${index + 1}. [${entry.status}] ${entry.role} (${minutes} min) — ${task}`;
159
+ });
160
+ ctx?.ui?.notify?.(`Subagents of this session:\n${lines.join("\n")}`);
161
+ },
162
+ });
163
+ }