pi-famulus 0.1.0

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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/bin/pi-famulus.js +29 -0
  4. package/package.json +49 -0
  5. package/src/agents/builtins.ts +41 -0
  6. package/src/agents/definition.ts +221 -0
  7. package/src/agents/loader.ts +119 -0
  8. package/src/bash-override.ts +372 -0
  9. package/src/behavior-guidelines.ts +42 -0
  10. package/src/clock.ts +169 -0
  11. package/src/comms/comms.ts +199 -0
  12. package/src/comms/mailbox.ts +156 -0
  13. package/src/comms/registry-host.ts +73 -0
  14. package/src/comms/reply-command.ts +52 -0
  15. package/src/comms/routing.ts +23 -0
  16. package/src/comms/tools.ts +339 -0
  17. package/src/comms/types.ts +68 -0
  18. package/src/config.ts +266 -0
  19. package/src/events.ts +61 -0
  20. package/src/exit-notify-gate.ts +69 -0
  21. package/src/exit-watchdog.ts +60 -0
  22. package/src/file-tail.ts +48 -0
  23. package/src/format.ts +276 -0
  24. package/src/index.ts +700 -0
  25. package/src/manager-client.ts +839 -0
  26. package/src/monitor-batching.ts +191 -0
  27. package/src/monitor.ts +517 -0
  28. package/src/native-manager.d.ts +9 -0
  29. package/src/native-manager.js +39 -0
  30. package/src/notify.ts +231 -0
  31. package/src/shell-exec.ts +145 -0
  32. package/src/subagent/agent-records.ts +168 -0
  33. package/src/subagent/child-bash.ts +183 -0
  34. package/src/subagent/conversation.ts +76 -0
  35. package/src/subagent/fleet-widget.ts +176 -0
  36. package/src/subagent/model-spec.ts +108 -0
  37. package/src/subagent/pi-runtime.ts +295 -0
  38. package/src/subagent/pool.ts +190 -0
  39. package/src/subagent/registry.ts +575 -0
  40. package/src/subagent/runner.ts +709 -0
  41. package/src/subagent/tool.ts +741 -0
  42. package/src/subagent/transcript.ts +71 -0
  43. package/src/subagent/types.ts +125 -0
  44. package/src/task-exit-notify.ts +21 -0
  45. package/src/task-tools.ts +330 -0
  46. package/src/tui/message-renderers.ts +183 -0
  47. package/src/tui/pi-tui-load.ts +186 -0
  48. package/src/tui/scroll-detail-view.ts +190 -0
  49. package/src/tui/task-output-paths.ts +46 -0
  50. package/src/tui/tasks-command.ts +515 -0
  51. package/src/tui/tool-component.ts +29 -0
  52. package/src/wake.ts +219 -0
  53. package/src/work-index.ts +182 -0
