omnirush 0.9.0 → 0.9.1

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,
@@ -71,6 +73,47 @@ export function childInactivityMs(env: NodeJS.ProcessEnv = process.env): number
71
73
  export const CHILD_KILL_GRACE_MS = 5_000;
72
74
  /** Per-child output cap in the structured result (bytes, UTF-8). */
73
75
  export const CHILD_OUTPUT_CAP_BYTES = 50 * 1024;
76
+ /**
77
+ * Finished sub-agents whose result reached the model keep their output (up
78
+ * to CHILD_OUTPUT_CAP_BYTES each) for agents_result; beyond this many per
79
+ * session, the oldest ones' output is let go (a swarm session runs
80
+ * hundreds of children).
81
+ */
82
+ export const MAX_KEPT_OUTPUTS = 200;
83
+ /** What a released output reads. */
84
+ export const RELEASED_OUTPUT = "(output no longer kept in memory: it was delivered to the conversation earlier)";
85
+
86
+ /**
87
+ * Sub-agent layers below the main session (the desktop app's
88
+ * OMNIRUSH_SUBAGENT_DEPTH): layers 1 and 2 may delegate, layer 3 may not.
89
+ * The session capture records exactly these layers (collector.ts).
90
+ */
91
+ export const MAX_SUBAGENT_DEPTH = 3;
92
+ /** The layer a process runs at, handed down to every child (+1 per layer). */
93
+ export const ENV_AGENT_DEPTH = "OMNIRUSH_AGENT_DEPTH";
94
+
95
+ /**
96
+ * This process's sub-agent layer: 0 for a main session. A child of a parent
97
+ * that predates the depth variable counts as layer 1.
98
+ */
99
+ export function agentDepth(env: NodeJS.ProcessEnv = process.env): number {
100
+ const text = String(env[ENV_AGENT_DEPTH] ?? "").trim();
101
+ const raw = /^\d+$/.test(text) ? Number(text) : NaN;
102
+ if (Number.isSafeInteger(raw)) return raw;
103
+ return String(env.OMNIRUSH_PARENT_SESSION ?? "").trim() ? 1 : 0;
104
+ }
105
+
106
+ /** Whether a process at `depth` may start sub-agents. */
107
+ export function canDelegate(depth: number): boolean {
108
+ return depth < MAX_SUBAGENT_DEPTH;
109
+ }
110
+
111
+ /** System text for a sub-agent: its layer, and whether it may delegate. */
112
+ export function subagentLayerNote(depth: number): string {
113
+ return canDelegate(depth)
114
+ ? `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.`
115
+ : `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.`;
116
+ }
74
117
 
75
118
  export const AGENT_ROLES = ["code-searcher", "researcher-web", "general-worker"] as const;
76
119
  export type AgentRole = (typeof AGENT_ROLES)[number];
@@ -161,6 +204,25 @@ export function childInvocation(args: string[]): {
161
204
  return { command: "pi", args };
162
205
  }
163
206
 
