infinity-harness 2.6.5 → 2.7.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.
@@ -0,0 +1,707 @@
1
+ /**
2
+ * infinity-harness — the background pi worker.
3
+ *
4
+ * The harness used to do all of its work inside the session the human was
5
+ * typing into. That is the wrong shape for this product, for three reasons:
6
+ *
7
+ * - every token the run spends comes out of the human's own session, on the
8
+ * human's own model, however carefully the router was configured
9
+ * - a "session handoff" that replaces the human's session takes their REPL
10
+ * away from them mid-run
11
+ * - one model does every task, so the difficulty tiers the wizard collects
12
+ * have nowhere to be applied
13
+ *
14
+ * A unit of work runs in a *separate pi process* instead: its own session
15
+ * file, its own model, its own context window, started with the brief and
16
+ * nothing else. The main session keeps the widget and the log, and speaks
17
+ * only when the human speaks to it.
18
+ *
19
+ * The child is driven over pi's RPC protocol rather than `--print`, because a
20
+ * unit is not always one turn. With handoff at `feature`, one worker must
21
+ * carry a whole feature across several gate cycles — same session, same
22
+ * model, growing context — and only *then* be replaced. `--print` would end
23
+ * the session after every turn, which is a handoff nobody asked for.
24
+ *
25
+ * This module owns exactly one thing: turning a `WorkerSpec` into a running
26
+ * pi and a stream of events. It has no idea what a phase is.
27
+ */
28
+
29
+ import { spawn, type ChildProcess } from "node:child_process";
30
+ import { createRequire } from "node:module";
31
+ import { existsSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs";
32
+ import { join, resolve, dirname } from "node:path";
33
+
34
+ /** Env flag the child sets so the harness extension there does not drive a loop of its own. */
35
+ export const WORKER_ENV = "INFINITY_HARNESS_WORKER";
36
+ /** Env var naming the unit the child was started for, for the child's own logging. */
37
+ export const WORKER_UNIT_ENV = "INFINITY_HARNESS_UNIT";
38
+ export const WORKER_RUN_ENV = "INFINITY_HARNESS_RUN";
39
+ /** Escape hatch: point at a pi CLI explicitly when discovery cannot find one. */
40
+ export const PI_CLI_ENV = "INFINITY_HARNESS_PI_CLI";
41
+
42
+ export const PROMPT_FILE = "prompt.md";
43
+ export const OUTPUT_FILE = "output.log";
44
+ export const EVENTS_FILE = "events.jsonl";
45
+
46
+ /** True when this process *is* a background worker rather than the human's session. */
47
+ export function isWorkerProcess(env: NodeJS.ProcessEnv = process.env): boolean {
48
+ return env[WORKER_ENV] === "1";
49
+ }
50
+
51
+ // ── locating pi ─────────────────────────────────────────────────────────────
52
+
53
+ export type PiCli = {
54
+ /** Executable to spawn. */
55
+ command: string;
56
+ /** Leading arguments before pi's own flags (e.g. the path to cli.js). */
57
+ leading: string[];
58
+ /** Where this came from, for the "could not find pi" message. */
59
+ source: string;
60
+ /**
61
+ * Spawn through a shell.
62
+ *
63
+ * Only ever true for a Windows `.cmd`/`.bat` shim: Node refuses to execute
64
+ * those directly (it has since the 2024 command-injection fix), so the npm
65
+ * `pi.cmd` wrapper cannot be spawned any other way. Every other case runs
66
+ * the executable directly, which is what keeps arguments out of a shell.
67
+ */
68
+ shell?: boolean;
69
+ };
70
+
71
+ /** A Windows batch shim — spawnable only through a shell. */
72
+ function isBatchShim(command: string): boolean {
73
+ return process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
74
+ }
75
+
76
+ /**
77
+ * The npm global-bin layout puts `pi.cmd` next to
78
+ * `node_modules/@earendil-works/pi-coding-agent/dist/cli.js`. Finding the real
79
+ * script means we can run it with this very node instead of going through a
80
+ * shell — faster, and with no quoting to get wrong.
81
+ */
82
+ function cliBesideShim(shimPath: string): string | null {
83
+ try {
84
+ const cli = join(dirname(shimPath), "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js");
85
+ return existsSync(cli) ? cli : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Find the pi that should run the worker.
93
+ *
94
+ * Order matters, and the first entry is the one that is almost always right:
95
+ * this code is running *inside* pi, so the CLI that started us is by
96
+ * definition a working pi with the user's config, on this machine, at the
97
+ * version they installed. Everything after it is a fallback for tests and for
98
+ * the odd embedding where argv is not what we expect.
99
+ */
100
+ export function resolvePiCli(env: NodeJS.ProcessEnv = process.env, argv: string[] = process.argv): PiCli {
101
+ const override = (env[PI_CLI_ENV] ?? "").trim();
102
+ if (override) {
103
+ // "node /path/cli.js" and "/path/to/pi" are both reasonable things to set.
104
+ const parts = override.split(/\s+/).filter(Boolean);
105
+ const [command, ...leading] = parts;
106
+ if (command) return { command, leading, source: PI_CLI_ENV };
107
+ }
108
+
109
+ const entry = argv[1] ?? "";
110
+ if (entry && /(^|[\\/])(cli|pi)(\.[cm]?js)?$/i.test(entry) && existsSync(entry)) {
111
+ // A compiled single-file pi has no separate script: argv[1] === execPath.
112
+ if (resolve(entry) === resolve(argv[0] ?? "")) return { command: entry, leading: [], source: "argv" };
113
+ return { command: process.execPath, leading: [entry], source: "argv" };
114
+ }
115
+
116
+ try {
117
+ const req = createRequire(import.meta.url);
118
+ const pkg = req.resolve("@earendil-works/pi-coding-agent/package.json");
119
+ const cli = join(dirname(pkg), "dist", "cli.js");
120
+ if (existsSync(cli)) return { command: process.execPath, leading: [cli], source: "node_modules" };
121
+ } catch {
122
+ /* not installed beside us — fall through */
123
+ }
124
+
125
+ // Last resort: whatever `pi` means on PATH. On Windows that is a `.cmd`
126
+ // shim, so look for the script it wraps before resorting to a shell.
127
+ if (process.platform === "win32") {
128
+ const found = whichSync("pi");
129
+ const beside = found ? cliBesideShim(found) : null;
130
+ if (beside) return { command: process.execPath, leading: [beside], source: "PATH (script beside shim)" };
131
+ const command = found ?? "pi.cmd";
132
+ return { command, leading: [], source: "PATH", shell: isBatchShim(command) };
133
+ }
134
+ return { command: "pi", leading: [], source: "PATH" };
135
+ }
136
+
137
+ /** Where a command lives, or null. Sync on purpose: it runs once per worker. */
138
+ function whichSync(name: string): string | null {
139
+ try {
140
+ const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
141
+ const cmd = process.platform === "win32" ? "where" : "which";
142
+ const out = String(execFileSync(cmd, [name], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }));
143
+ const first = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)[0];
144
+ return first && existsSync(first) ? first : null;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ // ── the event stream ────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * What the supervisor and the widget care about, distilled from pi's RPC
154
+ * stream. The raw stream is far richer; none of the rest belongs on a human's
155
+ * screen while several workers are running.
156
+ */
157
+ export type WorkerEvent =
158
+ | { kind: "spawn"; argv: string[] }
159
+ | { kind: "model"; provider: string | null; model: string | null }
160
+ | { kind: "tool"; tool: string; summary: string }
161
+ | { kind: "text"; text: string }
162
+ | { kind: "usage"; inputTokens: number; outputTokens: number }
163
+ | { kind: "compaction" }
164
+ | { kind: "settled" }
165
+ | { kind: "exit"; code: number | null }
166
+ | { kind: "error"; message: string };
167
+
168
+ export type WorkerUsage = { inputTokens: number; outputTokens: number };
169
+
170
+ export type TurnResult = {
171
+ /** What the worker said it did, trimmed. */
172
+ summary: string;
173
+ /** Tool calls it made this turn, newest last. */
174
+ tools: string[];
175
+ usage: WorkerUsage;
176
+ /** Fraction of the child's context window in use, or null when pi cannot say. */
177
+ contextRatio: number | null;
178
+ /** True when the worker stopped because it died or was killed, not because it settled. */
179
+ aborted: boolean;
180
+ /** Set when the turn could not run at all. */
181
+ error: string | null;
182
+ };
183
+
184
+ export type WorkerSpec = {
185
+ projectDir: string;
186
+ /** Where prompt.md, output.log and events.jsonl for this worker go. */
187
+ attemptDir: string;
188
+ /** "provider/id", or empty to let the child use pi's configured default. */
189
+ model?: string | null;
190
+ thinking?: string | null;
191
+ /** Session files go here so a human can `/resume` any worker afterwards. */
192
+ sessionDir?: string | null;
193
+ sessionName?: string | null;
194
+ unitKey?: string | null;
195
+ runId?: string | null;
196
+ /** Cap on one prompt→settled cycle. */
197
+ turnTimeoutMs?: number;
198
+ /** Extra argv for the child. Tests use it; nothing else should need to. */
199
+ extraArgs?: string[];
200
+ /**
201
+ * Load the harness extension explicitly.
202
+ *
203
+ * Normally the child discovers it exactly as the parent did — the harness
204
+ * is an installed pi package — and the worker gets `infinity_task_list` and
205
+ * friends for free. When the parent was started with `-e <path>` (a dev
206
+ * checkout, and every e2e run) discovery finds nothing, and a worker with
207
+ * no plan tools cannot record the work it just did. `harnessExtension` is
208
+ * the path to fall back to; `probeCommands` decides whether it is needed.
209
+ */
210
+ harnessExtension?: string | null;
211
+ env?: NodeJS.ProcessEnv;
212
+ /** Injected for tests: anything with the shape of node:child_process.spawn. */
213
+ spawnFn?: typeof spawn;
214
+ };
215
+
216
+ const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
217
+ const KILL_GRACE_MS = 5000;
218
+ const OUTPUT_TAIL_BYTES = 40000;
219
+ const START_TIMEOUT_MS = 60_000;
220
+
221
+ /**
222
+ * A model reference reaches the command line, so anything outside the
223
+ * characters a real reference uses is refused rather than escaped. The value
224
+ * comes from a config file a human edits; a typo must not become an argument
225
+ * injection. Nothing is passed through a shell here, but a leading `-` would
226
+ * still be read as a flag.
227
+ */
228
+ const MODEL_REF_RE = /^[A-Za-z0-9._:@/-]{1,160}$/;
229
+
230
+ export function safeModelRef(model: string | null | undefined): string | null {
231
+ const v = (model ?? "").trim();
232
+ if (!v || v.startsWith("-")) return null;
233
+ return MODEL_REF_RE.test(v) ? v : null;
234
+ }
235
+
236
+ const THINKING_RE = /^(off|minimal|low|medium|high|xhigh|max)$/;
237
+
238
+ export function safeThinking(level: string | null | undefined): string | null {
239
+ const v = (level ?? "").trim();
240
+ return v && THINKING_RE.test(v) ? v : null;
241
+ }
242
+
243
+ /** Compact one-liner for a tool call: `edit src/loop.ts`, `bash npm test`. */
244
+ export function describeToolCall(tool: string, args: unknown): string {
245
+ const a = (args ?? {}) as Record<string, unknown>;
246
+ const pick = (...keys: string[]): string => {
247
+ for (const k of keys) {
248
+ const v = a[k];
249
+ if (typeof v === "string" && v.trim()) return v.trim();
250
+ }
251
+ return "";
252
+ };
253
+ const detail =
254
+ pick("path", "file", "filePath", "file_path") ||
255
+ pick("command", "cmd", "script") ||
256
+ pick("pattern", "query", "regex") ||
257
+ pick("task", "name", "title", "url");
258
+ const oneLine = detail.replace(/\s+/g, " ").trim();
259
+ const clipped = oneLine.length > 68 ? oneLine.slice(0, 67) + "…" : oneLine;
260
+ return clipped ? `${tool} ${clipped}` : tool;
261
+ }
262
+
263
+ function num(v: unknown): number {
264
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
265
+ }
266
+
267
+ function textOf(content: unknown): string {
268
+ if (typeof content === "string") return content.trim();
269
+ if (!Array.isArray(content)) return "";
270
+ const parts: string[] = [];
271
+ for (const block of content) {
272
+ const b = block as { type?: string; text?: string };
273
+ if (b && b.type === "text" && typeof b.text === "string") parts.push(b.text);
274
+ }
275
+ return parts.join("").trim();
276
+ }
277
+
278
+ /**
279
+ * Turn one RPC line into an event, or null.
280
+ *
281
+ * Unknown event types are dropped rather than surfaced: pi adds them over
282
+ * time, and a harness that shows every one of them is a harness whose log is
283
+ * unreadable on the day pi ships a new event.
284
+ */
285
+ export function parseEventLine(line: string): WorkerEvent | null {
286
+ const trimmed = line.trim();
287
+ if (!trimmed || trimmed[0] !== "{") return null;
288
+ let ev: Record<string, unknown>;
289
+ try {
290
+ ev = JSON.parse(trimmed) as Record<string, unknown>;
291
+ } catch {
292
+ return null;
293
+ }
294
+ switch (typeof ev.type === "string" ? ev.type : "") {
295
+ case "message_start": {
296
+ const msg = ev.message as { role?: string; provider?: string; model?: string } | undefined;
297
+ if (!msg || msg.role !== "assistant" || !msg.model) return null;
298
+ // Proof of which model actually served the turn. A routed reference that
299
+ // silently fell back to pi's default is otherwise invisible.
300
+ return { kind: "model", provider: msg.provider ?? null, model: msg.model };
301
+ }
302
+ case "tool_execution_start":
303
+ return {
304
+ kind: "tool",
305
+ tool: String(ev.toolName ?? "tool"),
306
+ summary: describeToolCall(String(ev.toolName ?? "tool"), ev.args),
307
+ };
308
+ case "message_end": {
309
+ const msg = ev.message as { role?: string; content?: unknown } | undefined;
310
+ if (!msg || msg.role !== "assistant") return null;
311
+ const text = textOf(msg.content);
312
+ return text ? { kind: "text", text } : null;
313
+ }
314
+ case "message_update": {
315
+ const usage = ev.usage as Record<string, unknown> | undefined;
316
+ if (!usage) return null;
317
+ const inputTokens = num(usage.inputTokens ?? usage.input);
318
+ const outputTokens = num(usage.outputTokens ?? usage.output);
319
+ if (!inputTokens && !outputTokens) return null;
320
+ return { kind: "usage", inputTokens, outputTokens };
321
+ }
322
+ case "compaction_end":
323
+ return { kind: "compaction" };
324
+ case "agent_settled":
325
+ return { kind: "settled" };
326
+ case "error":
327
+ return { kind: "error", message: String((ev as { message?: unknown }).message ?? "error") };
328
+ default:
329
+ return null;
330
+ }
331
+ }
332
+
333
+ // ── argv ────────────────────────────────────────────────────────────────────
334
+
335
+ export function buildWorkerArgs(spec: WorkerSpec): string[] {
336
+ const args: string[] = ["--mode", "rpc", "--approve"];
337
+ const model = safeModelRef(spec.model);
338
+ if (model) args.push("--model", model);
339
+ const thinking = safeThinking(spec.thinking);
340
+ if (thinking) args.push("--thinking", thinking);
341
+ if (spec.sessionDir) args.push("--session-dir", spec.sessionDir);
342
+ else args.push("--no-session");
343
+ if (spec.sessionName) args.push("--name", spec.sessionName);
344
+ if (spec.harnessExtension) args.push("--no-extensions", "--extension", spec.harnessExtension);
345
+ if (spec.extraArgs?.length) args.push(...spec.extraArgs);
346
+ return args;
347
+ }
348
+
349
+ /**
350
+ * The one line of prompt that is not the brief.
351
+ *
352
+ * Sent with every unit so a worker that has been running for an hour still
353
+ * knows there is nobody to ask.
354
+ */
355
+ export const WORKER_DIRECTIVE =
356
+ "Work autonomously — there is no human in this session, so never ask a question or wait " +
357
+ "for confirmation. Carry out the brief above until the work it names is done or genuinely " +
358
+ "blocked, then stop and state in one short paragraph what you changed and what remains.";
359
+
360
+ // ── the worker session ──────────────────────────────────────────────────────
361
+
362
+ /**
363
+ * One background pi session, alive across several prompts.
364
+ *
365
+ * Alive matters. The unit of *model choice* is the unit of *session*, so a
366
+ * worker that owns a feature keeps its context across the three or four gate
367
+ * cycles that feature takes, exactly as a human's session would — and is torn
368
+ * down, not compacted into uselessness, when the run moves to the next one.
369
+ */
370
+ export class WorkerSession {
371
+ readonly spec: WorkerSpec;
372
+ readonly argv: string[];
373
+ private child: ChildProcess | null = null;
374
+ private buffer = "";
375
+ private output = "";
376
+ private seq = 0;
377
+ private pending = new Map<string, (msg: Record<string, unknown>) => void>();
378
+ private listeners = new Set<(e: WorkerEvent) => void>();
379
+ private settledWaiters: Array<() => void> = [];
380
+ private exitWaiters: Array<(code: number | null) => void> = [];
381
+ private eventsPath: string;
382
+
383
+ /** What actually served the last turn, as `provider/id`. */
384
+ servedModel: string | null = null;
385
+ sessionId: string | null = null;
386
+ usage: WorkerUsage = { inputTokens: 0, outputTokens: 0 };
387
+ exited = false;
388
+ exitCode: number | null = null;
389
+ startError: string | null = null;
390
+ /** Tool calls seen since the last `takeTools()`. */
391
+ private tools: string[] = [];
392
+ private lastText = "";
393
+
394
+ constructor(spec: WorkerSpec) {
395
+ this.spec = spec;
396
+ mkdirSync(spec.attemptDir, { recursive: true });
397
+ this.eventsPath = join(spec.attemptDir, EVENTS_FILE);
398
+ const cli = resolvePiCli(spec.env ?? process.env);
399
+ this.argv = [cli.command, ...cli.leading, ...buildWorkerArgs(spec)];
400
+ }
401
+
402
+ on(listener: (e: WorkerEvent) => void): () => void {
403
+ this.listeners.add(listener);
404
+ return () => this.listeners.delete(listener);
405
+ }
406
+
407
+ private emit(e: WorkerEvent): void {
408
+ try {
409
+ appendFileSync(this.eventsPath, JSON.stringify({ at: new Date().toISOString(), ...e }) + "\n", "utf-8");
410
+ } catch {
411
+ /* the log is a convenience, never a reason to fail a run */
412
+ }
413
+ for (const l of this.listeners) {
414
+ try {
415
+ l(e);
416
+ } catch {
417
+ /* a broken listener must not kill the worker */
418
+ }
419
+ }
420
+ }
421
+
422
+ start(): void {
423
+ const cli = resolvePiCli(this.spec.env ?? process.env);
424
+ const args = [...cli.leading, ...buildWorkerArgs(this.spec)];
425
+ const env: NodeJS.ProcessEnv = {
426
+ ...(this.spec.env ?? process.env),
427
+ [WORKER_ENV]: "1",
428
+ ...(this.spec.unitKey ? { [WORKER_UNIT_ENV]: this.spec.unitKey } : {}),
429
+ ...(this.spec.runId ? { [WORKER_RUN_ENV]: this.spec.runId } : {}),
430
+ // A worker's terminal is a pipe. Colour codes in the log help nobody.
431
+ NO_COLOR: "1",
432
+ FORCE_COLOR: "0",
433
+ };
434
+ const spawnImpl = this.spec.spawnFn ?? spawn;
435
+ try {
436
+ this.child = spawnImpl(cli.command, args, {
437
+ cwd: this.spec.projectDir,
438
+ env,
439
+ stdio: ["pipe", "pipe", "pipe"],
440
+ windowsHide: true,
441
+ ...(cli.shell ? { shell: true } : {}),
442
+ });
443
+ } catch (e) {
444
+ this.startError = `could not start pi (${cli.source}) — ${e instanceof Error ? e.message : String(e)}`;
445
+ this.exited = true;
446
+ this.emit({ kind: "error", message: this.startError });
447
+ return;
448
+ }
449
+ this.emit({ kind: "spawn", argv: this.argv });
450
+
451
+ // A worker that exits while we are writing to it turns the next write
452
+ // into an asynchronous EPIPE on the socket. With no listener, Node throws
453
+ // it as an unhandled 'error' — inside the human's pi process, which is how
454
+ // a dead child would take down the terminal it was supposed to serve.
455
+ this.child.stdin?.on("error", (e: Error) => {
456
+ this.emit({ kind: "error", message: `worker stdin: ${e.message}` });
457
+ });
458
+ this.child.stdout?.on("error", () => {});
459
+ this.child.stderr?.on("error", () => {});
460
+ this.child.stdout?.setEncoding("utf-8");
461
+ this.child.stderr?.setEncoding("utf-8");
462
+ this.child.stdout?.on("data", (c: string) => this.ingest(c));
463
+ this.child.stderr?.on("data", (c: string) => {
464
+ this.output = (this.output + c).slice(-OUTPUT_TAIL_BYTES);
465
+ });
466
+ this.child.on("error", (e: Error) => {
467
+ this.startError = e.message;
468
+ this.emit({ kind: "error", message: e.message });
469
+ this.finish(-1);
470
+ });
471
+ this.child.on("close", (code: number | null) => this.finish(code));
472
+ }
473
+
474
+ private finish(code: number | null): void {
475
+ if (this.exited) return;
476
+ this.exited = true;
477
+ this.exitCode = code;
478
+ try {
479
+ writeFileSync(join(this.spec.attemptDir, OUTPUT_FILE), this.output, "utf-8");
480
+ } catch {
481
+ /* best effort */
482
+ }
483
+ this.emit({ kind: "exit", code });
484
+ // Anything waiting on this child must be released, or the supervisor
485
+ // parks forever on a process that is already gone. This is the failure
486
+ // mode that turns "the run stopped" into "the run hung".
487
+ for (const w of this.settledWaiters.splice(0)) w();
488
+ for (const w of this.exitWaiters.splice(0)) w(code);
489
+ for (const [, resolveP] of this.pending) resolveP({ success: false, error: "worker exited" });
490
+ this.pending.clear();
491
+ }
492
+
493
+ private ingest(chunk: string): void {
494
+ this.output = (this.output + chunk).slice(-OUTPUT_TAIL_BYTES);
495
+ this.buffer += chunk;
496
+ // RPC is strict JSONL on LF. Node's readline also splits U+2028/U+2029,
497
+ // which are legal inside a JSON string, so it cannot be used here.
498
+ let nl: number;
499
+ while ((nl = this.buffer.indexOf("\n")) !== -1) {
500
+ const line = this.buffer.slice(0, nl).replace(/\r$/, "");
501
+ this.buffer = this.buffer.slice(nl + 1);
502
+ if (!line.trim()) continue;
503
+ let msg: Record<string, unknown>;
504
+ try {
505
+ msg = JSON.parse(line) as Record<string, unknown>;
506
+ } catch {
507
+ continue;
508
+ }
509
+ if (msg.type === "response" && typeof msg.id === "string") {
510
+ const waiter = this.pending.get(msg.id);
511
+ if (waiter) {
512
+ this.pending.delete(msg.id);
513
+ waiter(msg);
514
+ }
515
+ continue;
516
+ }
517
+ const ev = parseEventLine(line);
518
+ if (!ev) continue;
519
+ if (ev.kind === "model") this.servedModel = ev.provider ? `${ev.provider}/${ev.model}` : ev.model;
520
+ else if (ev.kind === "tool") this.tools.push(ev.summary);
521
+ else if (ev.kind === "text") this.lastText = ev.text;
522
+ else if (ev.kind === "usage") {
523
+ // pi reports cumulative usage; the last word wins rather than the sum.
524
+ this.usage.inputTokens = Math.max(this.usage.inputTokens, ev.inputTokens);
525
+ this.usage.outputTokens = Math.max(this.usage.outputTokens, ev.outputTokens);
526
+ }
527
+ this.emit(ev);
528
+ if (ev.kind === "settled") for (const w of this.settledWaiters.splice(0)) w();
529
+ }
530
+ }
531
+
532
+ private send(command: Record<string, unknown>, timeoutMs = 20_000): Promise<Record<string, unknown>> {
533
+ if (!this.child?.stdin || this.exited) return Promise.resolve({ success: false, error: "worker not running" });
534
+ const id = `ih-${++this.seq}`;
535
+ return new Promise((resolveP) => {
536
+ const timer = setTimeout(() => {
537
+ this.pending.delete(id);
538
+ resolveP({ success: false, error: "timeout" });
539
+ }, timeoutMs);
540
+ timer.unref?.();
541
+ this.pending.set(id, (msg) => {
542
+ clearTimeout(timer);
543
+ resolveP(msg);
544
+ });
545
+ try {
546
+ this.child?.stdin?.write(JSON.stringify({ id, ...command }) + "\n");
547
+ } catch (e) {
548
+ clearTimeout(timer);
549
+ this.pending.delete(id);
550
+ resolveP({ success: false, error: e instanceof Error ? e.message : String(e) });
551
+ }
552
+ });
553
+ }
554
+
555
+ /**
556
+ * Does this child have the harness's own commands?
557
+ *
558
+ * A worker without them can edit code but cannot tell the harness what it
559
+ * did, so the gate never sees the work and the run loops forever on a task
560
+ * that is actually finished. Cheap to ask, and the answer decides whether
561
+ * the session has to be restarted with an explicit `-e`.
562
+ */
563
+ async hasHarnessTools(): Promise<boolean> {
564
+ const res = await this.send({ type: "get_commands" });
565
+ if (res.success !== true) return true; // cannot tell — assume the normal case
566
+ const data = res.data as { commands?: Array<{ name?: string }> } | undefined;
567
+ const names = (data?.commands ?? []).map((c) => String(c.name ?? ""));
568
+ return names.some((n) => n.startsWith("infinity:"));
569
+ }
570
+
571
+ /** Wait until the child has answered anything at all, so a dead pi fails fast. */
572
+ async ready(timeoutMs = START_TIMEOUT_MS): Promise<boolean> {
573
+ if (this.startError) return false;
574
+ const res = await this.send({ type: "get_session_stats" }, timeoutMs);
575
+ if (res.success === true) {
576
+ const data = res.data as { sessionId?: string } | undefined;
577
+ if (data?.sessionId) this.sessionId = data.sessionId;
578
+ return true;
579
+ }
580
+ return false;
581
+ }
582
+
583
+ /** Context pressure inside the worker, 0..1, or null when pi cannot say. */
584
+ async contextRatio(): Promise<number | null> {
585
+ const res = await this.send({ type: "get_session_stats" });
586
+ if (res.success !== true) return null;
587
+ const data = res.data as { contextUsage?: { percent?: number | null }; sessionId?: string } | undefined;
588
+ if (data?.sessionId) this.sessionId = data.sessionId;
589
+ const pct = data?.contextUsage?.percent;
590
+ if (typeof pct !== "number") return null;
591
+ return pct > 1 ? pct / 100 : pct;
592
+ }
593
+
594
+ /** Send one prompt and wait for the session to settle. Never throws. */
595
+ async prompt(text: string, timeoutMs = this.spec.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS): Promise<TurnResult> {
596
+ this.tools = [];
597
+ this.lastText = "";
598
+ if (this.exited) {
599
+ return { summary: "", tools: [], usage: { ...this.usage }, contextRatio: null, aborted: true, error: this.startError ?? "worker exited" };
600
+ }
601
+ try {
602
+ writeFileSync(join(this.spec.attemptDir, PROMPT_FILE), text, "utf-8");
603
+ } catch {
604
+ /* the transcript is a convenience */
605
+ }
606
+ let releaseSettled: () => void = () => {};
607
+ const settled = new Promise<void>((resolveP) => {
608
+ releaseSettled = resolveP;
609
+ this.settledWaiters.push(resolveP);
610
+ });
611
+ const res = await this.send({ type: "prompt", message: text });
612
+ if (res.success !== true) {
613
+ // Drop the waiter we just queued, or it leaks for the life of the run.
614
+ const i = this.settledWaiters.indexOf(releaseSettled);
615
+ if (i >= 0) this.settledWaiters.splice(i, 1);
616
+ return {
617
+ summary: "",
618
+ tools: [],
619
+ usage: { ...this.usage },
620
+ contextRatio: null,
621
+ aborted: this.exited,
622
+ error: String(res.error ?? "prompt rejected"),
623
+ };
624
+ }
625
+ let timedOut = false;
626
+ await Promise.race([
627
+ settled,
628
+ new Promise<void>((resolveP) => {
629
+ const t = setTimeout(() => {
630
+ timedOut = true;
631
+ resolveP();
632
+ }, timeoutMs);
633
+ t.unref?.();
634
+ }),
635
+ ]);
636
+ if (timedOut) {
637
+ // A turn that will not settle is a wedged worker. Abort the turn, and
638
+ // let the supervisor decide whether to keep the session.
639
+ await this.send({ type: "abort" }, 5000);
640
+ }
641
+ return {
642
+ summary: this.lastText,
643
+ tools: [...this.tools],
644
+ usage: { ...this.usage },
645
+ contextRatio: await this.contextRatio(),
646
+ aborted: timedOut || this.exited,
647
+ error: timedOut ? "turn timed out" : null,
648
+ };
649
+ }
650
+
651
+ /** Stop the child. Resolves once it is actually gone. */
652
+ async close(): Promise<void> {
653
+ if (this.exited || !this.child) {
654
+ this.exited = true;
655
+ return;
656
+ }
657
+ const gone = new Promise<void>((resolveP) => this.exitWaiters.push(() => resolveP()));
658
+ try {
659
+ this.child.stdin?.end();
660
+ } catch {
661
+ /* already closed */
662
+ }
663
+ try {
664
+ this.child.kill("SIGTERM");
665
+ } catch {
666
+ /* already gone */
667
+ }
668
+ const killer = setTimeout(() => {
669
+ try {
670
+ this.child?.kill("SIGKILL");
671
+ } catch {
672
+ /* already gone */
673
+ }
674
+ }, KILL_GRACE_MS);
675
+ killer.unref?.();
676
+ await Promise.race([
677
+ gone,
678
+ new Promise<void>((resolveP) => {
679
+ const t = setTimeout(resolveP, KILL_GRACE_MS * 2);
680
+ t.unref?.();
681
+ }),
682
+ ]);
683
+ clearTimeout(killer);
684
+ this.exited = true;
685
+ }
686
+
687
+ get pid(): number | undefined {
688
+ return this.child?.pid;
689
+ }
690
+
691
+ get tail(): string {
692
+ return this.output;
693
+ }
694
+ }
695
+
696
+ /** Start a worker and wait for it to answer, or return null with the reason on the session. */
697
+ export async function startWorkerSession(spec: WorkerSpec): Promise<WorkerSession> {
698
+ const session = new WorkerSession(spec);
699
+ session.start();
700
+ await session.ready();
701
+ return session;
702
+ }
703
+
704
+ /** Where a worker's session files live, so `/resume` can reach them. */
705
+ export function workerSessionDir(projectDir: string): string {
706
+ return resolve(projectDir, "tmp", "infinity-harness", "sessions");
707
+ }