@@ -0,0 +1,741 @@
1
+ /**
2
+ * subagent tool (design doc §4.6).
3
+ *
4
+ * - `tasks`: parallel worker pool (ordinal-preserved results, fail_fast stops
5
+ * only not-yet-started children);
6
+ * - `chain`: sequential steps with {previous} / {outputs.<label>}
7
+ * interpolation (unknown labels rejected before anything starts);
8
+ * - sync wait bounded by subagentBudgetMs (default 45000, config subagent
9
+ * section); on expiry the run continues in the background and completion is
10
+ * delivered via a <pi-famulus-wake kind="subagent-done"> through the NotifyCenter;
11
+ * - management actions: list / get / status / interrupt / resume / steer.
12
+ *
13
+ * pi-free apart from type-only imports; the registry, runner and session
14
+ * factory are injected.
15
+ */
16
+ import { Type } from "typebox";
17
+ import type { AgentToolResult, ToolDefinition } from "@earendil-works/pi-coding-agent";
18
+ import { formatSubagentHandover, formatSubagentNotification, truncateTail } from "../format";
19
+ import { realClock, type Clock, type ClockTimer } from "../clock";
20
+
21
+ import type { NotifyCenter } from "../notify";
22
+ import { statusGlyph, toolComponent } from "../tui/tool-component";
23
+ import type { WorkIndex } from "../work-index";
24
+ import { runChain, runTasks, validateChainSteps } from "./pool";
25
+ import type { RunRecord, SubagentRegistry } from "./registry";
26
+ import type { AgentDefinition, ChildHandle, ChildResult, ChildRunRequest } from "./types";
27
+
28
+
29
+ /** Overall result text cap (§4.6: truncateTail 512 lines / 48KB). */
30
+ const RESULT_MAX_LINES = 512;
31
+ const RESULT_MAX_BYTES = 48 * 1024;
32
+
33
+ const MAX_TIMEOUT_MS = 3_600_000;
34
+ const MIN_TIMEOUT_MS = 1_000;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Schema (§4.6 field-name contract)
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const taskItem = Type.Object({
41
+ agent: Type.Optional(Type.String({ description: "Agent definition name (default: worker)" })),
42
+ prompt: Type.String({ description: "Task prompt for this subagent" }),
43
+ name: Type.Optional(Type.String({ description: "Display name (default: agent name + ordinal)" })),
44
+ });
45
+
46
+ const chainItem = Type.Object({
47
+ agent: Type.Optional(Type.String({ description: "Agent definition name (default: worker)" })),
48
+ prompt: Type.String({
49
+ description: "Prompt for this step; {previous} and {outputs.<label>} interpolate earlier results",
50
+ }),
51
+ label: Type.Optional(Type.String({ description: "Label for referencing this step's output later" })),
52
+ });
53
+
54
+ const subagentParameters = Type.Object({
55
+ tasks: Type.Optional(
56
+ Type.Array(taskItem, { minItems: 1, maxItems: 10, description: "Subagents to run in parallel" }),
57
+ ),
58
+ chain: Type.Optional(
59
+ Type.Array(chainItem, { minItems: 1, description: "Steps to run sequentially (always awaited)" }),
60
+ ),
61
+ async: Type.Optional(
62
+ Type.Boolean({ description: "Return immediately with a run_id; completion arrives via notification" }),
63
+ ),
64
+ concurrency: Type.Optional(
65
+ Type.Number({ minimum: 1, maximum: 8, description: "Max parallel subagents for tasks (default 4)" }),
66
+ ),
67
+ fail_fast: Type.Optional(
68
+ Type.Boolean({
69
+ description: "Cancel not-yet-started subagents on first failure (already-started ones finish)",
70
+ }),
71
+ ),
72
+ model: Type.Optional(
73
+ Type.String({
74
+ description:
75
+ 'Model override for all subagents: fuzzy ("haiku"), qualified ("provider/id"), ' +
76
+ 'optionally with ":<thinking>" suffix. Default: current model. ' +
77
+ 'Use action:"models" to list selectable values.',
78
+ }),
79
+ ),
80
+ timeout_ms: Type.Optional(
81
+ Type.Number({
82
+ description: `Hard timeout per subagent in ms (default 1800000, max ${MAX_TIMEOUT_MS})`,
83
+ maximum: MAX_TIMEOUT_MS,
84
+ }),
85
+ ),
86
+ action: Type.Optional(
87
+ Type.Union(
88
+ [
89
+ Type.Literal("list"),
90
+ Type.Literal("get"),
91
+ Type.Literal("status"),
92
+ Type.Literal("interrupt"),
93
+ Type.Literal("resume"),
94
+ Type.Literal("steer"),
95
+ Type.Literal("models"),
96
+ ],
97
+ { description: "Manage an existing run (or list selectable models) instead of starting a new one" },
98
+ ),
99
+ ),
100
+ run_id: Type.Optional(Type.String({ description: "Target run for action" })),
101
+ child_id: Type.Optional(Type.String({ description: "Target child (id or name) for steer/interrupt/resume" })),
102
+ message: Type.Optional(Type.String({ description: "Message content for steer/resume" })),
103
+ });
104
+
105
+ type SubagentParams = {
106
+ tasks?: { agent?: string; prompt: string; name?: string }[];
107
+ chain?: { agent?: string; prompt: string; label?: string }[];
108
+ async?: boolean;
109
+ concurrency?: number;
110
+ fail_fast?: boolean;
111
+ model?: string;
112
+ timeout_ms?: number;
113
+ action?: "list" | "get" | "status" | "interrupt" | "resume" | "steer" | "models";
114
+ run_id?: string;
115
+ child_id?: string;
116
+ message?: string;
117
+ };
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Deps
121
+ // ---------------------------------------------------------------------------
122
+
123
+ export interface SubagentToolDeps {
124
+ getRegistry: () => SubagentRegistry | null;
125
+ getNotifyCenter: () => Pick<NotifyCenter, "notify"> | null;
126
+ /** Sync-wait budget in ms (config subagent.budgetMs / subagentBudgetMs). */
127
+ budgetMs: () => number;
128
+ /** Default per-child hard timeout in ms. */
129
+ defaultTimeoutMs: number;
130
+ /** Default tasks worker-pool concurrency. */
131
+ defaultConcurrency: number;
132
+ clock?: Clock;
133
+ /** Live background work; lets a backgrounded run's row redraw as children finish. */
134
+ getIndex?: () => WorkIndex | null;
135
+ /** Agent definition resolver (M5 wires the real loader; default: worker). */
136
+ resolveAgent?: (name: string | undefined) => AgentDefinition;
137
+ /** Selectable models for action:"models" (§4.6); absent → action errors. */
138
+ listModels?: () => {
139
+ provider: string;
140
+ id: string;
141
+ name?: string;
142
+ current: boolean;
143
+ scoped: boolean;
144
+ }[];
145
+ }
146
+
147
+ /** M3 stopgap resolver until M5 wires the real agent loader (§4.8). */
148
+ const BUILTIN_WORKER: AgentDefinition = {
149
+ name: "worker",
150
+ description: "General-purpose subagent with the full default tool set",
151
+ tools: ["read", "bash", "edit", "write"],
152
+ systemPrompt: "",
153
+ source: "builtin",
154
+ };
155
+
156
+ function defaultResolveAgent(name: string | undefined): AgentDefinition {
157
+ if (name === undefined || name === BUILTIN_WORKER.name) return BUILTIN_WORKER;
158
+ throw new Error(`unknown agent "${name}" (available: ${BUILTIN_WORKER.name})`);
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Helpers
163
+ // ---------------------------------------------------------------------------
164
+
165
+ function errorText(message: string): never {
166
+ throw new Error(message);
167
+ }
168
+
169
+ function buildPrompt(agent: AgentDefinition, prompt: string): string {
170
+ const system = agent.systemPrompt.trim();
171
+ if (!system) return prompt;
172
+ return `${system}\n\n---\n\n${prompt}`;
173
+ }
174
+
175
+ function clampTimeout(timeoutMs: number | undefined, fallback: number): number {
176
+ if (timeoutMs === undefined) return fallback;
177
+ if (!Number.isFinite(timeoutMs)) return fallback;
178
+ return Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, Math.round(timeoutMs)));
179
+ }
180
+
181
+ function formatDurationMs(ms: number): string {
182
+ if (ms < 1000) return `${Math.round(ms)}ms`;
183
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
184
+ return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`;
185
+ }
186
+
187
+ function runDurationMs(record: RunRecord, now: number): number {
188
+ const end = record.children.reduce((acc, c) => Math.max(acc, c.endedAt ?? 0), 0);
189
+ return Math.max(0, (end || now) - record.createdAt);
190
+ }
191
+
192
+ /** Per-child sections plus a summary header, capped to 512 lines / 48KB. */
193
+ export function formatRunResults(record: RunRecord, now: number = realClock.now()): string {
194
+ const completed = record.children.filter((c) => c.status === "completed").length;
195
+ const header =
196
+ `Run ${record.runId} [${record.kind}] ${record.status} — ` +
197
+ `${completed}/${record.children.length} subagents completed in ${formatDurationMs(runDurationMs(record, now))}.`;
198
+ const sections = record.children.map((child) => {
199
+ const lines = [`## ${child.name} (${child.status})`];
200
+ if (child.model) lines.push(`Model: ${child.model}`);
201
+ if (child.result?.warning) lines.push(`Warning: ${child.result.warning}`);
202
+ if (child.result?.error) lines.push(`Error: ${child.result.error}`);
203
+ if (child.result) lines.push(child.result.text || "(no output)");
204
+ else lines.push("(still running)");
205
+ return lines.join("\n");
206
+ });
207
+ const full = [header, "", ...sections].join("\n\n");
208
+ const t = truncateTail(full, RESULT_MAX_LINES, RESULT_MAX_BYTES);
209
+ return t.truncated
210
+ ? `… (truncated: showing last ${t.text.split("\n").length} of ${t.totalLines} lines)\n${t.text}`
211
+ : full;
212
+ }
213
+
214
+ function toNotificationInfo(record: RunRecord, now: number) {
215
+ return {
216
+ runId: record.runId,
217
+ status: record.status as "completed" | "partial" | "failed" | "interrupted",
218
+ durationMs: runDurationMs(record, now),
219
+ children: record.children.map((c) => ({
220
+ childId: c.childId,
221
+ name: c.name,
222
+ status: c.status,
223
+ text: c.result?.text ?? "",
224
+ error: c.result?.error,
225
+ ...(c.prompt !== undefined ? { prompt: c.prompt } : {}),
226
+ })),
227
+ };
228
+ }
229
+
230
+ interface RaceOutcome<T> {
231
+ done: boolean;
232
+ value?: T;
233
+ }
234
+
235
+ /** Race a promise against the foreground budget and the tool abort signal. */
236
+ function raceBudget<T>(
237
+ promise: Promise<T>,
238
+ budgetMs: number,
239
+ signal: AbortSignal | undefined,
240
+ clock: Clock,
241
+ ): Promise<RaceOutcome<T>> {
242
+ return new Promise((resolve) => {
243
+ let finished = false;
244
+ let timer: ClockTimer;
245
+ const cleanup = () => {
246
+ clock.clearTimeout(timer);
247
+ signal?.removeEventListener("abort", onAbort);
248
+ };
249
+ const settle = (outcome: RaceOutcome<T>) => {
250
+ if (finished) return;
251
+ finished = true;
252
+ cleanup();
253
+ resolve(outcome);
254
+ };
255
+ timer = clock.setTimeout(() => settle({ done: false }), budgetMs);
256
+ clock.unref?.(timer);
257
+ const onAbort = () => settle({ done: false });
258
+ if (signal?.aborted) {
259
+ settle({ done: false });
260
+ return;
261
+ }
262
+ signal?.addEventListener("abort", onAbort, { once: true });
263
+ promise.then(
264
+ (value) => settle({ done: true, value }),
265
+ () => settle({ done: false }),
266
+ );
267
+ });
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Tool factory
272
+ // ---------------------------------------------------------------------------
273
+
274
+ export function createSubagentTool(
275
+ deps: SubagentToolDeps,
276
+ ): ToolDefinition<typeof subagentParameters, unknown> {
277
+ const resolveAgent = deps.resolveAgent ?? defaultResolveAgent;
278
+ const clock = deps.clock ?? realClock;
279
+
280
+ const requireRegistry = (): SubagentRegistry => {
281
+ const registry = deps.getRegistry();
282
+ if (!registry) {
283
+ throw new Error("subagent system is not initialized (no active session)");
284
+ }
285
+ return registry;
286
+ };
287
+
288
+ const notifyRunCompleted = (registry: SubagentRegistry, runId: string): void => {
289
+ const record = registry.get(runId);
290
+ if (!record) return;
291
+ deps.getNotifyCenter()?.notify(formatSubagentNotification(toNotificationInfo(record, clock.now())));
292
+ };
293
+
294
+ /**
295
+ * A child settled while siblings are still in flight. Returns true when a
296
+ * handover was sent. The run-complete notification covers the last child.
297
+ */
298
+ const notifyChildHandover = (
299
+ registry: SubagentRegistry,
300
+ runId: string,
301
+ childId: string,
302
+ handedOver: Set<string>,
303
+ ): boolean => {
304
+ if (handedOver.has(childId)) return false;
305
+ const record = registry.get(runId);
306
+ if (!record) return false;
307
+ const child = record.children.find((c) => c.childId === childId);
308
+ if (!child || child.status === "pending" || child.status === "running") return false;
309
+ const stillRunning = record.children
310
+ .filter((c) => c.status === "pending" || c.status === "running")
311
+ .map((c) => ({ id: c.childId, title: c.name }));
312
+ if (stillRunning.length === 0) return false;
313
+ handedOver.add(childId);
314
+ deps.getNotifyCenter()?.notify(
315
+ formatSubagentHandover({
316
+ runId,
317
+ childId: child.childId,
318
+ name: child.name,
319
+ status: child.status,
320
+ prompt: child.prompt ?? "",
321
+ text: child.result?.text ?? "",
322
+ ...(child.result?.error !== undefined ? { error: child.result.error } : {}),
323
+ stillRunning,
324
+ }),
325
+ );
326
+ return true;
327
+ };
328
+
329
+ const startRun = async (
330
+ params: SubagentParams,
331
+ signal: AbortSignal | undefined,
332
+ ): Promise<AgentToolResult<unknown>> => {
333
+ const registry = requireRegistry();
334
+ const kind = params.tasks !== undefined ? ("tasks" as const) : ("chain" as const);
335
+ const items = (params.tasks ?? params.chain)!;
336
+
337
+ if (kind === "chain") validateChainSteps(items);
338
+
339
+ const timeoutMs = clampTimeout(params.timeout_ms, deps.defaultTimeoutMs);
340
+ const concurrency = Math.min(
341
+ 8,
342
+ Math.max(1, Math.round(params.concurrency ?? deps.defaultConcurrency)),
343
+ );
344
+ const failFast = params.fail_fast === true;
345
+
346
+ const resolved = items.map((item, i) => {
347
+ const agent = resolveAgent(item.agent);
348
+ const label = "name" in item ? item.name : undefined;
349
+ const chainLabel = "label" in item ? item.label : undefined;
350
+ const name = label ?? chainLabel ?? `${agent.name}-${i + 1}`;
351
+ return { agent, name, prompt: item.prompt };
352
+ });
353
+
354
+ const run = registry.createRun(kind);
355
+ const childIds = resolved.map((r) =>
356
+ registry.addChild(run.runId, { name: r.name, agent: r.agent.name }),
357
+ );
358
+
359
+ const makeRequest = (ordinal: number, prompt: string): ChildRunRequest => ({
360
+ childId: childIds[ordinal],
361
+ runId: run.runId,
362
+ name: resolved[ordinal].name,
363
+ prompt: buildPrompt(resolved[ordinal].agent, prompt),
364
+ taskPrompt: prompt,
365
+ agent: resolved[ordinal].agent,
366
+ model: params.model,
367
+ timeoutMs,
368
+ depth: 1,
369
+ });
370
+
371
+ const handedOver = new Set<string>();
372
+ let backgrounded = false;
373
+ const armBackground = (): void => {
374
+ backgrounded = true;
375
+ for (const childId of childIds) {
376
+ notifyChildHandover(registry, run.runId, childId, handedOver);
377
+ }
378
+ };
379
+ const watchChild = (ordinal: number, started: Promise<ChildHandle>): Promise<ChildResult> =>
380
+ started.then((handle) => {
381
+ void handle.result.then(() => {
382
+ if (!backgrounded) return;
383
+ notifyChildHandover(registry, run.runId, childIds[ordinal], handedOver);
384
+ });
385
+ return handle.result;
386
+ });
387
+
388
+ const completion: Promise<ChildResult[]> =
389
+ kind === "tasks"
390
+ ? runTasks(items, {
391
+ concurrency,
392
+ failFast,
393
+ startChild: (_task, ordinal, ctx) =>
394
+ watchChild(
395
+ ordinal,
396
+ registry.startChild(makeRequest(ordinal, resolved[ordinal].prompt), {
397
+ shouldStart: () => !ctx.cancelled(),
398
+ }),
399
+ ),
400
+ })
401
+ : runChain(items, {
402
+ startChild: (_step, ordinal, interpolated) =>
403
+ watchChild(ordinal, registry.startChild(makeRequest(ordinal, interpolated))),
404
+ });
405
+
406
+ const tracked = completion.then((results) => {
407
+ registry.finalizeRun(
408
+ run.runId,
409
+ kind === "tasks" ? "cancelled (fail_fast)" : "skipped (chain aborted)",
410
+ );
411
+ return results;
412
+ });
413
+
414
+ // Completion notification: only when the result was not delivered
415
+ // synchronously (backgrounded or async runs, and aborted waits).
416
+ let deliveredSync = false;
417
+ let settleSync: () => void = () => {};
418
+ const syncSettled = new Promise<void>((resolve) => {
419
+ settleSync = resolve;
420
+ });
421
+ void tracked.then(async () => {
422
+ await syncSettled;
423
+ if (!deliveredSync) notifyRunCompleted(registry, run.runId);
424
+ });
425
+
426
+ const backgroundedText = (reason: string): AgentToolResult<unknown> => ({
427
+ content: [
428
+ {
429
+ type: "text",
430
+ text:
431
+ `Started ${items.length} subagent(s) in run ${run.runId}. ${reason}\n` +
432
+ `While others are still running, each finished subagent arrives as <pi-famulus-wake kind="subagent-handover"> ` +
433
+ `with that child's prompt and result. Read it and continue: subagent({action:"resume", run_id, child_id, message}) for that child, ` +
434
+ `or agent_message to steer the ones still running. Do not wait for the whole run. Do not poll. ` +
435
+ `<pi-famulus-wake kind="subagent-done"> arrives when every subagent in the run has finished. ` +
436
+ `Use subagent({action:"get", run_id:"${run.runId}"}) if you need the full record.`,
437
+ },
438
+ ],
439
+ details: { run_id: run.runId, status: "backgrounded" },
440
+ });
441
+
442
+ if (params.async === true) {
443
+ armBackground();
444
+ settleSync();
445
+ return backgroundedText("The run is executing in the background.");
446
+ }
447
+
448
+ const budgetMs = deps.budgetMs();
449
+ const outcome = await raceBudget(tracked, budgetMs, signal, clock);
450
+ if (outcome.done) {
451
+ deliveredSync = true;
452
+ settleSync();
453
+ const record = registry.get(run.runId);
454
+ const text = record
455
+ ? formatRunResults(record)
456
+ : `Run ${run.runId} finished but its record is gone.`;
457
+ return {
458
+ content: [{ type: "text", text }],
459
+ details: { run_id: run.runId, status: record?.status ?? "completed", results: outcome.value },
460
+ };
461
+ }
462
+ armBackground();
463
+ settleSync();
464
+ return backgroundedText(
465
+ `The foreground budget (${Math.round(budgetMs / 1000)}s) elapsed and the run continues in the background.`,
466
+ );
467
+ };
468
+
469
+ // -------------------------------------------------------------------------
470
+ // Management actions
471
+ // ---------------------------------------------------------------------------
472
+
473
+ const requireRun = (registry: SubagentRegistry, runId: string | undefined): RunRecord => {
474
+ if (!runId) throw new Error(`run_id is required for this action`);
475
+ const record = registry.get(runId);
476
+ if (!record) {
477
+ const known = registry
478
+ .list()
479
+ .map((r) => r.runId)
480
+ .join(", ");
481
+ throw new Error(`unknown run_id "${runId}" (known runs: ${known || "none"})`);
482
+ }
483
+ return record;
484
+ };
485
+
486
+ const runAction = async (params: SubagentParams): Promise<AgentToolResult<unknown>> => {
487
+ const action = params.action!;
488
+
489
+ // "models" needs neither a registry nor a run: list selectable models.
490
+ if (action === "models") {
491
+ if (!deps.listModels) {
492
+ return errorText('action "models" is not available in this session');
493
+ }
494
+ const models = deps.listModels();
495
+ if (models.length === 0) {
496
+ return { content: [{ type: "text", text: "No models available." }], details: { models: [] } };
497
+ }
498
+ const scoped = models.some((m) => m.scoped);
499
+ const lines = models.map((m) => {
500
+ const marks = [m.current ? "current" : "", m.scoped ? "scoped" : ""]
501
+ .filter(Boolean)
502
+ .join(", ");
503
+ return `${m.provider}/${m.id}${m.name ? ` — ${m.name}` : ""}${marks ? ` (${marks})` : ""}`;
504
+ });
505
+ const header = scoped
506
+ ? `${models.length} selectable model(s) (whitelist via enabledModels/--models is active):`
507
+ : `${models.length} selectable model(s):`;
508
+ return {
509
+ content: [{ type: "text", text: `${header}\n${lines.join("\n")}` }],
510
+ details: { models },
511
+ };
512
+ }
513
+
514
+ const registry = requireRegistry();
515
+
516
+ if (action === "list") {
517
+ const runs = registry.list();
518
+ if (runs.length === 0) {
519
+ return { content: [{ type: "text", text: "No subagent runs in this session." }], details: { runs: [] } };
520
+ }
521
+ const now = clock.now();
522
+ const lines = runs.map((run) => {
523
+ const counts = new Map<string, number>();
524
+ for (const c of run.children) counts.set(c.status, (counts.get(c.status) ?? 0) + 1);
525
+ const summary = [...counts.entries()].map(([s, n]) => `${n} ${s}`).join(", ");
526
+ const ago = formatDurationMs(now - run.createdAt);
527
+ return `${run.runId} [${run.kind}] ${run.status} — ${run.children.length} children (${summary}), created ${ago} ago`;
528
+ });
529
+ return {
530
+ content: [{ type: "text", text: `${runs.length} run(s):\n${lines.join("\n")}` }],
531
+ details: { runs },
532
+ };
533
+ }
534
+
535
+ const record = requireRun(registry, params.run_id);
536
+
537
+ if (action === "get") {
538
+ return {
539
+ content: [{ type: "text", text: formatRunResults(record, clock.now()) }],
540
+ details: { run_id: record.runId, status: record.status },
541
+ };
542
+ }
543
+
544
+ if (action === "status") {
545
+ const now = clock.now();
546
+ const lines = record.children.map((child) => {
547
+ const elapsed = formatDurationMs((child.endedAt ?? now) - child.startedAt);
548
+ let line = `${child.name} (${child.childId}): ${child.status}, ${elapsed} elapsed`;
549
+ if (child.model) line += `, model ${child.model}`;
550
+ if (child.status === "running" || child.status === "pending") {
551
+ const last = registry.handle(child.childId)?.lastEventAt() ?? child.startedAt;
552
+ line += `, last event ${formatDurationMs(now - last)} ago`;
553
+ }
554
+ if (child.result?.error) line += `, error: ${child.result.error}`;
555
+ return line;
556
+ });
557
+ const header = `Run ${record.runId} [${record.kind}] ${record.status}:`;
558
+ return {
559
+ content: [{ type: "text", text: [header, ...lines].join("\n") }],
560
+ details: { run_id: record.runId, status: record.status },
561
+ };
562
+ }
563
+
564
+ if (action === "interrupt") {
565
+ const targets = record.children.filter((c) => {
566
+ if (params.child_id && c.childId !== params.child_id && c.name !== params.child_id) return false;
567
+ return c.status === "running" || c.status === "pending";
568
+ });
569
+ if (params.child_id && !record.children.some((c) => c.childId === params.child_id || c.name === params.child_id)) {
570
+ const known = record.children.map((c) => `${c.name} (${c.childId})`).join(", ");
571
+ throw new Error(`no child "${params.child_id}" in run ${record.runId} (children: ${known})`);
572
+ }
573
+ for (const child of targets) {
574
+ await registry.handle(child.childId)?.interrupt();
575
+ }
576
+ const scope = params.child_id ? `subagent ${params.child_id}` : `${targets.length} subagent(s)`;
577
+ return {
578
+ content: [{ type: "text", text: `Interrupted ${scope} in run ${record.runId}.` }],
579
+ details: { run_id: record.runId, interrupted: targets.map((c) => c.childId) },
580
+ };
581
+ }
582
+
583
+ if (action === "steer") {
584
+ if (!params.message) throw new Error("message is required for steer");
585
+ const handle = resolveSingleActiveChild(registry, record, params.child_id, "steer");
586
+ await handle.steer(params.message);
587
+ return {
588
+ content: [{ type: "text", text: `Steered subagent in run ${record.runId}: delivered "${params.message}".` }],
589
+ details: { run_id: record.runId },
590
+ };
591
+ }
592
+
593
+ // resume
594
+ if (!params.message) throw new Error("message is required for resume");
595
+ const child = resolveSingleTerminalChild(record, params.child_id);
596
+ const handle = registry.handle(child.childId);
597
+ if (!handle) throw new Error(`subagent ${child.childId} has no live session to resume`);
598
+ await handle.resume(params.message);
599
+ // Resume is asynchronous. If siblings are still running, hand this child
600
+ // back as soon as it finishes; otherwise the run-complete wake covers it.
601
+ const handedOver = new Set<string>();
602
+ void registry.getResult(child.childId)?.then(() => {
603
+ if (!notifyChildHandover(registry, record.runId, child.childId, handedOver)) {
604
+ notifyRunCompleted(registry, record.runId);
605
+ }
606
+ });
607
+ return {
608
+ content: [
609
+ {
610
+ type: "text",
611
+ text:
612
+ `Resumed subagent ${child.name} (${child.childId}) in run ${record.runId}. ` +
613
+ "You will be notified via <pi-famulus-wake kind=\"subagent-handover\"> if others are still running, " +
614
+ "otherwise via <pi-famulus-wake kind=\"subagent-done\"> when it completes. Do not poll.",
615
+ },
616
+ ],
617
+ details: { run_id: record.runId, child_id: child.childId },
618
+ };
619
+ };
620
+
621
+ return {
622
+ name: "subagent",
623
+ label: "Subagent",
624
+ description:
625
+ "Run subagents in parallel (tasks) or sequentially (chain with {previous}/{outputs.<label>} " +
626
+ "interpolation). By default the call waits up to a foreground budget (default 45s); longer runs " +
627
+ "continue in the background. Each child that finishes while others are still running wakes you with " +
628
+ "<pi-famulus-wake kind=\"subagent-handover\"> (its prompt and result). The whole run wakes you with <pi-famulus-wake kind=\"subagent-done\">. " +
629
+ "Never poll or sleep to wait. Use action=list/get/status/interrupt/resume/steer to manage existing runs.",
630
+ promptSnippet: "Fan out subagents in parallel or sequence them in a chain",
631
+ promptGuidelines: [
632
+ 'When a <pi-famulus-wake kind="subagent-handover"> arrives, read <prompt> and <result> immediately and continue: subagent({action:"resume", run_id, child_id, message}) for that child, or agent_message to steer children that are still running. Do not wait for the rest of the run.',
633
+ "Subagent runs that exceed the foreground budget continue in the background; you are notified per finished child and again when the run completes — do not poll.",
634
+ "A failed subagent does not fail the whole run; inspect per-subagent sections in the result.",
635
+ "<pi-famulus-wake> is a system wake, not a user reply. kind=subagent-handover is one child; kind=subagent-done is the whole run.",
636
+ ],
637
+ parameters: subagentParameters,
638
+ renderResult(result, { expanded }, theme, context) {
639
+ const details = result.details as { run_id?: string; status?: string } | undefined;
640
+ const record = details?.run_id ? deps.getRegistry()?.get(details.run_id) : undefined;
641
+ if (details?.status === "backgrounded" && record) {
642
+ // UI row only: one line per child with live status. The model-facing
643
+ // instructions stay in result.content.
644
+ const index = deps.getIndex?.() ?? null;
645
+ const state = context.state as { unsubscribe?: () => void };
646
+ const active = record.children.some((c) => c.status === "pending" || c.status === "running");
647
+ if (index && !state.unsubscribe && active) {
648
+ state.unsubscribe = index.onChange(() => {
649
+ const now = deps.getRegistry()?.get(record.runId);
650
+ if (!now || now.children.every((c) => c.status !== "pending" && c.status !== "running")) {
651
+ state.unsubscribe?.();
652
+ state.unsubscribe = () => {};
653
+ }
654
+ context.invalidate();
655
+ });
656
+ }
657
+ const now = clock.now();
658
+ const lines = [theme.fg("muted", `run ${record.runId} · ${record.status} · /tasks`)];
659
+ for (const c of record.children) {
660
+ const g = statusGlyph(c.status);
661
+ const age = formatDurationMs((c.endedAt ?? now) - c.startedAt);
662
+ const err = c.result?.error ? ` · ${c.result.error}` : "";
663
+ lines.push(` ${theme.fg(g.color as never, g.glyph)} ${c.name} ${theme.fg("dim", `${c.status} ${age}${err}`)}`);
664
+ }
665
+ return toolComponent(lines) as never;
666
+ }
667
+ const text = result.content
668
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
669
+ .map((c) => c.text)
670
+ .join("\n");
671
+ const all = text.split("\n");
672
+ const shown = expanded ? all : all.slice(0, 10);
673
+ const out = shown.map((l) => theme.fg("toolOutput", l));
674
+ if (shown.length < all.length) out.push(theme.fg("muted", `... (${all.length - shown.length} more lines, ctrl+o to expand)`));
675
+ return toolComponent(out) as never;
676
+ },
677
+ async execute(_toolCallId, rawParams, signal, _onUpdate, _ctx) {
678
+ const params = rawParams as SubagentParams;
679
+ const hasTasks = params.tasks !== undefined;
680
+ const hasChain = params.chain !== undefined;
681
+ const hasAction = params.action !== undefined;
682
+ if (hasAction && (hasTasks || hasChain)) {
683
+ return errorText("action is mutually exclusive with tasks/chain");
684
+ }
685
+ if (hasTasks && hasChain) {
686
+ return errorText("tasks and chain are mutually exclusive");
687
+ }
688
+ if (!hasAction && !hasTasks && !hasChain) {
689
+ return errorText("one of tasks, chain, or action is required");
690
+ }
691
+ if (hasAction) return runAction(params);
692
+ return startRun(params, signal);
693
+ },
694
+ };
695
+ }
696
+
697
+ function resolveSingleActiveChild(
698
+ registry: SubagentRegistry,
699
+ record: RunRecord,
700
+ childIdOrName: string | undefined,
701
+ what: string,
702
+ ) {
703
+ if (childIdOrName) {
704
+ const handle = registry.findChild(record.runId, childIdOrName);
705
+ if (!handle) {
706
+ const known = record.children.map((c) => `${c.name} (${c.childId})`).join(", ");
707
+ throw new Error(`no child "${childIdOrName}" in run ${record.runId} (children: ${known})`);
708
+ }
709
+ return handle;
710
+ }
711
+ const active = record.children.filter((c) => c.status === "running");
712
+ if (active.length === 1) {
713
+ const handle = registry.handle(active[0].childId);
714
+ if (handle) return handle;
715
+ }
716
+ throw new Error(
717
+ `cannot ${what}: run ${record.runId} has ${active.length} running subagents; specify child_id ` +
718
+ `(${record.children.map((c) => `${c.name} (${c.childId}): ${c.status}`).join(", ") || "none"})`,
719
+ );
720
+ }
721
+
722
+ function resolveSingleTerminalChild(record: RunRecord, childIdOrName: string | undefined) {
723
+ const matches = (c: RunRecord["children"][number]) =>
724
+ c.status === "completed" || c.status === "failed" || c.status === "interrupted";
725
+ if (childIdOrName) {
726
+ const child = record.children.find((c) => c.childId === childIdOrName || c.name === childIdOrName);
727
+ if (!child) {
728
+ const known = record.children.map((c) => `${c.name} (${c.childId})`).join(", ");
729
+ throw new Error(`no child "${childIdOrName}" in run ${record.runId} (children: ${known})`);
730
+ }
731
+ if (!matches(child)) {
732
+ throw new Error(`subagent ${child.name} (${child.childId}) is still ${child.status}; use steer instead`);
733
+ }
734
+ return child;
735
+ }
736
+ const terminal = record.children.filter(matches);
737
+ if (terminal.length === 1 && record.children.length === 1) return terminal[0];
738
+ throw new Error(
739
+ `resume requires child_id (children: ${record.children.map((c) => `${c.name} (${c.childId}): ${c.status}`).join(", ") || "none"})`,
740
+ );
741
+ }