omnirush 0.8.6 → 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.
Files changed (54) hide show
  1. package/assets/CHANGELOG.md +120 -0
  2. package/assets/extensions/omnirush/agents-lib.ts +501 -63
  3. package/assets/extensions/omnirush/agents.ts +140 -115
  4. package/assets/extensions/omnirush/bgshell-lib.ts +432 -0
  5. package/assets/extensions/omnirush/bgshell.ts +392 -0
  6. package/assets/extensions/omnirush/capture/workspace-collector.ts +86 -22
  7. package/assets/extensions/omnirush/collector.ts +275 -43
  8. package/assets/extensions/omnirush/commands.ts +2 -0
  9. package/assets/extensions/omnirush/deliveries.ts +145 -0
  10. package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
  11. package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
  12. package/assets/extensions/omnirush/guard-lib.ts +230 -0
  13. package/assets/extensions/omnirush/guard.ts +340 -0
  14. package/assets/extensions/omnirush/index.ts +32 -6
  15. package/assets/extensions/omnirush/mcp.ts +103 -8
  16. package/assets/extensions/omnirush/memory-lib.ts +516 -0
  17. package/assets/extensions/omnirush/pi-engine.ts +115 -3
  18. package/assets/extensions/omnirush/plan-lib.ts +38 -9
  19. package/assets/extensions/omnirush/plan.ts +85 -30
  20. package/assets/extensions/omnirush/sota.ts +52 -4
  21. package/assets/extensions/omnirush/status-lib.ts +3 -0
  22. package/assets/extensions/omnirush/subagent-marker.ts +21 -0
  23. package/assets/extensions/omnirush/subagents-lib.ts +623 -0
  24. package/assets/extensions/omnirush/subagents.ts +305 -0
  25. package/assets/extensions/omnirush/swarm-lib.ts +142 -0
  26. package/assets/extensions/omnirush/swarm.ts +95 -0
  27. package/assets/extensions/omnirush/voice/capture.ts +502 -0
  28. package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
  29. package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
  30. package/assets/extensions/omnirush/voice/core/index.ts +21 -0
  31. package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
  32. package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
  33. package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
  34. package/assets/extensions/omnirush/voice/core/session.ts +403 -0
  35. package/assets/extensions/omnirush/voice/core/text.ts +81 -0
  36. package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
  37. package/assets/extensions/omnirush/voice/core/types.ts +102 -0
  38. package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
  39. package/assets/extensions/omnirush/voice/keys.ts +435 -0
  40. package/assets/extensions/omnirush/voice/kitty.ts +64 -0
  41. package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
  42. package/assets/extensions/omnirush/voice/settings.ts +67 -0
  43. package/assets/extensions/omnirush/voice.ts +838 -0
  44. package/assets/extensions/omnirush/yolo-lib.ts +80 -0
  45. package/assets/extensions/omnirush/yolo.ts +85 -0
  46. package/package.json +7 -3
  47. package/scripts/brand-engine.js +526 -0
  48. package/scripts/build-all-packages.py +29 -1
  49. package/scripts/smoke-packages.py +33 -1
  50. package/src/bin.js +232 -33
  51. package/src/compat.js +272 -0
  52. package/src/lib.js +64 -0
  53. package/src/sessions.js +222 -0
  54. package/scripts/patch-pi-branding.js +0 -251
@@ -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,
@@ -28,7 +30,16 @@ import { mkdtemp, writeFile, rm } from "node:fs/promises";
28
30
  import { tmpdir } from "node:os";
29
31
  import path from "node:path";
30
32
 
33
+ import {
34
+ ENV_FALLBACK_EFFORT,
35
+ ENV_FALLBACK_MODEL,
36
+ FALLBACK_MARKER,
37
+ fallbackNote,
38
+ piThinking,
39
+ type SubagentFallback,
40
+ } from "./subagents-lib";
31
41
  import { childAuthEnv } from "./auth";
42
+ import { yoloActive } from "./yolo-lib";
32
43
 
33
44
  /** childAuthEnv, never throwing (a spawn must not fail on the auth file). */