207
+ /** The folder, inside a session dir, that holds its sub-agents' sessions. */
208
+ export const SUBAGENT_SESSIONS_DIR = "subagents";
209
+
210
+ /**
211
+ * Where a child's session file goes: <parent's session dir>/subagents/.
212
+ * The core's session picker (/resume) and --continue only read the
213
+ * *.jsonl files directly in a session dir, so children stored one level
214
+ * down never show up as sessions to resume. A sub-agent's own children
215
+ * (nested) share its folder: a parent that is itself a sub-agent
216
+ * (OMNIRUSH_PARENT_SESSION set) already runs there. Undefined when the
217
+ * parent has no session dir (--no-session): the child keeps the core's
218
+ * default, and the launcher tidies such stragglers (src/sessions.js).
219
+ */
220
+ export function subagentSessionDir(parentSessionDir: unknown, env: NodeJS.ProcessEnv = process.env): string | undefined {
221
+ if (typeof parentSessionDir !== "string" || !parentSessionDir.trim()) return undefined;
222
+ if (String(env.OMNIRUSH_PARENT_SESSION ?? "").trim()) return parentSessionDir;
223
+ return path.join(parentSessionDir, SUBAGENT_SESSIONS_DIR);
224
+ }
225
+
164
226
  /**
165
227
  * Build the child's argv for one task: JSON print mode with the role's
166
228
  * system prompt appended via a temp file path (the caller writes and
@@ -175,6 +237,7 @@ export function buildChildArgs(
175
237
  sessionId?: string,
176
238
  effort?: string | null,
177
239
  approve = false,
240
+ sessionDir?: string,
178
241
  ): string[] {
179
242
  const args: string[] = ["--mode", "json", "-p"];
180
243
  if (approve) {
@@ -187,6 +250,10 @@ export function buildChildArgs(
187
250
  // be found and captured as this session's sub-agent.
188
251
  args.push("--session-id", sessionId);
189
252
  }
253
+ if (sessionDir) {
254
+ // Out of the user's resume list (subagentSessionDir).
255
+ args.push("--session-dir", sessionDir);
256
+ }
190
257
  if (promptFilePath) {
191
258
  args.push("--append-system-prompt", promptFilePath);
192
259
  }
@@ -351,38 +418,155 @@ export interface ParsedChildEvents {
351
418
  turns: number;
352
419
  }
353
420
 
421
+ /** Characters of an event line read to tell its type (and role / tool call id) apart. */
422
+ export const CHILD_EVENT_HEAD_CHARS = 512;
354
423
  /**
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.
424
+ * Longest event line kept whole for parsing (characters). Only an
425
+ * assistant `message_end` is ever kept; one longer than this (a huge tool
426
+ * call argument) still counts as a turn, its text is not read.
358
427
  */
