omnirush 0.9.0 → 0.10.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.
@@ -6,7 +6,9 @@
6
6
  // running pi entry — same runtime, same global extension/config dir)
7
7
  // in the parent's workspace, with `--mode json -p` so their output is
8
8
  // a parseable event stream. Each child writes its own session JSONL
9
- // under <agentDir>/sessions/<cwd-slug>/, under a session id the parent
9
+ // under <parent's session dir>/subagents/ (subagentSessionDir: kept out
10
+ // of the folder /resume and --continue read, so a user only ever sees
11
+ // their own conversations there), under a session id the parent
10
12
  // picks (`--session-id`) and returns in the result (`session_id`): the
11
13
  // parent session's collector reads the child's session file from it and
12
14
  // records the child as a sub-agent of the turn (session.child events,
@@ -37,6 +39,7 @@ import {
37
39
  type SubagentFallback,
38
40
  } from "./subagents-lib";
39
41
  import { childAuthEnv } from "./auth";
42
+ import { sanitizeToolEnvironment } from "./secret-env";
40
43
  import { yoloActive } from "./yolo-lib";
41
44
 
42
45
  /** childAuthEnv, never throwing (a spawn must not fail on the auth file). */
@@ -71,6 +74,47 @@ export function childInactivityMs(env: NodeJS.ProcessEnv = process.env): number
71
74
  export const CHILD_KILL_GRACE_MS = 5_000;
72
75
  /** Per-child output cap in the structured result (bytes, UTF-8). */
73
76
  export const CHILD_OUTPUT_CAP_BYTES = 50 * 1024;
77
+ /**
78
+ * Finished sub-agents whose result reached the model keep their output (up
79
+ * to CHILD_OUTPUT_CAP_BYTES each) for agents_result; beyond this many per
80
+ * session, the oldest ones' output is let go (a swarm session runs
81
+ * hundreds of children).
82
+ */
83
+ export const MAX_KEPT_OUTPUTS = 200;
84
+ /** What a released output reads. */
85
+ export const RELEASED_OUTPUT = "(output no longer kept in memory: it was delivered to the conversation earlier)";
86
+
87
+ /**
88
+ * Sub-agent layers below the main session (the desktop app's
89
+ * OMNIRUSH_SUBAGENT_DEPTH): layers 1 and 2 may delegate, layer 3 may not.
90
+ * The session capture records exactly these layers (collector.ts).
91
+ */
92
+ export const MAX_SUBAGENT_DEPTH = 3;
93
+ /** The layer a process runs at, handed down to every child (+1 per layer). */
94
+ export const ENV_AGENT_DEPTH = "OMNIRUSH_AGENT_DEPTH";
95
+
96
+ /**
97
+ * This process's sub-agent layer: 0 for a main session. A child of a parent
98
+ * that predates the depth variable counts as layer 1.
99
+ */
100
+ export function agentDepth(env: NodeJS.ProcessEnv = process.env): number {
101
+ const text = String(env[ENV_AGENT_DEPTH] ?? "").trim();
102
+ const raw = /^\d+$/.test(text) ? Number(text) : NaN;
103
+ if (Number.isSafeInteger(raw)) return raw;
104
+ return String(env.OMNIRUSH_PARENT_SESSION ?? "").trim() ? 1 : 0;
105
+ }
106
+
107
+ /** Whether a process at `depth` may start sub-agents. */
108
+ export function canDelegate(depth: number): boolean {
109
+ return depth < MAX_SUBAGENT_DEPTH;
110
+ }
111
+
112
+ /** System text for a sub-agent: its layer, and whether it may delegate. */
113
+ export function subagentLayerNote(depth: number): string {
114
+ return canDelegate(depth)
115
+ ? `You are a sub-agent: layer ${depth} of at most ${MAX_SUBAGENT_DEPTH} below the main session. You may delegate independent parts of your task with spawn_agents (your sub-agents are layer ${depth + 1}); otherwise do the work yourself.`
116
+ : `You are a sub-agent: layer ${depth} of at most ${MAX_SUBAGENT_DEPTH} below the main session. You cannot delegate further (there is no spawn_agents at this layer): do the work yourself.`;
117
+ }
74
118
 
75
119
  export const AGENT_ROLES = ["code-searcher", "researcher-web", "general-worker"] as const;
76
120
  export type AgentRole = (typeof AGENT_ROLES)[number];
@@ -161,6 +205,25 @@ export function childInvocation(args: string[]): {
161
205
  return { command: "pi", args };
162
206
  }
163
207
 