34
45
  function childAuthEnvSafe(): Record<string, string> {
@@ -62,6 +73,47 @@ export function childInactivityMs(env: NodeJS.ProcessEnv = process.env): number
62
73
  export const CHILD_KILL_GRACE_MS = 5_000;
63
74
  /** Per-child output cap in the structured result (bytes, UTF-8). */
64
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
+ }
65
117
 
66
118
  export const AGENT_ROLES = ["code-searcher", "researcher-web", "general-worker"] as const;
67
119
  export type AgentRole = (typeof AGENT_ROLES)[number];
@@ -152,6 +204,25 @@ export function childInvocation(args: string[]): {
152
204
  return { command: "pi", args };
153
205
  }
154
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
+
155
226
  /**
156
227
  * Build the child's argv for one task: JSON print mode with the role's
157
228
  * system prompt appended via a temp file path (the caller writes and
@@ -164,19 +235,36 @@ export function buildChildArgs(
164
235
  promptFilePath: string | null,
165
236
  model?: string,
166
237
  sessionId?: string,
238
+ effort?: string | null,
239
+ approve = false,
240
+ sessionDir?: string,
167
241
  ): string[] {
168
242
  const args: string[] = ["--mode", "json", "-p"];
243
+ if (approve) {
244
+ // Yolo mode: a headless child has no trust prompt, so without this it
245
+ // would ignore the project's settings the parent runs with.
246
+ args.push("--approve");
247
+ }
169
248
  if (sessionId) {
170
249
  // The child's session id, picked by the parent so its session file can
171
250
  // be found and captured as this session's sub-agent.
172
251
  args.push("--session-id", sessionId);
173
252
  }
253
+ if (sessionDir) {
254
+ // Out of the user's resume list (subagentSessionDir).
255
+ args.push("--session-dir", sessionDir);
256
+ }
174
257
  if (promptFilePath) {
175
258
  args.push("--append-system-prompt", promptFilePath);
176
259
  }
177
260
  if (model && model.trim()) {
178
261
  args.push("--provider", "omnirush", "--model", model.trim());
179
262
  }
263
+ if (effort && effort.trim()) {
264
+ // The effort the sub-agent runs on (the picked one, or the main agent's
265
+ // mapped to the levels this model offers).
266
+ args.push("--thinking", piThinking(effort.trim()));
267
+ }
180
268
  args.push(`Task: ${task}`);
181
269
  return args;
182
270
  }
@@ -207,6 +295,25 @@ export interface ChildTask {
207
295
  * (e.g. "muse-spark-1.3" for cheap swarm workers under an astra
208
296
  * parent). Undefined = inherit the parent's model. */
209
297
  model?: string;
298
+ /** The effort the child runs on (gateway spelling); undefined = pi's default. */
299
+ effort?: string;
300
+ /** The picked sub-agent model could not be used: `model` is the main one. */
301
+ fallback?: SubagentFallback;
302
+ /** The main model the child's gateway guard moves to when the gateway refuses `model`. */
303
+ gatewayFallback?: { model: string; effort: string | null };
304
+ /** Extra environment for the child (the sub-agent setting and main model, for nested layers). */
305
+ env?: Record<string, string>;
306
+ }
307
+
308
+ /** A sub-agent that ran on the main model instead of the picked one. */
309
+ export interface ChildModelFallback {
310
+ requested: string;
311
+ used: string;
312
+ effort?: string | null;
313
+ reason: string;
314
+ /** "selection": picked before it started; "gateway": the gateway refused the picked model mid-run. */
315
+ kind: "selection" | "gateway";
316
+ note: string;
210
317
  }
211
318
 
212
319
  export type ChildStatus = "completed" | "failed" | "timeout" | "stalled" | "cancelled" | "interrupted";