359
- export function parseChildEvents(stdout: string): ParsedChildEvents {
360
- let finalText = "";
361
- let turns = 0;
362
- for (const line of stdout.split("\n")) {
428
+ export const MAX_CHILD_EVENT_LINE_CHARS = 4 * 1024 * 1024;
429
+
430
+ const EVENT_TYPE_RE = /^\s*\{\s*"type"\s*:\s*"([A-Za-z_]+)"/;
431
+ const TOOL_CALL_ID_RE = /"toolCallId"\s*:\s*"((?:[^"\\]|\\.)*)"/;
432
+ const ROLE_RE = /^\s*\{\s*"type"\s*:\s*"message_end"\s*,\s*"message"\s*:\s*\{\s*"role"\s*:\s*"([A-Za-z_]+)"/;
433
+
434
+ /**
435
+ * Streaming reader of a `pi --mode json -p` child's stdout: one JSON event
436
+ * per line. Only what the parent needs is kept — the last assistant text,
437
+ * the assistant turn count and the tool calls in flight — and every other
438
+ * event (streaming deltas, tool results, turn_end / agent_end, which repeat
439
+ * the whole conversation) is skipped as it streams by, without buffering
440
+ * it. A child's stream is several times its conversation (agent_end alone
441
+ * repeats all of it), so keeping it whole cost the parent that much memory
442
+ * per child. Unparseable lines (banners, noise) are ignored.
443
+ */
444
+ export class ChildEventScanner {
445
+ finalText = "";
446
+ turns = 0;
447
+ readonly toolsRunning = new Set<string>();
448
+ private line = "";
449
+ /** head: deciding from the first characters; keep: buffering the whole line; skip: dropping it. */
450
+ private mode: "head" | "keep" | "skip" = "head";
451
+ private lineType: string | null = null;
452
+ private lineRole: string | null = null;
453
+
454
+ /** Feed a decoded chunk of stdout. */
455
+ push(text: string): void {
456
+ let start = 0;
457
+ while (start <= text.length) {
458
+ const newline = text.indexOf("\n", start);
459
+ const end = newline < 0 ? text.length : newline;
460
+ if (end > start) this.feed(text.slice(start, end));
461
+ if (newline < 0) break;
462
+ this.endLine();
463
+ start = newline + 1;
464
+ }
465
+ }
466
+
467
+ /** The stream ended: a last line without a newline still counts. */
468
+ end(): void {
469
+ if (this.line || this.mode !== "head") this.endLine();
470
+ }
471
+
472
+ private feed(part: string): void {
473
+ if (this.mode === "skip") return;
474
+ this.line += part;
475
+ if (this.mode === "head" && this.line.length >= CHILD_EVENT_HEAD_CHARS) this.decide();
476
+ if (this.mode === "keep" && this.line.length > MAX_CHILD_EVENT_LINE_CHARS) {
477
+ // Too long to keep: an assistant message still counts as a turn.
478
+ if (this.lineType === "message_end" && this.lineRole === "assistant") this.turns += 1;
479
+ this.line = "";
480
+ this.mode = "skip";
481
+ }
482
+ }
483
+
484
+ /** Classify the line from its head: keep it, take what it says from the head, or skip it. */
485
+ private decide(): void {
486
+ const head = this.line.slice(0, CHILD_EVENT_HEAD_CHARS);
487
+ const type = EVENT_TYPE_RE.exec(head)?.[1] ?? null;
488
+ this.lineType = type;
489
+ if (type === "message_end") {
490
+ const role = ROLE_RE.exec(head)?.[1] ?? null;
491
+ this.lineRole = role;
492
+ // Only an assistant message carries the final text; a role further
493
+ // into the object than the head (not how pi writes it) is kept too.
494
+ if (role !== null && role !== "assistant") this.skip();
495
+ else this.mode = "keep";
496
+ return;
497
+ }
498
+ if (type === "tool_execution_start" || type === "tool_execution_end") {
499
+ const id = TOOL_CALL_ID_RE.exec(head)?.[1];
500
+ if (id !== undefined) {
501
+ this.tool(type, JSON.parse(`"${id}"`));
502
+ this.skip();
503
+ } else {
504
+ this.mode = "keep";
505
+ }
506
+ return;
507
+ }
508
+ this.skip();
509
+ }
510
+
511
+ private skip(): void {
512
+ this.line = "";
513
+ this.mode = "skip";
514
+ }
515
+
516
+ private tool(type: string, id: string): void {
517
+ if (type === "tool_execution_start") this.toolsRunning.add(id);
518
+ else this.toolsRunning.delete(id);
519
+ }
520
+
521
+ private endLine(): void {
522
+ const line = this.line;
523
+ const mode = this.mode;
524
+ this.line = "";
525
+ this.mode = "head";
526
+ this.lineType = null;
527
+ this.lineRole = null;
528
+ if (mode === "skip") return;
363
529
  const trimmed = line.trim();
364
- if (!trimmed) continue;
530
+ if (!trimmed || !trimmed.includes('"type"')) return;
531
+ // A short line: skip the types nothing is read from without parsing them.
532
+ const type = mode === "head" ? EVENT_TYPE_RE.exec(trimmed)?.[1] : undefined;
533
+ if (type && type !== "message_end" && type !== "tool_execution_start" && type !== "tool_execution_end") return;
365
534
  let event: any;
366
535
  try {
367
536
  event = JSON.parse(trimmed);
368
537
  } catch {
369
- continue;
538
+ return;
539
+ }
540
+ if ((event?.type === "tool_execution_start" || event?.type === "tool_execution_end") && typeof event.toolCallId === "string") {
541
+ this.tool(event.type, event.toolCallId);
542
+ return;
370
543
  }
371
- if (event?.type !== "message_end" || !event.message) continue;
544
+ if (event?.type !== "message_end" || !event.message) return;
372
545
  const message = event.message;
373
- if (message.role !== "assistant") continue;
374
- turns += 1;
546
+ if (message.role !== "assistant") return;
547
+ this.turns += 1;
375
548
  const parts = Array.isArray(message.content) ? message.content : [];
376
549
  const text = parts
377
550
  .filter((part: any) => part?.type === "text" && typeof part.text === "string")
378
551
  .map((part: any) => part.text)
379
552
  .join("\n\n");
380
- if (text.trim()) finalText = text;
553
+ if (text.trim()) this.finalText = text;
381
554
  else if (typeof message.content === "string" && message.content.trim()) {
382
- finalText = message.content;
555
+ this.finalText = message.content;
383
556
  }
384
557
  }
385
- return { finalText, turns };
558
+ }
559
+
560
+ /**
561
+ * Parse the stdout of a `pi --mode json -p` run: one JSON event per line;
562
+ * `message_end` events carry the messages (see ChildEventScanner, which
563
+ * the runner feeds as the output streams).
564
+ */
565
+ export function parseChildEvents(stdout: string): ParsedChildEvents {
566
+ const scanner = new ChildEventScanner();
567
+ scanner.push(stdout);
568
+ scanner.end();
569
+ return { finalText: scanner.finalText, turns: scanner.turns };
386
570
  }
387
571
 
388
572
  // --- the runner -------------------------------------------------------------
@@ -390,6 +574,8 @@ export function parseChildEvents(stdout: string): ParsedChildEvents {
390
574
  export interface SpawnChildOptions {
391
575
  cwd: string;
392
576
  parentSessionId: string;
577
+ /** Where the child's session file goes (subagentSessionDir); the core's default when unset. */
578
+ sessionDir?: string;
393
579
  /**
394
580
  * Explicit wall-clock limit (the tool's `timeout_minutes`). Undefined =
395
581
  * none: a child runs until it is done, stalls (inactivityMs) or is
@@ -420,6 +606,10 @@ export interface SpawnChildOptions {
420
606
  onActivity?: (activity: ChildActivity) => void;
421
607
  /** The child's session id (a fresh UUID by default). */
422
608
  sessionId?: string;
609
+ /** The child process started (its pid), for memory accounting. */
610
+ onSpawn?: (pid: number | undefined) => void;
611
+ /** Extra environment for the child (e.g. the memory tree root). */
612
+ extraEnv?: Record<string, string>;
423
613
  }
424
614
 
425
615
  export interface ChildActivity {
@@ -466,19 +656,20 @@ export async function runChildAgent(
466
656
  const sessionId = options.sessionId ?? randomUUID();
467
657
 
468
658
  return withRolePromptFile(task.role, async (promptFile) => {
469
- const args = buildChildArgs(task.task, promptFile, task.model, sessionId, task.effort, yoloActive());
659
+ const args = buildChildArgs(task.task, promptFile, task.model, sessionId, task.effort, yoloActive(), options.sessionDir);
470
660
  const invocation = childInvocation(args);
471
661
 
472
662
  return await new Promise<ChildResult>((resolvePromise) => {
473
- let stdout = "";
663
+ // The event stream is read as it arrives; nothing of it is kept but
664
+ // what the result needs (the scanner), so a long child costs the
665
+ // parent next to nothing.
666
+ const events = new ChildEventScanner();
474
667
  let stderr = "";
475
- let pendingLine = "";
476
668
  let pendingErrLine = "";
477
669
  let gatewayFallback: ChildResultInput["gatewayFallback"];
478
670
  let settled = false;
479
671
  let killedFor: "timeout" | "stalled" | "cancelled" | "interrupted" | null = null;
480
- let turns = 0;
481
- const toolsRunning = new Set<string>();
672
+ const toolsRunning = events.toolsRunning;
482
673
  let lastActivity = now();
483
674
  let watchdog: ReturnType<typeof setTimeout> | null = null;
484
675
  let timer: ReturnType<typeof setTimeout> | null = null;
@@ -490,8 +681,9 @@ export async function runChildAgent(
490
681
  // Shared credentials by location (OMNIRUSH_DIR), never a token
491
682
  // frozen at the parent's launch: the child re-reads auth.json for
492
683
  // every request and takes part in the refresh lock.
493
- env: childEnvironment(task, options.parentSessionId, { ...process.env, ...childAuthEnvSafe() }),
684
+ env: childEnvironment(task, options.parentSessionId, { ...process.env, ...childAuthEnvSafe(), ...(options.extraEnv ?? {}) }),
494
685
  });
686
+ options.onSpawn?.(child.pid);
495
687
 
496
688
  const finish = (input: ChildResultInput) => {
497
689
  if (settled) return;
@@ -537,7 +729,7 @@ export async function runChildAgent(
537
729
  const alive = () => {
538
730
  lastActivity = now();
539
731
  armWatchdog();
540
- options.onActivity?.({ at: lastActivity, turns, toolsRunning: toolsRunning.size });
732
+ options.onActivity?.({ at: lastActivity, turns: events.turns, toolsRunning: toolsRunning.size });
541
733
  };
542
734
  armWatchdog();
543
735
 
@@ -554,34 +746,20 @@ export async function runChildAgent(
554
746
  else options.signal.addEventListener("abort", abortHandler, { once: true });
555
747
  }
556
748
 
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
-
749
+ child.stdout?.setEncoding?.("utf8");
571
750
  child.stdout?.on("data", (chunk: Buffer | string) => {
572
751
  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);
752
+ events.push(text);
577
753
  alive();
578
754
  options.onChildStdout?.(task.role, text);
579
755
  });
756
+ child.stderr?.setEncoding?.("utf8");
580
757
  child.stderr?.on("data", (chunk: Buffer | string) => {
581
758
  const text = String(chunk);
582
759
  // The child's gateway guard reports a move to the main model here.
583
760
  const lines = (pendingErrLine + text).split("\n");
584
761
  pendingErrLine = lines.pop() ?? "";
762
+ if (pendingErrLine.length > 64 * 1024) pendingErrLine = pendingErrLine.slice(-64 * 1024);
585
763
  for (const line of lines) gatewayFallback = parseFallbackMarker(line) ?? gatewayFallback;
586
764
  stderr += text;
587
765
  if (stderr.length > 256 * 1024) stderr = stderr.slice(-64 * 1024);
@@ -597,7 +775,8 @@ export async function runChildAgent(
597
775
  });
598
776
  });
599
777
  child.on("close", (code: number | null) => {
600
- const parsed = parseChildEvents(stdout);
778
+ events.end();
779
+ const parsed = { finalText: events.finalText, turns: events.turns };
601
780
  const partial = parsed.finalText ? " — partial result kept" : "";
602
781
  if (killedFor) {
603
782
  const error = killedFor === "timeout"
@@ -636,6 +815,8 @@ function formatMinutes(ms: number): string {
636
815
  */
637
816
  export function childEnvironment(task: ChildTask, parentSessionId: string, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
638
817
  const env: NodeJS.ProcessEnv = { ...base, ...(task.env ?? {}), OMNIRUSH_PARENT_SESSION: parentSessionId };
818
+ // One layer further down than the delegating agent.
819
+ env[ENV_AGENT_DEPTH] = String(agentDepth(base) + 1);
639
820
  // Guarded mode: the name the parent's approval prompt shows for this child.
640
821
  const oneLine = task.task.replace(/\s+/g, " ").trim();
641
822
  env.OMNIRUSH_SUBAGENT_LABEL = `${ROLE_PRESETS[task.role]?.label ?? task.role}: ${oneLine.length > 60 ? `${oneLine.slice(0, 59)}…` : oneLine}`;
@@ -731,6 +912,8 @@ export interface AgentRecord {
731
912
  /** Background delivery: one message per child, or one per batch. */
732
913
  notify: "each" | "batch";
733
914
  status: AgentState;
915
+ /** Queued because memory is short (see memory-lib.ts): why, and since when. */
916
+ waiting: { reason: "memory"; detail: string; since: number } | null;
734
917
  queuedAt: number;
735
918
  startedAt: number | null;
736
919
  finishedAt: number | null;
@@ -749,14 +932,37 @@ export type AgentRunner = (
749
932
  options: {
750
933
  sessionId: string;
751
934
  parentSessionId: string;
935
+ /** Where the child's session file goes (subagentSessionDir). */
936
+ sessionDir?: string;
752
937
  signal: AbortSignal;
753
938
  onActivity: (activity: ChildActivity) => void;
754
939
  /** The call's explicit timeout_minutes, if any. */
755
940
  timeoutMs?: number;
756
941
  cwd?: string;
942
+ /** The child process started (its pid). */
943
+ onSpawn?: (pid: number | undefined) => void;
757
944
  },
758
945
  ) => Promise<ChildResult>;
759
946
 
947
+ /**
948
+ * Memory-aware admission (memory-lib.ts MemoryAdmission): whether one more
949
+ * child may start, and the children it counts.
950
+ */
951
+ export interface AgentAdmission {
952
+ check(): { ok: true } | { ok: false; reason: string; detail: string };
953
+ started(key: object, pid?: number | null): void;
954
+ spawned(key: object, pid: number | null | undefined): void;
955
+ finished(key: object): void;
956
+ }
957
+
958
+ /** A queued child: waits for its batch's max_parallel slot and for memory. */
959
+ interface QueueEntry {
960
+ record: AgentRecord;
961
+ task: ChildTask;
962
+ options: DispatchOptions;
963
+ batch: { running: number; limit: number };
964
+ }
965
+
760
966
  export interface DispatchOptions {
761
967
  background: boolean;
762
968
  /** Children running at once (default: all of them). */
@@ -772,6 +978,8 @@ export interface DispatchOptions {
772
978
  timeoutMs?: number;
773
979
  /** Workspace the children run in. */
774
980
  cwd?: string;
981
+ /** Where the children's session files go (subagentSessionDir). */
982
+ sessionDir?: string;
775
983
  }
776
984
 
777
985
  /**
@@ -791,12 +999,33 @@ export class AgentManager {
791
999
  private readonly resolvers = new Map<AgentRecord, (result: ChildResult) => void>();
792
1000
  private nextAgent = 1;
793
1001
  private nextBatch = 1;
794
-
795
- constructor(options: { run: AgentRunner; now?: () => number; onDeliverable?: () => void; onChanged?: () => void }) {
1002
+ private readonly admission: AgentAdmission | null;
1003
+ private readonly retryMs: number;
1004
+ private readonly onMemoryWait: (info: { critical: boolean; detail: string; queued: number } | null) => void;
1005
+ private queue: QueueEntry[] = [];
1006
+ private retryTimer: ReturnType<typeof setTimeout> | null = null;
1007
+ /** The last memory wait reported (null when nothing waits for memory). */
1008
+ private memoryWait: { critical: boolean; detail: string } | null = null;
1009
+
1010
+ constructor(options: {
1011
+ run: AgentRunner;
1012
+ now?: () => number;
1013
+ onDeliverable?: () => void;
1014
+ onChanged?: () => void;
1015
+ /** Memory-aware admission; none = children start as soon as their batch allows. */
1016
+ admission?: AgentAdmission | null;
1017
+ /** How often a child waiting for memory checks again (ms). */
1018
+ retryMs?: number;
1019
+ /** Children started or stopped waiting for memory (null: nothing waits any more). */
1020
+ onMemoryWait?: (info: { critical: boolean; detail: string; queued: number } | null) => void;
1021
+ }) {
796
1022
  this.runner = options.run;
797
1023
  this.now = options.now ?? Date.now;
798
1024
  this.onDeliverable = options.onDeliverable ?? (() => undefined);
799
1025
  this.onChanged = options.onChanged ?? (() => undefined);
1026
+ this.admission = options.admission ?? null;
1027
+ this.retryMs = options.retryMs ?? 1_000;
1028
+ this.onMemoryWait = options.onMemoryWait ?? (() => undefined);
800
1029
  }
801
1030
 
802
1031
  dispatch(
@@ -805,6 +1034,7 @@ export class AgentManager {
805
1034
  options: DispatchOptions,
806
1035
  ): { batch: string; records: AgentRecord[]; done: Promise<ChildResult[]> } {
807
1036
  const batch = `b${this.nextBatch++}`;
1037
+ this.releaseOldOutputs(parentSessionId);
808
1038
  const list = this.bySession.get(parentSessionId) ?? [];
809
1039
  this.bySession.set(parentSessionId, list);
810
1040
  const at = this.now();
@@ -824,6 +1054,7 @@ export class AgentManager {
824
1054
  parallelLimit: options.concurrency === undefined ? null : Math.max(1, Math.floor(options.concurrency)),
825
1055
  notify: options.notify ?? "batch",
826
1056
  status: "queued",
1057
+ waiting: null,
827
1058
  queuedAt: at,
828
1059
  startedAt: null,
829
1060
  finishedAt: null,
@@ -847,19 +1078,81 @@ export class AgentManager {
847
1078
  if (options.signal.aborted) abort();
848
1079
  else options.signal.addEventListener("abort", abort, { once: true });
849
1080
  }
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
- });
1081
+ // No count cap: every task of the batch may run at once unless the call
1082
+ // set max_parallel; memory admission decides when each one starts.
1083
+ const slots = { running: 0, limit: Math.max(1, Math.floor(options.concurrency ?? records.length)) };
1084
+ records.forEach((record, index) => this.queue.push({ record, task: tasks[index], options, batch: slots }));
1085
+ this.pump();
856
1086
  return { batch, records, done: Promise.all(records.map((record) => record.done)) };
857
1087
  }
858
1088
 
859
- private async start(record: AgentRecord, task: ChildTask, options: DispatchOptions): Promise<void> {
1089
+ /**
1090
+ * Start queued children, oldest first, while their batch has a free slot
1091
+ * and memory admission allows; the rest wait for a child to finish or
1092
+ * for the next check (every retryMs while something waits for memory).
1093
+ */
1094
+ private pump(): void {
1095
+ if (this.retryTimer) {
1096
+ clearTimeout(this.retryTimer);
1097
+ this.retryTimer = null;
1098
+ }
1099
+ let blocked: { reason: string; detail: string } | null = null;
1100
+ const still: QueueEntry[] = [];
1101
+ let changed = false;
1102
+ for (const entry of this.queue) {
1103
+ const { record } = entry;
1104
+ if (record.result) {
1105
+ // Cancelled while it waited: it never starts.
1106
+ entry.options.onSettled?.(record);
1107
+ continue;
1108
+ }
1109
+ if (blocked || entry.batch.running >= entry.batch.limit) {
1110
+ if (blocked && entry.batch.running < entry.batch.limit) changed = this.markWaiting(record, blocked.detail) || changed;
1111
+ still.push(entry);
1112
+ continue;
1113
+ }
1114
+ const verdict = this.admission ? this.admission.check() : { ok: true as const };
1115
+ if (!verdict.ok) {
1116
+ blocked = verdict;
1117
+ changed = this.markWaiting(record, verdict.detail) || changed;
1118
+ still.push(entry);
1119
+ continue;
1120
+ }
1121
+ entry.batch.running += 1;
1122
+ void this.start(entry);
1123
+ }
1124
+ this.queue = still;
1125
+ const waiting = still.filter((entry) => entry.record.waiting);
1126
+ if (blocked && waiting.length > 0) {
1127
+ const info = { critical: blocked.reason === "critical", detail: blocked.detail };
1128
+ // Once when children start waiting, and when it turns critical (or back):
1129
+ // the figures in the detail change with every check.
1130
+ if (!this.memoryWait || this.memoryWait.critical !== info.critical) {
1131
+ this.memoryWait = info;
1132
+ this.onMemoryWait({ ...info, queued: waiting.length });
1133
+ }
1134
+ // A ref'd timer: a one-shot run must not end while children wait.
1135
+ this.retryTimer = setTimeout(() => this.pump(), this.retryMs);
1136
+ } else if (this.memoryWait) {
1137
+ this.memoryWait = null;
1138
+ this.onMemoryWait(null);
1139
+ }
1140
+ if (changed) this.onChanged();
1141
+ }
1142
+
1143
+ private markWaiting(record: AgentRecord, detail: string): boolean {
1144
+ if (record.waiting?.detail === detail) return false;
1145
+ record.waiting = { reason: "memory", detail, since: record.waiting?.since ?? this.now() };
1146
+ return true;
1147
+ }
1148
+
1149
+ private async start(entry: QueueEntry): Promise<void> {
1150
+ const { record, task, options } = entry;
860
1151
  record.status = "running";
1152
+ record.waiting = null;
861
1153
  record.startedAt = this.now();
862
1154
  record.lastActivityAt = record.startedAt;
1155
+ this.admission?.started(record);
863
1156
  this.onChanged();
864
1157
  options.onChanged?.();
865
1158
  let result: ChildResult;
@@ -868,8 +1161,10 @@ export class AgentManager {
868
1161
  sessionId: record.sessionId,
869
1162
  parentSessionId: record.parentSessionId,
870
1163
  signal: record.controller.signal,
1164
+ onSpawn: (pid) => this.admission?.spawned(record, pid),
871
1165
  ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
872
1166
  ...(options.cwd ? { cwd: options.cwd } : {}),
1167
+ ...(options.sessionDir ? { sessionDir: options.sessionDir } : {}),
873
1168
  onActivity: (activity) => {
874
1169
  record.lastActivityAt = activity.at;
875
1170
  record.turns = activity.turns;
@@ -887,7 +1182,12 @@ export class AgentManager {
887
1182
  error: error instanceof Error ? error.message : String(error),
888
1183
  }, this.now() - (record.startedAt ?? this.now()), record.sessionId);
889
1184
  }
1185
+ this.admission?.finished(record);
1186
+ entry.batch.running -= 1;
890
1187
  this.settle(record, result);
1188
+ options.onSettled?.(record);
1189
+ // Its slot and its memory are free: the queue moves on.
1190
+ this.pump();
891
1191
  }
892
1192
 
893
1193
  private settle(record: AgentRecord, result: ChildResult): void {
@@ -919,6 +1219,18 @@ export class AgentManager {
919
1219
  if (record.background && !record.delivered) this.onDeliverable();
920
1220
  }
921
1221
 
1222
+ /** Memory: delivered results beyond the newest MAX_KEPT_OUTPUTS keep no output. */
1223
+ private releaseOldOutputs(parentSessionId: string): void {
1224
+ const list = this.bySession.get(parentSessionId) ?? [];
1225
+ let kept = 0;
1226
+ for (let index = list.length - 1; index >= 0; index--) {
1227
+ const result = list[index].result;
1228
+ if (!result || !list[index].delivered || result.output === RELEASED_OUTPUT) continue;
1229
+ kept += 1;
1230
+ if (kept > MAX_KEPT_OUTPUTS) list[index].result = { ...result, output: RELEASED_OUTPUT };
1231
+ }
1232
+ }
1233
+
922
1234
  list(parentSessionId: string): AgentRecord[] {
923
1235
  return [...(this.bySession.get(parentSessionId) ?? [])];
924
1236
  }
@@ -1026,6 +1338,7 @@ export class AgentManager {
1026
1338
  }, 0, record.sessionId));
1027
1339
  }
1028
1340
  }
1341
+ if (live.some((record) => record.startedAt === null)) this.pump();
1029
1342
  await Promise.all(live.map((record) => record.done));
1030
1343
  }
1031
1344
 
@@ -1048,6 +1361,7 @@ export function agentStatusSummary(records: AgentRecord[]): string {
1048
1361
  if (records.length === 0) return "No sub-agents in this session.";
1049
1362
  const running = records.filter((record) => record.status === "running").length;
1050
1363
  const queued = records.filter((record) => record.status === "queued").length;
1364
+ const forMemory = records.filter((record) => record.status === "queued" && record.waiting?.reason === "memory").length;
1051
1365
  const settled = records.filter((record) => Boolean(record.result)).length;
1052
1366
  const limits = [...new Set(records.map((record) => record.parallelLimit))];
1053
1367
  const parallel = limits.length === 1 && limits[0] === null
@@ -1055,7 +1369,8 @@ export function agentStatusSummary(records: AgentRecord[]): string {
1055
1369
  : limits.length === 1
1056
1370
  ? String(limits[0])
1057
1371
  : "per-batch";
1058
- return `${records.length} sub-agents: ${running} running, ${queued} queued, ${settled} settled (parallel: ${parallel})`;
1372
+ const queuedText = forMemory > 0 ? `${queued} queued: waiting for memory` : `${queued} queued`;
1373
+ return `${records.length} sub-agents: ${running} running, ${queuedText}, ${settled} settled (parallel: ${parallel})`;
1059
1374
  }
1060
1375
 
1061
1376
  /** One status line per sub-agent (agents_status, /agents). */
@@ -1064,7 +1379,8 @@ export function renderAgentStatus(records: AgentRecord[], now: number = Date.now
1064
1379
  const lines = records.map((record) => {
1065
1380
  const since = record.startedAt ?? record.queuedAt;
1066
1381
  const elapsed = minutesOf((record.finishedAt ?? now) - since);
1067
- const bits = [`${record.id}`, `[${record.status}]`, record.role];
1382
+ const state = record.status === "queued" && record.waiting ? "queued: waiting for memory" : record.status;
1383
+ const bits = [`${record.id}`, `[${state}]`, record.role];
1068
1384
  if (record.model) bits.push(`[${record.model}${record.effort ? ` · ${record.effort}` : ""}]`);
1069
1385
  bits.push(record.background ? "background" : "blocking");
1070
1386
  const detail: string[] = [`${elapsed} min`];
@@ -1074,6 +1390,7 @@ export function renderAgentStatus(records: AgentRecord[], now: number = Date.now
1074
1390
  detail.push(`last activity ${minutesOf(now - record.lastActivityAt)} min ago`);
1075
1391
  }
1076
1392
  if (record.result && !record.delivered) detail.push("result not yet read");
1393
+ if (!record.result && record.waiting) detail.push(record.waiting.detail);
1077
1394
  const task = record.task.length > 80 ? `${record.task.slice(0, 79)}…` : record.task;
1078
1395
  return `- ${bits.join(" ")} (${detail.join(", ")}) — ${task}`;
1079
1396
  });