208
+ /** The folder, inside a session dir, that holds its sub-agents' sessions. */
209
+ export const SUBAGENT_SESSIONS_DIR = "subagents";
210
+
211
+ /**
212
+ * Where a child's session file goes: <parent's session dir>/subagents/.
213
+ * The core's session picker (/resume) and --continue only read the
214
+ * *.jsonl files directly in a session dir, so children stored one level
215
+ * down never show up as sessions to resume. A sub-agent's own children
216
+ * (nested) share its folder: a parent that is itself a sub-agent
217
+ * (OMNIRUSH_PARENT_SESSION set) already runs there. Undefined when the
218
+ * parent has no session dir (--no-session): the child keeps the core's
219
+ * default, and the launcher tidies such stragglers (src/sessions.js).
220
+ */
221
+ export function subagentSessionDir(parentSessionDir: unknown, env: NodeJS.ProcessEnv = process.env): string | undefined {
222
+ if (typeof parentSessionDir !== "string" || !parentSessionDir.trim()) return undefined;
223
+ if (String(env.OMNIRUSH_PARENT_SESSION ?? "").trim()) return parentSessionDir;
224
+ return path.join(parentSessionDir, SUBAGENT_SESSIONS_DIR);
225
+ }
226
+
164
227
  /**
165
228
  * Build the child's argv for one task: JSON print mode with the role's
166
229
  * system prompt appended via a temp file path (the caller writes and
@@ -175,6 +238,7 @@ export function buildChildArgs(
175
238
  sessionId?: string,
176
239
  effort?: string | null,
177
240
  approve = false,
241
+ sessionDir?: string,
178
242
  ): string[] {
179
243
  const args: string[] = ["--mode", "json", "-p"];
180
244
  if (approve) {
@@ -187,6 +251,10 @@ export function buildChildArgs(
187
251
  // be found and captured as this session's sub-agent.
188
252
  args.push("--session-id", sessionId);
189
253
  }
254
+ if (sessionDir) {
255
+ // Out of the user's resume list (subagentSessionDir).
256
+ args.push("--session-dir", sessionDir);
257
+ }
190
258
  if (promptFilePath) {
191
259
  args.push("--append-system-prompt", promptFilePath);
192
260
  }
@@ -351,38 +419,164 @@ export interface ParsedChildEvents {
351
419
  turns: number;
352
420
  }
353
421
 
422
+ /** Characters of an event line read to tell its type (and role / tool call id) apart. */
423
+ export const CHILD_EVENT_HEAD_CHARS = 512;
354
424
  /**
355
- * Parse the accumulated stdout of a `pi --mode json -p` run: one JSON
356
- * event per line; `message_end` events carry the messages. Unparseable
357
- * lines (banners, noise) are ignored.
425
+ * Longest event line kept whole for parsing (characters). Only an
426
+ * assistant `message_end` is ever kept; one longer than this (a huge tool
427
+ * call argument) still counts as a turn, its text is not read.
358
428
  */
