viewable-one 0.0.0-stage → 0.12.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/CHANGELOG.md +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
package/lib/child.mjs
ADDED
|
@@ -0,0 +1,1252 @@
|
|
|
1
|
+
import { imageAttachments } from "./imageAttachments.mjs";
|
|
2
|
+
// RUNNING A CHILD PROCESS, and nothing about the board.
|
|
3
|
+
//
|
|
4
|
+
// Split out of runner.mjs on 2026-09-02, verbatim: this is the "process half"
|
|
5
|
+
// its own section comment already named, plus the bounds that only the child
|
|
6
|
+
// uses and the environment it is handed. runner.mjs kept `runRequest` - the
|
|
7
|
+
// one function that has to know about both halves - and re-exports everything
|
|
8
|
+
// here, so every existing importer and every test is unchanged.
|
|
9
|
+
//
|
|
10
|
+
// Nothing in this file reads or writes the board. It takes a binary, some
|
|
11
|
+
// arguments and a wall clock, and gives back what happened. That is what made
|
|
12
|
+
// it separable, and keeping it that way is what keeps the crash, timeout,
|
|
13
|
+
// runaway and stall paths testable against a fake binary.
|
|
14
|
+
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { readFile, readdir, unlink } from "node:fs/promises";
|
|
17
|
+
import { createWriteStream } from "node:fs";
|
|
18
|
+
import { createGzip, constants as zlibConstants } from "node:zlib";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { killTree } from "./kill.mjs";
|
|
22
|
+
import { RUNNER_BEAT_MS } from "./spawn.mjs";
|
|
23
|
+
import { runnableCommand } from "./windows-command.mjs";
|
|
24
|
+
|
|
25
|
+
// Defaults, all overridable by env and all bounds rather than preferences.
|
|
26
|
+
export const DEFAULTS = {
|
|
27
|
+
// It was 1, and nobody ever chose that either. What it cost is worth
|
|
28
|
+
// writing down because the symptom looked like something else entirely:
|
|
29
|
+
// with one watcher the cap was never even reached, because pollOnce awaited
|
|
30
|
+
// each child, so sessions were serial by construction and the number was
|
|
31
|
+
// decoration. The visible failure was the runner going quiet - see
|
|
32
|
+
// RUNNER_BEAT_MS in spawn.mjs.
|
|
33
|
+
//
|
|
34
|
+
// Infinity is "any number at once", not "unbounded spending", and the guards that
|
|
35
|
+
// actually bound spend all stay: off unless PM_SPAWN_RUNNER is exactly
|
|
36
|
+
// "on", approved: true per request, one click per session, the board
|
|
37
|
+
// answering before anything is billed, the wall clock, no retry, no re-run
|
|
38
|
+
// on edit. PM_SPAWN_MAX_CONCURRENT still sets how many run at once for anyone
|
|
39
|
+
// who wants that, and the count is still read and still shown.
|
|
40
|
+
maxConcurrent: Infinity,
|
|
41
|
+
pollMs: 5_000,
|
|
42
|
+
// How often the watcher updates row-backed presence. Overridable so a
|
|
43
|
+
// heartbeat test does not have to wait ten real seconds.
|
|
44
|
+
beatMs: RUNNER_BEAT_MS,
|
|
45
|
+
// How long a SIGTERM'd child gets to exit before SIGKILL.
|
|
46
|
+
killGraceMs: 5_000,
|
|
47
|
+
// Stall watchdog: no stdout for this long with a turn open (no result envelope
|
|
48
|
+
// since the last user message AND no tool_use waiting for its tool_result) kills
|
|
49
|
+
// the child. Default 15 minutes. Armed only when the MODEL should be producing
|
|
50
|
+
// output; a running tool is allowed to be silent (the wall clock covers a hung tool).
|
|
51
|
+
stallMs: 15 * 60_000,
|
|
52
|
+
// Child output kept per session. Enough to debug, small enough that a
|
|
53
|
+
// runaway printing a gigabyte does not fill his disk - and deliberately
|
|
54
|
+
// BELOW what the app will read.
|
|
55
|
+
//
|
|
56
|
+
// It was 512KB against the app's 500,000-byte read limit, so a long session
|
|
57
|
+
// produced a log the board could not open and the terminal vanished mid-run
|
|
58
|
+
// with no explanation. Two caps that disagree are one bug waiting for a long
|
|
59
|
+
// enough session, and this one waited about six minutes.
|
|
60
|
+
maxLogBytes: 400_000,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// How the cap SPENDS itself, once it binds. Keeping the first 400,000 bytes
|
|
64
|
+
// and dropping everything after is the wrong way round: the beginning is a
|
|
65
|
+
// prompt he already wrote, and the end is why the session died. So the kept
|
|
66
|
+
// log has two ends and a hole in the middle.
|
|
67
|
+
//
|
|
68
|
+
// The share is head-light on purpose. What a session started with is a few
|
|
69
|
+
// hundred lines; what killed it is the last few.
|
|
70
|
+
const HEAD_SHARE = 0.25;
|
|
71
|
+
// Stderr, kept in its OWN buffer that the stdout flood cannot reach. A crash
|
|
72
|
+
// reason is one line, and merging the two streams is how that one line ends
|
|
73
|
+
// up buried under 400,000 bytes of event JSON with no way to find it.
|
|
74
|
+
const STDERR_KEEP = 8_000;
|
|
75
|
+
|
|
76
|
+
/** A ring with two ends, and the single definition of what a capped log is.
|
|
77
|
+
*
|
|
78
|
+
* The first headBytes are kept as they arrive; after that everything lands in
|
|
79
|
+
* a rolling tail, and what falls out of the tail is counted rather than
|
|
80
|
+
* silently forgotten.
|
|
81
|
+
*
|
|
82
|
+
* What the old head-only cap cost: spawn-mtbqgnys-67d5e4 (2026-08-27) ran
|
|
83
|
+
* 6m18s, filled the cap with opencode event JSON, exited 1, and had its
|
|
84
|
+
* reason thrown away. The frame then showed 150 characters of whatever source
|
|
85
|
+
* file the session happened to be reading last, because "the tail of the log"
|
|
86
|
+
* was really "wherever the cap landed, mid-line".
|
|
87
|
+
*
|
|
88
|
+
* It lives out here because THREE readers need the same answer: runChild's
|
|
89
|
+
* final log, runStreamingChild's, and the live file the APP tails while the
|
|
90
|
+
* session is still running. That third one used to keep its own head-only
|
|
91
|
+
* rule, which is how a run could go blind seven minutes in - see the live
|
|
92
|
+
* writer in runRequest. (DATED REWRITE 2026-09-07, brief 26 ruling 4: the
|
|
93
|
+
* tailer was the session frame's terminal, which is retired; the same file
|
|
94
|
+
* is now the run's raw transcript, read from the expanded run.) */
|
|
95
|
+
export function makeLogRing(maxBytes) {
|
|
96
|
+
const headBytes = Math.floor(maxBytes * HEAD_SHARE);
|
|
97
|
+
const cutNote = (n) =>
|
|
98
|
+
`\n\n[truncated: ${n} characters dropped from the MIDDLE, limit ${maxBytes}. The start and the END are both here.]\n\n`;
|
|
99
|
+
// Leave room for the note itself, so a capped log still fits under the cap.
|
|
100
|
+
// The reserve is measured with a zero count plus room for any digits the
|
|
101
|
+
// real one turns out to have - a reserve sized from maxBytes is three
|
|
102
|
+
// characters short the moment a session drops more than it kept.
|
|
103
|
+
const tailBytes = Math.max(0, maxBytes - headBytes - cutNote(0).length - 24);
|
|
104
|
+
let head = "";
|
|
105
|
+
let tail = "";
|
|
106
|
+
let dropped = 0;
|
|
107
|
+
return {
|
|
108
|
+
push(chunk) {
|
|
109
|
+
if (head.length < headBytes) {
|
|
110
|
+
const room = headBytes - head.length;
|
|
111
|
+
head += chunk.slice(0, room);
|
|
112
|
+
chunk = chunk.slice(room);
|
|
113
|
+
if (chunk.length === 0) return;
|
|
114
|
+
}
|
|
115
|
+
tail += chunk;
|
|
116
|
+
if (tail.length > tailBytes) {
|
|
117
|
+
dropped += tail.length - tailBytes;
|
|
118
|
+
tail = tail.slice(-tailBytes);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
get dropped() {
|
|
122
|
+
return dropped;
|
|
123
|
+
},
|
|
124
|
+
text() {
|
|
125
|
+
return dropped > 0 ? `${head}${cutNote(dropped)}${tail}` : head + tail;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A second, independent sink for a child's stdout: the FULL, untruncated
|
|
131
|
+
* bytes, gzipped as they stream, written to `<dir>/transcript.jsonl.gz`.
|
|
132
|
+
*
|
|
133
|
+
* Deliberately NOT makeLogRing. The ring stays exactly what it was - head,
|
|
134
|
+
* tail, hole in the middle - because live in-process readers (finalObject,
|
|
135
|
+
* the app's own log reads) depend on that shape today.
|
|
136
|
+
* This is a tee beside it, fed the same chunks, for the operator who needs
|
|
137
|
+
* the part the ring dropped.
|
|
138
|
+
*
|
|
139
|
+
* `--output-format stream-json` already emits one JSON object per line, so
|
|
140
|
+
* this writes each raw chunk exactly as it arrives on stdout - no
|
|
141
|
+
* line-buffering, no re-parse/re-serialize. Concatenating the gunzipped
|
|
142
|
+
* bytes back together reproduces the child's stdout byte for byte.
|
|
143
|
+
*
|
|
144
|
+
* Opened in append mode: one job attempt runs several containers (triage,
|
|
145
|
+
* each implement round, verify, review) sharing the same evidence dir, and
|
|
146
|
+
* each is a fresh call to this function. A concatenated multi-member gzip
|
|
147
|
+
* file is a valid gzip stream - every member decodes in order - so append
|
|
148
|
+
* keeps every stage's bytes instead of the last container truncating the
|
|
149
|
+
* ones before it.
|
|
150
|
+
*
|
|
151
|
+
* Diagnostic tooling must never be a reason a real job fails: every failure
|
|
152
|
+
* here (a full disk, a read-only mount, a bad path) is caught, reported once
|
|
153
|
+
* through `warn`, and the sink goes quietly inert. Nothing here ever throws
|
|
154
|
+
* into the caller or touches the child's own outcome. */
|
|
155
|
+
export function openTranscriptSink(dir, {
|
|
156
|
+
warn = (message) => { try { process.stderr.write(`${message}\n`); } catch { /* nowhere left to say it */ } },
|
|
157
|
+
// An interval scheduler that cannot outlive its owner, e.g. attachChild's
|
|
158
|
+
// own `every` (unref'd, tracked in a Set settle() clears). Standalone
|
|
159
|
+
// callers (tests, or any caller with no such lifecycle to join) fall back
|
|
160
|
+
// to a private unref'd setInterval with the same shape.
|
|
161
|
+
every = (fn, ms) => { const t = setInterval(fn, ms); if (t.unref) t.unref(); return t; },
|
|
162
|
+
} = {}) {
|
|
163
|
+
if (!dir) return null;
|
|
164
|
+
let failed = false;
|
|
165
|
+
const fail = (label, error) => {
|
|
166
|
+
if (failed) return;
|
|
167
|
+
failed = true;
|
|
168
|
+
warn(`[transcript] ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
169
|
+
// A write() just before this may have returned false, leaving the caller
|
|
170
|
+
// paused and waiting on onceDrained (an alias for gzip's own 'drain') to
|
|
171
|
+
// resume its source. The real drain will never come once the stream has
|
|
172
|
+
// errored, so fire it here - a no-op if nothing is listening. gzip may
|
|
173
|
+
// not exist yet if createGzip() itself is what failed.
|
|
174
|
+
gzip?.emit("drain");
|
|
175
|
+
};
|
|
176
|
+
let gzip;
|
|
177
|
+
let file;
|
|
178
|
+
let closing = null;
|
|
179
|
+
try {
|
|
180
|
+
gzip = createGzip();
|
|
181
|
+
file = createWriteStream(join(dir, "transcript.jsonl.gz"), { flags: "a" });
|
|
182
|
+
} catch (error) {
|
|
183
|
+
fail("failed to open transcript sink", error);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
gzip.on("error", (error) => fail("gzip stream error", error));
|
|
187
|
+
file.on("error", (error) => fail("write stream error", error));
|
|
188
|
+
gzip.pipe(file);
|
|
189
|
+
// A hard kill (OOM, SIGKILL on the container) never gets to run close()
|
|
190
|
+
// below. A periodic sync flush means gunzip can still read everything up
|
|
191
|
+
// to the last flush instead of refusing the whole file for a trailer that
|
|
192
|
+
// never landed.
|
|
193
|
+
const flushTimer = every(() => {
|
|
194
|
+
if (failed || !gzip.writable) return;
|
|
195
|
+
try { gzip.flush(zlibConstants.Z_SYNC_FLUSH); } catch { /* next tick retries */ }
|
|
196
|
+
}, 5000);
|
|
197
|
+
return {
|
|
198
|
+
/** Returns false when the caller should pause its source until
|
|
199
|
+
* `onceDrained` fires - the same contract as `Writable.write()` - so a
|
|
200
|
+
* slow disk applies backpressure instead of buffering every stdout
|
|
201
|
+
* chunk of a 20-minute run in memory. */
|
|
202
|
+
write(chunk) {
|
|
203
|
+
if (failed || !gzip.writable) return true;
|
|
204
|
+
try { return gzip.write(chunk); } catch (error) { fail("write failed", error); return true; }
|
|
205
|
+
},
|
|
206
|
+
onceDrained(callback) { gzip.once("drain", callback); },
|
|
207
|
+
/** Ends the gzip stream so the trailer is flushed and the file on disk is
|
|
208
|
+
* valid gzip rather than a stream cut mid-frame. Safe to call more than
|
|
209
|
+
* once and safe to call after a failure.
|
|
210
|
+
*
|
|
211
|
+
* RESOLVES WHEN THE FILE IS COMPLETE (2026-09-19, brief 43 review). It
|
|
212
|
+
* used to return at once, and runChild settled on the same tick, so a
|
|
213
|
+
* reader that opened transcript.jsonl.gz straight after the run saw the
|
|
214
|
+
* trailer still in flight: gunzip answered "unexpected end of file"
|
|
215
|
+
* (Z_BUF_ERROR), one run in ten under the test suite and the same odds
|
|
216
|
+
* for any caller that reads the transcript right away. The promise
|
|
217
|
+
* settles on the write stream's `finish` (all bytes handed to the fd),
|
|
218
|
+
* on its error, or after two seconds, so a hung disk never pins a run
|
|
219
|
+
* that has already ended. */
|
|
220
|
+
close() {
|
|
221
|
+
clearInterval(flushTimer);
|
|
222
|
+
if (failed) return Promise.resolve();
|
|
223
|
+
if (!closing) {
|
|
224
|
+
closing = new Promise((done) => {
|
|
225
|
+
const timer = setTimeout(done, CLOSE_WAIT_MS);
|
|
226
|
+
if (timer.unref) timer.unref();
|
|
227
|
+
const finish = () => { clearTimeout(timer); done(); };
|
|
228
|
+
file.once("finish", finish);
|
|
229
|
+
file.once("error", finish);
|
|
230
|
+
if (gzip.writableEnded && file.writableFinished) finish();
|
|
231
|
+
else { try { gzip.end(); } catch { finish(); } }
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return closing;
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** How long close() waits for the transcript file to finish before letting
|
|
240
|
+
* the run settle anyway. */
|
|
241
|
+
const CLOSE_WAIT_MS = 2000;
|
|
242
|
+
|
|
243
|
+
/** Compute context fill from assistant usage.
|
|
244
|
+
* Fill = input_tokens + cache_read_input_tokens + cache_creation_input_tokens. */
|
|
245
|
+
export function computeContextFill(usage) {
|
|
246
|
+
if (!usage || typeof usage !== "object") return null;
|
|
247
|
+
const input = Number(usage.input_tokens) || 0;
|
|
248
|
+
const cacheRead = Number(usage.cache_read_input_tokens) || 0;
|
|
249
|
+
const cacheCreation = Number(usage.cache_creation_input_tokens) || 0;
|
|
250
|
+
return input + cacheRead + cacheCreation;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Tokens on ONE assistant usage event - input + output + both cache kinds.
|
|
254
|
+
* `computeContextFill` above answers "how full is the window right now"
|
|
255
|
+
* from the LAST such event alone; this is the per-event number `totalTokens`
|
|
256
|
+
* sums across every event in the run, for the receipt. */
|
|
257
|
+
export function computeTurnTokens(usage) {
|
|
258
|
+
if (!usage || typeof usage !== "object") return 0;
|
|
259
|
+
const input = Number(usage.input_tokens) || 0;
|
|
260
|
+
const output = Number(usage.output_tokens) || 0;
|
|
261
|
+
const cacheRead = Number(usage.cache_read_input_tokens) || 0;
|
|
262
|
+
const cacheCreation = Number(usage.cache_creation_input_tokens) || 0;
|
|
263
|
+
return input + output + cacheRead + cacheCreation;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The money on a result envelope, under whichever name the build that wrote
|
|
267
|
+
* it used. `total_cost_usd` is what Claude Code emits today; the two older
|
|
268
|
+
* spellings are read after it so an older recording is not silently free.
|
|
269
|
+
*
|
|
270
|
+
* IT IS THE SESSION TOTAL SO FAR, not this turn's cost, so the last envelope
|
|
271
|
+
* is the whole answer and adding them up would bill a long session several
|
|
272
|
+
* times over. The same reading src/folder/sessionLog/claude.ts gives it. */
|
|
273
|
+
export function costOf(envelope) {
|
|
274
|
+
if (!envelope || typeof envelope !== "object") return null;
|
|
275
|
+
for (const key of ["total_cost_usd", "cost_usd", "costUSD"]) {
|
|
276
|
+
const value = envelope[key];
|
|
277
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** EVERYTHING BOTH CHILDREN DO, once.
|
|
283
|
+
*
|
|
284
|
+
* runChild and runStreamingChild are two ways of running the same process,
|
|
285
|
+
* and about sixty percent of them was the same code written twice: the log
|
|
286
|
+
* ring, the stderr tail, the stream-json scanner, the wall clock with its
|
|
287
|
+
* SIGTERM-then-SIGKILL grace, the stop-file watcher, and the settle that
|
|
288
|
+
* clears every timer and assembles the result.
|
|
289
|
+
*
|
|
290
|
+
* Two copies of a scanner is the expensive kind of duplication here, because
|
|
291
|
+
* the copies had already drifted: only the streaming one tracked `sawResult`
|
|
292
|
+
* (the envelope that says a turn is over) and `openToolUses` (what the stall
|
|
293
|
+
* watchdog arms on). A one-shot child running `--output-format stream-json`
|
|
294
|
+
* emits exactly the same events, so the one-shot path was throwing away the
|
|
295
|
+
* tool tracking it could have had. It has it now, and that is the only
|
|
296
|
+
* BEHAVIOUR this extraction changes.
|
|
297
|
+
*
|
|
298
|
+
* What is deliberately NOT here, because it is genuinely different: stdin.
|
|
299
|
+
* The streaming child has a pipe, an opening message, reply files, pushed
|
|
300
|
+
* answers and a stall watchdog; the one-shot child has none of it and its
|
|
301
|
+
* stdio is `ignore`. Those stay in runStreamingChild, written against the
|
|
302
|
+
* handle this returns.
|
|
303
|
+
*
|
|
304
|
+
* The handle:
|
|
305
|
+
* state the mutable facts every watcher reads and writes
|
|
306
|
+
* kill() SIGTERM, then SIGKILL after the grace - the one kill path
|
|
307
|
+
* every() an interval that is cleared on settle, so a watcher cannot
|
|
308
|
+
* outlive the child it watches
|
|
309
|
+
* markSent() a message went in: the turn is open again and the stall
|
|
310
|
+
* clock restarts
|
|
311
|
+
* settled() whether the promise has already been resolved
|
|
312
|
+
*/
|
|
313
|
+
export function attachChild(child, {
|
|
314
|
+
resolve,
|
|
315
|
+
maxMs,
|
|
316
|
+
maxLogBytes = DEFAULTS.maxLogBytes,
|
|
317
|
+
killGraceMs = DEFAULTS.killGraceMs,
|
|
318
|
+
stopPollMs = 2000,
|
|
319
|
+
onChunk,
|
|
320
|
+
stopCheck,
|
|
321
|
+
killProcessGroup = false,
|
|
322
|
+
// What the wall clock means for this child. The one-shot answer is "out of
|
|
323
|
+
// time"; the streaming answer is "out of time ONLY if the turn is still
|
|
324
|
+
// open", because for an idle child the wall clock is the reply window
|
|
325
|
+
// closing and the work is done. Defaults to the one-shot reading.
|
|
326
|
+
onWallClock = (state) => { state.timedOut = true; },
|
|
327
|
+
// A result envelope just landed: the turn is over and nothing is billing.
|
|
328
|
+
// The one-shot child has nothing to do with that (it is about to exit
|
|
329
|
+
// anyway); the streaming child closes its stdin here, which is the
|
|
330
|
+
// 2026-09-06 ruling and the whole of "a finished session exits". Called
|
|
331
|
+
// once per envelope, never after the promise has settled.
|
|
332
|
+
onResult = () => {},
|
|
333
|
+
// A `control_request` or `control_cancel_request` line arrived on stdout:
|
|
334
|
+
// the CLI is asking the host something (today, "may I use this tool?" under
|
|
335
|
+
// `--permission-prompt-tool stdio`). Only the streaming child has a stdin to
|
|
336
|
+
// answer on; the one-shot child never passes this and the lines are ignored
|
|
337
|
+
// as they always were. Never called after settle.
|
|
338
|
+
onControl = () => {},
|
|
339
|
+
// What has been counted so far, after every assistant usage event and
|
|
340
|
+
// every result envelope. The runner uses it to move the card's numbers
|
|
341
|
+
// mid-flight instead of only at the end.
|
|
342
|
+
onSpend = () => {},
|
|
343
|
+
// Whether this run counts as a success, given how the child ended.
|
|
344
|
+
okOf,
|
|
345
|
+
// Fields only one of the two shapes carries.
|
|
346
|
+
extraFields = () => ({}),
|
|
347
|
+
// A directory to tee this child's FULL, untruncated stdout into as
|
|
348
|
+
// `transcript.jsonl.gz` - see openTranscriptSink. Undefined (the default)
|
|
349
|
+
// means no sink: every existing caller that does not pass this is
|
|
350
|
+
// unaffected, byte for byte.
|
|
351
|
+
transcriptDir,
|
|
352
|
+
}) {
|
|
353
|
+
const ring = makeLogRing(maxLogBytes);
|
|
354
|
+
const timers = new Set();
|
|
355
|
+
/** An interval that cannot outlive the child: settle clears every one. */
|
|
356
|
+
const every = (fn, ms) => {
|
|
357
|
+
const timer = setInterval(fn, ms);
|
|
358
|
+
if (timer.unref) timer.unref();
|
|
359
|
+
timers.add(timer);
|
|
360
|
+
return timer;
|
|
361
|
+
};
|
|
362
|
+
const transcriptSink = openTranscriptSink(transcriptDir, { every });
|
|
363
|
+
let errTail = "";
|
|
364
|
+
const keepErr = (chunk) => {
|
|
365
|
+
errTail += chunk;
|
|
366
|
+
if (errTail.length > STDERR_KEEP) errTail = errTail.slice(-STDERR_KEEP);
|
|
367
|
+
};
|
|
368
|
+
// onChunk is called for EVERY chunk, cap or no cap. It used to sit behind
|
|
369
|
+
// the memory cap's early return, so a full buffer silenced the live terminal
|
|
370
|
+
// as well - two caps stacked, one of them invisible. The stream side owns
|
|
371
|
+
// its own limit and says so on the file when it binds.
|
|
372
|
+
const collect = (chunk) => {
|
|
373
|
+
ring.push(chunk);
|
|
374
|
+
if (onChunk) onChunk(chunk);
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const state = {
|
|
378
|
+
// Any assistant event at all: the line between 'retry' and 'do not'.
|
|
379
|
+
sawAssistant: false,
|
|
380
|
+
// A result envelope stands: the turn is over and nothing is billing.
|
|
381
|
+
sawResult: false,
|
|
382
|
+
resultWasError: false,
|
|
383
|
+
// The LAST result envelope parsed, for cost and turn count.
|
|
384
|
+
resultEnvelope: null,
|
|
385
|
+
// Usage from the last assistant event, for context fill.
|
|
386
|
+
lastAssistantUsage: null,
|
|
387
|
+
// Input + output + cache tokens, SUMMED across every assistant usage
|
|
388
|
+
// event this run has had, for the receipt. Different from
|
|
389
|
+
// `lastAssistantUsage`/context fill above, which is a snapshot of the
|
|
390
|
+
// LAST turn alone: this one never resets.
|
|
391
|
+
//
|
|
392
|
+
// SUMMED ACROSS DISTINCT MESSAGES, not distinct events (2026-09-21, live
|
|
393
|
+
// run spawn-qa3-fable-01): Claude Code's stream-json emits one
|
|
394
|
+
// `assistant` EVENT PER CONTENT BLOCK of a turn (thinking, text, each
|
|
395
|
+
// tool_use), and every one of them carries the SAME `message.usage` -
|
|
396
|
+
// same `message.id` - because it is the running total for that message,
|
|
397
|
+
// not a per-block figure. A three-block turn of ~57k tokens was being
|
|
398
|
+
// added three times, crossed the 400k cap in 27 seconds and three real
|
|
399
|
+
// turns.
|
|
400
|
+
// `lastUsageMessageId`/`lastUsageMessageTokens` below dedupe it: a repeat
|
|
401
|
+
// of the same message id adds only the DELTA (a later chunk can report a
|
|
402
|
+
// larger output_tokens than an earlier one), so this equals the sum over
|
|
403
|
+
// distinct messages of their final usage.
|
|
404
|
+
totalTokens: 0,
|
|
405
|
+
// The message id `totalTokens` was last counted against, and the token
|
|
406
|
+
// figure counted for it - see the note on `totalTokens` above. A message
|
|
407
|
+
// with no id (an older CLI, a fixture) is never treated as a repeat, so
|
|
408
|
+
// nothing here changes what an id-less run counts.
|
|
409
|
+
lastUsageMessageId: null,
|
|
410
|
+
lastUsageMessageTokens: 0,
|
|
411
|
+
// tool_use ids without their tool_result yet: the stall watchdog is
|
|
412
|
+
// disarmed while a tool is running, because a running tool is allowed to
|
|
413
|
+
// be silent and the wall clock covers a hung one.
|
|
414
|
+
openToolUses: new Set(),
|
|
415
|
+
lastStdoutAt: Date.now(),
|
|
416
|
+
timedOut: false,
|
|
417
|
+
stoppedByUser: false,
|
|
418
|
+
stalled: false,
|
|
419
|
+
settled: false,
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
/** COUNT WHAT THIS RUN HAS USED, for the card's numbers and the receipt.
|
|
423
|
+
*
|
|
424
|
+
* Called after every event that can move any of the three numbers: a
|
|
425
|
+
* result envelope (money) and an assistant event with usage (room, and the
|
|
426
|
+
* running total). `cost` is the session total the envelope reports;
|
|
427
|
+
* `contextTokens` is the LAST turn's input side; `totalTokens` is the
|
|
428
|
+
* running total summed across every turn. Nothing here stops a run: there
|
|
429
|
+
* are no run limits (#364). */
|
|
430
|
+
const countSpend = () => {
|
|
431
|
+
const spend = {
|
|
432
|
+
cost: costOf(state.resultEnvelope),
|
|
433
|
+
contextTokens: state.lastAssistantUsage ? computeContextFill(state.lastAssistantUsage) : null,
|
|
434
|
+
totalTokens: state.lastAssistantUsage ? state.totalTokens : null,
|
|
435
|
+
};
|
|
436
|
+
try { onSpend(spend, state); } catch { /* a listener must not take the child down */ }
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// THE STREAM-JSON SCANNER. Events are one JSON object per line and only
|
|
440
|
+
// stdout carries them.
|
|
441
|
+
let lineBuf = "";
|
|
442
|
+
const scan = (chunk) => {
|
|
443
|
+
lineBuf += chunk;
|
|
444
|
+
// A line the child never ends must not grow this without bound. Nothing
|
|
445
|
+
// parseable we care about is this long; a real envelope is a few KB.
|
|
446
|
+
if (!lineBuf.includes("\n") && lineBuf.length > 1_000_000) {
|
|
447
|
+
lineBuf = "";
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const lines = lineBuf.split("\n");
|
|
451
|
+
lineBuf = lines.pop() ?? "";
|
|
452
|
+
for (const raw of lines) {
|
|
453
|
+
const line = raw.trim();
|
|
454
|
+
if (!line.startsWith("{")) continue;
|
|
455
|
+
try {
|
|
456
|
+
const parsed = JSON.parse(line);
|
|
457
|
+
if (parsed === null || typeof parsed !== "object") continue;
|
|
458
|
+
if (parsed.type === "assistant") state.sawAssistant = true;
|
|
459
|
+
if ((parsed.type === "control_request" || parsed.type === "control_cancel_request") && !state.settled) {
|
|
460
|
+
try { onControl(parsed); } catch { /* a listener must not take the child down */ }
|
|
461
|
+
}
|
|
462
|
+
if (parsed.type === "result") {
|
|
463
|
+
state.sawResult = true;
|
|
464
|
+
state.resultWasError = parsed.is_error === true;
|
|
465
|
+
state.resultEnvelope = parsed; // keep the LAST one
|
|
466
|
+
// AFTER the state is written, so a listener that closes stdin sees
|
|
467
|
+
// a consistent picture, and never after settle - the child is gone
|
|
468
|
+
// and there is nothing left to tell.
|
|
469
|
+
if (!state.settled) {
|
|
470
|
+
try { onResult(state); } catch { /* a listener must not take the child down */ }
|
|
471
|
+
}
|
|
472
|
+
// AFTER onResult, so a streaming child has already closed its stdin
|
|
473
|
+
// for the turn before the wall is read. Where the wall fires, the
|
|
474
|
+
// handoff message reopens the turn through markSent - which is the
|
|
475
|
+
// one order in which "finish the turn, then stop" can actually be
|
|
476
|
+
// said to a child that was about to exit.
|
|
477
|
+
countSpend();
|
|
478
|
+
}
|
|
479
|
+
if (parsed.type === "assistant" && parsed.message?.usage && !parsed.parent_tool_use_id) {
|
|
480
|
+
// Context fill = input_tokens + cache_read + cache_creation, from
|
|
481
|
+
// the LAST assistant event. parent_tool_use_id is skipped so a
|
|
482
|
+
// subagent's small turn cannot overwrite the lead's number - and
|
|
483
|
+
// for the same reason it is skipped below, so a subagent's tokens
|
|
484
|
+
// cannot inflate the lead run's own total either.
|
|
485
|
+
state.lastAssistantUsage = parsed.message.usage;
|
|
486
|
+
// ONCE PER MESSAGE, not once per content-block event - see the note
|
|
487
|
+
// on `state.totalTokens` above. A repeat of the same message id
|
|
488
|
+
// adds only what grew since the last chunk; a new id (or no id at
|
|
489
|
+
// all) adds its full figure, same as before.
|
|
490
|
+
const messageId = parsed.message.id ?? null;
|
|
491
|
+
const messageTokens = computeTurnTokens(parsed.message.usage);
|
|
492
|
+
if (messageId && messageId === state.lastUsageMessageId) {
|
|
493
|
+
state.totalTokens += Math.max(0, messageTokens - state.lastUsageMessageTokens);
|
|
494
|
+
} else {
|
|
495
|
+
state.totalTokens += messageTokens;
|
|
496
|
+
}
|
|
497
|
+
state.lastUsageMessageId = messageId;
|
|
498
|
+
state.lastUsageMessageTokens = messageTokens;
|
|
499
|
+
countSpend();
|
|
500
|
+
}
|
|
501
|
+
// Usage and tool lifecycle may appear in the same assistant event.
|
|
502
|
+
if (parsed.type === "assistant" && Array.isArray(parsed.message?.content)) {
|
|
503
|
+
for (const block of parsed.message.content) {
|
|
504
|
+
if (block.type === "tool_use" && block.id) state.openToolUses.add(block.id);
|
|
505
|
+
}
|
|
506
|
+
} else if (parsed.type === "user" && Array.isArray(parsed.message?.content)) {
|
|
507
|
+
// Tool results arrive as USER events with tool_result blocks.
|
|
508
|
+
for (const block of parsed.message.content) {
|
|
509
|
+
if (block.type === "tool_result" && block.tool_use_id) {
|
|
510
|
+
state.openToolUses.delete(block.tool_use_id);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
} catch { /* a partial or non-event line is not an envelope */ }
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
child.stdout?.setEncoding("utf8");
|
|
519
|
+
child.stderr?.setEncoding("utf8");
|
|
520
|
+
child.stdout?.on("data", (chunk) => {
|
|
521
|
+
state.lastStdoutAt = Date.now();
|
|
522
|
+
scan(chunk);
|
|
523
|
+
collect(chunk);
|
|
524
|
+
// The disk tee gets the raw chunk directly, same as the ring - stderr
|
|
525
|
+
// never reaches it, and it sees every byte the ring's cap would drop.
|
|
526
|
+
// Respect backpressure: a slow disk pauses stdout rather than growing
|
|
527
|
+
// an unbounded internal buffer for the life of the run.
|
|
528
|
+
if (transcriptSink?.write(chunk) === false) {
|
|
529
|
+
child.stdout.pause();
|
|
530
|
+
transcriptSink.onceDrained(() => child.stdout.resume());
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
child.stderr?.on("data", (chunk) => {
|
|
534
|
+
keepErr(chunk);
|
|
535
|
+
collect(chunk);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
let killTimer;
|
|
539
|
+
/** The one kill path: SIGTERM, then SIGKILL after the grace. A child that
|
|
540
|
+
* ignores SIGTERM is still billing.
|
|
541
|
+
*
|
|
542
|
+
* IT IS THE CHILD'S PID AND NOT ITS PROCESS GROUP, and since 2026-09-06
|
|
543
|
+
* that is load-bearing rather than incidental. `child.kill(sig)` signals
|
|
544
|
+
* one pid; `process.kill(-pgid, sig)` would signal everything the child
|
|
545
|
+
* started, and the child is spawned WITHOUT `detached`, so it shares the
|
|
546
|
+
* runner's group and a group kill here would reach the runner itself.
|
|
547
|
+
*
|
|
548
|
+
* Ruling A that day is what makes the distinction matter: a session
|
|
549
|
+
* with the Browser switch on starts its dev server detached so the browser
|
|
550
|
+
* frame outlives the session, and a group kill on the envelope - or on the
|
|
551
|
+
* wall clock, or on Stop - would take that server down with the child and
|
|
552
|
+
* put the frame back on a dead port. The daily sweep stops it instead
|
|
553
|
+
* (lib/sweep.mjs, lib/devserver.mjs), and
|
|
554
|
+
* bridge/test/detachedserver.test.mjs pins that a grandchild started
|
|
555
|
+
* detached is still alive after this close.
|
|
556
|
+
*
|
|
557
|
+
* ON WINDOWS THE SAME TWO LINES WOULD DO THE OPPOSITE (brief 21, ruling
|
|
558
|
+
* 4A of 2026-09-07): Node maps both signals to TerminateProcess, so the
|
|
559
|
+
* grace is a fiction, and it ends one pid while `claude.cmd` runs the real
|
|
560
|
+
* agent as a grandchild that survives and keeps billing. `killTree` sends
|
|
561
|
+
* `taskkill /pid <pid> /t` and then `/t /f` there. The POSIX call it makes
|
|
562
|
+
* is `child.kill(sig)`, this closure's own, so the paragraph above still
|
|
563
|
+
* describes exactly what happens on his Mac. Package checks alone opt into
|
|
564
|
+
* killProcessGroup: runChild gives them their OWN group, with no preview
|
|
565
|
+
* intended to survive. Agent sessions never enable it. */
|
|
566
|
+
const kill = () => {
|
|
567
|
+
const send = (force) =>
|
|
568
|
+
killTree(child.pid, { force, signal: (_pid, sig) => killProcessGroup ? process.kill(-child.pid, sig) : child.kill(sig) }).catch(() => {
|
|
569
|
+
/* a child that is already gone is not an error here */
|
|
570
|
+
});
|
|
571
|
+
send(false);
|
|
572
|
+
killTimer = setTimeout(() => send(true), killGraceMs);
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const wallTimer = setTimeout(() => {
|
|
576
|
+
onWallClock(state);
|
|
577
|
+
kill();
|
|
578
|
+
}, maxMs);
|
|
579
|
+
|
|
580
|
+
// STOP ON REQUEST: the app writes a .stop file and this poll picks it up.
|
|
581
|
+
// The SIGTERM + grace + SIGKILL path is the same one the wall clock uses,
|
|
582
|
+
// so a stopped session ends the same way a timed-out one does.
|
|
583
|
+
if (stopCheck) {
|
|
584
|
+
const stopWatcher = every(async () => {
|
|
585
|
+
if (state.settled) { clearInterval(stopWatcher); return; }
|
|
586
|
+
try {
|
|
587
|
+
if (await stopCheck()) {
|
|
588
|
+
state.stoppedByUser = true;
|
|
589
|
+
kill();
|
|
590
|
+
clearInterval(stopWatcher);
|
|
591
|
+
}
|
|
592
|
+
} catch { /* a failed check is not a stop */ }
|
|
593
|
+
}, stopPollMs);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const settle = (result) => {
|
|
597
|
+
state.settled = true;
|
|
598
|
+
clearTimeout(wallTimer);
|
|
599
|
+
clearTimeout(killTimer);
|
|
600
|
+
if (killProcessGroup && (state.timedOut || state.stoppedByUser)) {
|
|
601
|
+
// The npm parent may close before a script that ignored SIGTERM.
|
|
602
|
+
try { process.kill(-child.pid, "SIGKILL"); } catch { /* group already gone */ }
|
|
603
|
+
}
|
|
604
|
+
for (const timer of timers) clearInterval(timer);
|
|
605
|
+
// Flush and close the gzip trailer so the file on disk is complete and
|
|
606
|
+
// valid gzip, whichever way this child ended - and only THEN resolve,
|
|
607
|
+
// so whoever reads the transcript next finds the whole of it (see
|
|
608
|
+
// openTranscriptSink's close()).
|
|
609
|
+
const finish = () => resolve({
|
|
610
|
+
...result,
|
|
611
|
+
log: ring.text(),
|
|
612
|
+
stderr: errTail.trim(),
|
|
613
|
+
truncated: ring.dropped > 0,
|
|
614
|
+
dropped: ring.dropped,
|
|
615
|
+
timedOut: state.timedOut,
|
|
616
|
+
stoppedByUser: state.stoppedByUser,
|
|
617
|
+
envelope: state.resultEnvelope,
|
|
618
|
+
contextFill: state.lastAssistantUsage ? computeContextFill(state.lastAssistantUsage) : null,
|
|
619
|
+
// The running total - see the note on `state.totalTokens`.
|
|
620
|
+
totalTokens: state.totalTokens,
|
|
621
|
+
sawAssistant: state.sawAssistant,
|
|
622
|
+
...extraFields(state),
|
|
623
|
+
});
|
|
624
|
+
const closed = transcriptSink?.close();
|
|
625
|
+
if (closed && typeof closed.then === "function") closed.then(finish, finish);
|
|
626
|
+
else finish();
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
child.on("error", (error) => settle({ ok: false, spawnError: error.message }));
|
|
630
|
+
child.on("close", (code, signal) => settle({ ok: okOf(state, code, signal), code, signal }));
|
|
631
|
+
|
|
632
|
+
return {
|
|
633
|
+
state,
|
|
634
|
+
kill,
|
|
635
|
+
every,
|
|
636
|
+
settled: () => state.settled,
|
|
637
|
+
/** A message went in, so whatever envelope stood no longer describes the
|
|
638
|
+
* child's state: the turn is open again and the stall clock restarts. A
|
|
639
|
+
* kill from here on is a kill of work in progress. */
|
|
640
|
+
markSent() {
|
|
641
|
+
state.sawResult = false;
|
|
642
|
+
state.resultWasError = false;
|
|
643
|
+
state.lastStdoutAt = Date.now();
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Run the child to completion, or kill it at the wall clock.
|
|
649
|
+
*
|
|
650
|
+
* The wall clock is not a progress protocol and does not put a third state on
|
|
651
|
+
* the board. It is the one bound between "a session hung" and "a session
|
|
652
|
+
* billed all night". */
|
|
653
|
+
export function runChild({ bin, args, cwd, env, input, maxMs, maxLogBytes = DEFAULTS.maxLogBytes, killGraceMs = DEFAULTS.killGraceMs, onChunk, onStdout, stopCheck, stopPollMs = 2000, onSpend, platform = process.platform, transcriptDir, killProcessGroup = false }) {
|
|
654
|
+
return new Promise((resolve) => {
|
|
655
|
+
let child;
|
|
656
|
+
try {
|
|
657
|
+
// On Windows, npm .cmd shims resolve to their JavaScript entry point
|
|
658
|
+
// so no request argument passes through a shell. `env` is the lookup source
|
|
659
|
+
// for Path/PATHEXT: it is the child's own environment, already
|
|
660
|
+
// filtered by childEnv, and carries those names on Windows (session 2
|
|
661
|
+
// of brief 21, ruling 5B: claude AND opencode both spawn this way).
|
|
662
|
+
const resolved = runnableCommand(bin, args, { platform, env });
|
|
663
|
+
// `windowsHide: true` is harmless on POSIX (detect.mjs's execProbe has
|
|
664
|
+
// carried it unconditionally since before this brief) and stops a
|
|
665
|
+
// console window flashing per session on Windows.
|
|
666
|
+
child = spawn(resolved.file, resolved.args, { cwd, env, stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], shell: resolved.shell, windowsHide: true, detached: killProcessGroup && platform !== "win32" });
|
|
667
|
+
} catch (error) {
|
|
668
|
+
resolve({ ok: false, spawnError: error instanceof Error ? error.message : String(error), log: "" });
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
// Machine-readable callers must not parse the combined diagnostic log.
|
|
672
|
+
if (onStdout) {
|
|
673
|
+
child.stdout.setEncoding("utf8");
|
|
674
|
+
child.stdout.on("data", onStdout);
|
|
675
|
+
}
|
|
676
|
+
// Everything below this line is attachChild's: the ring, the stderr tail,
|
|
677
|
+
// the envelope scan, the wall clock, the stop watcher and the settle. A
|
|
678
|
+
// one-shot child adds nothing to it - it has no stdin - so this is the
|
|
679
|
+
// whole function.
|
|
680
|
+
attachChild(child, {
|
|
681
|
+
resolve,
|
|
682
|
+
maxMs,
|
|
683
|
+
maxLogBytes,
|
|
684
|
+
killGraceMs,
|
|
685
|
+
onChunk,
|
|
686
|
+
stopCheck,
|
|
687
|
+
stopPollMs,
|
|
688
|
+
killProcessGroup: killProcessGroup && platform !== "win32",
|
|
689
|
+
transcriptDir,
|
|
690
|
+
...(onSpend ? { onSpend } : {}),
|
|
691
|
+
okOf: (state, code) => !state.timedOut && !state.stoppedByUser && code === 0,
|
|
692
|
+
});
|
|
693
|
+
if (input !== undefined) {
|
|
694
|
+
// A child that dies during startup can close the pipe before end lands.
|
|
695
|
+
// Its process error/close event owns the outcome; stdin's EPIPE is not a
|
|
696
|
+
// second uncaught failure in the runner.
|
|
697
|
+
child.stdin.on("error", () => {});
|
|
698
|
+
child.stdin.end(input);
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** A streaming child: stdin stays open, takes JSON-line user messages, exits
|
|
704
|
+
* when there is nothing more to say. Used for part 3 of the persistent session
|
|
705
|
+
* spec: typing a reply while the session is still running.
|
|
706
|
+
*
|
|
707
|
+
* The initial prompt is sent as the first message on stdin. After that, the
|
|
708
|
+
* runner watches for reply files in `inbox/spawn/<id>.reply-*.json` and sends
|
|
709
|
+
* each one as a new user message. The child stays alive until:
|
|
710
|
+
* - Its turn ends with a result envelope, which CLOSES stdin (2026-09-06):
|
|
711
|
+
* nothing more can be said to it, so it exits 0 on its own
|
|
712
|
+
* - The wall clock fires on a turn that never ended
|
|
713
|
+
* - The stall watchdog, or a human pressing Stop, kills it
|
|
714
|
+
*
|
|
715
|
+
* SHAPE OF A STDIN MESSAGE (verified on CLI 2.1.34):
|
|
716
|
+
* {"type":"user","message":{"role":"user","content":[{"type":"text","text":"..."}]}}
|
|
717
|
+
*
|
|
718
|
+
* A FINISHED STREAM IS FINISHED, whatever closes the process (2026-08-30).
|
|
719
|
+
* "Exits when there is nothing more to say" was never true of this child:
|
|
720
|
+
* with --input-format stream-json it ends each turn with a result envelope
|
|
721
|
+
* ({"type":"result", ..., "total_cost_usd", "session_id"}) and then sits on
|
|
722
|
+
* stdin waiting for the next message. That wait IS the reply window, but the
|
|
723
|
+
* ending was read like runChild's: wall clock, SIGTERM, timedOut: true, an
|
|
724
|
+
* ERROR receipt saying the work is unfinished. 26 of the 28 sessions killed
|
|
725
|
+
* at the 90-minute wall by 2026-08-30 had their final envelope in the log -
|
|
726
|
+
* work done, billed, and reported as failed (spawn-mtfx5hex-bb857e: "tsc
|
|
727
|
+
* clean, build clean, 1865 tests pass", $7.52, receipt ERROR).
|
|
728
|
+
*
|
|
729
|
+
* So stdout is scanned for result envelopes as it streams. An envelope means
|
|
730
|
+
* the child is IDLE - the turn is over and nothing is billing. Sending it a
|
|
731
|
+
* new user message (a reply, a pushed answer) starts a new turn, so every
|
|
732
|
+
* send clears the flag. When the child ends while the flag stands - the wall
|
|
733
|
+
* clock closing the reply window, the human clicking Stop, or its own exit -
|
|
734
|
+
* the outcome carries `finished: true` and is NOT a timeout: the receipt
|
|
735
|
+
* settles as success and the worktree keeps its persistent-session rules.
|
|
736
|
+
* The wall clock stays exactly what it was for a child with no envelope: the
|
|
737
|
+
* money bound on a session that hangs mid-work.
|
|
738
|
+
*
|
|
739
|
+
* What a reply does now: a `.reply-*.json` arriving for a child whose stdin
|
|
740
|
+
* is closed cannot be spoken, so it is handed back through
|
|
741
|
+
* `onUndeliveredReply` and the RUNNER turns it into a new spawn request that
|
|
742
|
+
* resumes the session (runner.mjs, promoteReplyToRequest). Nothing is written
|
|
743
|
+
* to a dead stdin and no reply is dropped. A reply arriving mid-turn - before
|
|
744
|
+
* any envelope - still goes straight in, which is what the stall watchdog and
|
|
745
|
+
* `markSent` are about.
|
|
746
|
+
*
|
|
747
|
+
* The wall clock keeps its whole meaning for a child MID-TURN, and the
|
|
748
|
+
* SIGTERM-then-SIGKILL path stays for a child that ignores a closed stdin.
|
|
749
|
+
*
|
|
750
|
+
* Returns the same shape as runChild, plus `finished` (the final result
|
|
751
|
+
* envelope stood when the child ended) and `finishedWithError` (that
|
|
752
|
+
* envelope said is_error: true - it ended, but not well).
|
|
753
|
+
*
|
|
754
|
+
* REPLY POLL: 2000ms until 2026-09-08, 500ms since. A reply typed mid-turn
|
|
755
|
+
* waited up to two seconds (one on average) before this loop even looked, on
|
|
756
|
+
* top of everything the CLI then does with it, and that wait is on the path a
|
|
757
|
+
* person is watching. The cost of the change is one `readdir` of
|
|
758
|
+
* `inbox/spawn/` four times a second per streaming child instead of twice -
|
|
759
|
+
* sub-millisecond even on a folder with the 639 files his has - and the loop
|
|
760
|
+
* only opens a file when a reply is actually sitting there.
|
|
761
|
+
*
|
|
762
|
+
* It is NOT the latency he actually complained about, and shortening it is
|
|
763
|
+
* not a claim to have fixed that: a reply arriving after the turn's result
|
|
764
|
+
* envelope cannot be spoken at all (stdin is closed on the envelope, the
|
|
765
|
+
* ruling of 2026-09-06), so it is handed to `onUndeliveredReply` and becomes a
|
|
766
|
+
* whole new resume request - the watch loop's 5s poll, the app's receipt, the
|
|
767
|
+
* worktree, and a cold `claude --resume`. That is tens of seconds and it is a
|
|
768
|
+
* design decision, not a constant. */
|
|
769
|
+
export function runStreamingChild({ bin, args, cwd, env, onSpawn, maxMs, maxLogBytes = DEFAULTS.maxLogBytes, killGraceMs = DEFAULTS.killGraceMs, stallMs = DEFAULTS.stallMs, onChunk, prompt, images, replyDir, replyPrefix, replyPollMs = 500, onUndeliveredReply, answerPoll, answerPollMs = 4000, askPermission, stopCheck, stopPollMs = 2000, onSpend, platform = process.platform, transcriptDir }) {
|
|
770
|
+
return new Promise((resolve) => {
|
|
771
|
+
try { imageAttachments(images); } catch (error) { resolve({ ok: false, log: "", spawnError: error.message }); return; }
|
|
772
|
+
let child;
|
|
773
|
+
try {
|
|
774
|
+
// Same resolution as runChild above, and for the same engines (claude
|
|
775
|
+
// AND opencode per ruling 5B) - streaming is the path a persistent
|
|
776
|
+
// Claude Code session takes.
|
|
777
|
+
const resolved = runnableCommand(bin, args, { platform, env });
|
|
778
|
+
child = spawn(resolved.file, resolved.args, { cwd, env, stdio: ["pipe", "pipe", "pipe"], shell: resolved.shell, windowsHide: true });
|
|
779
|
+
} catch (error) {
|
|
780
|
+
resolve({ ok: false, spawnError: error instanceof Error ? error.message : String(error), log: "" });
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (onSpawn) child.once("spawn", () => onSpawn());
|
|
785
|
+
|
|
786
|
+
// The ring, the stderr tail, the envelope scan, the wall clock, the kill
|
|
787
|
+
// path and the stop watcher are attachChild's - the same ones runChild
|
|
788
|
+
// uses. What is left in this function is the half a one-shot child does
|
|
789
|
+
// not have: stdin.
|
|
790
|
+
const handle = attachChild(child, {
|
|
791
|
+
resolve,
|
|
792
|
+
maxMs,
|
|
793
|
+
maxLogBytes,
|
|
794
|
+
killGraceMs,
|
|
795
|
+
onChunk,
|
|
796
|
+
stopCheck,
|
|
797
|
+
stopPollMs,
|
|
798
|
+
transcriptDir,
|
|
799
|
+
// THE WALL CLOCK MEANS SOMETHING ELSE HERE. Closing an IDLE child is
|
|
800
|
+
// the reply window ending, not a timeout: the work finished when the
|
|
801
|
+
// envelope landed. Only a child still mid-turn - no envelope since the
|
|
802
|
+
// last message it was sent - is out of time. This is the fix for 26
|
|
803
|
+
// finished sessions receipted as ERROR (see the doc comment above).
|
|
804
|
+
onWallClock: (state) => { if (!state.sawResult) state.timedOut = true; },
|
|
805
|
+
...(onSpend ? { onSpend } : {}),
|
|
806
|
+
// CLOSE ON THE ENVELOPE, IMMEDIATELY (the ruling, 2026-09-06). The turn
|
|
807
|
+
// is over; a child holding an open stdin from here is an idle process
|
|
808
|
+
// costing a file descriptor and a slot until the wall clock, which is
|
|
809
|
+
// what a background daemon multiplies by every session it has ever run.
|
|
810
|
+
// Closing stdin is how the real CLI is told there is nothing more to
|
|
811
|
+
// say: it exits on its own, and `okOf`/`extraFields` below read that
|
|
812
|
+
// exit exactly as they read a wall-clock close of an idle child.
|
|
813
|
+
//
|
|
814
|
+
// Not a kill: a SIGTERM here would race the child's own flush and lose
|
|
815
|
+
// the tail of the log. The wall clock's SIGTERM-then-SIGKILL path stays
|
|
816
|
+
// for a child that ignores a closed stdin.
|
|
817
|
+
onResult: () => {
|
|
818
|
+
abortPermissions();
|
|
819
|
+
const stdin = child.stdin;
|
|
820
|
+
if (!stdin || stdin.destroyed || !stdin.writable) return;
|
|
821
|
+
try { stdin.end(); } catch { /* the pipe is already gone */ }
|
|
822
|
+
},
|
|
823
|
+
onControl: (message) => handleControl(message),
|
|
824
|
+
// A standing SUCCESS envelope is a finished session whatever the exit
|
|
825
|
+
// looks like - SIGTERM from the wall clock or from Stop leaves a null
|
|
826
|
+
// code, and that must not turn done work into a failure. An ERROR
|
|
827
|
+
// envelope is the opposite: the child itself said the turn went wrong,
|
|
828
|
+
// and exit 0 must not paper over it.
|
|
829
|
+
okOf: (state, code) =>
|
|
830
|
+
state.sawResult
|
|
831
|
+
? !state.resultWasError
|
|
832
|
+
: !state.timedOut && !state.stoppedByUser && !state.stalled && code === 0,
|
|
833
|
+
extraFields: (state) => ({
|
|
834
|
+
stalled: state.stalled,
|
|
835
|
+
...(state.stalled ? { stallMs } : {}),
|
|
836
|
+
// Read at the end rather than latched at the kill, so a child that
|
|
837
|
+
// ended any other way with its envelope standing says so too.
|
|
838
|
+
finished: state.sawResult,
|
|
839
|
+
finishedWithError: state.sawResult && state.resultWasError,
|
|
840
|
+
}),
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// Send a user message, optionally with images as content blocks.
|
|
844
|
+
// Images are data URLs (data:image/...;base64,...) from an idea's pictures or
|
|
845
|
+
// reply files. Claude Code's streaming protocol accepts content arrays with
|
|
846
|
+
// image blocks alongside text.
|
|
847
|
+
const userMessage = (text, images) => {
|
|
848
|
+
const content = [{ type: "text", text }, ...imageAttachments(images).map((image) => ({
|
|
849
|
+
type: "image", source: { type: "base64", media_type: image.mimeType, data: image.data },
|
|
850
|
+
}))];
|
|
851
|
+
return JSON.stringify({ type: "user", message: { role: "user", content } }) + "\n";
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
// AN 'error' ON stdin USED TO KILL THE WHOLE DAEMON. `child.on("error")`
|
|
855
|
+
// catches SPAWN failures only; it does not cover stream errors on the
|
|
856
|
+
// pipe, and an unhandled 'error' on a Writable throws. The child can exit
|
|
857
|
+
// inside the reply poll's own `await readFile`, so the next write lands on
|
|
858
|
+
// a closed pipe: EPIPE, uncaught, runner dead - and every session still in
|
|
859
|
+
// flight stays "running" forever, the one leak SPAWN.md only accepts for a
|
|
860
|
+
// crash. Swallowing here is right because the guarded write below already
|
|
861
|
+
// reports undeliverable messages by returning false.
|
|
862
|
+
child.stdin?.on("error", () => {});
|
|
863
|
+
|
|
864
|
+
/** Write to the child unless the pipe is gone. Returns whether it went. */
|
|
865
|
+
const sendToChild = (payload) => {
|
|
866
|
+
const stdin = child.stdin;
|
|
867
|
+
if (handle.settled() || !stdin || stdin.destroyed || !stdin.writable) return false;
|
|
868
|
+
try {
|
|
869
|
+
stdin.write(payload);
|
|
870
|
+
handle.markSent();
|
|
871
|
+
return true;
|
|
872
|
+
} catch {
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
// ASK IN THE IDEA (#478). With `--permission-prompt-tool stdio` the CLI
|
|
878
|
+
// sends a `control_request` (subtype `can_use_tool`) on stdout for every
|
|
879
|
+
// tool call outside its allowlist and WAITS for a `control_response` on
|
|
880
|
+
// stdin. `askPermission` files the question and resolves once the person
|
|
881
|
+
// answers. This is a control message, not a conversation turn: it is
|
|
882
|
+
// written straight to stdin and NEVER through sendToChild/userMessage, so
|
|
883
|
+
// the answer resolves the pending request and is not also typed into the
|
|
884
|
+
// session as a user message.
|
|
885
|
+
//
|
|
886
|
+
// While one is pending the stall watchdog stands down (below): a run
|
|
887
|
+
// waiting on a person is not a stalled run. The wall clock and Stop still
|
|
888
|
+
// apply, and settling the child aborts every wait.
|
|
889
|
+
const pendingPermissions = new Map();
|
|
890
|
+
const abortPermissions = () => { for (const controller of pendingPermissions.values()) controller.abort(); };
|
|
891
|
+
child.once("close", abortPermissions);
|
|
892
|
+
child.once("error", abortPermissions);
|
|
893
|
+
const writeControl = (response) => {
|
|
894
|
+
const stdin = child.stdin;
|
|
895
|
+
if (handle.settled() || !stdin || stdin.destroyed || !stdin.writable) return false;
|
|
896
|
+
try {
|
|
897
|
+
stdin.write(JSON.stringify({ type: "control_response", response }) + "\n");
|
|
898
|
+
// The CLI is about to resume; its silence so far was ours.
|
|
899
|
+
handle.state.lastStdoutAt = Date.now();
|
|
900
|
+
return true;
|
|
901
|
+
} catch {
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
const answerControl = (requestId, payload) => writeControl({ subtype: "success", request_id: requestId, response: payload });
|
|
906
|
+
const refuseControl = (requestId, error) => writeControl({ subtype: "error", request_id: requestId, error });
|
|
907
|
+
function handleControl(message) {
|
|
908
|
+
if (message.type === "control_cancel_request") {
|
|
909
|
+
pendingPermissions.get(message.request_id)?.abort();
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const requestId = message.request_id;
|
|
913
|
+
const request = message.request ?? {};
|
|
914
|
+
if (typeof requestId !== "string") return;
|
|
915
|
+
// Any other question the CLI might put to its host: answer with an error
|
|
916
|
+
// rather than leave it hanging on a reply that never comes.
|
|
917
|
+
if (request.subtype !== "can_use_tool") {
|
|
918
|
+
refuseControl(requestId, `Unsupported control request: ${String(request.subtype)}`);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const input = request.input && typeof request.input === "object" ? request.input : {};
|
|
922
|
+
if (!askPermission) {
|
|
923
|
+
answerControl(requestId, { behavior: "deny", message: "This run cannot ask the person for permission. Carry on without it." });
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
const controller = new AbortController();
|
|
927
|
+
pendingPermissions.set(requestId, controller);
|
|
928
|
+
Promise.resolve()
|
|
929
|
+
.then(() => askPermission({ toolName: request.tool_name, input, toolUseId: request.tool_use_id, signal: controller.signal }))
|
|
930
|
+
.then(
|
|
931
|
+
(verdict) => {
|
|
932
|
+
// null: the wait was cancelled (the CLI withdrew it, or the run ended).
|
|
933
|
+
if (!verdict || controller.signal.aborted) return;
|
|
934
|
+
if (verdict.behavior === "allow") answerControl(requestId, { behavior: "allow", updatedInput: input });
|
|
935
|
+
else answerControl(requestId, { behavior: "deny", message: verdict.message ?? "The person said no to this. Carry on without it." });
|
|
936
|
+
},
|
|
937
|
+
() => { answerControl(requestId, { behavior: "deny", message: "The person could not be asked just now. Carry on without it." }); },
|
|
938
|
+
)
|
|
939
|
+
.finally(() => { pendingPermissions.delete(requestId); });
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
sendToChild(userMessage(prompt, images));
|
|
943
|
+
|
|
944
|
+
// Watch for reply files. Each file is a JSON object with a `message` string.
|
|
945
|
+
// Files are sorted by name (timestamp-based) and consumed in order.
|
|
946
|
+
const watchReplies = () => {
|
|
947
|
+
if (handle.settled() || !replyDir || !replyPrefix) return;
|
|
948
|
+
const replyWatcher = handle.every(async () => {
|
|
949
|
+
if (handle.settled()) { clearInterval(replyWatcher); return; }
|
|
950
|
+
try {
|
|
951
|
+
const files = (await readdir(replyDir))
|
|
952
|
+
.filter((f) => f.startsWith(replyPrefix) && f.endsWith(".json"))
|
|
953
|
+
.sort();
|
|
954
|
+
for (const file of files) {
|
|
955
|
+
if (handle.settled()) break;
|
|
956
|
+
const full = join(replyDir, file);
|
|
957
|
+
let text = null;
|
|
958
|
+
let replyImages;
|
|
959
|
+
let fromField = null;
|
|
960
|
+
try {
|
|
961
|
+
const data = JSON.parse(await readFile(full, "utf8"));
|
|
962
|
+
text = typeof data.message === "string" ? data.message : null;
|
|
963
|
+
// Reply files may carry images alongside text, same data URL
|
|
964
|
+
// format as the spawn request. The runner sends them as image
|
|
965
|
+
// content blocks on stdin.
|
|
966
|
+
replyImages = data.images;
|
|
967
|
+
// Brief 12: agent-to-agent messaging. A `from` field in the
|
|
968
|
+
// reply file labels the sender (another session's id, the human's
|
|
969
|
+
// name, anything) so the receiver never mistakes a peer's words
|
|
970
|
+
// for the user's. The runner wraps it: injection is prompt
|
|
971
|
+
// injection by design; label it.
|
|
972
|
+
fromField = typeof data.from === "string" && data.from.trim() !== "" ? data.from.trim() : null;
|
|
973
|
+
} catch { continue; /* skip malformed reply files */ }
|
|
974
|
+
try { imageAttachments(replyImages); } catch (error) {
|
|
975
|
+
const detail = `Reference image reply was not delivered: ${error.message}`;
|
|
976
|
+
const failure = JSON.stringify({ type: "result", subtype: "error_during_execution", is_error: true, errors: [detail] }) + "\n";
|
|
977
|
+
child.stdout.emit("data", Buffer.from(failure));
|
|
978
|
+
handle.kill();
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
// CONSUME BEFORE SENDING. The write used to come first, and an
|
|
982
|
+
// unlink that then failed (permission, or the app rewriting the
|
|
983
|
+
// file) left the reply on disk for the next 2s poll to send
|
|
984
|
+
// again - the human's message arrived at the session twice, with
|
|
985
|
+
// the failure swallowed. Deleting first means the worst case is a
|
|
986
|
+
// reply that is not delivered because the child is already gone,
|
|
987
|
+
// which is not a delivery this could have made anyway.
|
|
988
|
+
try {
|
|
989
|
+
await unlink(full);
|
|
990
|
+
} catch {
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
if (text) {
|
|
994
|
+
const attributed = fromField ? `[message from ${fromField}] ${text}` : text;
|
|
995
|
+
// A REPLY THAT CANNOT BE SPOKEN IS NOT A LOST REPLY (2026-09-06).
|
|
996
|
+
// Since the envelope closes stdin, this is the ordinary ending
|
|
997
|
+
// of a finished session rather than a rare race: the human typed
|
|
998
|
+
// into a session whose turn was already over. The file has been
|
|
999
|
+
// consumed above, so the words exist only here - hand them
|
|
1000
|
+
// straight back to whoever passed this callback (runner.mjs
|
|
1001
|
+
// turns them into a resume request) rather than dropping them on
|
|
1002
|
+
// the floor the way a `false` from sendToChild used to.
|
|
1003
|
+
if (!sendToChild(userMessage(attributed, replyImages)) && onUndeliveredReply) {
|
|
1004
|
+
try {
|
|
1005
|
+
await onUndeliveredReply({ text, from: fromField, images: replyImages, attributed });
|
|
1006
|
+
} catch { /* the caller's problem to log; never take the child down */ }
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
} catch { /* replyDir may not exist yet */ }
|
|
1011
|
+
}, replyPollMs);
|
|
1012
|
+
};
|
|
1013
|
+
watchReplies();
|
|
1014
|
+
|
|
1015
|
+
// THE SAME DOOR, for answers the human gave on the canvas after ask_user
|
|
1016
|
+
// had already returned pending. `answerPoll` hands back ready-made
|
|
1017
|
+
// messages so this function stays free of board vocabulary; it knows only
|
|
1018
|
+
// that something upstream may have words for the child.
|
|
1019
|
+
//
|
|
1020
|
+
// Delivery is confirmed AFTER the write, not before: onDelivered is what
|
|
1021
|
+
// records that the session has been told, and marking first would lose a
|
|
1022
|
+
// ruling for good if the child settled in between.
|
|
1023
|
+
let answerBusy = false;
|
|
1024
|
+
const watchAnswers = () => {
|
|
1025
|
+
if (handle.settled() || !answerPoll) return;
|
|
1026
|
+
const collect = async () => {
|
|
1027
|
+
if (handle.settled()) return;
|
|
1028
|
+
// setInterval does not wait for an async callback, and a slow mirror
|
|
1029
|
+
// read would otherwise let two passes see the same unmarked answer.
|
|
1030
|
+
if (answerBusy) return;
|
|
1031
|
+
answerBusy = true;
|
|
1032
|
+
try {
|
|
1033
|
+
for (const answer of await answerPoll()) {
|
|
1034
|
+
if (handle.settled()) break;
|
|
1035
|
+
// Only record delivery if the pipe actually took it, or a ruling
|
|
1036
|
+
// would be marked delivered to a child that never heard it.
|
|
1037
|
+
// `answer.images` (item 6, brief 30 Move 2b): a message's
|
|
1038
|
+
// images, delivered through the SAME content-block channel a
|
|
1039
|
+
// reply file's images already use (`userMessage`, above).
|
|
1040
|
+
if (!sendToChild(userMessage(answer.text, answer.images))) break;
|
|
1041
|
+
if (answer.onDelivered) await answer.onDelivered();
|
|
1042
|
+
}
|
|
1043
|
+
} catch { /* a poll that throws is not an answer either way */ }
|
|
1044
|
+
answerBusy = false;
|
|
1045
|
+
};
|
|
1046
|
+
handle.every(collect, answerPollMs);
|
|
1047
|
+
answerPoll.subscribe?.(collect);
|
|
1048
|
+
};
|
|
1049
|
+
watchAnswers();
|
|
1050
|
+
|
|
1051
|
+
// STALL WATCHDOG: no stdout for stallMs while the MODEL should be producing
|
|
1052
|
+
// output. Armed when a turn is open (no result envelope since the last user
|
|
1053
|
+
// message) AND no tool_use is awaiting its tool_result (a running tool is
|
|
1054
|
+
// allowed to be silent; the wall clock covers a hung tool).
|
|
1055
|
+
const stallWatcher = handle.every(() => {
|
|
1056
|
+
const state = handle.state;
|
|
1057
|
+
if (state.settled) return;
|
|
1058
|
+
const elapsed = Date.now() - state.lastStdoutAt;
|
|
1059
|
+
// A pending permission question is the PERSON's silence, not the model's.
|
|
1060
|
+
const armed = !state.sawResult && state.openToolUses.size === 0 && pendingPermissions.size === 0;
|
|
1061
|
+
if (armed && elapsed >= stallMs) {
|
|
1062
|
+
state.stalled = true;
|
|
1063
|
+
handle.kill();
|
|
1064
|
+
clearInterval(stallWatcher);
|
|
1065
|
+
}
|
|
1066
|
+
}, Math.min(10_000, stallMs / 4));
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// WHAT A SPAWNED SESSION IS ALLOWED TO INHERIT.
|
|
1071
|
+
//
|
|
1072
|
+
// This used to be `{ ...process.env }`. A spawned session is an autonomous
|
|
1073
|
+
// agent with Write, Edit and Bash(node:*) in a worktree on this machine, and
|
|
1074
|
+
// handing it the runner's whole environment hands it every secret the shell
|
|
1075
|
+
// that started the runner happened to be carrying - a deploy token, a
|
|
1076
|
+
// database URL, a payment key, whatever `direnv` loaded for the project. None
|
|
1077
|
+
// of that is needed to read a board and write code, and a leak through this
|
|
1078
|
+
// door is invisible: no error, no log line, nothing on the board.
|
|
1079
|
+
//
|
|
1080
|
+
// So it is an ALLOWLIST, and everything on it is here for a stated reason.
|
|
1081
|
+
// Two rules, so the list can be read rather than trusted:
|
|
1082
|
+
//
|
|
1083
|
+
// 1. a process has to be able to BE a process (find binaries, have a home
|
|
1084
|
+
// directory, write temp files, speak a locale);
|
|
1085
|
+
// 2. the two engines this runner knows have to be able to AUTHENTICATE.
|
|
1086
|
+
//
|
|
1087
|
+
// Rule 2 is mostly HOME, and that is worth saying out loud: Claude Code's
|
|
1088
|
+
// subscription credentials live in ~/.claude/.credentials.json (or the macOS
|
|
1089
|
+
// keychain, which also needs the user's session), and opencode's live under
|
|
1090
|
+
// ~/.local/share/opencode/auth.json. Neither needs an API key in the
|
|
1091
|
+
// environment to work; the key variables below are for the people who DO set
|
|
1092
|
+
// one - checked against the shipped binaries on 2026-09-02 rather than
|
|
1093
|
+
// assumed, `strings` on the claude 2.1.x binary and on opencode.
|
|
1094
|
+
//
|
|
1095
|
+
// Anything else is dropped. `PM_SPAWN_ENV_ALLOW` is the documented escape
|
|
1096
|
+
// hatch for a provider not listed here (opencode ships ~200 `*_API_KEY`
|
|
1097
|
+
// names and enumerating them would make this list a lie); it is a
|
|
1098
|
+
// comma-separated list of names, and a trailing `*` makes one a prefix.
|
|
1099
|
+
const CHILD_ENV_ALLOW_EXACT = new Set([
|
|
1100
|
+
// Rule 1: be a process.
|
|
1101
|
+
"PATH",
|
|
1102
|
+
"HOME",
|
|
1103
|
+
"USER",
|
|
1104
|
+
"LOGNAME",
|
|
1105
|
+
"SHELL",
|
|
1106
|
+
"TMPDIR",
|
|
1107
|
+
"TMP",
|
|
1108
|
+
"TEMP",
|
|
1109
|
+
"LANG",
|
|
1110
|
+
"TERM",
|
|
1111
|
+
"TZ",
|
|
1112
|
+
// Reaching the network the way the machine does. A corporate proxy or a
|
|
1113
|
+
// custom CA is the difference between a session that runs and a session
|
|
1114
|
+
// that fails at its first request, and neither is a secret.
|
|
1115
|
+
"HTTP_PROXY",
|
|
1116
|
+
"HTTPS_PROXY",
|
|
1117
|
+
"NO_PROXY",
|
|
1118
|
+
"http_proxy",
|
|
1119
|
+
"https_proxy",
|
|
1120
|
+
"no_proxy",
|
|
1121
|
+
"NODE_EXTRA_CA_CERTS",
|
|
1122
|
+
"SSL_CERT_FILE",
|
|
1123
|
+
"SSL_CERT_DIR",
|
|
1124
|
+
// Vertex, for a Claude Code pointed at Google Cloud.
|
|
1125
|
+
"CLOUD_ML_REGION",
|
|
1126
|
+
// Claude Code sets this for its own children; harmless and expected.
|
|
1127
|
+
"CLAUDECODE",
|
|
1128
|
+
// Rule 1 again, Windows' own names for it (session 2 of brief 21,
|
|
1129
|
+
// 2026-09-07). Without SystemRoot a Node child on Windows cannot resolve
|
|
1130
|
+
// DNS - winsock loads from it - so the session starts and then cannot
|
|
1131
|
+
// reach the network, which reads as an auth failure rather than what it
|
|
1132
|
+
// is. Harmless on POSIX: none of these are set there, so listing them here
|
|
1133
|
+
// adds nothing to what a macOS or Linux child receives.
|
|
1134
|
+
"SystemRoot",
|
|
1135
|
+
"windir",
|
|
1136
|
+
"COMSPEC",
|
|
1137
|
+
"PATHEXT",
|
|
1138
|
+
"USERPROFILE",
|
|
1139
|
+
"HOMEDRIVE",
|
|
1140
|
+
"HOMEPATH",
|
|
1141
|
+
"APPDATA",
|
|
1142
|
+
"LOCALAPPDATA",
|
|
1143
|
+
"ProgramData",
|
|
1144
|
+
"ProgramFiles",
|
|
1145
|
+
"NUMBER_OF_PROCESSORS",
|
|
1146
|
+
"OS",
|
|
1147
|
+
]);
|
|
1148
|
+
|
|
1149
|
+
// Same set, uppercased, for the WINDOWS-ONLY case-insensitive lookup below.
|
|
1150
|
+
// Built once rather than per call.
|
|
1151
|
+
const CHILD_ENV_ALLOW_EXACT_UPPER = new Set([...CHILD_ENV_ALLOW_EXACT].map((n) => n.toUpperCase()));
|
|
1152
|
+
|
|
1153
|
+
const CHILD_ENV_ALLOW_PREFIX = [
|
|
1154
|
+
"LC_", // the rest of the locale
|
|
1155
|
+
"PM_", // this bridge's own contract - see the explicit deletions below
|
|
1156
|
+
"ANTHROPIC_", // API key, auth token, base url, model overrides
|
|
1157
|
+
"CLAUDE_", // CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CONFIG_DIR, CLAUDE_CODE_*
|
|
1158
|
+
"AWS_", // Claude Code on Bedrock
|
|
1159
|
+
"GOOGLE_", // Claude Code on Vertex (GOOGLE_APPLICATION_CREDENTIALS)
|
|
1160
|
+
"XDG_", // opencode reads XDG_CONFIG_HOME and XDG_DATA_HOME for its auth
|
|
1161
|
+
"OPENCODE_",
|
|
1162
|
+
"CODEX_",
|
|
1163
|
+
"OPENAI_",
|
|
1164
|
+
"OPENROUTER_",
|
|
1165
|
+
// THE FAKE BINARY IS A SHIPPED SEAM, not a test detail sneaking in here.
|
|
1166
|
+
// `PM_CLAUDE_BIN` is a row in SPAWN.md's money table ("every test in this
|
|
1167
|
+
// suite costing money"), and test/fixtures/fake-claude.mjs is driven
|
|
1168
|
+
// entirely by FAKE_CLAUDE_* - which mode to play, where to drop its marker
|
|
1169
|
+
// file, how long to hang. Drop these and the fake binary cannot be told
|
|
1170
|
+
// what to be, so the crash, timeout, runaway and missing-binary paths lose
|
|
1171
|
+
// their only tests. A real engine ignores the name.
|
|
1172
|
+
"FAKE_CLAUDE_",
|
|
1173
|
+
"FAKE_CODEX_",
|
|
1174
|
+
];
|
|
1175
|
+
|
|
1176
|
+
/** The names in `PM_SPAWN_ENV_ALLOW`, split into exact names and prefixes. */
|
|
1177
|
+
function extraAllowFrom(baseEnv) {
|
|
1178
|
+
const raw = typeof baseEnv?.PM_SPAWN_ENV_ALLOW === "string" ? baseEnv.PM_SPAWN_ENV_ALLOW : "";
|
|
1179
|
+
const exact = new Set();
|
|
1180
|
+
const prefixes = [];
|
|
1181
|
+
for (const entry of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
1182
|
+
if (entry.endsWith("*")) prefixes.push(entry.slice(0, -1));
|
|
1183
|
+
else exact.add(entry);
|
|
1184
|
+
}
|
|
1185
|
+
return { exact, prefixes };
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/** The names on the allowlist under `prefixes`, plus `PM_SPAWN_ENV_ALLOW`'s
|
|
1189
|
+
* unless `agentAllow` is false. */
|
|
1190
|
+
function allowedEnv(baseEnv, prefixes, platform, { agentAllow = true } = {}) {
|
|
1191
|
+
const extra = agentAllow ? extraAllowFrom(baseEnv) : { exact: new Set(), prefixes: [] };
|
|
1192
|
+
const isWindows = platform === "win32";
|
|
1193
|
+
const env = {};
|
|
1194
|
+
for (const [name, value] of Object.entries(baseEnv ?? {})) {
|
|
1195
|
+
if (value === undefined) continue;
|
|
1196
|
+
const allowed =
|
|
1197
|
+
CHILD_ENV_ALLOW_EXACT.has(name) ||
|
|
1198
|
+
(isWindows && CHILD_ENV_ALLOW_EXACT_UPPER.has(name.toUpperCase())) ||
|
|
1199
|
+
extra.exact.has(name) ||
|
|
1200
|
+
prefixes.some((p) => name.startsWith(p)) ||
|
|
1201
|
+
extra.prefixes.some((p) => name.startsWith(p));
|
|
1202
|
+
if (allowed) env[name] = value;
|
|
1203
|
+
}
|
|
1204
|
+
return env;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** What a relaunched dev server is told (#324): rule 1 only. A dev server
|
|
1208
|
+
* has to be a process; it has no engine to sign in, so no model key, no
|
|
1209
|
+
* cloud credential and none of this bridge's own `PM_` names reach it. Nor
|
|
1210
|
+
* does `PM_SPAWN_ENV_ALLOW`: that list lets a key through to the AGENT, and
|
|
1211
|
+
* a dev server is the project's own code, not the agent. */
|
|
1212
|
+
export function devServerEnv(baseEnv, platform = process.platform) {
|
|
1213
|
+
return allowedEnv(baseEnv, ["LC_", "XDG_"], platform, { agentAllow: false });
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** Everything a child is told, and everything it is deliberately NOT told.
|
|
1217
|
+
*
|
|
1218
|
+
* `platform` gates a CASE-INSENSITIVE match against CHILD_ENV_ALLOW_EXACT,
|
|
1219
|
+
* Windows only: `Object.entries` keeps whatever casing the OS actually used
|
|
1220
|
+
* for a name, and Windows spells the path variable `Path` while this list
|
|
1221
|
+
* (like everything else in Node) says `PATH`. An exact-only match on Windows
|
|
1222
|
+
* drops it silently and hands the child no path at all - one line breaking
|
|
1223
|
+
* every spawn even after the `.cmd` fix. POSIX stays exact-only on purpose:
|
|
1224
|
+
* a stray secret that happens to share a name's case is not this list's to
|
|
1225
|
+
* forgive. */
|
|
1226
|
+
export function childEnv(baseEnv, { root, session, depth, fromId, platform = process.platform }) {
|
|
1227
|
+
const env = allowedEnv(baseEnv, CHILD_ENV_ALLOW_PREFIX, platform);
|
|
1228
|
+
// The board it writes to, and the frame its report lands in.
|
|
1229
|
+
//
|
|
1230
|
+
// PM_SESSION_KEY is THE key - the same string startSession was given, taken
|
|
1231
|
+
// from the session record rather than derived again here. The board reads
|
|
1232
|
+
// it and hands it to report() as the key, which is the only reason the
|
|
1233
|
+
// session's report circles land inside its own frame. Mint a different one
|
|
1234
|
+
// and report() silently builds a second frame at top level: no error, and
|
|
1235
|
+
// the board back to being all over the place.
|
|
1236
|
+
env.PM_BOARD_ROOT = root;
|
|
1237
|
+
env.PM_SESSION_KEY = session.key;
|
|
1238
|
+
// THE IDEA THIS RUN CAME FROM, so the child's questions land as notes on
|
|
1239
|
+
// the same idea its frame hangs off. Without it a session's ask_user left
|
|
1240
|
+
// questions floating beside the board: answered, feeding nothing,
|
|
1241
|
+
// and asked again from scratch on the next run. Absent for a CLI request,
|
|
1242
|
+
// and every reader treats absent as "leave it unwired".
|
|
1243
|
+
if (fromId) env.PM_SESSION_FROM = fromId;
|
|
1244
|
+
else delete env.PM_SESSION_FROM;
|
|
1245
|
+
// A spawned session must not be able to switch a runner on. Inheriting this
|
|
1246
|
+
// would let one approved spawn quietly become a spawning machine.
|
|
1247
|
+
delete env.PM_SPAWN_RUNNER;
|
|
1248
|
+
// And it must not be able to spawn even if a runner is somehow started for
|
|
1249
|
+
// it: depth 1 is refused before anything is claimed.
|
|
1250
|
+
env.PM_SPAWN_DEPTH = String((depth ?? 0) + 1);
|
|
1251
|
+
return env;
|
|
1252
|
+
}
|