@@ -218,6 +325,10 @@ export interface ChildResult {
218
325
  session_id?: string;
219
326
  /** The gateway model the child ran on (cross-model children). */
220
327
  model?: string;
328
+ /** The effort it ran on. */
329
+ effort?: string;
330
+ /** It ran on the main model instead of the picked one (why, and since when). */
331
+ model_fallback?: ChildModelFallback;
221
332
  status: ChildStatus;
222
333
  /** Process exit code (null when killed by a signal or still unknown). */
223
334
  exitCode: number | null;
@@ -237,6 +348,33 @@ export interface ChildResultInput {
237
348
  turns: number;
238
349
  status?: ChildStatus;
239
350
  error?: string;
351
+ /** The gateway moved the child to the main model mid-run. */
352
+ gatewayFallback?: { requested: string; used: string; effort?: string | null; reason: string };
353
+ }
354
+
355
+ /** The selection or gateway fallback of a child, for its result. */
356
+ export function childModelFallback(task: ChildTask, gateway?: ChildResultInput["gatewayFallback"]): ChildModelFallback | undefined {
357
+ if (task.fallback) {
358
+ return {
359
+ requested: task.fallback.requested,
360
+ used: task.fallback.used,
361
+ effort: task.effort ?? null,
362
+ reason: String(task.fallback.reason),
363
+ kind: "selection",
364
+ note: fallbackNote(task.fallback),
365
+ };
366
+ }
367
+ if (gateway) {
368
+ return {
369
+ requested: gateway.requested,
370
+ used: gateway.used,
371
+ effort: gateway.effort ?? null,
372
+ reason: gateway.reason,
373
+ kind: "gateway",
374
+ note: fallbackNote({ requested: gateway.requested, used: gateway.used, reason: gateway.reason }),
375
+ };
376
+ }
377
+ return undefined;
240
378
  }
241
379
 
242
380
  /** Final structured result for one child. */
@@ -253,11 +391,14 @@ export function buildChildResult(
253
391
  capped = output.slice(0, CHILD_OUTPUT_CAP_BYTES);
254
392
  while (Buffer.byteLength(capped, "utf8") > CHILD_OUTPUT_CAP_BYTES) capped = capped.slice(0, -1);
255
393
  }
394
+ const modelFallback = childModelFallback(task, input.gatewayFallback);
256
395
  return {
257
396
  role: task.role,
258
397
  task: task.task,
259
398
  ...(sessionId ? { session_id: sessionId } : {}),
260
399
  ...(task.model ? { model: task.model } : {}),
400
+ ...(task.effort ? { effort: task.effort } : {}),
401
+ ...(modelFallback ? { model_fallback: modelFallback } : {}),
261
402
  status: input.status ?? (input.exitCode === 0 ? "completed" : "failed"),
262
403
  exitCode: input.exitCode,
263
404
  output: capped,
@@ -277,38 +418,155 @@ export interface ParsedChildEvents {
277
418
  turns: number;
278
419
  }
279
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;
280
423
  /**
281
- * Parse the accumulated stdout of a `pi --mode json -p` run: one JSON
282
- * event per line; `message_end` events carry the messages. Unparseable
283
- * 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.
284
427
  */
285
- export function parseChildEvents(stdout: string): ParsedChildEvents {
286
- let finalText = "";
287
- let turns = 0;
288
- 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;
289
529
  const trimmed = line.trim();
290
- 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;
291
534
  let event: any;
292
535
  try {
293
536
  event = JSON.parse(trimmed);
294
537
  } catch {
295
- 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;
296
543
  }
297
- if (event?.type !== "message_end" || !event.message) continue;
544
+ if (event?.type !== "message_end" || !event.message) return;
298
545
  const message = event.message;
299
- if (message.role !== "assistant") continue;
300
- turns += 1;
546
+ if (message.role !== "assistant") return;
547
+ this.turns += 1;
301
548
  const parts = Array.isArray(message.content) ? message.content : [];
302
549
  const text = parts
303
550
  .filter((part: any) => part?.type === "text" && typeof part.text === "string")
304
551
  .map((part: any) => part.text)
305
552
  .join("\n\n");
306
- if (text.trim()) finalText = text;
553
+ if (text.trim()) this.finalText = text;
307
554
  else if (typeof message.content === "string" && message.content.trim()) {
308
- finalText = message.content;
555
+ this.finalText = message.content;
309
556
  }
310
557
  }
311
- 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 };
312
570
  }
