bare-agent 0.32.0 → 0.33.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,496 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * BA-16 — CLIPipe NATIVE tool mode (`toolProtocol:'claude-mcp'`).
5
+ *
6
+ * ── Why this exists beside the 0.32.0 envelope emulation ─────────────────────────────────────────
7
+ *
8
+ * The emulation treats the CLI as a plain turn-provider: one spawn per round, the whole transcript
9
+ * re-rendered and re-sent every time. That is the right instrument for a CLI with no tool channel,
10
+ * and the WRONG one for the claude CLI, which has a native one. The cost is the argument, and it is
11
+ * measured, not asserted: re-sending the full prefix every turn pays fresh `cache_creation` on all
12
+ * of it, which the adopter measured at **$0.25–0.55/round** on a real ~40-round job transcript —
13
+ * against **$0.0055–0.0074/turn** for a native session (independently reproduced here and by the
14
+ * adopter). A session has session-incremental caching that the emulation structurally cannot have.
15
+ *
16
+ * NOT CLAIMED: an output-quality improvement. There is n=2 suggestive evidence that the emulation's
17
+ * JSON-questionnaire framing makes a model act less, and it is NOT minted — do not sell it. Cost is
18
+ * the claim. (The BA-7 precedent: a protocol fix ships labeled honestly or not at all.)
19
+ *
20
+ * ── What changes, and what that costs ────────────────────────────────────────────────────────────
21
+ *
22
+ * The CLI owns the inner cycle. That is a real trade and it is stated plainly rather than papered
23
+ * over: the Loop's PER-ROUND machinery cannot run, because there are no rounds to run it on. What
24
+ * the Loop was buying is bought back HERE, at the `tools/call` bridge — which sees every call, so it
25
+ * is a strictly sufficient seam for the guards that matter:
26
+ *
27
+ * - authorization the SAME `policy(tool, args, ctx)` chokepoint the Loop uses, so a wired gate
28
+ * writes AUDIT ROWS OF IDENTICAL SHAPE with zero gate changes (adopters read
29
+ * that file as an instrument; a shape change would blind them silently)
30
+ * - deny streak BA-11, same default (3), same reset-on-any-allowed-call semantics
31
+ * - identical-error BA-12, same default (3), same NARROWEST trigger (name + JSON args, byte-equal)
32
+ * - turn bound `--max-turns`, whose stop is NAMED (`error_max_turns`), never a silent success
33
+ *
34
+ * What is genuinely LOST and cannot be bought back here: the per-round context seams (`assemble`,
35
+ * `trim`, stash compaction) and `cacheMessages` — the CLI owns the transcript, so no caller seam can
36
+ * reach it. Those options THROW at construction rather than sit silently dead.
37
+ *
38
+ * ── The failure mode this module exists to not have ──────────────────────────────────────────────
39
+ *
40
+ * A dead bridge does NOT make the CLI report failure. Measured before building: with the bridge
41
+ * killed mid-call, every tool call failed and the session still ended `subtype:'success'` — the
42
+ * model wrote a tidy final answer explaining that its tools were broken. Mapping that subtype
43
+ * straight onto `error:null` would report a run in which NOTHING WORKED as converged: the exact
44
+ * BA-4/5/6/13 "under-modeled boundary rounds optimistically toward success" class this repo keeps
45
+ * paying for. So bridge health is tracked PARENT-SIDE and error-tags the session regardless of what
46
+ * the CLI's own subtype says. The CLI's subtype is not a sufficient success signal.
47
+ */
48
+
49
+ const net = require('net');
50
+ const fs = require('fs');
51
+ const os = require('os');
52
+ const path = require('path');
53
+ const { spawn } = require('child_process');
54
+ const { ProviderError, HaltError } = require('./errors');
55
+
56
+ /** Path to the stdio stub the CLI spawns. Resolved once — it ships inside the package. */
57
+ const STUB_PATH = path.join(__dirname, 'mcp-bridge-stub.js');
58
+
59
+ /** Mirrors the Loop's own BA-11 / BA-12 defaults so native mode is not quietly laxer. */
60
+ const DEFAULT_MAX_CONSECUTIVE_DENIALS = 3;
61
+ const DEFAULT_MAX_IDENTICAL_TOOL_ERRORS = 3;
62
+
63
+ /** The MCP server name the CLI sees; tool names reach the model as `mcp__bareagent__<tool>`. */
64
+ const SERVER_NAME = 'bareagent';
65
+
66
+ /**
67
+ * Stable key for BA-12's identical-tool-error guard. NARROWEST trigger by design: only a
68
+ * BYTE-IDENTICAL repeat counts. Counting any consecutive failure was measured to punish the
69
+ * recovery it exists to protect (a model varying args while working through an ENOENT).
70
+ * @param {string} name
71
+ * @param {any} args
72
+ * @returns {string}
73
+ */
74
+ function toolErrorKey(name, args) {
75
+ let serialized;
76
+ try { serialized = JSON.stringify(args ?? {}); } catch (_) { serialized = '<unserializable>'; }
77
+ return `${name}:${serialized}`;
78
+ }
79
+
80
+ /**
81
+ * Clamp a diagnostic built from a thrown value of unknown shape before it can ride into a
82
+ * `receipts`/audit string. A non-Error throw serialized wholesale has previously pushed a secret
83
+ * into a plaintext audit log (the F16/BA-1 capture class), and an append-only log that captures a
84
+ * key captures it forever. Conventional fields only, hard length cap, never the whole object.
85
+ * @param {any} err
86
+ * @returns {string}
87
+ */
88
+ function safeErrorText(err) {
89
+ let msg;
90
+ if (err instanceof Error) msg = `${err.constructor.name}: ${err.message}`;
91
+ else if (typeof err === 'string') msg = err;
92
+ else if (err && typeof err === 'object' && typeof err.message === 'string') msg = err.message;
93
+ else msg = `non-Error throw (${typeof err})`;
94
+ return msg.length > 300 ? `${msg.slice(0, 300)}…` : msg;
95
+ }
96
+
97
+ /**
98
+ * The parent-side bridge: a unix-socket server backed by the caller's in-process tool closures,
99
+ * with the gate and both spin guards wired at the one seam that sees every call.
100
+ *
101
+ * Unix socket, not loopback TCP: a library must not open a listening network port as a side effect
102
+ * of running a tool loop. The socket lives in a 0700 directory and is itself chmod 0600.
103
+ *
104
+ * @param {object} opts
105
+ * @param {import('../types').ToolDef[]} opts.tools - the caller's tools (with live `execute` closures).
106
+ * @param {Function|null} [opts.policy] - the SAME contract as `Loop({policy})`: only `true` allows;
107
+ * a string is the deny reason fed back verbatim.
108
+ * @param {any} [opts.ctx] - opaque governance ctx, forwarded to `policy` unchanged.
109
+ * @param {number} [opts.maxConsecutiveDenials]
110
+ * @param {number} [opts.maxIdenticalToolErrors]
111
+ * @returns {Promise<{sockPath: string, state: any, close: () => void}>}
112
+ */
113
+ async function createBridge({ tools, policy = null, ctx = undefined, maxConsecutiveDenials, maxIdenticalToolErrors }) {
114
+ const denyCap = Number.isFinite(maxConsecutiveDenials) ? Number(maxConsecutiveDenials) : DEFAULT_MAX_CONSECUTIVE_DENIALS;
115
+ const errCap = Number.isFinite(maxIdenticalToolErrors) ? Number(maxIdenticalToolErrors) : DEFAULT_MAX_IDENTICAL_TOOL_ERRORS;
116
+
117
+ const byName = new Map(tools.map((t) => [t.name, t]));
118
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bareagent-bridge-'));
119
+ fs.chmodSync(dir, 0o700);
120
+ const sockPath = path.join(dir, 'bridge.sock');
121
+
122
+ const state = {
123
+ toolCalls: 0,
124
+ consecutiveDenials: 0,
125
+ /** @type {{key: string, count: number}} */ lastError: { key: '', count: 0 },
126
+ /** @type {string|null} A guard tripped — the session must END, not merely refuse this call. */
127
+ terminal: null,
128
+ /** @type {Error|null} A HaltError raised by the gate inside a tool call — re-thrown by the caller. */
129
+ halt: null,
130
+ /** Parent-side bridge health. A dead bridge must not be able to read as a clean session. */
131
+ bridgeDown: false,
132
+ };
133
+
134
+ /**
135
+ * Handle one `tools/call`. Every outcome is a RESULT the model can act on; the only thing that
136
+ * ends the session is a guard tripping, which is reported through `state.terminal`.
137
+ *
138
+ * The CLI can fire PARALLEL tool calls (multiple `tool_use` blocks in one turn), so two of these
139
+ * can be in flight across their `await` points. That is safe here: `state.toolCalls++` is a
140
+ * synchronous atomic bump, and the two guard counters are ADVISORY bounds whose precondition — a
141
+ * SEQUENTIAL retry spin — does not apply to calls the model issued simultaneously. The worst case
142
+ * (an early `stuck:` on genuinely-identical parallel failures) is benign and gate-bounded, so no
143
+ * per-call lock is warranted.
144
+ */
145
+ async function handleCall(name, args) {
146
+ state.toolCalls++;
147
+ const tool = byName.get(name);
148
+
149
+ // An unknown/hallucinated tool name feeds the SAME spin counter as a thrown error — BA-12 had to
150
+ // count both paths, since a model looping on a name that does not exist spins just as hard.
151
+ if (!tool) return recordFailure(name, args, `unknown tool '${name}'`);
152
+
153
+ if (policy) {
154
+ let verdict;
155
+ try {
156
+ verdict = await policy(name, args, ctx);
157
+ } catch (err) {
158
+ // A governance halt is a clean exit, not a tool error — stash it and end the session.
159
+ if (err instanceof HaltError) { state.halt = err; state.terminal = `halt:${err.message}`; return { isError: true, text: 'session halted by governance' }; }
160
+ throw err;
161
+ }
162
+ if (verdict !== true) {
163
+ state.consecutiveDenials++;
164
+ const reason = typeof verdict === 'string' ? verdict : `denied by policy: ${name}`;
165
+ // BA-11: a single deny stays ADVISORY so the model can pivot to an allowed tool
166
+ // (allowlist-safe). Only a STREAK with no allowed call in between ends the session.
167
+ if (denyCap > 0 && Number.isFinite(denyCap) && state.consecutiveDenials >= denyCap) {
168
+ state.terminal = `denied:${name}`;
169
+ return { isError: true, text: `${reason} — ${state.consecutiveDenials} consecutive denials; ending session` };
170
+ }
171
+ return { text: reason };
172
+ }
173
+ state.consecutiveDenials = 0; // any allowed call resets the streak
174
+ }
175
+
176
+ try {
177
+ const out = await tool.execute(args || {});
178
+ state.lastError = { key: '', count: 0 }; // success resets the identical-error streak
179
+ return { text: typeof out === 'string' ? out : JSON.stringify(out) };
180
+ } catch (err) {
181
+ if (err instanceof HaltError) { state.halt = err; state.terminal = `halt:${err.message}`; return { isError: true, text: 'session halted by governance' }; }
182
+ return recordFailure(name, args, safeErrorText(err));
183
+ }
184
+ }
185
+
186
+ /** BA-12: count a byte-identical repeat; N in a row ends the session, anything else is feedback. */
187
+ function recordFailure(name, args, text) {
188
+ const key = toolErrorKey(name, args);
189
+ state.lastError = key === state.lastError.key
190
+ ? { key, count: state.lastError.count + 1 }
191
+ : { key, count: 1 };
192
+ if (errCap > 0 && Number.isFinite(errCap) && state.lastError.count >= errCap) {
193
+ state.terminal = `stuck:${name}`;
194
+ return { isError: true, text: `${text} — repeated ${state.lastError.count}× identically; ending session` };
195
+ }
196
+ return { isError: true, text };
197
+ }
198
+
199
+ const manifest = tools.map((t) => ({
200
+ name: t.name,
201
+ description: t.description || '',
202
+ inputSchema: t.parameters || { type: 'object', properties: {} },
203
+ }));
204
+
205
+ const server = net.createServer((conn) => {
206
+ let buf = '';
207
+ conn.on('data', async (d) => {
208
+ buf += d;
209
+ const nl = buf.indexOf('\n');
210
+ if (nl === -1) return;
211
+ let req;
212
+ try { req = JSON.parse(buf.slice(0, nl)); } catch (_) { return conn.end(); }
213
+ buf = '';
214
+ try {
215
+ if (req.op === 'list') return conn.write(JSON.stringify({ tools: manifest }) + '\n');
216
+ if (req.op === 'call') {
217
+ const res = await handleCall(req.name, req.args);
218
+ return conn.write(JSON.stringify(res) + '\n');
219
+ }
220
+ } catch (err) {
221
+ // Never let a handler fault kill the bridge — it becomes a result the model reads.
222
+ try { conn.write(JSON.stringify({ isError: true, text: safeErrorText(err) }) + '\n'); } catch (_) { /* peer gone */ }
223
+ }
224
+ });
225
+ conn.on('error', () => { /* peer vanished mid-call; the stub turns that into a tool result */ });
226
+ });
227
+
228
+ // Parent-side health: if our own listener dies while a session is live, every subsequent tool
229
+ // call silently fails on the CLI side and the session can still end 'success'. Record it.
230
+ server.on('error', () => { state.bridgeDown = true; });
231
+
232
+ await new Promise((resolve, reject) => {
233
+ server.once('error', reject);
234
+ server.listen(sockPath, () => { try { fs.chmodSync(sockPath, 0o600); } catch (_) { /* best effort */ } resolve(undefined); });
235
+ });
236
+
237
+ return {
238
+ sockPath,
239
+ state,
240
+ close() {
241
+ try { server.close(); } catch (_) { /* already closed */ }
242
+ try { fs.rmSync(path.dirname(sockPath), { recursive: true, force: true }); } catch (_) { /* best effort */ }
243
+ },
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Map the CLI's session `subtype` onto (neutral stop reason, session error).
249
+ *
250
+ * Own-property lookup only: `subtype` is provider-supplied, so a value like `toString` would
251
+ * otherwise resolve to an inherited `Object.prototype` function and be mistaken for a mapping —
252
+ * the proto-key footgun this repo has now fixed three times.
253
+ */
254
+ const SUBTYPE_MAP = {
255
+ success: { stopReason: 'end_turn', error: null },
256
+ error_max_turns: { stopReason: 'max_turns', error: 'max_turns' },
257
+ error_during_execution: { stopReason: null, error: 'session_error' },
258
+ };
259
+
260
+ /**
261
+ * @param {string|null|undefined} subtype
262
+ * @returns {{stopReason: string|null, error: string|null}}
263
+ */
264
+ function classifySubtype(subtype) {
265
+ if (typeof subtype !== 'string' || subtype === '') {
266
+ // No result event at all — the session died without reporting. NOT a success.
267
+ return { stopReason: null, error: 'session_incomplete' };
268
+ }
269
+ if (Object.prototype.hasOwnProperty.call(SUBTYPE_MAP, subtype)) return SUBTYPE_MAP[subtype];
270
+ // An unrecognized subtype is surfaced verbatim and ERROR-TAGGED, never passed through as clean:
271
+ // the whole BA-13 lesson is that a terminal the consumer branches on must be tagged, not just seen.
272
+ return { stopReason: subtype, error: `session:${subtype}` };
273
+ }
274
+
275
+ /**
276
+ * Decide the session's terminal tag. Pure, so the precedence is testable without a live CLI.
277
+ *
278
+ * The ordering is the whole point:
279
+ * 1. `terminal` — a guard tripped and WE killed the session; most specific, and it explains why
280
+ * the CLI's own subtype is missing or odd.
281
+ * 2. bridge broken — a tool call the CLI ATTEMPTED but the bridge never SERVED never reached the
282
+ * caller's closure. This must outrank a reported `success`, because a session
283
+ * whose tools were all broken still ends `subtype:'success'` — the model writes
284
+ * a tidy final answer explaining that its tools failed, and mapping that onto
285
+ * `error:null` reports a run in which nothing worked as converged.
286
+ * 3. `timedOut` — we killed it on the wall clock.
287
+ * 4. the subtype — what the CLI itself said.
288
+ *
289
+ * @param {{terminal: string|null, bridgeDown: boolean, attempted: number, served: number, timedOut: boolean, subtype: string|null|undefined}} facts
290
+ * @returns {{stopReason: string|null, error: string|null}}
291
+ */
292
+ function resolveSessionError(facts) {
293
+ const fromSubtype = classifySubtype(facts.subtype);
294
+ if (facts.terminal) return { stopReason: fromSubtype.stopReason, error: facts.terminal };
295
+ const bridgeBroken = facts.bridgeDown
296
+ || (Number.isFinite(facts.attempted) && Number.isFinite(facts.served) && facts.attempted > facts.served);
297
+ if (bridgeBroken) return { stopReason: fromSubtype.stopReason, error: 'bridge-failed' };
298
+ if (facts.timedOut) return { stopReason: fromSubtype.stopReason, error: 'session_timeout' };
299
+ return fromSubtype;
300
+ }
301
+
302
+ /**
303
+ * Line-oriented processor for the CLI's `stream-json` stdout.
304
+ *
305
+ * FRAMING IS SYNCHRONOUS. The obvious shape — an `async` stdout handler that `await`s `onTurn`
306
+ * inside its parse loop — has a data-corruption bug: while the `await` is suspended, the next
307
+ * `'data'` event re-enters the handler and mutates the SHARED line buffer, so the suspended parse
308
+ * resumes against a buffer that moved under it (dropped/duplicated/mangled lines). It is invisible
309
+ * with a synchronous `onTurn` (the await resolves on the microtask queue before the next macrotask
310
+ * `'data'` event) and REACHABLE the moment `onTurn` does real async work — e.g. a wired gate's
311
+ * `gate.record`, which is exactly the intended consumer. So framing never awaits: it parses lines
312
+ * synchronously and pushes per-turn forwards onto a queue that a SERIAL async drainer empties in
313
+ * arrival order. Extracted from `runSession` so this can be unit-tested without spawning the CLI.
314
+ *
315
+ * @param {object} o
316
+ * @param {Function|null} o.onTurn
317
+ * @param {any} o.ctx
318
+ * @param {number} o.startedAt
319
+ * @param {(err: Error) => void} o.onHalt - called if a forwarded `onTurn` throws a HaltError.
320
+ */
321
+ function createSessionStream({ onTurn, ctx, startedAt, onHalt }) {
322
+ let buf = '';
323
+ let attempted = 0;
324
+ let final = null;
325
+ /** @type {import('../types').Usage[]} */ const turns = [];
326
+ /** @type {any[]} */ const queue = [];
327
+ /** @type {Promise<void>|null} */ let draining = null;
328
+
329
+ async function drain() {
330
+ if (!onTurn) return;
331
+ while (queue.length) {
332
+ const payload = queue.shift();
333
+ try {
334
+ await onTurn(payload);
335
+ } catch (err) {
336
+ // A governance HaltError from the gate ends the session; anything else is surfaced, not fatal.
337
+ if (err instanceof HaltError) { queue.length = 0; onHalt(/** @type {Error} */ (err)); return; }
338
+ }
339
+ }
340
+ }
341
+ function pump() {
342
+ if (!onTurn || draining) return;
343
+ draining = drain().finally(() => { draining = null; if (queue.length) pump(); });
344
+ }
345
+
346
+ return {
347
+ /** Feed a stdout chunk. Pure synchronous framing — never awaits. */
348
+ feed(/** @type {Buffer|string} */ chunk) {
349
+ buf += chunk;
350
+ let nl;
351
+ while ((nl = buf.indexOf('\n')) !== -1) {
352
+ const line = buf.slice(0, nl);
353
+ buf = buf.slice(nl + 1);
354
+ if (!line.trim()) continue;
355
+ let ev;
356
+ try { ev = JSON.parse(line); } catch (_) { continue; }
357
+
358
+ // Count tool calls the CLI ATTEMPTED against our server. Compared later with what the bridge
359
+ // actually SERVED, this is the only precise parent-side detector of a broken bridge: if the
360
+ // stub cannot reach the socket our server never sees the connection, so a server-side flag
361
+ // stays clean while every tool call fails — and the session still ends `subtype:'success'`
362
+ // (measured). Attempted > served is the honest signal.
363
+ if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {
364
+ for (const block of ev.message.content) {
365
+ if (block && block.type === 'tool_use' && typeof block.name === 'string'
366
+ && block.name.startsWith(`mcp__${SERVER_NAME}__`)) attempted++;
367
+ }
368
+ }
369
+
370
+ if (ev.type === 'assistant' && ev.message && ev.message.usage) {
371
+ const u = ev.message.usage;
372
+ /** @type {import('../types').Usage} */
373
+ const usage = {
374
+ inputTokens: Number(u.input_tokens) || 0,
375
+ outputTokens: Number(u.output_tokens) || 0,
376
+ };
377
+ // Omit an absent tier rather than emit a synthetic 0 (per the Usage contract).
378
+ if (Number.isFinite(u.cache_read_input_tokens)) usage.cacheReadTokens = u.cache_read_input_tokens;
379
+ if (Number.isFinite(u.cache_creation_input_tokens)) usage.cacheCreationTokens = u.cache_creation_input_tokens;
380
+ turns.push(usage);
381
+ // Stream it: a session that dies mid-run must already have surfaced every completed turn's
382
+ // spend, or the gate loses it. Queued (not awaited here) so framing cannot race the buffer.
383
+ if (onTurn) queue.push({
384
+ model: (ev.message && ev.message.model) || null,
385
+ provider: 'clipipe',
386
+ usage,
387
+ costUsd: null, // the CLI prices the SESSION, not the turn — explicitly unpriced, never a synthetic 0.
388
+ pricing: 'unpriced',
389
+ durationMs: Date.now() - startedAt,
390
+ ctx, // what a wired gate records spend against — same as the Loop's onLlmResult.
391
+ kind: 'turn',
392
+ });
393
+ }
394
+
395
+ if (ev.type === 'result') final = ev;
396
+ }
397
+ pump();
398
+ },
399
+ /** Await every queued per-turn forward — call before resolving the session. */
400
+ async flush() { if (draining) await draining; await drain(); },
401
+ get turns() { return turns; },
402
+ get attempted() { return attempted; },
403
+ get final() { return final; },
404
+ };
405
+ }
406
+
407
+ /**
408
+ * Run ONE native CLI session to completion, streaming per-turn usage as it arrives.
409
+ *
410
+ * @param {object} opts
411
+ * @returns {Promise<any>}
412
+ */
413
+ function runSession(opts) {
414
+ const {
415
+ command, baseArgs = [], systemPrompt, task, sockPath, maxTurns, timeoutMs,
416
+ onTurn = null, ctx, cwd, env, bridgeTimeoutMs,
417
+ } = opts;
418
+
419
+ const mcpConfig = JSON.stringify({
420
+ mcpServers: {
421
+ [SERVER_NAME]: {
422
+ command: process.execPath,
423
+ args: [STUB_PATH],
424
+ env: {
425
+ BAREAGENT_BRIDGE_SOCK: sockPath,
426
+ ...(bridgeTimeoutMs ? { BAREAGENT_BRIDGE_TIMEOUT_MS: String(bridgeTimeoutMs) } : {}),
427
+ },
428
+ },
429
+ },
430
+ });
431
+
432
+ // The fence is set BY THE MODE, never left to the caller: `--tools ''` + `--strict-mcp-config`
433
+ // strip the CLI's own tool surface (so a granted-verb fence cannot be widened by the CLI's
434
+ // built-ins), and `--setting-sources ''` suppresses cwd CLAUDE.md/memory auto-discovery — a
435
+ // measured ~18× input-token cut, and also a confidentiality boundary (the CLI would otherwise
436
+ // read the host repo's memory into every turn).
437
+ const args = [
438
+ ...baseArgs,
439
+ '-p',
440
+ '--mcp-config', mcpConfig,
441
+ '--tools', '',
442
+ '--strict-mcp-config',
443
+ '--setting-sources', '',
444
+ '--allowedTools', `mcp__${SERVER_NAME}__*`,
445
+ '--system-prompt', systemPrompt,
446
+ '--output-format', 'stream-json',
447
+ '--verbose',
448
+ ];
449
+ if (Number.isFinite(maxTurns) && maxTurns > 0) args.push('--max-turns', String(maxTurns));
450
+
451
+ return new Promise((resolve) => {
452
+ const child = spawn(command, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
453
+ let stderr = '', settled = false;
454
+ /** @type {Error|null} */ let turnHalt = null;
455
+ const started = Date.now();
456
+
457
+ /** Kill the session now — a guard tripped or governance halted mid-flight. */
458
+ const abort = () => { try { child.kill('SIGTERM'); } catch (_) { /* already gone */ } };
459
+ const stream = createSessionStream({ onTurn, ctx, startedAt: started, onHalt: (err) => { turnHalt = err; abort(); } });
460
+
461
+ const done = async (extra = {}) => {
462
+ if (settled) return;
463
+ settled = true;
464
+ clearTimeout(timer);
465
+ // Flush queued per-turn forwards before resolving — a session that ends while a forward is
466
+ // pending must still surface that turn's spend to the gate (F12/F18).
467
+ try { await stream.flush(); } catch (_) { /* onTurn failures are surfaced in-drain, never fatal */ }
468
+ resolve({ turns: stream.turns, final: stream.final, stderr, ms: Date.now() - started, turnHalt, attempted: stream.attempted, ...extra });
469
+ };
470
+
471
+ const timer = setTimeout(() => {
472
+ try { child.kill('SIGKILL'); } catch (_) { /* already gone */ }
473
+ done({ timedOut: true });
474
+ }, timeoutMs);
475
+
476
+ child.stdout.on('data', (d) => stream.feed(d));
477
+ child.stderr.on('data', (d) => { stderr += d; });
478
+ child.on('error', (err) => done({ spawnError: err }));
479
+ child.on('close', () => done());
480
+ child.stdin.end(task);
481
+ });
482
+ }
483
+
484
+ module.exports = {
485
+ STUB_PATH,
486
+ SERVER_NAME,
487
+ DEFAULT_MAX_CONSECUTIVE_DENIALS,
488
+ DEFAULT_MAX_IDENTICAL_TOOL_ERRORS,
489
+ toolErrorKey,
490
+ safeErrorText,
491
+ createBridge,
492
+ classifySubtype,
493
+ resolveSessionError,
494
+ createSessionStream,
495
+ runSession,
496
+ };