omnirush 0.8.4 → 0.8.6

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.
@@ -14,6 +14,12 @@
14
14
  //
15
15
  // Children get OMNIRUSH_PARENT_SESSION=<parent session id> in their
16
16
  // environment: a child process uploads nothing itself.
17
+ //
18
+ // A child has no wall-clock limit unless the caller sets one: it runs until
19
+ // it is done. The only automatic stop is an inactivity watchdog (no output
20
+ // at all for CHILD_INACTIVITY_MS, longer while a tool runs). AgentManager
21
+ // runs children blocking or in the background and hands finished
22
+ // background results back for delivery to the parent.
17
23
 
18
24
  import { spawn as nodeSpawn } from "node:child_process";
19
25
  import { randomUUID } from "node:crypto";
@@ -22,8 +28,36 @@ import { mkdtemp, writeFile, rm } from "node:fs/promises";
22
28
  import { tmpdir } from "node:os";
23
29
  import path from "node:path";
24
30
 
25
- /** Default per-child wall clock budget. */
26
- export const CHILD_TIMEOUT_MS = 10 * 60_000;
31
+ import { childAuthEnv } from "./auth";
32
+
33
+ /** childAuthEnv, never throwing (a spawn must not fail on the auth file). */
34
+ function childAuthEnvSafe(): Record<string, string> {
35
+ try {
36
+ return childAuthEnv(process.env);
37
+ } catch {
38
+ return {};
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Inactivity watchdog default: a child that prints nothing (no model
44
+ * stream, no tool event) for this long is treated as hung and stopped.
45
+ * There is no wall-clock limit by default (a child works as long as it
46
+ * needs); OMNIRUSH_AGENT_INACTIVITY_MINUTES overrides the window and 0
47
+ * turns the watchdog off.
48
+ */
49
+ export const CHILD_INACTIVITY_MS = 30 * 60_000;
50
+ /** While a child's tool call runs (a build, a polling `sleep`), its silence window is this many times longer. */
51
+ export const TOOL_INACTIVITY_FACTOR = 4;
52
+
53
+ /** The watchdog window from the environment (ms; 0 = off). */
54
+ export function childInactivityMs(env: NodeJS.ProcessEnv = process.env): number {
55
+ const raw = String(env.OMNIRUSH_AGENT_INACTIVITY_MINUTES ?? "").trim().toLowerCase();
56
+ if (!raw) return CHILD_INACTIVITY_MS;
57
+ if (raw === "off" || raw === "0") return 0;
58
+ const minutes = Number(raw);
59
+ return Number.isFinite(minutes) && minutes > 0 ? Math.round(minutes * 60_000) : CHILD_INACTIVITY_MS;
60
+ }
27
61
  /** Grace between SIGTERM and SIGKILL on timeout. */
28
62
  export const CHILD_KILL_GRACE_MS = 5_000;
29
63
  /** Per-child output cap in the structured result (bytes, UTF-8). */
@@ -32,6 +66,24 @@ export const CHILD_OUTPUT_CAP_BYTES = 50 * 1024;
32
66
  export const AGENT_ROLES = ["code-searcher", "researcher-web", "general-worker"] as const;
33
67
  export type AgentRole = (typeof AGENT_ROLES)[number];
34
68
 
69
+ /**
70
+ * Resolve the model selected for the current parent session. Pi keeps the
71
+ * effort level separately from the model id, while the CLI accepts the
72
+ * canonical `model:effort` spelling for child argv.
73
+ */
74
+ export function modelSelectionFromContext(ctx: any): string | undefined {
75
+ const model = ctx?.model;
76
+ const id = typeof model?.id === "string"
77
+ ? model.id.trim()
78
+ : typeof model?.modelID === "string"
79
+ ? model.modelID.trim()
80
+ : "";
81
+ if (!id) return undefined;
82
+ const effort = typeof ctx?.thinkingLevel === "string" ? ctx.thinkingLevel.trim() : "";
83
+ if (!effort || effort === "off" || id.includes(":")) return id;
84
+ return `${id}:${effort}`;
85
+ }
86
+
35
87
  export interface RolePreset {
36
88
  role: AgentRole;
37
89
  label: string;
@@ -133,10 +185,10 @@ export function buildChildArgs(
133
185
  * Write a role's system prompt to a private temp file and run `body`
134
186
  * with its path; the file is cleaned up afterwards either way.
135
187
  */
136
- export async function withRolePromptFile(
188
+ export async function withRolePromptFile<T>(
137
189
  role: AgentRole,
138
- body: (promptFilePath: string) => Promise<ChildResultInput>,
139
- ): Promise<ChildResultInput> {
190
+ body: (promptFilePath: string) => Promise<T>,
191
+ ): Promise<T> {
140
192
  let dir: string | null = null;
141
193
  try {
142
194
  dir = await mkdtemp(path.join(tmpdir(), "omnirush-agent-"));
@@ -157,7 +209,7 @@ export interface ChildTask {
157
209
  model?: string;
158
210
  }
159
211
 
160
- export type ChildStatus = "completed" | "failed" | "timeout" | "cancelled";
212
+ export type ChildStatus = "completed" | "failed" | "timeout" | "stalled" | "cancelled" | "interrupted";
161
213
 
162
214
  export interface ChildResult {
163
215
  role: AgentRole;
@@ -264,8 +316,25 @@ export function parseChildEvents(stdout: string): ParsedChildEvents {
264
316
  export interface SpawnChildOptions {
265
317
  cwd: string;
266
318
  parentSessionId: string;
319
+ /**
320
+ * Explicit wall-clock limit (the tool's `timeout_minutes`). Undefined =
321
+ * none: a child runs until it is done, stalls (inactivityMs) or is
322
+ * cancelled.
323
+ */
267
324
  timeoutMs?: number;
268
- /** Kill the child when this fires (the tool's cancellation signal). */
325
+ /**
326
+ * Inactivity watchdog: a child that prints no event for this long is
327
+ * treated as hung and killed (status "stalled"). While one of its tools
328
+ * is running (a long build, a `sleep` poll), the window is
329
+ * TOOL_INACTIVITY_FACTOR times longer. Default: childInactivityMs(env);
330
+ * 0 disables the watchdog.
331
+ */
332
+ inactivityMs?: number;
333
+ /**
334
+ * Kill the child when this fires (cancellation). An abort whose reason is
335
+ * "interrupted" (the session ended under it) reports status
336
+ * "interrupted"; any other abort reports "cancelled".
337
+ */
269
338
  signal?: AbortSignal;
270
339
  /** Grace between SIGTERM and SIGKILL when killing (default CHILD_KILL_GRACE_MS). */
271
340
  killGraceMs?: number;
@@ -273,23 +342,52 @@ export interface SpawnChildOptions {
273
342
  now?: () => number;
274
343
  /** Progress callback: partial output lines as the child runs. */
275
344
  onChildStdout?: (role: AgentRole, chunk: string) => void;
345
+ /** Every sign of life from the child (any output), with its event types. */
346
+ onActivity?: (activity: ChildActivity) => void;
276
347
  /** The child's session id (a fresh UUID by default). */
277
348
  sessionId?: string;
278
349
  }
279
350
 
351
+ export interface ChildActivity {
352
+ at: number;
353
+ /** Assistant messages finished so far. */
354
+ turns: number;
355
+ /** Tool calls running right now. */
356
+ toolsRunning: number;
357
+ }
358
+
359
+ /**
360
+ * The collector reaches the running sub-agents through this global (it must
361
+ * stop them before its last capture; pi runs its session_shutdown handler
362
+ * before the agents extension's).
363
+ */
364
+ export const AGENTS_HOOK = Symbol.for("omnirush.agents");
365
+ export interface AgentsHook {
366
+ /** Stop every unfinished child (of one session, or all); resolves with them once they exited. */
367
+ interruptAll(parentSessionId?: string): Promise<Array<{ sessionId: string; id: string; role: string }>>;
368
+ }
369
+
370
+ /** Reason passed to AbortController.abort() when the session ends under a child. */
371
+ export const INTERRUPTED_REASON = "interrupted";
372
+
280
373
  /**
281
- * Run ONE child agent to completion (or timeout/cancellation). The
282
- * child inherits the parent's environment plus OMNIRUSH_PARENT_SESSION.
283
- * On timeout: SIGTERM, a short grace, then SIGKILL; whatever the child
284
- * printed so far is kept as the partial result.
374
+ * Run ONE child agent to completion. No wall-clock limit unless the caller
375
+ * sets timeoutMs; the only automatic kill is the inactivity watchdog (a
376
+ * child silent for the whole window). The child inherits the parent's
377
+ * environment plus OMNIRUSH_PARENT_SESSION. On a kill: SIGTERM, a short
378
+ * grace, then SIGKILL; whatever the child printed so far is kept as the
379
+ * partial result.
285
380
  */
286
381
  export async function runChildAgent(
287
382
  task: ChildTask,
288
383
  options: SpawnChildOptions,
289
384
  ): Promise<ChildResult> {
290
- const preset = ROLE_PRESETS[task.role] ?? ROLE_PRESETS["general-worker"];
291
- const startedAt = (options.now ?? Date.now)();
292
- const timeoutMs = Math.max(1_000, options.timeoutMs ?? CHILD_TIMEOUT_MS);
385
+ const now = options.now ?? Date.now;
386
+ const startedAt = now();
387
+ const timeoutMs = typeof options.timeoutMs === "number" && options.timeoutMs > 0
388
+ ? Math.max(1_000, options.timeoutMs)
389
+ : null;
390
+ const inactivityMs = options.inactivityMs ?? childInactivityMs();
293
391
  const spawnImpl = options.spawnImpl ?? nodeSpawn;
294
392
  const sessionId = options.sessionId ?? randomUUID();
295
393
 
@@ -300,8 +398,14 @@ export async function runChildAgent(
300
398
  return await new Promise<ChildResult>((resolvePromise) => {
301
399
  let stdout = "";
302
400
  let stderr = "";
401
+ let pendingLine = "";
303
402
  let settled = false;
304
- let killedFor: "timeout" | "cancelled" | null = null;
403
+ let killedFor: "timeout" | "stalled" | "cancelled" | "interrupted" | null = null;
404
+ let turns = 0;
405
+ const toolsRunning = new Set<string>();
406
+ let lastActivity = now();
407
+ let watchdog: ReturnType<typeof setTimeout> | null = null;
408
+ let timer: ReturnType<typeof setTimeout> | null = null;
305
409
 
306
410
  const child = spawnImpl(invocation.command, invocation.args, {
307
411
  cwd: options.cwd,
@@ -309,6 +413,10 @@ export async function runChildAgent(
309
413
  stdio: ["ignore", "pipe", "pipe"],
310
414
  env: {
311
415
  ...process.env,
416
+ // Shared credentials by location (OMNIRUSH_DIR), never a token
417
+ // frozen at the parent's launch: the child re-reads auth.json
418
+ // for every request and takes part in the refresh lock.
419
+ ...childAuthEnvSafe(),
312
420
  OMNIRUSH_PARENT_SESSION: options.parentSessionId,
313
421
  },
314
422
  });
@@ -316,13 +424,12 @@ export async function runChildAgent(
316
424
  const finish = (input: ChildResultInput) => {
317
425
  if (settled) return;
318
426
  settled = true;
319
- clearTimeout(timer);
427
+ if (timer) clearTimeout(timer);
428
+ if (watchdog) clearTimeout(watchdog);
320
429
  if (options.signal && abortHandler) {
321
430
  options.signal.removeEventListener("abort", abortHandler);
322
431
  }
323
- resolvePromise(
324
- buildChildResult(task, input, (options.now ?? Date.now)() - startedAt, sessionId),
325
- );
432
+ resolvePromise(buildChildResult(task, input, now() - startedAt, sessionId));
326
433
  };
327
434
 
328
435
  const killTree = () => {
@@ -340,32 +447,68 @@ export async function runChildAgent(
340
447
  }, Math.max(50, options.killGraceMs ?? CHILD_KILL_GRACE_MS)).unref?.();
341
448
  };
342
449
 
343
- const timer = setTimeout(() => {
344
- if (settled) return;
345
- killedFor = "timeout";
450
+ const kill = (reason: NonNullable<typeof killedFor>) => {
451
+ if (settled || killedFor) return;
452
+ killedFor = reason;
346
453
  killTree();
347
- }, timeoutMs);
348
- timer.unref?.();
454
+ };
455
+
456
+ // The watchdog re-arms on every sign of life; it fires only after a
457
+ // full window of silence (longer while a tool is running).
458
+ const armWatchdog = () => {
459
+ if (!(inactivityMs > 0) || settled || killedFor) return;
460
+ if (watchdog) clearTimeout(watchdog);
461
+ const window = toolsRunning.size > 0 ? inactivityMs * TOOL_INACTIVITY_FACTOR : inactivityMs;
462
+ watchdog = setTimeout(() => kill("stalled"), window);
463
+ watchdog.unref?.();
464
+ };
465
+ const alive = () => {
466
+ lastActivity = now();
467
+ armWatchdog();
468
+ options.onActivity?.({ at: lastActivity, turns, toolsRunning: toolsRunning.size });
469
+ };
470
+ armWatchdog();
471
+
472
+ if (timeoutMs !== null) {
473
+ timer = setTimeout(() => kill("timeout"), timeoutMs);
474
+ timer.unref?.();
475
+ }
349
476
 
350
477
  const abortHandler = options.signal
351
- ? () => {
352
- if (settled) return;
353
- killedFor = "cancelled";
354
- killTree();
355
- }
478
+ ? () => kill(options.signal?.reason === INTERRUPTED_REASON ? "interrupted" : "cancelled")
356
479
  : null;
357
480
  if (options.signal && abortHandler) {
358
481
  if (options.signal.aborted) abortHandler();
359
482
  else options.signal.addEventListener("abort", abortHandler, { once: true });
360
483
  }
361
484
 
485
+ // Tool calls in flight and finished turns, from the event lines.
486
+ const observe = (line: string) => {
487
+ if (!line.includes('"type"')) return;
488
+ let event: any;
489
+ try {
490
+ event = JSON.parse(line);
491
+ } catch {
492
+ return;
493
+ }
494
+ if (event?.type === "tool_execution_start" && typeof event.toolCallId === "string") toolsRunning.add(event.toolCallId);
495
+ else if (event?.type === "tool_execution_end" && typeof event.toolCallId === "string") toolsRunning.delete(event.toolCallId);
496
+ else if (event?.type === "message_end" && event.message?.role === "assistant") turns += 1;
497
+ };
498
+
362
499
  child.stdout?.on("data", (chunk: Buffer | string) => {
363
500
  const text = String(chunk);
364
501
  stdout += text;
502
+ const lines = (pendingLine + text).split("\n");
503
+ pendingLine = lines.pop() ?? "";
504
+ for (const line of lines) observe(line);
505
+ alive();
365
506
  options.onChildStdout?.(task.role, text);
366
507
  });
367
508
  child.stderr?.on("data", (chunk: Buffer | string) => {
368
509
  stderr += String(chunk);
510
+ if (stderr.length > 256 * 1024) stderr = stderr.slice(-64 * 1024);
511
+ alive();
369
512
  });
370
513
  child.on("error", (error: Error) => {
371
514
  finish({
@@ -376,26 +519,18 @@ export async function runChildAgent(
376
519
  error: `failed to start: ${error.message}`,
377
520
  });
378
521
  });
379
- child.on("close", (code: number | null, signalName: string | null) => {
522
+ child.on("close", (code: number | null) => {
380
523
  const parsed = parseChildEvents(stdout);
381
- if (killedFor === "timeout") {
382
- finish({
383
- exitCode: code,
384
- finalText: parsed.finalText,
385
- turns: parsed.turns,
386
- status: "timeout",
387
- error: `timed out after ${Math.round(timeoutMs / 60_000)} min${parsed.finalText ? " — partial result kept" : ""}`,
388
- });
389
- return;
390
- }
391
- if (killedFor === "cancelled") {
392
- finish({
393
- exitCode: code,
394
- finalText: parsed.finalText,
395
- turns: parsed.turns,
396
- status: "cancelled",
397
- error: "cancelled by the parent session",
398
- });
524
+ const partial = parsed.finalText ? " — partial result kept" : "";
525
+ if (killedFor) {
526
+ const error = killedFor === "timeout"
527
+ ? `timed out after ${formatMinutes(timeoutMs ?? 0)}${partial}`
528
+ : killedFor === "stalled"
529
+ ? `stopped: no activity for ${formatMinutes(toolsRunning.size > 0 ? inactivityMs * TOOL_INACTIVITY_FACTOR : inactivityMs)}${partial}`
530
+ : killedFor === "interrupted"
531
+ ? `interrupted: the parent session ended${partial}`
532
+ : `cancelled by the parent session${partial}`;
533
+ finish({ exitCode: code, finalText: parsed.finalText, turns: parsed.turns, status: killedFor, error });
399
534
  return;
400
535
  }
401
536
  finish({
@@ -405,12 +540,17 @@ export async function runChildAgent(
405
540
  status: code === 0 ? "completed" : "failed",
406
541
  ...(code !== 0 && !parsed.finalText && stderr.trim() ? { error: stderr.trim().slice(-500) } : {}),
407
542
  });
408
- void signalName;
409
543
  });
410
544
  });
411
545
  });
412
546
  }
413
547
 
548
+ function formatMinutes(ms: number): string {
549
+ const minutes = ms / 60_000;
550
+ if (minutes >= 1) return `${Math.round(minutes * 10) / 10} min`;
551
+ return `${Math.max(1, Math.round(ms / 1000))} s`;
552
+ }
553
+
414
554
  /**
415
555
  * Run tasks with a concurrency cap, preserving input order in the
416
556
  * results (ports the pi subagent example's mapWithConcurrencyLimit).
@@ -436,14 +576,396 @@ export async function mapWithConcurrency<TIn, TOut>(
436
576
  }
437
577
 
438
578
  /** Structured result text for the parent model (one section per child). */
439
- export function renderChildResults(results: ChildResult[]): string {
579
+ export function renderChildResults(results: ChildResult[], ids?: readonly string[]): string {
440
580
  const succeeded = results.filter((result) => result.status === "completed").length;
441
- const sections = results.map((result) => {
581
+ const sections = results.map((result, index) => {
442
582
  const minutes = Math.round((result.durationMs / 60_000) * 10) / 10;
443
- const header = `### ${result.role}${result.model ? ` [${result.model}]` : ""} — ${result.status} (${minutes} min${result.outputTruncated ? ", output capped" : ""})`;
583
+ const header = `### ${ids?.[index] ? `${ids[index]} ` : ""}${result.role}${result.model ? ` [${result.model}]` : ""} — ${result.status} (${minutes} min${result.outputTruncated ? ", output capped" : ""})`;
444
584
  const meta: string[] = [`task: ${result.task}`];
445
585
  if (result.error) meta.push(`error: ${result.error}`);
446
586
  return `${header}\n${meta.join("\n")}\n\n${result.output || "(no output)"}`;
447
587
  });
448
588
  return `spawn_agents: ${succeeded}/${results.length} completed\n\n${sections.join("\n\n---\n\n")}`;
449
589
  }
590
+
591
+ // --- sub-agent manager: blocking and background dispatch --------------------
592
+
593
+ export type AgentState = "queued" | "running" | ChildStatus;
594
+
595
+ /** One sub-agent of a session, running or finished. */
596
+ export interface AgentRecord {
597
+ /** Short handle the model uses with the agents_* tools ("a1", "a2", ...). */
598
+ id: string;
599
+ /** The spawn_agents call it came from ("b1", ...). */
600
+ batch: string;
601
+ parentSessionId: string;
602
+ /** The child's pi session id (its trace is captured under it). */
603
+ sessionId: string;
604
+ role: AgentRole;
605
+ task: string;
606
+ model?: string;
607
+ /** Dispatched with wait:false: its result comes back as a message. */
608
+ background: boolean;
609
+ /** Null means the batch was intentionally uncapped; otherwise the batch limit. */
610
+ parallelLimit: number | null;
611
+ /** Background delivery: one message per child, or one per batch. */
612
+ notify: "each" | "batch";
613
+ status: AgentState;
614
+ queuedAt: number;
615
+ startedAt: number | null;
616
+ finishedAt: number | null;
617
+ lastActivityAt: number;
618
+ turns: number;
619
+ toolsRunning: number;
620
+ result: ChildResult | null;
621
+ /** Its result reached the model (tool result or delivered message). */
622
+ delivered: boolean;
623
+ controller: AbortController;
624
+ done: Promise<ChildResult>;
625
+ }
626
+
627
+ export type AgentRunner = (
628
+ task: ChildTask,
629
+ options: {
630
+ sessionId: string;
631
+ parentSessionId: string;
632
+ signal: AbortSignal;
633
+ onActivity: (activity: ChildActivity) => void;
634
+ /** The call's explicit timeout_minutes, if any. */
635
+ timeoutMs?: number;
636
+ cwd?: string;
637
+ },
638
+ ) => Promise<ChildResult>;
639
+
640
+ export interface DispatchOptions {
641
+ background: boolean;
642
+ /** Children running at once (default: all of them). */
643
+ concurrency?: number;
644
+ notify?: "each" | "batch";
645
+ /** Blocking calls: the tool's signal cancels the children. */
646
+ signal?: AbortSignal;
647
+ /** A child settled (progress updates). */
648
+ onSettled?: (record: AgentRecord) => void;
649
+ /** A child was queued, started, updated, or settled. */
650
+ onChanged?: () => void;
651
+ /** Explicit wall-clock limit per child (none by default). */
652
+ timeoutMs?: number;
653
+ /** Workspace the children run in. */
654
+ cwd?: string;
655
+ }
656
+
657
+ /**
658
+ * Tracks every sub-agent of this process per parent session: runs them
659
+ * (through an injected runner), answers status/wait/result/cancel, and
660
+ * queues finished background results for delivery back to the parent
661
+ * (`takeDeliverable`, signalled through `onDeliverable`).
662
+ */
663
+ export class AgentManager {
664
+ private readonly runner: AgentRunner;
665
+ private readonly now: () => number;
666
+ private readonly onDeliverable: () => void;
667
+ private readonly onChanged: () => void;
668
+ private readonly bySession = new Map<string, AgentRecord[]>();
669
+ private waiters: Array<{ records: AgentRecord[]; resolve: () => void }> = [];
670
+ private changeWaiters: Array<() => void> = [];
671
+ private readonly resolvers = new Map<AgentRecord, (result: ChildResult) => void>();
672
+ private nextAgent = 1;
673
+ private nextBatch = 1;
674
+
675
+ constructor(options: { run: AgentRunner; now?: () => number; onDeliverable?: () => void; onChanged?: () => void }) {
676
+ this.runner = options.run;
677
+ this.now = options.now ?? Date.now;
678
+ this.onDeliverable = options.onDeliverable ?? (() => undefined);
679
+ this.onChanged = options.onChanged ?? (() => undefined);
680
+ }
681
+
682
+ dispatch(
683
+ parentSessionId: string,
684
+ tasks: ChildTask[],
685
+ options: DispatchOptions,
686
+ ): { batch: string; records: AgentRecord[]; done: Promise<ChildResult[]> } {
687
+ const batch = `b${this.nextBatch++}`;
688
+ const list = this.bySession.get(parentSessionId) ?? [];
689
+ this.bySession.set(parentSessionId, list);
690
+ const at = this.now();
691
+ const records = tasks.map((task) => {
692
+ let resolveDone!: (result: ChildResult) => void;
693
+ const done = new Promise<ChildResult>((resolve) => { resolveDone = resolve; });
694
+ const record: AgentRecord = {
695
+ id: `a${this.nextAgent++}`,
696
+ batch,
697
+ parentSessionId,
698
+ sessionId: randomUUID(),
699
+ role: task.role,
700
+ task: task.task,
701
+ ...(task.model ? { model: task.model } : {}),
702
+ background: options.background,
703
+ parallelLimit: options.concurrency === undefined ? null : Math.max(1, Math.floor(options.concurrency)),
704
+ notify: options.notify ?? "batch",
705
+ status: "queued",
706
+ queuedAt: at,
707
+ startedAt: null,
708
+ finishedAt: null,
709
+ lastActivityAt: at,
710
+ turns: 0,
711
+ toolsRunning: 0,
712
+ result: null,
713
+ // A blocking call returns its results itself.
714
+ delivered: !options.background,
715
+ controller: new AbortController(),
716
+ done,
717
+ };
718
+ this.resolvers.set(record, resolveDone);
719
+ list.push(record);
720
+ return record;
721
+ });
722
+ this.onChanged();
723
+ options.onChanged?.();
724
+ if (options.signal) {
725
+ const abort = () => { void this.cancel(records); };
726
+ if (options.signal.aborted) abort();
727
+ else options.signal.addEventListener("abort", abort, { once: true });
728
+ }
729
+ const concurrency = Math.max(1, Math.floor(options.concurrency ?? records.length));
730
+ void mapWithConcurrency(records, concurrency, async (record, index) => {
731
+ // Cancelled while it waited for a slot: it never starts.
732
+ if (!record.result) await this.start(record, tasks[index], options);
733
+ options.onSettled?.(record);
734
+ });
735
+ return { batch, records, done: Promise.all(records.map((record) => record.done)) };
736
+ }
737
+
738
+ private async start(record: AgentRecord, task: ChildTask, options: DispatchOptions): Promise<void> {
739
+ record.status = "running";
740
+ record.startedAt = this.now();
741
+ record.lastActivityAt = record.startedAt;
742
+ this.onChanged();
743
+ options.onChanged?.();
744
+ let result: ChildResult;
745
+ try {
746
+ result = await this.runner(task, {
747
+ sessionId: record.sessionId,
748
+ parentSessionId: record.parentSessionId,
749
+ signal: record.controller.signal,
750
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
751
+ ...(options.cwd ? { cwd: options.cwd } : {}),
752
+ onActivity: (activity) => {
753
+ record.lastActivityAt = activity.at;
754
+ record.turns = activity.turns;
755
+ record.toolsRunning = activity.toolsRunning;
756
+ this.onChanged();
757
+ options.onChanged?.();
758
+ },
759
+ });
760
+ } catch (error) {
761
+ result = buildChildResult(task, {
762
+ exitCode: null,
763
+ finalText: "",
764
+ turns: record.turns,
765
+ status: "failed",
766
+ error: error instanceof Error ? error.message : String(error),
767
+ }, this.now() - (record.startedAt ?? this.now()), record.sessionId);
768
+ }
769
+ this.settle(record, result);
770
+ }
771
+
772
+ private settle(record: AgentRecord, result: ChildResult): void {
773
+ if (record.result) return;
774
+ record.result = result;
775
+ record.status = result.status;
776
+ record.finishedAt = this.now();
777
+ record.turns = result.turns;
778
+ record.toolsRunning = 0;
779
+ this.onChanged();
780
+ // The dispatch callback is not available here; settle callers also invoke
781
+ // their onSettled hook immediately after start() returns.
782
+ this.resolvers.get(record)?.(result);
783
+ this.resolvers.delete(record);
784
+ // Waiters first: what they return is delivered through their tool result.
785
+ const still: typeof this.waiters = [];
786
+ for (const waiter of this.waiters) {
787
+ if (waiter.records.every((candidate) => candidate.result)) {
788
+ waiter.records.forEach((candidate) => { candidate.delivered = true; });
789
+ waiter.resolve();
790
+ } else {
791
+ still.push(waiter);
792
+ }
793
+ }
794
+ this.waiters = still;
795
+ const changed = this.changeWaiters;
796
+ this.changeWaiters = [];
797
+ changed.forEach((resolve) => resolve());
798
+ if (record.background && !record.delivered) this.onDeliverable();
799
+ }
800
+
801
+ list(parentSessionId: string): AgentRecord[] {
802
+ return [...(this.bySession.get(parentSessionId) ?? [])];
803
+ }
804
+
805
+ /** Records by id (or all of the session's when `ids` is empty); unknown ids are listed apart. */
806
+ resolve(parentSessionId: string, ids: readonly string[] | undefined): { records: AgentRecord[]; unknown: string[] } {
807
+ const all = this.list(parentSessionId);
808
+ if (!ids || ids.length === 0 || ids.includes("all")) return { records: all, unknown: [] };
809
+ const records: AgentRecord[] = [];
810
+ const unknown: string[] = [];
811
+ for (const raw of ids) {
812
+ const id = String(raw).trim();
813
+ const record = all.find((candidate) => candidate.id === id || candidate.sessionId === id);
814
+ if (record && !records.includes(record)) records.push(record);
815
+ else if (!record) unknown.push(id);
816
+ }
817
+ return { records, unknown };
818
+ }
819
+
820
+ /** Children not finished yet (queued or running), of one session or of all. */
821
+ active(parentSessionId?: string): AgentRecord[] {
822
+ const lists = parentSessionId ? [this.list(parentSessionId)] : [...this.bySession.values()];
823
+ return lists.flat().filter((record) => !record.result);
824
+ }
825
+
826
+ /** Background results nobody has seen yet, of one session or of all. */
827
+ hasPending(parentSessionId?: string): boolean {
828
+ const lists = parentSessionId ? [this.list(parentSessionId)] : [...this.bySession.values()];
829
+ return lists.flat().some((record) => record.background && !record.delivered);
830
+ }
831
+
832
+ /**
833
+ * Finished background results ready to go back to the parent, grouped per
834
+ * delivery (a whole batch, or one child with notify "each"); marked
835
+ * delivered.
836
+ */
837
+ takeDeliverable(parentSessionId?: string): Array<{ parentSessionId: string; records: AgentRecord[] }> {
838
+ const out: Array<{ parentSessionId: string; records: AgentRecord[] }> = [];
839
+ const sessions = parentSessionId ? [parentSessionId] : [...this.bySession.keys()];
840
+ for (const session of sessions) {
841
+ const records = this.list(session).filter((record) => record.background);
842
+ const batches = new Map<string, AgentRecord[]>();
843
+ for (const record of records) batches.set(record.batch, [...(batches.get(record.batch) ?? []), record]);
844
+ for (const members of batches.values()) {
845
+ const ready = members.filter((record) => record.result && !record.delivered);
846
+ if (ready.length === 0) continue;
847
+ if (members[0].notify === "batch" && members.some((record) => !record.result)) continue;
848
+ ready.forEach((record) => { record.delivered = true; });
849
+ out.push({ parentSessionId: session, records: ready });
850
+ }
851
+ }
852
+ return out;
853
+ }
854
+
855
+ /**
856
+ * Wait until every one of `records` finished, the wait's own limit passed
857
+ * or `signal` fired (the children keep running then). Finished ones are
858
+ * marked delivered: the caller returns them.
859
+ */
860
+ async wait(records: AgentRecord[], options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<void> {
861
+ if (!records.every((record) => record.result)) {
862
+ await new Promise<void>((resolve) => {
863
+ let timer: ReturnType<typeof setTimeout> | null = null;
864
+ const waiter = {
865
+ records,
866
+ resolve: () => {
867
+ if (timer) clearTimeout(timer);
868
+ options.signal?.removeEventListener("abort", stop);
869
+ resolve();
870
+ },
871
+ };
872
+ const stop = () => {
873
+ this.waiters = this.waiters.filter((candidate) => candidate !== waiter);
874
+ waiter.resolve();
875
+ };
876
+ this.waiters.push(waiter);
877
+ if (options.timeoutMs && options.timeoutMs > 0) timer = setTimeout(stop, options.timeoutMs);
878
+ if (options.signal?.aborted) stop();
879
+ else options.signal?.addEventListener("abort", stop, { once: true });
880
+ });
881
+ }
882
+ records.filter((record) => record.result).forEach((record) => { record.delivered = true; });
883
+ }
884
+
885
+ /** Resolves at the next child that settles. */
886
+ changed(): Promise<void> {
887
+ return new Promise((resolve) => this.changeWaiters.push(resolve));
888
+ }
889
+
890
+ /** Stop children (SIGTERM, then SIGKILL) and wait for them to exit. */
891
+ async cancel(records: AgentRecord[], reason: string = "cancelled", options: { delivered?: boolean } = {}): Promise<void> {
892
+ const live = records.filter((record) => !record.result);
893
+ // The caller hands the results over itself: no delivery message.
894
+ if (options.delivered) live.forEach((record) => { record.delivered = true; });
895
+ for (const record of live) {
896
+ record.controller.abort(reason);
897
+ if (record.startedAt === null) {
898
+ // Still waiting for a slot: settled here, it never starts.
899
+ this.settle(record, buildChildResult(record, {
900
+ exitCode: null,
901
+ finalText: "",
902
+ turns: 0,
903
+ status: reason === INTERRUPTED_REASON ? "interrupted" : "cancelled",
904
+ error: "cancelled before it started",
905
+ }, 0, record.sessionId));
906
+ }
907
+ }
908
+ await Promise.all(live.map((record) => record.done));
909
+ }
910
+
911
+ /** The session ends: every unfinished child is interrupted; returns them once they exited. */
912
+ async interruptAll(parentSessionId?: string): Promise<AgentRecord[]> {
913
+ const live = this.active(parentSessionId);
914
+ // Nobody is left to read them.
915
+ await this.cancel(live, INTERRUPTED_REASON, { delivered: true });
916
+ return live;
917
+ }
918
+ }
919
+
920
+ /** Minutes, rounded for status lines. */
921
+ function minutesOf(ms: number): number {
922
+ return Math.round((ms / 60_000) * 10) / 10;
923
+ }
924
+
925
+ /** Compact status used by both the tool stream and the TUI status bar. */
926
+ export function agentStatusSummary(records: AgentRecord[]): string {
927
+ if (records.length === 0) return "No sub-agents in this session.";
928
+ const running = records.filter((record) => record.status === "running").length;
929
+ const queued = records.filter((record) => record.status === "queued").length;
930
+ const settled = records.filter((record) => Boolean(record.result)).length;
931
+ const limits = [...new Set(records.map((record) => record.parallelLimit))];
932
+ const parallel = limits.length === 1 && limits[0] === null
933
+ ? "unlimited"
934
+ : limits.length === 1
935
+ ? String(limits[0])
936
+ : "per-batch";
937
+ return `${records.length} sub-agents: ${running} running, ${queued} queued, ${settled} settled (parallel: ${parallel})`;
938
+ }
939
+
940
+ /** One status line per sub-agent (agents_status, /agents). */
941
+ export function renderAgentStatus(records: AgentRecord[], now: number = Date.now()): string {
942
+ if (records.length === 0) return "No sub-agents in this session.";
943
+ const lines = records.map((record) => {
944
+ const since = record.startedAt ?? record.queuedAt;
945
+ const elapsed = minutesOf((record.finishedAt ?? now) - since);
946
+ const bits = [`${record.id}`, `[${record.status}]`, record.role];
947
+ if (record.model) bits.push(`[${record.model}]`);
948
+ bits.push(record.background ? "background" : "blocking");
949
+ const detail: string[] = [`${elapsed} min`];
950
+ if (!record.result && record.startedAt) {
951
+ detail.push(`${record.turns} turns`);
952
+ if (record.toolsRunning > 0) detail.push("tool running");
953
+ detail.push(`last activity ${minutesOf(now - record.lastActivityAt)} min ago`);
954
+ }
955
+ if (record.result && !record.delivered) detail.push("result not yet read");
956
+ const task = record.task.length > 80 ? `${record.task.slice(0, 79)}…` : record.task;
957
+ return `- ${bits.join(" ")} (${detail.join(", ")}) — ${task}`;
958
+ });
959
+ return `${agentStatusSummary(records)}:\n${lines.join("\n")}`;
960
+ }
961
+
962
+ /** The message that brings finished background agents back to the parent. */
963
+ export function renderDelivery(records: AgentRecord[]): string {
964
+ const results = records.map((record) => record.result).filter((result): result is ChildResult => Boolean(result));
965
+ const ids = records.map((record) => record.id).join(", ");
966
+ return [
967
+ `Background sub-agents finished (${ids}). Their results follow; continue your work with them.`,
968
+ "",
969
+ renderChildResults(results, records.map((record) => record.id)),
970
+ ].join("\n");
971
+ }