omnirush 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,516 @@
1
+ // memory-lib — memory-aware admission of sub-agents (spawn_agents).
2
+ //
3
+ // Every sub-agent is a separate agent process (its own runtime and engine,
4
+ // ~150-250 MB each), and spawn_agents has no count cap: 20 or 40 children at
5
+ // once used to start together whatever the machine had left, and machines
6
+ // ran out of memory. Admission keeps the "no fixed cap" rule but starts a
7
+ // child only while the memory allows it:
8
+ //
9
+ // - the system keeps at least MIN_FREE of its RAM available after the
10
+ // child's expected footprint (default 20% of RAM; inside a cgroup with a
11
+ // memory limit — a container, a systemd scope — the limit counts as RAM
12
+ // and what it has left as available);
13
+ // - this agent's whole process tree (the top agent, every sub-agent at
14
+ // every nesting layer, their tools) stays under BUDGET (default 50% of
15
+ // RAM);
16
+ // - critically low (available under CRITICAL_FREE, default 10% of RAM):
17
+ // nothing new starts, not even a first child, and the user is warned;
18
+ // running children go on. Otherwise one child may always run, so a
19
+ // batch makes progress on a small machine.
20
+ //
21
+ // The rest wait in the queue ("queued: waiting for memory") and start as
22
+ // memory frees up (a child finishing, or the next check, every second).
23
+ // Thresholds: OMNIRUSH_AGENT_MIN_FREE, OMNIRUSH_AGENT_MEMORY_BUDGET,
24
+ // OMNIRUSH_AGENT_CRITICAL_FREE and OMNIRUSH_AGENT_CHILD_ESTIMATE ("20%",
25
+ // "1.5G", "800M", a plain number is MiB), OMNIRUSH_AGENT_MEMORY=off turns
26
+ // admission off; the same keys (minFree, budget, criticalFree,
27
+ // childEstimate, enabled) under "subagentMemory" in <omnirush
28
+ // dir>/settings.json. The environment wins.
29
+ //
30
+ // Measuring: Linux reads /proc/meminfo (MemAvailable), the cgroup's
31
+ // memory.max / memory.current / memory.stat, and /proc/<pid>/statm of the
32
+ // process tree; macOS asks vm_stat and ps; elsewhere os.freemem() and an
33
+ // estimate per running child stand in for the tree.
34
+
35
+ import { execFileSync } from "node:child_process";
36
+ import { readdirSync, readFileSync } from "node:fs";
37
+ import os from "node:os";
38
+ import path from "node:path";
39
+
40
+ const MIB = 1024 * 1024;
41
+
42
+ /** Environment variable the top agent hands down: the pid whose process tree the budget covers. */
43
+ export const ENV_MEMORY_ROOT = "OMNIRUSH_AGENT_MEMORY_ROOT";
44
+
45
+ export interface MemoryPolicy {
46
+ enabled: boolean;
47
+ /** Keep at least this much of RAM available (bytes, resolved). */
48
+ minFreeBytes: number;
49
+ /** The whole process tree stays under this (bytes, resolved). */
50
+ budgetBytes: number;
51
+ /** Below this nothing new starts and the user is warned (bytes, resolved). */
52
+ criticalFreeBytes: number;
53
+ /** Footprint expected of a child before it is measured (bytes). */
54
+ childEstimateBytes: number;
55
+ /** Where each value came from ("env", "settings", "default"), for /agents memory. */
56
+ sources: Record<"minFree" | "budget" | "criticalFree" | "childEstimate" | "enabled", "env" | "settings" | "default">;
57
+ }
58
+
59
+ export const DEFAULT_MIN_FREE = "20%";
60
+ export const DEFAULT_BUDGET = "50%";
61
+ export const DEFAULT_CRITICAL_FREE = "10%";
62
+ export const DEFAULT_CHILD_ESTIMATE = "256M";
63
+
64
+ /**
65
+ * "20%" of `total`, "1.5G", "800M", "512K", or a plain number of MiB; null
66
+ * when unreadable. Percentages are clamped to 0-100.
67
+ */
68
+ export function parseAmount(raw: unknown, total: number): number | null {
69
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return Math.round(raw * MIB);
70
+ if (typeof raw !== "string") return null;
71
+ const text = raw.trim().toLowerCase();
72
+ if (!text) return null;
73
+ const match = /^(\d+(?:\.\d+)?)\s*(%|b|k|kb|kib|m|mb|mib|g|gb|gib|t|tb|tib)?$/.exec(text);
74
+ if (!match) return null;
75
+ const value = Number(match[1]);
76
+ const unit = match[2] ?? "m";
77
+ if (unit === "%") return Math.round((Math.min(100, value) / 100) * total);
78
+ const scale = unit === "b" ? 1 : unit.startsWith("k") ? 1024 : unit.startsWith("m") ? MIB : unit.startsWith("g") ? 1024 * MIB : 1024 * 1024 * MIB;
79
+ return Math.round(value * scale);
80
+ }
81
+
82
+ /** The "subagentMemory" block of <dir>/settings.json (empty when absent). */
83
+ export function readMemorySettings(dir: string): Record<string, unknown> {
84
+ try {
85
+ const parsed = JSON.parse(readFileSync(path.join(dir, "settings.json"), "utf8"));
86
+ const block = parsed?.subagentMemory;
87
+ return block && typeof block === "object" && !Array.isArray(block) ? block : {};
88
+ } catch {
89
+ return {};
90
+ }
91
+ }
92
+
93
+ /** Resolve the thresholds for a machine with `totalBytes` of RAM (env over settings over defaults). */
94
+ export function resolveMemoryPolicy(totalBytes: number, env: NodeJS.ProcessEnv = process.env, settings: Record<string, unknown> = {}): MemoryPolicy {
95
+ const sources = {} as MemoryPolicy["sources"];
96
+ const pick = (key: keyof MemoryPolicy["sources"], envName: string, fallback: string): number => {
97
+ const fromEnv = parseAmount(env[envName], totalBytes);
98
+ if (fromEnv !== null) {
99
+ sources[key] = "env";
100
+ return fromEnv;
101
+ }
102
+ const fromSettings = parseAmount(settings[key], totalBytes);
103
+ if (fromSettings !== null) {
104
+ sources[key] = "settings";
105
+ return fromSettings;
106
+ }
107
+ sources[key] = "default";
108
+ return parseAmount(fallback, totalBytes) ?? 0;
109
+ };
110
+ const rawEnabled = String(env.OMNIRUSH_AGENT_MEMORY ?? "").trim().toLowerCase();
111
+ let enabled = true;
112
+ if (rawEnabled) {
113
+ sources.enabled = "env";
114
+ enabled = !["off", "0", "false", "no", "disabled"].includes(rawEnabled);
115
+ } else if (typeof settings.enabled === "boolean") {
116
+ sources.enabled = "settings";
117
+ enabled = settings.enabled;
118
+ } else {
119
+ sources.enabled = "default";
120
+ }
121
+ const minFreeBytes = pick("minFree", "OMNIRUSH_AGENT_MIN_FREE", DEFAULT_MIN_FREE);
122
+ const budgetBytes = pick("budget", "OMNIRUSH_AGENT_MEMORY_BUDGET", DEFAULT_BUDGET);
123
+ const criticalFreeBytes = Math.min(pick("criticalFree", "OMNIRUSH_AGENT_CRITICAL_FREE", DEFAULT_CRITICAL_FREE), minFreeBytes);
124
+ const childEstimateBytes = Math.max(32 * MIB, pick("childEstimate", "OMNIRUSH_AGENT_CHILD_ESTIMATE", DEFAULT_CHILD_ESTIMATE));
125
+ return { enabled, minFreeBytes, budgetBytes, criticalFreeBytes, childEstimateBytes, sources };
126
+ }
127
+
128
+ // --- measuring ---------------------------------------------------------------
129
+
130
+ export interface SystemMemory {
131
+ /** RAM, or the cgroup's memory limit when that is smaller. */
132
+ totalBytes: number;
133
+ /** What can still be used before the system (or the cgroup) runs out. */
134
+ availableBytes: number;
135
+ source: string;
136
+ }
137
+
138
+ function readText(file: string): string | null {
139
+ try {
140
+ return readFileSync(file, "utf8");
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /** MemTotal / MemAvailable from /proc/meminfo (bytes), or null. */
147
+ export function parseMeminfo(text: string | null): { total: number; available: number } | null {
148
+ if (!text) return null;
149
+ const field = (name: string) => {
150
+ const match = new RegExp(`^${name}:\\s+(\\d+)\\s*kB`, "m").exec(text);
151
+ return match ? Number(match[1]) * 1024 : null;
152
+ };
153
+ const total = field("MemTotal");
154
+ let available = field("MemAvailable");
155
+ if (available === null) {
156
+ const free = field("MemFree");
157
+ const cached = field("Cached");
158
+ const buffers = field("Buffers");
159
+ available = free === null ? null : free + (cached ?? 0) + (buffers ?? 0);
160
+ }
161
+ return total !== null && available !== null ? { total, available } : null;
162
+ }
163
+
164
+ /**
165
+ * The tightest cgroup v2 memory limit on the way from this process's cgroup
166
+ * to the root: {limit, used}, `used` without the page cache the kernel can
167
+ * drop (file pages minus shmem). Null outside a limited cgroup.
168
+ */
169
+ export function readCgroupLimit(root = "/sys/fs/cgroup", selfCgroup = readText("/proc/self/cgroup")): { limit: number; used: number; available: number } | null {
170
+ if (!selfCgroup) return null;
171
+ const line = selfCgroup.split("\n").find((candidate) => candidate.startsWith("0::"));
172
+ if (!line) return null;
173
+ let dir = line.slice(3).trim() || "/";
174
+ let best: { limit: number; used: number; available: number } | null = null;
175
+ for (let guard = 0; guard < 32; guard++) {
176
+ const base = path.join(root, dir);
177
+ const max = readText(path.join(base, "memory.max"))?.trim();
178
+ if (max && max !== "max" && /^\d+$/.test(max)) {
179
+ const limit = Number(max);
180
+ const current = Number(readText(path.join(base, "memory.current"))?.trim() ?? NaN);
181
+ const stat = readText(path.join(base, "memory.stat")) ?? "";
182
+ const statField = (name: string) => Number(new RegExp(`^${name} (\\d+)`, "m").exec(stat)?.[1] ?? 0);
183
+ const reclaimable = Math.max(0, statField("file") - statField("shmem"));
184
+ const used = Number.isFinite(current) ? Math.max(0, current - reclaimable) : 0;
185
+ const available = Math.max(0, limit - used);
186
+ if (!best || available < best.available) best = { limit, used, available };
187
+ }
188
+ if (dir === "/" || dir === "") break;
189
+ dir = path.dirname(dir);
190
+ }
191
+ return best;
192
+ }
193
+
194
+ /** macOS: available ≈ free + inactive + speculative + purgeable pages (vm_stat). */
195
+ export function parseVmStat(text: string): number | null {
196
+ const pageSize = Number(/page size of (\d+) bytes/.exec(text)?.[1] ?? 4096);
197
+ const pages = (name: string) => Number(new RegExp(`^Pages ${name}:\\s+(\\d+)`, "m").exec(text)?.[1] ?? NaN);
198
+ const free = pages("free");
199
+ if (!Number.isFinite(free)) return null;
200
+ const extra = ["inactive", "speculative", "purgeable"].map(pages).filter(Number.isFinite).reduce((a, b) => a + b, 0);
201
+ return (free + extra) * pageSize;
202
+ }
203
+
204
+ /** RAM and what is available of it right now (the cgroup's limit when tighter). */
205
+ export function readSystemMemory(): SystemMemory {
206
+ let total = os.totalmem();
207
+ let available = os.freemem();
208
+ let source = "os";
209
+ if (process.platform === "linux") {
210
+ const info = parseMeminfo(readText("/proc/meminfo"));
211
+ if (info) {
212
+ total = info.total;
213
+ available = info.available;
214
+ source = "meminfo";
215
+ }
216
+ const cgroup = readCgroupLimit();
217
+ if (cgroup && cgroup.limit < total) {
218
+ total = cgroup.limit;
219
+ source = "cgroup";
220
+ }
221
+ if (cgroup && cgroup.available < available) {
222
+ available = cgroup.available;
223
+ source = "cgroup";
224
+ }
225
+ } else if (process.platform === "darwin") {
226
+ try {
227
+ const parsed = parseVmStat(String(execFileSync("vm_stat", { timeout: 2_000, maxBuffer: 64 * 1024 })));
228
+ if (parsed !== null) {
229
+ available = Math.min(total, parsed);
230
+ source = "vm_stat";
231
+ }
232
+ } catch {
233
+ /* os.freemem() stands */
234
+ }
235
+ }
236
+ return { totalBytes: total, availableBytes: Math.max(0, available), source };
237
+ }
238
+
239
+ export interface ProcessSample {
240
+ pid: number;
241
+ ppid: number;
242
+ /** Private resident memory (bytes): resident minus file-backed shared pages. */
243
+ privateBytes: number;
244
+ /** File-backed shared resident pages (the runtime binary, mapped files). */
245
+ sharedBytes: number;
246
+ }
247
+
248
+ /** Every process's parent and memory (Linux /proc, macOS ps); null where neither works. */
249
+ export function listProcesses(): ProcessSample[] | null {
250
+ if (process.platform === "linux") {
251
+ const page = 4096;
252
+ const out: ProcessSample[] = [];
253
+ let names: string[];
254
+ try {
255
+ names = readdirSync("/proc");
256
+ } catch {
257
+ return null;
258
+ }
259
+ for (const name of names) {
260
+ if (!/^\d+$/.test(name)) continue;
261
+ const stat = readText(`/proc/${name}/stat`);
262
+ const statm = readText(`/proc/${name}/statm`);
263
+ if (!stat || !statm) continue;
264
+ // Fields after the ")" of the command name: state ppid ...
265
+ const after = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
266
+ const ppid = Number(after[1]);
267
+ const [, resident, shared] = statm.trim().split(/\s+/).map(Number);
268
+ if (!Number.isFinite(ppid) || !Number.isFinite(resident)) continue;
269
+ out.push({ pid: Number(name), ppid, privateBytes: Math.max(0, resident - (shared || 0)) * page, sharedBytes: (shared || 0) * page });
270
+ }
271
+ return out;
272
+ }
273
+ if (process.platform === "darwin") {
274
+ try {
275
+ const text = String(execFileSync("ps", ["-A", "-o", "pid=,ppid=,rss="], { timeout: 3_000, maxBuffer: 8 * MIB }));
276
+ return text
277
+ .split("\n")
278
+ .map((line) => line.trim().split(/\s+/).map(Number))
279
+ .filter((parts) => parts.length === 3 && parts.every(Number.isFinite))
280
+ .map(([pid, ppid, rss]) => ({ pid, ppid, privateBytes: rss * 1024, sharedBytes: 0 }));
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+ return null;
286
+ }
287
+
288
+ /**
289
+ * Memory of `rootPid` and all its descendants: private memory summed, the
290
+ * shared file pages counted once (the largest; every agent process maps the
291
+ * same runtime binary). `byPid` has each member's private + shared bytes.
292
+ */
293
+ export function treeMemory(processes: readonly ProcessSample[], rootPid: number): { bytes: number; count: number; byPid: Map<number, number> } {
294
+ const children = new Map<number, ProcessSample[]>();
295
+ const self = processes.find((sample) => sample.pid === rootPid);
296
+ for (const sample of processes) {
297
+ const list = children.get(sample.ppid);
298
+ if (list) list.push(sample);
299
+ else children.set(sample.ppid, [sample]);
300
+ }
301
+ const byPid = new Map<number, number>();
302
+ let privateTotal = 0;
303
+ let sharedMax = 0;
304
+ const stack: ProcessSample[] = self ? [self] : (children.get(rootPid) ?? []).slice();
305
+ const seen = new Set<number>();
306
+ while (stack.length > 0) {
307
+ const sample = stack.pop()!;
308
+ if (seen.has(sample.pid)) continue;
309
+ seen.add(sample.pid);
310
+ privateTotal += sample.privateBytes;
311
+ sharedMax = Math.max(sharedMax, sample.sharedBytes);
312
+ byPid.set(sample.pid, sample.privateBytes + sample.sharedBytes);
313
+ for (const child of children.get(sample.pid) ?? []) stack.push(child);
314
+ }
315
+ return { bytes: privateTotal + sharedMax, count: seen.size, byPid };
316
+ }
317
+
318
+ // --- the admission decision ---------------------------------------------------
319
+
320
+ export interface AdmissionInput {
321
+ policy: MemoryPolicy;
322
+ system: SystemMemory;
323
+ /** The process tree's memory now (null when it cannot be measured). */
324
+ treeBytes: number | null;
325
+ /** Children of this process running now. */
326
+ running: number;
327
+ /**
328
+ * Memory the running children have not taken yet but will (young children
329
+ * still starting up): counted as used already.
330
+ */
331
+ reservedBytes: number;
332
+ /** What the next child is expected to take. */
333
+ estimateBytes: number;
334
+ }
335
+
336
+ export type AdmissionVerdict =
337
+ | { ok: true }
338
+ | { ok: false; reason: "critical" | "free" | "budget"; detail: string };
339
+
340
+ function mb(bytes: number): string {
341
+ return bytes >= 10 * 1024 * MIB ? `${(bytes / (1024 * MIB)).toFixed(1)} GB` : `${Math.round(bytes / MIB)} MB`;
342
+ }
343
+
344
+ /** Whether one more child may start now. */
345
+ export function admit(input: AdmissionInput): AdmissionVerdict {
346
+ const { policy, system } = input;
347
+ if (!policy.enabled) return { ok: true };
348
+ const available = Math.max(0, system.availableBytes - input.reservedBytes);
349
+ if (available < policy.criticalFreeBytes) {
350
+ return {
351
+ ok: false,
352
+ reason: "critical",
353
+ detail: `memory critically low: ${mb(available)} free of ${mb(system.totalBytes)} (the floor is ${mb(policy.criticalFreeBytes)})`,
354
+ };
355
+ }
356
+ const after = available - input.estimateBytes;
357
+ if (input.running === 0) {
358
+ // Progress: one child may run whenever the machine is not critically low.
359
+ if (after >= policy.criticalFreeBytes) return { ok: true };
360
+ return {
361
+ ok: false,
362
+ reason: "free",
363
+ detail: `${mb(available)} free, a sub-agent needs about ${mb(input.estimateBytes)} and ${mb(policy.criticalFreeBytes)} must stay free`,
364
+ };
365
+ }
366
+ if (after < policy.minFreeBytes) {
367
+ return {
368
+ ok: false,
369
+ reason: "free",
370
+ detail: `${mb(available)} free of ${mb(system.totalBytes)}; ${mb(policy.minFreeBytes)} stays free, a sub-agent needs about ${mb(input.estimateBytes)}`,
371
+ };
372
+ }
373
+ if (input.treeBytes !== null && input.treeBytes + input.reservedBytes + input.estimateBytes > policy.budgetBytes) {
374
+ return {
375
+ ok: false,
376
+ reason: "budget",
377
+ detail: `agents use ${mb(input.treeBytes + input.reservedBytes)} of their ${mb(policy.budgetBytes)} budget; a sub-agent needs about ${mb(input.estimateBytes)}`,
378
+ };
379
+ }
380
+ return { ok: true };
381
+ }
382
+
383
+ /** A child this process started: its pid and when, for the reservation. */
384
+ interface Tracked {
385
+ pid: number | null;
386
+ startedAt: number;
387
+ }
388
+
389
+ /** How long a new child counts at least its estimate (it is still loading). */
390
+ export const CHILD_WARMUP_MS = 15_000;
391
+
392
+ /**
393
+ * The admission state of one agent process: measures (at most every
394
+ * `sampleMs`), keeps track of its own running children, and answers
395
+ * `check()`. Injectable readers keep it testable.
396
+ */
397
+ export class MemoryAdmission {
398
+ readonly policy: MemoryPolicy;
399
+ private readonly rootPid: number;
400
+ private readonly now: () => number;
401
+ private readonly readSystem: () => SystemMemory;
402
+ private readonly readProcesses: () => ProcessSample[] | null;
403
+ private readonly sampleMs: number;
404
+ private readonly tracked = new Map<object, Tracked>();
405
+ private sample: { at: number; system: SystemMemory; tree: ReturnType<typeof treeMemory> | null } | null = null;
406
+ /** Largest footprint measured of a child of this process (bytes). */
407
+ private observedChildBytes = 0;
408
+
409
+ constructor(options: {
410
+ policy?: MemoryPolicy;
411
+ env?: NodeJS.ProcessEnv;
412
+ settings?: Record<string, unknown>;
413
+ rootPid?: number;
414
+ now?: () => number;
415
+ readSystem?: () => SystemMemory;
416
+ readProcesses?: () => ProcessSample[] | null;
417
+ sampleMs?: number;
418
+ } = {}) {
419
+ const env = options.env ?? process.env;
420
+ this.now = options.now ?? Date.now;
421
+ this.readSystem = options.readSystem ?? readSystemMemory;
422
+ this.readProcesses = options.readProcesses ?? listProcesses;
423
+ this.sampleMs = options.sampleMs ?? 1_000;
424
+ const rootFromEnv = Number(env[ENV_MEMORY_ROOT]);
425
+ this.rootPid = options.rootPid ?? (Number.isInteger(rootFromEnv) && rootFromEnv > 0 ? rootFromEnv : process.pid);
426
+ this.policy = options.policy ?? resolveMemoryPolicy(this.readSystem().totalBytes, env, options.settings ?? {});
427
+ }
428
+
429
+ /** The environment a child gets: the tree root it counts against. */
430
+ childEnv(): Record<string, string> {
431
+ return { [ENV_MEMORY_ROOT]: String(this.rootPid) };
432
+ }
433
+
434
+ private measure(force = false) {
435
+ const at = this.now();
436
+ if (!force && this.sample && at - this.sample.at < this.sampleMs) return this.sample;
437
+ const system = this.readSystem();
438
+ const processes = this.readProcesses();
439
+ const tree = processes ? treeMemory(processes, this.rootPid) : null;
440
+ if (tree) {
441
+ for (const entry of this.tracked.values()) {
442
+ const bytes = entry.pid !== null ? tree.byPid.get(entry.pid) : undefined;
443
+ if (bytes !== undefined && at - entry.startedAt >= CHILD_WARMUP_MS) this.observedChildBytes = Math.max(this.observedChildBytes, bytes);
444
+ }
445
+ }
446
+ this.sample = { at, system, tree };
447
+ return this.sample;
448
+ }
449
+
450
+ /** What the next child is expected to take: the estimate, or more once children were measured bigger. */
451
+ estimate(): number {
452
+ return Math.max(this.policy.childEstimateBytes, Math.min(this.observedChildBytes, 4 * this.policy.childEstimateBytes));
453
+ }
454
+
455
+ /** Memory young children will still take (estimate minus what they use now). */
456
+ private reserved(tree: ReturnType<typeof treeMemory> | null): number {
457
+ const at = this.now();
458
+ let total = 0;
459
+ for (const entry of this.tracked.values()) {
460
+ if (at - entry.startedAt >= CHILD_WARMUP_MS) continue;
461
+ const used = entry.pid !== null ? tree?.byPid.get(entry.pid) ?? 0 : 0;
462
+ total += Math.max(0, this.estimate() - used);
463
+ }
464
+ return total;
465
+ }
466
+
467
+ /** May one more child start now? */
468
+ check(): AdmissionVerdict & { system: SystemMemory; treeBytes: number | null } {
469
+ const sample = this.measure();
470
+ const treeBytes = sample.tree ? sample.tree.bytes : null;
471
+ // Without a process list (Windows), the children's estimates stand in for the tree.
472
+ const tree = treeBytes ?? (this.tracked.size > 0 ? this.tracked.size * this.estimate() : null);
473
+ const verdict = admit({
474
+ policy: this.policy,
475
+ system: sample.system,
476
+ treeBytes: tree,
477
+ running: this.tracked.size,
478
+ reservedBytes: this.reserved(sample.tree),
479
+ estimateBytes: this.estimate(),
480
+ });
481
+ return { ...verdict, system: sample.system, treeBytes: tree };
482
+ }
483
+
484
+ /** A child started (its pid once known): it counts from now on. */
485
+ started(key: object, pid: number | null = null): void {
486
+ // No new measurement for it: until the next sample (and while it warms
487
+ // up) the reservation counts its estimate.
488
+ this.tracked.set(key, { pid, startedAt: this.now() });
489
+ }
490
+
491
+ /** The child's pid arrived after it was started. */
492
+ spawned(key: object, pid: number | null | undefined): void {
493
+ const entry = this.tracked.get(key);
494
+ if (entry && typeof pid === "number") entry.pid = pid;
495
+ }
496
+
497
+ /** A child ended. */
498
+ finished(key: object): void {
499
+ this.tracked.delete(key);
500
+ if (this.sample) this.sample = { ...this.sample, at: -Infinity };
501
+ }
502
+
503
+ /** Children of this process that count now. */
504
+ get running(): number {
505
+ return this.tracked.size;
506
+ }
507
+
508
+ /** One line for /agents and status: RAM, available, the tree, the thresholds. */
509
+ describe(): string {
510
+ const sample = this.measure(true);
511
+ const tree = sample.tree ? mb(sample.tree.bytes) : "unknown";
512
+ const p = this.policy;
513
+ if (!p.enabled) return `memory admission off (${mb(sample.system.availableBytes)} free of ${mb(sample.system.totalBytes)})`;
514
+ return `memory: ${mb(sample.system.availableBytes)} free of ${mb(sample.system.totalBytes)} (${sample.system.source}); agents ${tree} of ${mb(p.budgetBytes)} budget; keeps ${mb(p.minFreeBytes)} free, stops at ${mb(p.criticalFreeBytes)}; ~${mb(this.estimate())} per sub-agent`;
515
+ }
516
+ }
@@ -17,7 +17,11 @@
17
17
  //
18
18
  // user message -> user message: text parts, image file parts
19
19
  // (bytes not embedded, as the desktop's
20
- // engine.request records attachments)
20
+ // engine.request records attachments); one
21
+ // the user did not submit (a sub-agent's task,
22
+ // an extension's sendUserMessage: the caller's
23
+ // `injectedPrompts`) has synthetic text parts,
24
+ // so it never becomes a turn's prompt
21
25
  // assistant message -> assistant message: step-start, reasoning
22
26
  // (the model's thinking), text and tool parts
23
27
  // in the model's order, a patch part for the
@@ -55,6 +59,14 @@ import { isAbsolute, resolve } from "node:path";
55
59
 
56
60
  /** customType of bgshell.ts's finished-background-command message. */
57
61
  export const BACKGROUND_BASH_TYPE = "omnirush-bash-result";
62
+ /**
63
+ * customType of the entry a sub-agent process writes at the start of its
64
+ * own session (data: `{parent_session_id}`): the session is a sub-agent's,
65
+ * and its first user message is the task the parent agent wrote.
66
+ */
67
+ export const SUBAGENT_ENTRY_TYPE = "omnirush-subagent";
68
+ /** A sub-agent's prompt: the task the parent agent wrote, after this prefix (agents-lib.ts buildChildArgs). */
69
+ export const SUBAGENT_TASK_PREFIX = "Task: ";
58
70
 
59
71
  export type EngineMessage = { info: Record<string, unknown>; parts: Array<Record<string, unknown>> };
60
72
 
@@ -87,6 +99,13 @@ export type EngineConvertOptions = {
87
99
  * `--model <id>:<effort>` flag of a resumed session.
88
100
  */
89
101
  promptSettings?: ReadonlyMap<string, { model?: { providerID: string; modelID: string } | null; variant?: string | null }>;
102
+ /**
103
+ * User entries (by id) whose text the user did not submit, with the
104
+ * metadata their synthetic text parts carry: the task a parent agent gave
105
+ * a sub-agent whose session is continued as a main session, a prompt an
106
+ * extension sent. The viewer never takes them for a prompt.
107
+ */
108
+ injectedPrompts?: ReadonlyMap<string, Record<string, unknown>>;
90
109
  };
91
110
 
92
111
  /** The shell-command marker the engine writes on a user message holding a command the user ran (public_trace.py `_USER_SHELL_TEXT`). */
@@ -412,7 +431,8 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
412
431
  const created = epochMs(message.timestamp) ?? at;
413
432
  switch (message.role) {
414
433
  case "user": {
415
- const parts = contentParts(message.content);
434
+ const injected = options.injectedPrompts?.get(entryId);
435
+ const parts = contentParts(message.content, injected ? { synthetic: true, metadata: { ...injected } } : {});
416
436
  if (parts.length > 0) turnVariant = userMessage(id, created, parts, index);
417
437
  return;
418
438
  }
@@ -573,6 +593,34 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
573
593
  return out;
574
594
  }
575
595
 
596
+ /** The text of a pi user message (its text blocks). */
597
+ export function userText(message: Record<string, any> | undefined): string {
598
+ return message ? contentText(message.content) : "";
599
+ }
600
+
601
+ /**
602
+ * The first user message of a branch when it may be a sub-agent's task
603
+ * (the session is a sub-agent's, opened as a main session by `--continue`,
604
+ * `--resume` or `--session`): `marked` when the sub-agent's own marker
605
+ * entry comes before it (then `parentSessionId` is known), else when its
606
+ * text has the task prefix, for the caller to confirm (sessions of
607
+ * sub-agents that wrote no marker). Null for any other session.
608
+ */
609
+ export function subagentTask(entries: readonly PiEntry[]): { entryId: string; marked: boolean; parentSessionId: string | null } | null {
610
+ let marker: PiEntry | null = null;
611
+ for (const entry of entries) {
612
+ if (entry.type === "custom" && entry.customType === SUBAGENT_ENTRY_TYPE) marker ??= entry;
613
+ if (entry.type !== "message" || !isRecord(entry.message) || entry.message.role !== "user") continue;
614
+ if (typeof entry.id !== "string" || !entry.id) return null;
615
+ if (marker) {
616
+ const parent = isRecord(marker.data) && typeof marker.data.parent_session_id === "string" ? marker.data.parent_session_id : null;
617
+ return { entryId: entry.id, marked: true, parentSessionId: parent };
618
+ }
619
+ return userText(entry.message).startsWith(SUBAGENT_TASK_PREFIX) ? { entryId: entry.id, marked: false, parentSessionId: null } : null;
620
+ }
621
+ return null;
622
+ }
623
+
576
624
  /** The active branch of a pi session file's entries: from the last entry up its parent chain. */
577
625
  export function branchOfEntries(entries: readonly PiEntry[]): PiEntry[] {
578
626
  const withIds = entries.filter((entry) => entry.type !== "session" && typeof entry.id === "string");
@@ -16,6 +16,9 @@ export interface PlanFile {
16
16
  schema_version: 1;
17
17
  items: PlanItem[];
18
18
  updated_at: string;
19
+ /** The session that wrote it (a new session in the same folder does
20
+ * not pick up another session's list). Absent in older files. */
21
+ session_id?: string;
19
22
  }
20
23
 
21
24
  export const PLAN_MAX_ITEMS = 50;
@@ -66,19 +69,44 @@ function clip(text: string, max: number): string {
66
69
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
67
70
  }
68
71
 
72
+ /** Non-plan tool calls after which an untouched list reads as stale. */
73
+ export const PLAN_STALE_TOOL_CALLS = 25;
74
+
75
+ /**
76
+ * How current the list is, for the footer:
77
+ * - "active": updated during the running request,
78
+ * - "idle": no request running, or the list is left over from an
79
+ * earlier request (restored on resume / not touched since).
80
+ */
81
+ export type PlanFooterPhase = "active" | "idle";
82
+
83
+ export interface PlanFooterOptions {
84
+ phase?: PlanFooterPhase;
85
+ /** Non-plan tool calls since the last plan update. */
86
+ toolCallsSinceUpdate?: number;
87
+ }
88
+
69
89
  /**
70
- * One-line footer for ctx.ui.setStatus, e.g.
71
- * "plan 2/5 · ▸ wire tests"
72
- * or "plan 4/5 done". Null when there is nothing to show.
90
+ * One-line footer for ctx.ui.setStatus. It reads as a checklist (never
91
+ * as a mode — the footer's status line also carries "guarded"/"yolo"
92
+ * right before it), e.g.
93
+ * "tasks 2/5 · ▸ wire tests" (being worked on)
94
+ * "tasks 2/5 · not updated for 30 tool calls" (stale while working)
95
+ * "tasks 2/5 · idle" (between requests)
96
+ * Null when there is nothing to show — including a finished list.
73
97
  */
74
- export function renderPlanFooter(items: PlanItem[]): string | null {
98
+ export function renderPlanFooter(items: PlanItem[] | null, options: PlanFooterOptions = {}): string | null {
75
99
  if (!Array.isArray(items) || items.length === 0) return null;
76
100
  const { done, total, current } = planProgress(items);
101
+ if (done === total) return null;
102
+ const head = `tasks ${done}/${total}`;
103
+ if ((options.phase ?? "active") === "idle") return `${head} · idle`;
104
+ const since = options.toolCallsSinceUpdate ?? 0;
105
+ if (since >= PLAN_STALE_TOOL_CALLS) return `${head} · not updated for ${since} tool calls`;
77
106
  // The ▸ task is the in-progress one, falling back to the first
78
107
  // not-done item (what is next up).
79
108
  const pointed = current ?? items.find((item) => item.status !== "done")?.title ?? null;
80
- if (pointed) return `plan ${done}/${total} · ▸ ${clip(pointed, 60)}`;
81
- return `plan ${done}/${total} done`;
109
+ return pointed ? `${head} · ▸ ${clip(pointed, 60)}` : head;
82
110
  }
83
111
 
84
112
  /**
@@ -94,8 +122,8 @@ export function renderPlanItems(items: PlanItem[]): string {
94
122
  return `${lines.join("\n")}\n\n${done}/${total} done`;
95
123
  }
96
124
 
97
- export function buildPlanFile(items: PlanItem[], updatedAt: string): PlanFile {
98
- return { schema_version: 1, items, updated_at: updatedAt };
125
+ export function buildPlanFile(items: PlanItem[], updatedAt: string, sessionId?: string): PlanFile {
126
+ return { schema_version: 1, items, updated_at: updatedAt, ...(sessionId ? { session_id: sessionId } : {}) };
99
127
  }
100
128
 
101
129
  /** Parse a persisted plan.json, returning null on anything unusable. */
@@ -106,7 +134,8 @@ export function parsePlanFile(text: string): PlanFile | null {
106
134
  const items = normalizePlanItems(parsed.items);
107
135
  if (!items) return null;
108
136
  const updatedAt = typeof parsed.updated_at === "string" ? parsed.updated_at : "";
109
- return { schema_version: 1, items, updated_at: updatedAt };
137
+ const sessionId = typeof parsed.session_id === "string" && parsed.session_id ? parsed.session_id : undefined;
138
+ return { schema_version: 1, items, updated_at: updatedAt, ...(sessionId ? { session_id: sessionId } : {}) };
110
139
  } catch {
111
140
  return null;
112
141
  }