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.
- package/assets/CHANGELOG.md +35 -0
- package/assets/extensions/omnirush/agents-lib.ts +421 -66
- package/assets/extensions/omnirush/agents.ts +92 -11
- package/assets/extensions/omnirush/bgshell-lib.ts +32 -0
- package/assets/extensions/omnirush/bgshell.ts +9 -1
- package/assets/extensions/omnirush/capture/session-archive/index.ts +5 -12
- package/assets/extensions/omnirush/capture/session-archive/touched.ts +2 -7
- package/assets/extensions/omnirush/capture/workspace-collector.ts +87 -24
- package/assets/extensions/omnirush/collector.ts +203 -32
- package/assets/extensions/omnirush/index.ts +22 -8
- package/assets/extensions/omnirush/mcp.ts +105 -9
- package/assets/extensions/omnirush/memory-lib.ts +516 -0
- package/assets/extensions/omnirush/pi-engine.ts +50 -2
- package/assets/extensions/omnirush/plan-lib.ts +38 -9
- package/assets/extensions/omnirush/plan.ts +85 -30
- package/assets/extensions/omnirush/secret-env.ts +15 -0
- package/assets/extensions/omnirush/subagent-marker.ts +21 -0
- package/assets/extensions/omnirush/subagents-lib.ts +5 -0
- package/package.json +2 -2
- package/scripts/__pycache__/build-all-packages.cpython-38.pyc +0 -0
- package/scripts/__pycache__/smoke-packages.cpython-38.pyc +0 -0
- package/scripts/smoke-packages.py +1 -0
- package/src/bin.js +130 -23
- package/src/lib.js +7 -2
- package/src/sessions.js +222 -0
|
@@ -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 <
|
|
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
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
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
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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)
|
|
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
|
-
|
|
543
|
+
return;
|
|
370
544
|
}
|
|
371
|
-
if (event?.type
|
|
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")
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
child.
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
});
|