359
- export function parseChildEvents(stdout: string): ParsedChildEvents {
360
- let finalText = "";
361
- let turns = 0;
362
- for (const line of stdout.split("\n")) {
429
+ export const MAX_CHILD_EVENT_LINE_CHARS = 4 * 1024 * 1024;
430
+
431
+ const EVENT_TYPE_RE = /^\s*\{\s*"type"\s*:\s*"([A-Za-z_]+)"/;
432
+ const TOOL_CALL_ID_RE = /"toolCallId"\s*:\s*"((?:[^"\\]|\\.)*)"/;
433
+ const ROLE_RE = /^\s*\{\s*"type"\s*:\s*"message_end"\s*,\s*"message"\s*:\s*\{\s*"role"\s*:\s*"([A-Za-z_]+)"/;
434
+
435
+ /**
436
+ * Streaming reader of a `pi --mode json -p` child's stdout: one JSON event
437
+ * per line. Only what the parent needs is kept — the last assistant text,
438
+ * the assistant turn count and the tool calls in flight — and every other
439
+ * event (streaming deltas, tool results, turn_end / agent_end, which repeat
440
+ * the whole conversation) is skipped as it streams by, without buffering
441
+ * it. A child's stream is several times its conversation (agent_end alone
442
+ * repeats all of it), so keeping it whole cost the parent that much memory
443
+ * per child. Unparseable lines (banners, noise) are ignored.
444
+ */
445
+ export class ChildEventScanner {
446
+ finalText = "";
447
+ turns = 0;
448
+ /** Monotonic count of model/tool events that represent real progress. */
449
+ progressSerial = 0;
450
+ readonly toolsRunning = new Set<string>();
451
+ private line = "";
452
+ /** head: deciding from the first characters; keep: buffering the whole line; skip: dropping it. */
453
+ private mode: "head" | "keep" | "skip" = "head";
454
+ private lineType: string | null = null;
455
+ private lineRole: string | null = null;
456
+
457
+ /** Feed a decoded chunk of stdout. */
458
+ push(text: string): void {
459
+ let start = 0;
460
+ while (start <= text.length) {
461
+ const newline = text.indexOf("\n", start);
462
+ const end = newline < 0 ? text.length : newline;
463
+ if (end > start) this.feed(text.slice(start, end));
464
+ if (newline < 0) break;
465
+ this.endLine();
466
+ start = newline + 1;
467
+ }
468
+ }
469
+
470
+ /** The stream ended: a last line without a newline still counts. */
471
+ end(): void {
472
+ if (this.line || this.mode !== "head") this.endLine();
473
+ }
474
+
475
+ private feed(part: string): void {
476
+ if (this.mode === "skip") return;
477
+ this.line += part;
478
+ if (this.mode === "head" && this.line.length >= CHILD_EVENT_HEAD_CHARS) this.decide();
479
+ if (this.mode === "keep" && this.line.length > MAX_CHILD_EVENT_LINE_CHARS) {
480
+ // Too long to keep: an assistant message still counts as a turn.
481
+ if (this.lineType === "message_end" && this.lineRole === "assistant") this.turns += 1;
482
+ this.line = "";
483
+ this.mode = "skip";
484
+ }
485
+ }
486
+
487
+ /** Classify the line from its head: keep it, take what it says from the head, or skip it. */
488
+ private decide(): void {
489
+ const head = this.line.slice(0, CHILD_EVENT_HEAD_CHARS);
490
+ const type = EVENT_TYPE_RE.exec(head)?.[1] ?? null;
491
+ this.lineType = type;
492
+ if (type === "message_update" || type === "message_start" || type === "turn_start") this.progressSerial += 1;
493
+ if (type === "message_end") {
494
+ const role = ROLE_RE.exec(head)?.[1] ?? null;
495
+ this.lineRole = role;
496
+ // Only an assistant message carries the final text; a role further
497
+ // into the object than the head (not how pi writes it) is kept too.
498
+ if (role !== null && role !== "assistant") this.skip();
499
+ else this.mode = "keep";
500
+ return;
501
+ }
502
+ if (type === "tool_execution_start" || type === "tool_execution_end") {
503
+ const id = TOOL_CALL_ID_RE.exec(head)?.[1];
504
+ if (id !== undefined) {
505
+ this.tool(type, JSON.parse(`"${id}"`));
506
+ this.skip();
507
+ } else {
508
+ this.mode = "keep";
509
+ }
510
+ return;
511
+ }
512
+ this.skip();
513
+ }
514
+
515
+ private skip(): void {
516
+ this.line = "";
517
+ this.mode = "skip";
518
+ }
519
+
520
+ private tool(type: string, id: string): void {
521
+ this.progressSerial += 1;
522
+ if (type === "tool_execution_start") this.toolsRunning.add(id);
523
+ else this.toolsRunning.delete(id);
524
+ }
525
+
526
+ private endLine(): void {
527
+ const line = this.line;
528
+ const mode = this.mode;
529
+ this.line = "";
530
+ this.mode = "head";
531
+ this.lineType = null;
532
+ this.lineRole = null;
533
+ if (mode === "skip") return;
363
534
  const trimmed = line.trim();
364
- if (!trimmed) continue;
535
+ if (!trimmed || !trimmed.includes('"type"')) return;
536
+ // A short line: skip the types nothing is read from without parsing them.
537
+ const type = mode === "head" ? EVENT_TYPE_RE.exec(trimmed)?.[1] : undefined;
538
+ if (type && type !== "message_end" && type !== "message_update" && type !== "message_start" && type !== "turn_start" && type !== "tool_execution_start" && type !== "tool_execution_end") return;
365
539
  let event: any;
366
540
  try {
367
541
  event = JSON.parse(trimmed);
368
542
  } catch {
369
- continue;
543
+ return;
370
544
  }
371
- if (event?.type !== "message_end" || !event.message) continue;
545
+ if ((event?.type === "tool_execution_start" || event?.type === "tool_execution_end") && typeof event.toolCallId === "string") {
546
+ this.tool(event.type, event.toolCallId);
547
+ return;
548
+ }
549
+ if (event?.type === "message_update" || event?.type === "message_start" || event?.type === "turn_start") {
550
+ this.progressSerial += 1;
551
+ return;
552
+ }
553
+ if (event?.type !== "message_end" || !event.message) return;
372
554
  const message = event.message;
373
- if (message.role !== "assistant") continue;
374
- turns += 1;
555
+ if (message.role !== "assistant") return;
556
+ this.turns += 1;
557
+ this.progressSerial += 1;
375
558
  const parts = Array.isArray(message.content) ? message.content : [];
376
559
  const text = parts
377
560
  .filter((part: any) => part?.type === "text" && typeof part.text === "string")
378
561
  .map((part: any) => part.text)
379
562
  .join("\n\n");
380
- if (text.trim()) finalText = text;
563
+ if (text.trim()) this.finalText = text;
381
564
  else if (typeof message.content === "string" && message.content.trim()) {
382
- finalText = message.content;
565
+ this.finalText = message.content;
383
566
  }
384
567
  }
385
- return { finalText, turns };
568
+ }
569
+
570
+ /**
571
+ * Parse the stdout of a `pi --mode json -p` run: one JSON event per line;
572
+ * `message_end` events carry the messages (see ChildEventScanner, which
573
+ * the runner feeds as the output streams).
574
+ */
575
+ export function parseChildEvents(stdout: string): ParsedChildEvents {
576
+ const scanner = new ChildEventScanner();
577
+ scanner.push(stdout);
578
+ scanner.end();
579
+ return { finalText: scanner.finalText, turns: scanner.turns };
386
580
  }
387
581
 
388
582
  // --- the runner -------------------------------------------------------------
@@ -390,6 +584,8 @@ export function parseChildEvents(stdout: string): ParsedChildEvents {
390
584
  export interface SpawnChildOptions {
391
585
  cwd: string;
392
586
  parentSessionId: string;
587
+ /** Where the child's session file goes (subagentSessionDir); the core's default when unset. */
588
+ sessionDir?: string;
393
589
  /**
394
590
  * Explicit wall-clock limit (the tool's `timeout_minutes`). Undefined =
395
591
  * none: a child runs until it is done, stalls (inactivityMs) or is
@@ -416,10 +612,14 @@ export interface SpawnChildOptions {
416
612
  now?: () => number;
417
613
  /** Progress callback: partial output lines as the child runs. */
418
614
  onChildStdout?: (role: AgentRole, chunk: string) => void;
419
- /** Every sign of life from the child (any output), with its event types. */
615
+ /** Meaningful model/tool progress from the child, with its event types. */
420
616
  onActivity?: (activity: ChildActivity) => void;
421
617
  /** The child's session id (a fresh UUID by default). */
422
618
  sessionId?: string;
619
+ /** The child process started (its pid), for memory accounting. */
620
+ onSpawn?: (pid: number | undefined) => void;
621
+ /** Extra environment for the child (e.g. the memory tree root). */
622
+ extraEnv?: Record<string, string>;
423
623
  }
424
624
 
425
625
  export interface ChildActivity {
@@ -428,6 +628,7 @@ export interface ChildActivity {
428
628
  turns: number;
429
629
  /** Tool calls running right now. */
430
630
  toolsRunning: number;
631
+ meaningful: true;
431
632
  }
432
633
 
433
634
  /**
@@ -466,19 +667,20 @@ export async function runChildAgent(
466
667
  const sessionId = options.sessionId ?? randomUUID();
467
668
 
468
669
  return withRolePromptFile(task.role, async (promptFile) => {
469
- const args = buildChildArgs(task.task, promptFile, task.model, sessionId, task.effort, yoloActive());
670
+ const args = buildChildArgs(task.task, promptFile, task.model, sessionId, task.effort, yoloActive(), options.sessionDir);
470
671
  const invocation = childInvocation(args);
471
672
 
472
673
  return await new Promise<ChildResult>((resolvePromise) => {
473
- let stdout = "";
674
+ // The event stream is read as it arrives; nothing of it is kept but
675
+ // what the result needs (the scanner), so a long child costs the
676
+ // parent next to nothing.
677
+ const events = new ChildEventScanner();
474
678
  let stderr = "";
475
- let pendingLine = "";
476
679
  let pendingErrLine = "";
477
680
  let gatewayFallback: ChildResultInput["gatewayFallback"];
478
681
  let settled = false;
479
682
  let killedFor: "timeout" | "stalled" | "cancelled" | "interrupted" | null = null;
480
- let turns = 0;
481
- const toolsRunning = new Set<string>();
683
+ const toolsRunning = events.toolsRunning;
482
684
  let lastActivity = now();
483
685
  let watchdog: ReturnType<typeof setTimeout> | null = null;
484
686
  let timer: ReturnType<typeof setTimeout> | null = null;
@@ -486,12 +688,14 @@ export async function runChildAgent(
486
688
  const child = spawnImpl(invocation.command, invocation.args, {
487
689
  cwd: options.cwd,
488
690
  shell: false,
691
+ detached: process.platform !== "win32",
489
692
  stdio: ["ignore", "pipe", "pipe"],
490
693
  // Shared credentials by location (OMNIRUSH_DIR), never a token
491
694
  // frozen at the parent's launch: the child re-reads auth.json for
492
695
  // every request and takes part in the refresh lock.
493
- env: childEnvironment(task, options.parentSessionId, { ...process.env, ...childAuthEnvSafe() }),
696
+ env: childEnvironment(task, options.parentSessionId, { ...process.env, ...childAuthEnvSafe(), ...(options.extraEnv ?? {}) }),
494
697
  });
698
+ options.onSpawn?.(child.pid);
495
699
 
496
700
  const finish = (input: ChildResultInput) => {
497
701
  if (settled) return;
@@ -505,16 +709,33 @@ export async function runChildAgent(
505
709
  };
506
710
 
507
711
  const killTree = () => {
508
- try {
509
- child.kill("SIGTERM");
510
- } catch {
511
- /* already gone */
712
+ const signalGroup = (signal: NodeJS.Signals) => {
713
+ if (process.platform !== "win32" && typeof child.pid === "number" && child.pid > 0) {
714
+ try {
715
+ process.kill(-child.pid, signal);
716
+ return true;
717
+ } catch {
718
+ /* fall back to the direct child below */
719
+ }
720
+ }
721
+ if (process.platform === "win32" && typeof child.pid === "number" && child.pid > 0) {
722
+ try {
723
+ const args = ["/PID", String(child.pid), "/T"];
724
+ if (signal === "SIGKILL") args.push("/F");
725
+ nodeSpawn("taskkill", args, { windowsHide: true, stdio: "ignore" });
726
+ return true;
727
+ } catch {
728
+ /* fall back to the direct child below */
729
+ }
730
+ }
731
+ return false;
732
+ };
733
+ if (!signalGroup("SIGTERM")) {
734
+ try { child.kill("SIGTERM"); } catch { /* already gone */ }
512
735
  }
513
736
  setTimeout(() => {
514
- try {
515
- child.kill("SIGKILL");
516
- } catch {
517
- /* already gone */
737
+ if (!signalGroup("SIGKILL")) {
738
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
518
739
  }
519
740
  }, Math.max(50, options.killGraceMs ?? CHILD_KILL_GRACE_MS)).unref?.();
520
741
  };
@@ -534,10 +755,11 @@ export async function runChildAgent(
534
755
  watchdog = setTimeout(() => kill("stalled"), window);
535
756
  watchdog.unref?.();
536
757
  };
537
- const alive = () => {
758
+ const alive = (meaningful: boolean) => {
759
+ if (!meaningful) return;
538
760
  lastActivity = now();
539
761
  armWatchdog();
540
- options.onActivity?.({ at: lastActivity, turns, toolsRunning: toolsRunning.size });
762
+ options.onActivity?.({ at: lastActivity, turns: events.turns, toolsRunning: toolsRunning.size, meaningful: true });
541
763
  };
542
764
  armWatchdog();
543
765
 
@@ -554,38 +776,32 @@ export async function runChildAgent(
554
776
  else options.signal.addEventListener("abort", abortHandler, { once: true });
555
777
  }
556
778
 
557
- // Tool calls in flight and finished turns, from the event lines.
558
- const observe = (line: string) => {
559
- if (!line.includes('"type"')) return;
560
- let event: any;
561
- try {
562
- event = JSON.parse(line);
563
- } catch {
564
- return;
565
- }
566
- if (event?.type === "tool_execution_start" && typeof event.toolCallId === "string") toolsRunning.add(event.toolCallId);
567
- else if (event?.type === "tool_execution_end" && typeof event.toolCallId === "string") toolsRunning.delete(event.toolCallId);
568
- else if (event?.type === "message_end" && event.message?.role === "assistant") turns += 1;
569
- };
570
-
779
+ child.stdout?.setEncoding?.("utf8");
571
780
  child.stdout?.on("data", (chunk: Buffer | string) => {
572
781
  const text = String(chunk);
573
- stdout += text;
574
- const lines = (pendingLine + text).split("\n");
575
- pendingLine = lines.pop() ?? "";
576
- for (const line of lines) observe(line);
577
- alive();
782
+ const before = events.progressSerial;
783
+ events.push(text);
784
+ alive(events.progressSerial !== before);
578
785
  options.onChildStdout?.(task.role, text);
579
786
  });
787
+ child.stderr?.setEncoding?.("utf8");
580
788
  child.stderr?.on("data", (chunk: Buffer | string) => {
581
789
  const text = String(chunk);
582
790
  // The child's gateway guard reports a move to the main model here.
583
791
  const lines = (pendingErrLine + text).split("\n");
584
792
  pendingErrLine = lines.pop() ?? "";
585
- for (const line of lines) gatewayFallback = parseFallbackMarker(line) ?? gatewayFallback;
793
+ if (pendingErrLine.length > 64 * 1024) pendingErrLine = pendingErrLine.slice(-64 * 1024);
794
+ let meaningful = false;
795
+ for (const line of lines) {
796
+ const fallback = parseFallbackMarker(line);
797
+ if (fallback) {
798
+ gatewayFallback = fallback;
799
+ meaningful = true;
800
+ }
801
+ }
586
802
  stderr += text;
587
803
  if (stderr.length > 256 * 1024) stderr = stderr.slice(-64 * 1024);
588
- alive();
804
+ alive(meaningful);
589
805
  });
590
806
  child.on("error", (error: Error) => {
591
807
  finish({
@@ -597,7 +813,8 @@ export async function runChildAgent(
597
813
  });
598
814
  });
599
815
  child.on("close", (code: number | null) => {
600
- const parsed = parseChildEvents(stdout);
816
+ events.end();
817
+ const parsed = { finalText: events.finalText, turns: events.turns };
601
818
  const partial = parsed.finalText ? " — partial result kept" : "";
602
819
  if (killedFor) {
603
820
  const error = killedFor === "timeout"
@@ -636,6 +853,8 @@ function formatMinutes(ms: number): string {
636
853
  */
637
854
  export function childEnvironment(task: ChildTask, parentSessionId: string, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
638
855
  const env: NodeJS.ProcessEnv = { ...base, ...(task.env ?? {}), OMNIRUSH_PARENT_SESSION: parentSessionId };
856
+ // One layer further down than the delegating agent.
857
+ env[ENV_AGENT_DEPTH] = String(agentDepth(base) + 1);
639
858
  // Guarded mode: the name the parent's approval prompt shows for this child.
640
859
  const oneLine = task.task.replace(/\s+/g, " ").trim();
641
860
  env.OMNIRUSH_SUBAGENT_LABEL = `${ROLE_PRESETS[task.role]?.label ?? task.role}: ${oneLine.length > 60 ? `${oneLine.slice(0, 59)}…` : oneLine}`;
@@ -731,6 +950,8 @@ export interface AgentRecord {
731
950
  /** Background delivery: one message per child, or one per batch. */
732
951
  notify: "each" | "batch";
733
952
  status: AgentState;
953
+ /** Queued because memory is short (see memory-lib.ts): why, and since when. */
954
+ waiting: { reason: "memory"; detail: string; since: number } | null;
734
955
  queuedAt: number;
735
956
  startedAt: number | null;
736
957
  finishedAt: number | null;
@@ -749,14 +970,37 @@ export type AgentRunner = (
749
970
  options: {
750
971
  sessionId: string;
751
972
  parentSessionId: string;
973
+ /** Where the child's session file goes (subagentSessionDir). */
974
+ sessionDir?: string;
752
975
  signal: AbortSignal;
753
976
  onActivity: (activity: ChildActivity) => void;
754
977
  /** The call's explicit timeout_minutes, if any. */
755
978
  timeoutMs?: number;
756
979
  cwd?: string;
980
+ /** The child process started (its pid). */
981
+ onSpawn?: (pid: number | undefined) => void;
757
982
  },
758
983
  ) => Promise<ChildResult>;
759
984
 
985
+ /**
986
+ * Memory-aware admission (memory-lib.ts MemoryAdmission): whether one more
987
+ * child may start, and the children it counts.
988
+ */
989
+ export interface AgentAdmission {
990
+ check(): { ok: true } | { ok: false; reason: string; detail: string };
991
+ started(key: object, pid?: number | null): void;
992
+ spawned(key: object, pid: number | null | undefined): void;
993
+ finished(key: object): void;
994
+ }
995
+
996
+ /** A queued child: waits for its batch's max_parallel slot and for memory. */
997
+ interface QueueEntry {
998
+ record: AgentRecord;
999
+ task: ChildTask;
1000
+ options: DispatchOptions;
1001
+ batch: { running: number; limit: number };
1002
+ }
1003
+
760
1004
  export interface DispatchOptions {
761
1005
  background: boolean;
762
1006
  /** Children running at once (default: all of them). */
@@ -772,6 +1016,8 @@ export interface DispatchOptions {
772
1016
  timeoutMs?: number;
773
1017
  /** Workspace the children run in. */
774
1018
  cwd?: string;
1019
+ /** Where the children's session files go (subagentSessionDir). */
1020
+ sessionDir?: string;
775
1021
  }
776
1022
 
777
1023
  /**
@@ -791,12 +1037,33 @@ export class AgentManager {
791
1037
  private readonly resolvers = new Map<AgentRecord, (result: ChildResult) => void>();
792
1038
  private nextAgent = 1;
793
1039
  private nextBatch = 1;
794
-
795
- constructor(options: { run: AgentRunner; now?: () => number; onDeliverable?: () => void; onChanged?: () => void }) {
1040
+ private readonly admission: AgentAdmission | null;
1041
+ private readonly retryMs: number;
1042
+ private readonly onMemoryWait: (info: { critical: boolean; detail: string; queued: number } | null) => void;
1043
+ private queue: QueueEntry[] = [];
1044
+ private retryTimer: ReturnType<typeof setTimeout> | null = null;
1045
+ /** The last memory wait reported (null when nothing waits for memory). */
1046
+ private memoryWait: { critical: boolean; detail: string } | null = null;
1047
+
1048
+ constructor(options: {
1049
+ run: AgentRunner;
1050
+ now?: () => number;
1051
+ onDeliverable?: () => void;
1052
+ onChanged?: () => void;
1053
+ /** Memory-aware admission; none = children start as soon as their batch allows. */
1054
+ admission?: AgentAdmission | null;
1055
+ /** How often a child waiting for memory checks again (ms). */
1056
+ retryMs?: number;
1057
+ /** Children started or stopped waiting for memory (null: nothing waits any more). */
1058
+ onMemoryWait?: (info: { critical: boolean; detail: string; queued: number } | null) => void;
1059
+ }) {
796
1060
  this.runner = options.run;
797
1061
  this.now = options.now ?? Date.now;
798
1062
  this.onDeliverable = options.onDeliverable ?? (() => undefined);
799
1063
  this.onChanged = options.onChanged ?? (() => undefined);
1064
+ this.admission = options.admission ?? null;
1065
+ this.retryMs = options.retryMs ?? 1_000;
1066
+ this.onMemoryWait = options.onMemoryWait ?? (() => undefined);
800
1067
  }
801
1068
 
802
1069
  dispatch(
@@ -805,6 +1072,7 @@ export class AgentManager {
805
1072
  options: DispatchOptions,
806
1073
  ): { batch: string; records: AgentRecord[]; done: Promise<ChildResult[]> } {
807
1074
  const batch = `b${this.nextBatch++}`;
1075
+ this.releaseOldOutputs(parentSessionId);
808
1076
  const list = this.bySession.get(parentSessionId) ?? [];
809
1077
  this.bySession.set(parentSessionId, list);
810
1078
  const at = this.now();
@@ -824,6 +1092,7 @@ export class AgentManager {
824
1092
  parallelLimit: options.concurrency === undefined ? null : Math.max(1, Math.floor(options.concurrency)),
825
1093
  notify: options.notify ?? "batch",
826
1094
  status: "queued",
1095
+ waiting: null,
827
1096
  queuedAt: at,
828
1097
  startedAt: null,
829
1098
  finishedAt: null,
@@ -847,19 +1116,81 @@ export class AgentManager {
847
1116
  if (options.signal.aborted) abort();
848
1117
  else options.signal.addEventListener("abort", abort, { once: true });
849
1118
  }
850
- const concurrency = Math.max(1, Math.floor(options.concurrency ?? records.length));
851
- void mapWithConcurrency(records, concurrency, async (record, index) => {
852
- // Cancelled while it waited for a slot: it never starts.
853
- if (!record.result) await this.start(record, tasks[index], options);
854
- options.onSettled?.(record);
855
- });
1119
+ // No count cap: every task of the batch may run at once unless the call
1120
+ // set max_parallel; memory admission decides when each one starts.
1121
+ const slots = { running: 0, limit: Math.max(1, Math.floor(options.concurrency ?? records.length)) };
1122
+ records.forEach((record, index) => this.queue.push({ record, task: tasks[index], options, batch: slots }));
1123
+ this.pump();
856
1124
  return { batch, records, done: Promise.all(records.map((record) => record.done)) };
857
1125
  }
858
1126
 
859
- private async start(record: AgentRecord, task: ChildTask, options: DispatchOptions): Promise<void> {
1127
+ /**
1128
+ * Start queued children, oldest first, while their batch has a free slot
1129
+ * and memory admission allows; the rest wait for a child to finish or
1130
+ * for the next check (every retryMs while something waits for memory).
1131
+ */
1132
+ private pump(): void {
1133
+ if (this.retryTimer) {
1134
+ clearTimeout(this.retryTimer);
1135
+ this.retryTimer = null;
1136
+ }
1137
+ let blocked: { reason: string; detail: string } | null = null;
1138
+ const still: QueueEntry[] = [];
1139
+ let changed = false;
1140
+ for (const entry of this.queue) {
1141
+ const { record } = entry;
1142
+ if (record.result) {
1143
+ // Cancelled while it waited: it never starts.
1144
+ entry.options.onSettled?.(record);
1145
+ continue;
1146
+ }
1147
+ if (blocked || entry.batch.running >= entry.batch.limit) {
1148
+ if (blocked && entry.batch.running < entry.batch.limit) changed = this.markWaiting(record, blocked.detail) || changed;
1149
+ still.push(entry);
1150
+ continue;
1151
+ }
1152
+ const verdict = this.admission ? this.admission.check() : { ok: true as const };
1153
+ if (!verdict.ok) {
1154
+ blocked = verdict;
1155
+ changed = this.markWaiting(record, verdict.detail) || changed;
1156
+ still.push(entry);
1157
+ continue;
1158
+ }
1159
+ entry.batch.running += 1;
1160
+ void this.start(entry);
1161
+ }
1162
+ this.queue = still;
1163
+ const waiting = still.filter((entry) => entry.record.waiting);
1164
+ if (blocked && waiting.length > 0) {
1165
+ const info = { critical: blocked.reason === "critical", detail: blocked.detail };
1166
+ // Once when children start waiting, and when it turns critical (or back):
1167
+ // the figures in the detail change with every check.
1168
+ if (!this.memoryWait || this.memoryWait.critical !== info.critical) {
1169
+ this.memoryWait = info;
1170
+ this.onMemoryWait({ ...info, queued: waiting.length });
1171
+ }
1172
+ // A ref'd timer: a one-shot run must not end while children wait.
1173
+ this.retryTimer = setTimeout(() => this.pump(), this.retryMs);
1174
+ } else if (this.memoryWait) {
1175
+ this.memoryWait = null;
1176
+ this.onMemoryWait(null);
1177
+ }
1178
+ if (changed) this.onChanged();
1179
+ }
1180
+
1181
+ private markWaiting(record: AgentRecord, detail: string): boolean {
1182
+ if (record.waiting?.detail === detail) return false;
1183
+ record.waiting = { reason: "memory", detail, since: record.waiting?.since ?? this.now() };
1184
+ return true;
1185
+ }
1186
+
1187
+ private async start(entry: QueueEntry): Promise<void> {
1188
+ const { record, task, options } = entry;
860
1189
  record.status = "running";
1190
+ record.waiting = null;
861
1191
  record.startedAt = this.now();
862
1192
  record.lastActivityAt = record.startedAt;
1193
+ this.admission?.started(record);
863
1194
  this.onChanged();
864
1195
  options.onChanged?.();
865
1196
  let result: ChildResult;
@@ -868,8 +1199,10 @@ export class AgentManager {
868
1199
  sessionId: record.sessionId,
869
1200
  parentSessionId: record.parentSessionId,
870
1201
  signal: record.controller.signal,
1202
+ onSpawn: (pid) => this.admission?.spawned(record, pid),
871
1203
  ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
872
1204
  ...(options.cwd ? { cwd: options.cwd } : {}),
1205
+ ...(options.sessionDir ? { sessionDir: options.sessionDir } : {}),
873
1206
  onActivity: (activity) => {
874
1207
  record.lastActivityAt = activity.at;
875
1208
  record.turns = activity.turns;
@@ -887,7 +1220,12 @@ export class AgentManager {
887
1220
  error: error instanceof Error ? error.message : String(error),
888
1221
  }, this.now() - (record.startedAt ?? this.now()), record.sessionId);
889
1222
  }
1223
+ this.admission?.finished(record);
1224
+ entry.batch.running -= 1;
890
1225
  this.settle(record, result);
1226
+ options.onSettled?.(record);
1227
+ // Its slot and its memory are free: the queue moves on.
1228
+ this.pump();
891
1229
  }
892
1230
 
893
1231
  private settle(record: AgentRecord, result: ChildResult): void {
@@ -919,6 +1257,18 @@ export class AgentManager {
919
1257
  if (record.background && !record.delivered) this.onDeliverable();
920
1258
  }
921
1259
 
1260
+ /** Memory: delivered results beyond the newest MAX_KEPT_OUTPUTS keep no output. */
1261
+ private releaseOldOutputs(parentSessionId: string): void {
1262
+ const list = this.bySession.get(parentSessionId) ?? [];
1263
+ let kept = 0;
1264
+ for (let index = list.length - 1; index >= 0; index--) {
1265
+ const result = list[index].result;
1266
+ if (!result || !list[index].delivered || result.output === RELEASED_OUTPUT) continue;
1267
+ kept += 1;
1268
+ if (kept > MAX_KEPT_OUTPUTS) list[index].result = { ...result, output: RELEASED_OUTPUT };
1269
+ }
1270
+ }
1271
+
922
1272
  list(parentSessionId: string): AgentRecord[] {
923
1273
  return [...(this.bySession.get(parentSessionId) ?? [])];
924
1274
  }
@@ -1026,6 +1376,7 @@ export class AgentManager {
1026
1376
  }, 0, record.sessionId));
1027
1377
  }
1028
1378
  }
1379
+ if (live.some((record) => record.startedAt === null)) this.pump();
1029
1380
  await Promise.all(live.map((record) => record.done));
1030
1381
  }
1031
1382
 
@@ -1048,6 +1399,7 @@ export function agentStatusSummary(records: AgentRecord[]): string {
1048
1399
  if (records.length === 0) return "No sub-agents in this session.";
1049
1400
  const running = records.filter((record) => record.status === "running").length;
1050
1401
  const queued = records.filter((record) => record.status === "queued").length;
1402
+ const forMemory = records.filter((record) => record.status === "queued" && record.waiting?.reason === "memory").length;
1051
1403
  const settled = records.filter((record) => Boolean(record.result)).length;
1052
1404
  const limits = [...new Set(records.map((record) => record.parallelLimit))];
1053
1405
  const parallel = limits.length === 1 && limits[0] === null
@@ -1055,7 +1407,8 @@ export function agentStatusSummary(records: AgentRecord[]): string {
1055
1407
  : limits.length === 1
1056
1408
  ? String(limits[0])
1057
1409
  : "per-batch";
1058
- return `${records.length} sub-agents: ${running} running, ${queued} queued, ${settled} settled (parallel: ${parallel})`;
1410
+ const queuedText = forMemory > 0 ? `${queued} queued: waiting for memory` : `${queued} queued`;
1411
+ return `${records.length} sub-agents: ${running} running, ${queuedText}, ${settled} settled (parallel: ${parallel})`;
1059
1412
  }
1060
1413
 
1061
1414
  /** One status line per sub-agent (agents_status, /agents). */
@@ -1064,7 +1417,8 @@ export function renderAgentStatus(records: AgentRecord[], now: number = Date.now
1064
1417
  const lines = records.map((record) => {
1065
1418
  const since = record.startedAt ?? record.queuedAt;
1066
1419
  const elapsed = minutesOf((record.finishedAt ?? now) - since);
1067
- const bits = [`${record.id}`, `[${record.status}]`, record.role];
1420
+ const state = record.status === "queued" && record.waiting ? "queued: waiting for memory" : record.status;
1421
+ const bits = [`${record.id}`, `[${state}]`, record.role];
1068
1422
  if (record.model) bits.push(`[${record.model}${record.effort ? ` · ${record.effort}` : ""}]`);
1069
1423
  bits.push(record.background ? "background" : "blocking");
1070
1424
  const detail: string[] = [`${elapsed} min`];
@@ -1074,6 +1428,7 @@ export function renderAgentStatus(records: AgentRecord[], now: number = Date.now
1074
1428
  detail.push(`last activity ${minutesOf(now - record.lastActivityAt)} min ago`);
1075
1429
  }
1076
1430
  if (record.result && !record.delivered) detail.push("result not yet read");
1431
+ if (!record.result && record.waiting) detail.push(record.waiting.detail);
1077
1432
  const task = record.task.length > 80 ? `${record.task.slice(0, 79)}…` : record.task;
1078
1433
  return `- ${bits.join(" ")} (${detail.join(", ")}) — ${task}`;
1079
1434
  });