omnirush 0.8.3 → 0.8.5

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