313
571
 
314
572
  // --- the runner -------------------------------------------------------------
@@ -316,6 +574,8 @@ export function parseChildEvents(stdout: string): ParsedChildEvents {
316
574
  export interface SpawnChildOptions {
317
575
  cwd: string;
318
576
  parentSessionId: string;
577
+ /** Where the child's session file goes (subagentSessionDir); the core's default when unset. */
578
+ sessionDir?: string;
319
579
  /**
320
580
  * Explicit wall-clock limit (the tool's `timeout_minutes`). Undefined =
321
581
  * none: a child runs until it is done, stalls (inactivityMs) or is
@@ -346,6 +606,10 @@ export interface SpawnChildOptions {
346
606
  onActivity?: (activity: ChildActivity) => void;
347
607
  /** The child's session id (a fresh UUID by default). */
348
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>;
349
613
  }
350
614
 
351
615
  export interface ChildActivity {
@@ -392,17 +656,20 @@ export async function runChildAgent(
392
656
  const sessionId = options.sessionId ?? randomUUID();
393
657
 
394
658
  return withRolePromptFile(task.role, async (promptFile) => {
395
- const args = buildChildArgs(task.task, promptFile, task.model, sessionId);
659
+ const args = buildChildArgs(task.task, promptFile, task.model, sessionId, task.effort, yoloActive(), options.sessionDir);
396
660
  const invocation = childInvocation(args);
397
661
 
398
662
  return await new Promise<ChildResult>((resolvePromise) => {
399
- 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();
400
667
  let stderr = "";
401
- let pendingLine = "";
668
+ let pendingErrLine = "";
669
+ let gatewayFallback: ChildResultInput["gatewayFallback"];
402
670
  let settled = false;
403
671
  let killedFor: "timeout" | "stalled" | "cancelled" | "interrupted" | null = null;
404
- let turns = 0;
405
- const toolsRunning = new Set<string>();
672
+ const toolsRunning = events.toolsRunning;
406
673
  let lastActivity = now();
407
674
  let watchdog: ReturnType<typeof setTimeout> | null = null;
408
675
  let timer: ReturnType<typeof setTimeout> | null = null;
@@ -411,15 +678,12 @@ export async function runChildAgent(
411
678
  cwd: options.cwd,
412
679
  shell: false,
413
680
  stdio: ["ignore", "pipe", "pipe"],
414
- env: {
415
- ...process.env,
416
- // Shared credentials by location (OMNIRUSH_DIR), never a token
417
- // frozen at the parent's launch: the child re-reads auth.json
418
- // for every request and takes part in the refresh lock.
419
- ...childAuthEnvSafe(),
420
- OMNIRUSH_PARENT_SESSION: options.parentSessionId,
421
- },
681
+ // Shared credentials by location (OMNIRUSH_DIR), never a token
682
+ // frozen at the parent's launch: the child re-reads auth.json for
683
+ // every request and takes part in the refresh lock.
684
+ env: childEnvironment(task, options.parentSessionId, { ...process.env, ...childAuthEnvSafe(), ...(options.extraEnv ?? {}) }),
422
685
  });
686
+ options.onSpawn?.(child.pid);
423
687
 
424
688
  const finish = (input: ChildResultInput) => {
425
689
  if (settled) return;
@@ -429,7 +693,7 @@ export async function runChildAgent(
429
693
  if (options.signal && abortHandler) {
430
694
  options.signal.removeEventListener("abort", abortHandler);
431
695
  }
432
- resolvePromise(buildChildResult(task, input, now() - startedAt, sessionId));
696
+ resolvePromise(buildChildResult(task, { ...input, ...(gatewayFallback ? { gatewayFallback } : {}) }, now() - startedAt, sessionId));
433
697
  };
434
698
 
435
699
  const killTree = () => {
@@ -465,7 +729,7 @@ export async function runChildAgent(
465
729
  const alive = () => {
466
730
  lastActivity = now();
467
731
  armWatchdog();
468
- options.onActivity?.({ at: lastActivity, turns, toolsRunning: toolsRunning.size });
732
+ options.onActivity?.({ at: lastActivity, turns: events.turns, toolsRunning: toolsRunning.size });
469
733
  };
470
734
  armWatchdog();
471
735
 
@@ -482,31 +746,22 @@ export async function runChildAgent(
482
746
  else options.signal.addEventListener("abort", abortHandler, { once: true });
483
747
  }
484
748
 
485
- // Tool calls in flight and finished turns, from the event lines.
486
- const observe = (line: string) => {
487
- if (!line.includes('"type"')) return;
488
- let event: any;
489
- try {
490
- event = JSON.parse(line);
491
- } catch {
492
- return;
493
- }
494
- if (event?.type === "tool_execution_start" && typeof event.toolCallId === "string") toolsRunning.add(event.toolCallId);
495
- else if (event?.type === "tool_execution_end" && typeof event.toolCallId === "string") toolsRunning.delete(event.toolCallId);
496
- else if (event?.type === "message_end" && event.message?.role === "assistant") turns += 1;
497
- };
498
-
749
+ child.stdout?.setEncoding?.("utf8");
499
750
  child.stdout?.on("data", (chunk: Buffer | string) => {
500
751
  const text = String(chunk);
501
- stdout += text;
502
- const lines = (pendingLine + text).split("\n");
503
- pendingLine = lines.pop() ?? "";
504
- for (const line of lines) observe(line);
752
+ events.push(text);
505
753
  alive();
506
754
  options.onChildStdout?.(task.role, text);
507
755
  });
756
+ child.stderr?.setEncoding?.("utf8");
508
757
  child.stderr?.on("data", (chunk: Buffer | string) => {
509
- stderr += String(chunk);
758
+ const text = String(chunk);
759
+ // The child's gateway guard reports a move to the main model here.
760
+ const lines = (pendingErrLine + text).split("\n");
761
+ pendingErrLine = lines.pop() ?? "";
762
+ if (pendingErrLine.length > 64 * 1024) pendingErrLine = pendingErrLine.slice(-64 * 1024);
763
+ for (const line of lines) gatewayFallback = parseFallbackMarker(line) ?? gatewayFallback;
764
+ stderr += text;
510
765
  if (stderr.length > 256 * 1024) stderr = stderr.slice(-64 * 1024);
511
766
  alive();
512
767
  });
@@ -520,7 +775,8 @@ export async function runChildAgent(
520
775
  });
521
776
  });
522
777
  child.on("close", (code: number | null) => {
523
- const parsed = parseChildEvents(stdout);
778
+ events.end();
779
+ const parsed = { finalText: events.finalText, turns: events.turns };
524
780
  const partial = parsed.finalText ? " — partial result kept" : "";
525
781
  if (killedFor) {
526
782
  const error = killedFor === "timeout"
@@ -551,6 +807,46 @@ function formatMinutes(ms: number): string {
551
807
  return `${Math.max(1, Math.round(ms / 1000))} s`;
552
808
  }
553
809
 
810
+ /**
811
+ * A child's environment: the parent's, the parent session id (the child
812
+ * uploads nothing itself), the sub-agent setting and main model handed down
813
+ * to nested layers, and the main model its gateway guard falls back to (only
814
+ * for this child: a nested one gets its own or none).
815
+ */
816
+ export function childEnvironment(task: ChildTask, parentSessionId: string, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
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);
820
+ // Guarded mode: the name the parent's approval prompt shows for this child.
821
+ const oneLine = task.task.replace(/\s+/g, " ").trim();
822
+ env.OMNIRUSH_SUBAGENT_LABEL = `${ROLE_PRESETS[task.role]?.label ?? task.role}: ${oneLine.length > 60 ? `${oneLine.slice(0, 59)}…` : oneLine}`;
823
+ delete env[ENV_FALLBACK_MODEL];
824
+ delete env[ENV_FALLBACK_EFFORT];
825
+ if (task.gatewayFallback?.model) {
826
+ env[ENV_FALLBACK_MODEL] = task.gatewayFallback.model;
827
+ if (task.gatewayFallback.effort) env[ENV_FALLBACK_EFFORT] = task.gatewayFallback.effort;
828
+ }
829
+ return env;
830
+ }
831
+
832
+ /** A child's stderr line reporting its gateway fallback (see sota.ts), or null. */
833
+ export function parseFallbackMarker(line: string): ChildResultInput["gatewayFallback"] | null {
834
+ const at = line.indexOf(FALLBACK_MARKER);
835
+ if (at < 0) return null;
836
+ try {
837
+ const parsed = JSON.parse(line.slice(at + FALLBACK_MARKER.length));
838
+ if (typeof parsed?.requested !== "string" || typeof parsed?.used !== "string") return null;
839
+ return {
840
+ requested: parsed.requested,
841
+ used: parsed.used,
842
+ effort: typeof parsed.effort === "string" ? parsed.effort : null,
843
+ reason: typeof parsed.reason === "string" ? parsed.reason : "refused",
844
+ };
845
+ } catch {
846
+ return null;
847
+ }
848
+ }
849
+
554
850
  /**
555
851
  * Run tasks with a concurrency cap, preserving input order in the
556
852
  * results (ports the pi subagent example's mapWithConcurrencyLimit).
@@ -580,8 +876,12 @@ export function renderChildResults(results: ChildResult[], ids?: readonly string
580
876
  const succeeded = results.filter((result) => result.status === "completed").length;
581
877
  const sections = results.map((result, index) => {
582
878
  const minutes = Math.round((result.durationMs / 60_000) * 10) / 10;
583
- const header = `### ${ids?.[index] ? `${ids[index]} ` : ""}${result.role}${result.model ? ` [${result.model}]` : ""} — ${result.status} (${minutes} min${result.outputTruncated ? ", output capped" : ""})`;
879
+ const ranOn = result.model_fallback?.kind === "gateway" ? result.model_fallback.used : result.model;
880
+ const effort = result.model_fallback?.kind === "gateway" ? result.model_fallback.effort ?? undefined : result.effort;
881
+ const label = ranOn ? ` [${ranOn}${effort ? ` · ${effort}` : ""}]` : "";
882
+ const header = `### ${ids?.[index] ? `${ids[index]} ` : ""}${result.role}${label} — ${result.status} (${minutes} min${result.outputTruncated ? ", output capped" : ""})`;
584
883
  const meta: string[] = [`task: ${result.task}`];
884
+ if (result.model_fallback) meta.push(`note: ${result.model_fallback.note}`);
585
885
  if (result.error) meta.push(`error: ${result.error}`);
586
886
  return `${header}\n${meta.join("\n")}\n\n${result.output || "(no output)"}`;
587
887
  });
@@ -604,6 +904,7 @@ export interface AgentRecord {
604
904
  role: AgentRole;
605
905
  task: string;
606
906
  model?: string;
907
+ effort?: string;
607
908
  /** Dispatched with wait:false: its result comes back as a message. */
608
909
  background: boolean;
609
910
  /** Null means the batch was intentionally uncapped; otherwise the batch limit. */
@@ -611,6 +912,8 @@ export interface AgentRecord {
611
912
  /** Background delivery: one message per child, or one per batch. */
612
913
  notify: "each" | "batch";
613
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;
614
917
  queuedAt: number;
615
918
  startedAt: number | null;
616
919
  finishedAt: number | null;
@@ -629,14 +932,37 @@ export type AgentRunner = (
629
932
  options: {
630
933
  sessionId: string;
631
934
  parentSessionId: string;
935
+ /** Where the child's session file goes (subagentSessionDir). */
936
+ sessionDir?: string;
632
937
  signal: AbortSignal;
633
938
  onActivity: (activity: ChildActivity) => void;
634
939
  /** The call's explicit timeout_minutes, if any. */
635
940
  timeoutMs?: number;
636
941
  cwd?: string;
942
+ /** The child process started (its pid). */
943
+ onSpawn?: (pid: number | undefined) => void;
637
944
  },
638
945
  ) => Promise<ChildResult>;
639
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
+
640
966
  export interface DispatchOptions {
641
967
  background: boolean;
642
968
  /** Children running at once (default: all of them). */
@@ -652,6 +978,8 @@ export interface DispatchOptions {
652
978
  timeoutMs?: number;
653
979
  /** Workspace the children run in. */
654
980
  cwd?: string;
981
+ /** Where the children's session files go (subagentSessionDir). */
982
+ sessionDir?: string;
655
983
  }
656
984
 
657
985
  /**
@@ -671,12 +999,33 @@ export class AgentManager {
671
999
  private readonly resolvers = new Map<AgentRecord, (result: ChildResult) => void>();
672
1000
  private nextAgent = 1;
673
1001
  private nextBatch = 1;
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;
674
1009
 
675
- constructor(options: { run: AgentRunner; now?: () => number; onDeliverable?: () => void; onChanged?: () => void }) {
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
+ }) {
676
1022
  this.runner = options.run;
677
1023
  this.now = options.now ?? Date.now;
678
1024
  this.onDeliverable = options.onDeliverable ?? (() => undefined);
679
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);
680
1029
  }
681
1030
 
682
1031
  dispatch(
@@ -685,6 +1034,7 @@ export class AgentManager {
685
1034
  options: DispatchOptions,
686
1035
  ): { batch: string; records: AgentRecord[]; done: Promise<ChildResult[]> } {
687
1036
  const batch = `b${this.nextBatch++}`;
1037
+ this.releaseOldOutputs(parentSessionId);
688
1038
  const list = this.bySession.get(parentSessionId) ?? [];
689
1039
  this.bySession.set(parentSessionId, list);
690
1040
  const at = this.now();
@@ -699,10 +1049,12 @@ export class AgentManager {
699
1049
  role: task.role,
700
1050
  task: task.task,
701
1051
  ...(task.model ? { model: task.model } : {}),
1052
+ ...(task.effort ? { effort: task.effort } : {}),
702
1053
  background: options.background,
703
1054
  parallelLimit: options.concurrency === undefined ? null : Math.max(1, Math.floor(options.concurrency)),
704
1055
  notify: options.notify ?? "batch",
705
1056
  status: "queued",
1057
+ waiting: null,
706
1058
  queuedAt: at,
707
1059
  startedAt: null,
708
1060
  finishedAt: null,
@@ -726,19 +1078,81 @@ export class AgentManager {
726
1078
  if (options.signal.aborted) abort();
727
1079
  else options.signal.addEventListener("abort", abort, { once: true });
728
1080
  }
729
- const concurrency = Math.max(1, Math.floor(options.concurrency ?? records.length));
730
- void mapWithConcurrency(records, concurrency, async (record, index) => {
731
- // Cancelled while it waited for a slot: it never starts.
732
- if (!record.result) await this.start(record, tasks[index], options);
733
- options.onSettled?.(record);
734
- });
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();
735
1086
  return { batch, records, done: Promise.all(records.map((record) => record.done)) };
736
1087
  }
737
1088
 
738
- 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;
739
1151
  record.status = "running";
1152
+ record.waiting = null;
740
1153
  record.startedAt = this.now();
741
1154
  record.lastActivityAt = record.startedAt;
1155
+ this.admission?.started(record);
742
1156
  this.onChanged();
743
1157
  options.onChanged?.();
744
1158
  let result: ChildResult;
@@ -747,8 +1161,10 @@ export class AgentManager {
747
1161
  sessionId: record.sessionId,
748
1162
  parentSessionId: record.parentSessionId,
749
1163
  signal: record.controller.signal,
1164
+ onSpawn: (pid) => this.admission?.spawned(record, pid),
750
1165
  ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
751
1166
  ...(options.cwd ? { cwd: options.cwd } : {}),
1167
+ ...(options.sessionDir ? { sessionDir: options.sessionDir } : {}),
752
1168
  onActivity: (activity) => {
753
1169
  record.lastActivityAt = activity.at;
754
1170
  record.turns = activity.turns;
@@ -766,7 +1182,12 @@ export class AgentManager {
766
1182
  error: error instanceof Error ? error.message : String(error),
767
1183
  }, this.now() - (record.startedAt ?? this.now()), record.sessionId);
768
1184
  }
1185
+ this.admission?.finished(record);
1186
+ entry.batch.running -= 1;
769
1187
  this.settle(record, result);
1188
+ options.onSettled?.(record);
1189
+ // Its slot and its memory are free: the queue moves on.
1190
+ this.pump();
770
1191
  }
771
1192
 
772
1193
  private settle(record: AgentRecord, result: ChildResult): void {
@@ -798,6 +1219,18 @@ export class AgentManager {
798
1219
  if (record.background && !record.delivered) this.onDeliverable();
799
1220
  }
800
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
+
801
1234
  list(parentSessionId: string): AgentRecord[] {
802
1235
  return [...(this.bySession.get(parentSessionId) ?? [])];
803
1236
  }
@@ -905,6 +1338,7 @@ export class AgentManager {
905
1338
  }, 0, record.sessionId));
906
1339
  }
907
1340
  }
1341
+ if (live.some((record) => record.startedAt === null)) this.pump();
908
1342
  await Promise.all(live.map((record) => record.done));
909
1343
  }
910
1344
 
@@ -927,6 +1361,7 @@ export function agentStatusSummary(records: AgentRecord[]): string {
927
1361
  if (records.length === 0) return "No sub-agents in this session.";
928
1362
  const running = records.filter((record) => record.status === "running").length;
929
1363
  const queued = records.filter((record) => record.status === "queued").length;
1364
+ const forMemory = records.filter((record) => record.status === "queued" && record.waiting?.reason === "memory").length;
930
1365
  const settled = records.filter((record) => Boolean(record.result)).length;
931
1366
  const limits = [...new Set(records.map((record) => record.parallelLimit))];
932
1367
  const parallel = limits.length === 1 && limits[0] === null
@@ -934,7 +1369,8 @@ export function agentStatusSummary(records: AgentRecord[]): string {
934
1369
  : limits.length === 1
935
1370
  ? String(limits[0])
936
1371
  : "per-batch";
937
- 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})`;
938
1374
  }
939
1375
 
940
1376
  /** One status line per sub-agent (agents_status, /agents). */
@@ -943,8 +1379,9 @@ export function renderAgentStatus(records: AgentRecord[], now: number = Date.now
943
1379
  const lines = records.map((record) => {
944
1380
  const since = record.startedAt ?? record.queuedAt;
945
1381
  const elapsed = minutesOf((record.finishedAt ?? now) - since);
946
- const bits = [`${record.id}`, `[${record.status}]`, record.role];
947
- if (record.model) bits.push(`[${record.model}]`);
1382
+ const state = record.status === "queued" && record.waiting ? "queued: waiting for memory" : record.status;
1383
+ const bits = [`${record.id}`, `[${state}]`, record.role];
1384
+ if (record.model) bits.push(`[${record.model}${record.effort ? ` · ${record.effort}` : ""}]`);
948
1385
  bits.push(record.background ? "background" : "blocking");
949
1386
  const detail: string[] = [`${elapsed} min`];
950
1387
  if (!record.result && record.startedAt) {
@@ -953,6 +1390,7 @@ export function renderAgentStatus(records: AgentRecord[], now: number = Date.now
953
1390
  detail.push(`last activity ${minutesOf(now - record.lastActivityAt)} min ago`);
954
1391
  }
955
1392
  if (record.result && !record.delivered) detail.push("result not yet read");
1393
+ if (!record.result && record.waiting) detail.push(record.waiting.detail);
956
1394
  const task = record.task.length > 80 ? `${record.task.slice(0, 79)}…` : record.task;
957
1395
  return `- ${bits.join(" ")} (${detail.join(", ")}) — ${task}`;
958
1396
  });