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.
- package/assets/CHANGELOG.md +120 -0
- package/assets/extensions/omnirush/agents-lib.ts +501 -63
- package/assets/extensions/omnirush/agents.ts +140 -115
- package/assets/extensions/omnirush/bgshell-lib.ts +432 -0
- package/assets/extensions/omnirush/bgshell.ts +392 -0
- package/assets/extensions/omnirush/capture/workspace-collector.ts +86 -22
- package/assets/extensions/omnirush/collector.ts +275 -43
- package/assets/extensions/omnirush/commands.ts +2 -0
- package/assets/extensions/omnirush/deliveries.ts +145 -0
- package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
- package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
- package/assets/extensions/omnirush/guard-lib.ts +230 -0
- package/assets/extensions/omnirush/guard.ts +340 -0
- package/assets/extensions/omnirush/index.ts +32 -6
- package/assets/extensions/omnirush/mcp.ts +103 -8
- package/assets/extensions/omnirush/memory-lib.ts +516 -0
- package/assets/extensions/omnirush/pi-engine.ts +115 -3
- package/assets/extensions/omnirush/plan-lib.ts +38 -9
- package/assets/extensions/omnirush/plan.ts +85 -30
- package/assets/extensions/omnirush/sota.ts +52 -4
- package/assets/extensions/omnirush/status-lib.ts +3 -0
- package/assets/extensions/omnirush/subagent-marker.ts +21 -0
- package/assets/extensions/omnirush/subagents-lib.ts +623 -0
- package/assets/extensions/omnirush/subagents.ts +305 -0
- package/assets/extensions/omnirush/swarm-lib.ts +142 -0
- package/assets/extensions/omnirush/swarm.ts +95 -0
- package/assets/extensions/omnirush/voice/capture.ts +502 -0
- package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
- package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
- package/assets/extensions/omnirush/voice/core/index.ts +21 -0
- package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
- package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
- package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
- package/assets/extensions/omnirush/voice/core/session.ts +403 -0
- package/assets/extensions/omnirush/voice/core/text.ts +81 -0
- package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
- package/assets/extensions/omnirush/voice/core/types.ts +102 -0
- package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
- package/assets/extensions/omnirush/voice/keys.ts +435 -0
- package/assets/extensions/omnirush/voice/kitty.ts +64 -0
- package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
- package/assets/extensions/omnirush/voice/settings.ts +67 -0
- package/assets/extensions/omnirush/voice.ts +838 -0
- package/assets/extensions/omnirush/yolo-lib.ts +80 -0
- package/assets/extensions/omnirush/yolo.ts +85 -0
- package/package.json +7 -3
- package/scripts/brand-engine.js +526 -0
- package/scripts/build-all-packages.py +29 -1
- package/scripts/smoke-packages.py +33 -1
- package/src/bin.js +232 -33
- package/src/compat.js +272 -0
- package/src/lib.js +64 -0
- package/src/sessions.js +222 -0
- 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 <
|
|
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
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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)
|
|
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
|
-
|
|
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)
|
|
544
|
+
if (event?.type !== "message_end" || !event.message) return;
|
|
298
545
|
const message = event.message;
|
|
299
|
-
if (message.role !== "assistant")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
668
|
+
let pendingErrLine = "";
|
|
669
|
+
let gatewayFallback: ChildResultInput["gatewayFallback"];
|
|
402
670
|
let settled = false;
|
|
403
671
|
let killedFor: "timeout" | "stalled" | "cancelled" | "interrupted" | null = null;
|
|
404
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
947
|
-
|
|
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
|
});
|