@jentrix/runner 0.5.3

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,1894 @@
1
+ import {
2
+ RUNNER_VERSION
3
+ } from "./chunk-A6NTXRVS.js";
4
+ import {
5
+ appendHookEvent,
6
+ readHookLines,
7
+ safeParse
8
+ } from "./chunk-PMRDMBQR.js";
9
+
10
+ // lib/session-host.ts
11
+ import { spawn } from "node:child_process";
12
+ import {
13
+ existsSync,
14
+ readFileSync as readFileSync3,
15
+ statSync as statSync2,
16
+ unlinkSync as unlinkSync3,
17
+ writeFileSync as writeFileSync4
18
+ } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join as join3 } from "node:path";
21
+ import { createInterface } from "node:readline";
22
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
23
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
24
+
25
+ // lib/session-auth.ts
26
+ import { randomBytes } from "node:crypto";
27
+ import {
28
+ chmodSync,
29
+ mkdirSync,
30
+ readFileSync,
31
+ renameSync,
32
+ writeFileSync
33
+ } from "node:fs";
34
+ import { dirname } from "node:path";
35
+ function staticBearerSource(bearer) {
36
+ return { get: () => bearer, refresh: async () => null };
37
+ }
38
+ function readConfig(configPath) {
39
+ try {
40
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
41
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
42
+ return null;
43
+ return parsed;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+ function writeConfig(configPath, config) {
49
+ mkdirSync(dirname(configPath), { recursive: true });
50
+ const tmp = `${configPath}.tmp.${process.pid}.${randomBytes(6).toString("hex")}`;
51
+ writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
52
+ `, {
53
+ mode: 384,
54
+ flag: "wx"
55
+ });
56
+ chmodSync(tmp, 384);
57
+ renameSync(tmp, configPath);
58
+ }
59
+ function oauthRecordOf(config) {
60
+ const oauth = config?.oauth;
61
+ if (oauth && typeof oauth.refreshToken === "string" && oauth.refreshToken.length > 0 && typeof oauth.tokenEndpoint === "string" && typeof oauth.clientId === "string") {
62
+ return oauth;
63
+ }
64
+ return null;
65
+ }
66
+ var FRESH_BEARER_WINDOW_MS = 12e4;
67
+ function createConfigBearerSource(opts) {
68
+ const doFetch = opts.fetchImpl ?? fetch;
69
+ const log = opts.log ?? (() => void 0);
70
+ const now = opts.now ?? Date.now;
71
+ let pending = null;
72
+ let lastProduced = null;
73
+ let halted = false;
74
+ const get = () => {
75
+ const token = readConfig(opts.configPath)?.token;
76
+ return typeof token === "string" && token.length > 0 ? token : opts.fallback;
77
+ };
78
+ const refreshOnce = async (failedBearer) => {
79
+ const current = get();
80
+ if (current !== failedBearer) return current;
81
+ const config = readConfig(opts.configPath);
82
+ const oauth = oauthRecordOf(config);
83
+ if (!oauth) return null;
84
+ try {
85
+ const res = await doFetch(oauth.tokenEndpoint, {
86
+ method: "POST",
87
+ headers: { "content-type": "application/x-www-form-urlencoded" },
88
+ body: new URLSearchParams({
89
+ grant_type: "refresh_token",
90
+ refresh_token: oauth.refreshToken,
91
+ client_id: oauth.clientId
92
+ }).toString()
93
+ });
94
+ if (!res.ok) throw new Error(`token endpoint ${res.status}`);
95
+ const pair = await res.json();
96
+ if (!pair.access_token || !pair.refresh_token) {
97
+ throw new Error("token endpoint returned no pair");
98
+ }
99
+ const expiresAt = new Date(
100
+ Date.now() + (pair.expires_in ?? 3600) * 1e3
101
+ ).toISOString();
102
+ writeConfig(opts.configPath, {
103
+ ...readConfig(opts.configPath) ?? {},
104
+ token: pair.access_token,
105
+ oauth: {
106
+ refreshToken: pair.refresh_token,
107
+ expiresAt,
108
+ clientId: oauth.clientId,
109
+ tokenEndpoint: oauth.tokenEndpoint,
110
+ ...pair.scope ? { scope: pair.scope } : {}
111
+ }
112
+ });
113
+ log("bearer refreshed (session host rotated the OAuth token)");
114
+ return pair.access_token;
115
+ } catch (error) {
116
+ const after = get();
117
+ if (after !== failedBearer) {
118
+ log("bearer refreshed by a concurrent process \u2014 adopted");
119
+ return after;
120
+ }
121
+ log(
122
+ `bearer refresh failed (${error instanceof Error ? error.message : String(error)})`
123
+ );
124
+ return null;
125
+ }
126
+ };
127
+ return {
128
+ get,
129
+ refresh: (failedBearer) => {
130
+ if (halted) return Promise.resolve(null);
131
+ if (lastProduced !== null && failedBearer === lastProduced.bearer && now() - lastProduced.at < FRESH_BEARER_WINDOW_MS) {
132
+ halted = true;
133
+ log(
134
+ "bearer halt: a freshly refreshed token was still unauthorized \u2014 the endpoint rejects this credential's whole chain (wrong deployment for this bearer?). Halting token rotation so sibling hosts keep theirs; end this host and re-align against the right deployment."
135
+ );
136
+ return Promise.resolve(null);
137
+ }
138
+ if (!pending) {
139
+ pending = refreshOnce(failedBearer).then((produced) => {
140
+ if (produced !== null) {
141
+ lastProduced = { bearer: produced, at: now() };
142
+ }
143
+ return produced;
144
+ }).finally(() => {
145
+ pending = null;
146
+ });
147
+ }
148
+ return pending;
149
+ }
150
+ };
151
+ }
152
+ function isUnauthorizedishError(e) {
153
+ if (typeof e === "object" && e !== null) {
154
+ const rec = e;
155
+ if (rec.code === 401 || rec.status === 401) return true;
156
+ }
157
+ const message = e instanceof Error ? e.message : String(e);
158
+ return /\b401\b|unauthorized|invalid_token|no authorization/i.test(message);
159
+ }
160
+
161
+ // lib/session-bridge.ts
162
+ import { unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
163
+ import { join } from "node:path";
164
+
165
+ // lib/session-events.ts
166
+ var SESSION_EVENT_VERSION = 1;
167
+ function serializeSessionEvent(event) {
168
+ return `${JSON.stringify({
169
+ version: event.version,
170
+ sequence: event.sequence,
171
+ at: event.at,
172
+ provider: event.provider,
173
+ ...event.providerEventId ? { providerEventId: event.providerEventId } : {},
174
+ kind: event.kind,
175
+ payload: event.payload
176
+ })}
177
+ `;
178
+ }
179
+
180
+ // lib/session-usage.ts
181
+ function insideOneRange(ranges, from, to) {
182
+ return ranges.some((r) => r.from <= from && to <= r.to);
183
+ }
184
+ function aggregateSessionUsage(input) {
185
+ const missing = [];
186
+ const seen = /* @__PURE__ */ new Set();
187
+ const receipts = input.receipts.filter((r) => {
188
+ if (seen.has(r.eventId)) return false;
189
+ seen.add(r.eventId);
190
+ return true;
191
+ }).sort((a, b) => a.at - b.at);
192
+ let inputTokens = 0;
193
+ let outputTokens = 0;
194
+ let usable = 0;
195
+ const receiptTurnIds = /* @__PURE__ */ new Set();
196
+ let cacheReadTokens = 0;
197
+ let cacheReadReported = false;
198
+ let cacheCreationTokens = 0;
199
+ let cacheCreationReported = false;
200
+ let reasoningOutputTokens = 0;
201
+ let reasoningReported = false;
202
+ const buckets = /* @__PURE__ */ new Map();
203
+ const contribute = (c) => {
204
+ inputTokens += c.inputTokens;
205
+ outputTokens += c.outputTokens;
206
+ const bucket = buckets.get(c.modelId) ?? {
207
+ inputTokens: 0,
208
+ outputTokens: 0,
209
+ cacheReadTokens: 0,
210
+ cacheReadReported: false,
211
+ cacheCreationTokens: 0,
212
+ cacheCreationReported: false,
213
+ reasoningOutputTokens: 0,
214
+ reasoningReported: false
215
+ };
216
+ bucket.inputTokens += c.inputTokens;
217
+ bucket.outputTokens += c.outputTokens;
218
+ if (typeof c.cacheReadTokens === "number") {
219
+ cacheReadTokens += c.cacheReadTokens;
220
+ cacheReadReported = true;
221
+ bucket.cacheReadTokens += c.cacheReadTokens;
222
+ bucket.cacheReadReported = true;
223
+ }
224
+ if (typeof c.cacheCreationTokens === "number") {
225
+ cacheCreationTokens += c.cacheCreationTokens;
226
+ cacheCreationReported = true;
227
+ bucket.cacheCreationTokens += c.cacheCreationTokens;
228
+ bucket.cacheCreationReported = true;
229
+ }
230
+ if (typeof c.reasoningOutputTokens === "number") {
231
+ reasoningOutputTokens += c.reasoningOutputTokens;
232
+ reasoningReported = true;
233
+ bucket.reasoningOutputTokens += c.reasoningOutputTokens;
234
+ bucket.reasoningReported = true;
235
+ }
236
+ buckets.set(c.modelId, bucket);
237
+ usable += 1;
238
+ };
239
+ const modelOf = (receipt) => receipt.modelId?.trim() || null;
240
+ let cumulativeBaseline = null;
241
+ for (const receipt of receipts) {
242
+ if (receipt.turnId) receiptTurnIds.add(receipt.turnId);
243
+ if (receipt.kind === "delta") {
244
+ contribute({
245
+ modelId: modelOf(receipt),
246
+ inputTokens: receipt.inputTokens,
247
+ outputTokens: receipt.outputTokens,
248
+ ...typeof receipt.cacheReadTokens === "number" ? { cacheReadTokens: receipt.cacheReadTokens } : {},
249
+ ...typeof receipt.cacheCreationTokens === "number" ? { cacheCreationTokens: receipt.cacheCreationTokens } : {},
250
+ ...typeof receipt.reasoningOutputTokens === "number" ? { reasoningOutputTokens: receipt.reasoningOutputTokens } : {}
251
+ });
252
+ continue;
253
+ }
254
+ if (cumulativeBaseline === null) {
255
+ cumulativeBaseline = receipt;
256
+ missing.push(
257
+ `cumulative receipt ${receipt.eventId} established a baseline only \u2014 the thread total before it is not attributable to this session`
258
+ );
259
+ continue;
260
+ }
261
+ if (!insideOneRange(input.observedRanges, cumulativeBaseline.at, receipt.at)) {
262
+ missing.push(
263
+ `cumulative interval ${cumulativeBaseline.eventId}\u2192${receipt.eventId} crossed an unobserved range and was not counted`
264
+ );
265
+ cumulativeBaseline = receipt;
266
+ continue;
267
+ }
268
+ const dIn = receipt.inputTokens - cumulativeBaseline.inputTokens;
269
+ const dOut = receipt.outputTokens - cumulativeBaseline.outputTokens;
270
+ if (dIn < 0 || dOut < 0) {
271
+ missing.push(
272
+ `cumulative receipt ${receipt.eventId} regressed below its baseline and was not counted`
273
+ );
274
+ cumulativeBaseline = receipt;
275
+ continue;
276
+ }
277
+ contribute({
278
+ modelId: modelOf(receipt),
279
+ inputTokens: dIn,
280
+ outputTokens: dOut,
281
+ ...typeof receipt.cacheReadTokens === "number" && typeof cumulativeBaseline.cacheReadTokens === "number" && receipt.cacheReadTokens >= cumulativeBaseline.cacheReadTokens ? {
282
+ cacheReadTokens: receipt.cacheReadTokens - cumulativeBaseline.cacheReadTokens
283
+ } : {},
284
+ ...typeof receipt.cacheCreationTokens === "number" && typeof cumulativeBaseline.cacheCreationTokens === "number" && receipt.cacheCreationTokens >= cumulativeBaseline.cacheCreationTokens ? {
285
+ cacheCreationTokens: receipt.cacheCreationTokens - cumulativeBaseline.cacheCreationTokens
286
+ } : {},
287
+ ...typeof receipt.reasoningOutputTokens === "number" && typeof cumulativeBaseline.reasoningOutputTokens === "number" && receipt.reasoningOutputTokens >= cumulativeBaseline.reasoningOutputTokens ? {
288
+ reasoningOutputTokens: receipt.reasoningOutputTokens - cumulativeBaseline.reasoningOutputTokens
289
+ } : {}
290
+ });
291
+ cumulativeBaseline = receipt;
292
+ }
293
+ const perModel = [...buckets.entries()].map(
294
+ ([modelId, bucket]) => ({
295
+ modelId,
296
+ inputTokens: bucket.inputTokens,
297
+ outputTokens: bucket.outputTokens,
298
+ cacheReadTokens: bucket.cacheReadReported ? bucket.cacheReadTokens : null,
299
+ cacheCreationTokens: bucket.cacheCreationReported ? bucket.cacheCreationTokens : null,
300
+ reasoningOutputTokens: bucket.reasoningReported ? bucket.reasoningOutputTokens : null
301
+ })
302
+ );
303
+ function sumIntervals(intervals, label) {
304
+ let total = 0;
305
+ let closed = 0;
306
+ for (const interval of intervals) {
307
+ if (interval.endedAt == null) {
308
+ missing.push(`${label} interval ${interval.id} never observed its terminal event`);
309
+ continue;
310
+ }
311
+ total += Math.max(0, interval.endedAt - interval.startedAt);
312
+ closed += 1;
313
+ }
314
+ if (intervals.length === 0) return { total: null, complete: true };
315
+ return { total, complete: closed === intervals.length };
316
+ }
317
+ const provider = sumIntervals(input.providerTurns, "provider turn");
318
+ const tool = sumIntervals(input.toolIntervals, "tool");
319
+ const turnsWithoutReceipts = input.providerTurns.filter(
320
+ (t) => !receiptTurnIds.has(t.id)
321
+ );
322
+ for (const turn of turnsWithoutReceipts) {
323
+ missing.push(`provider turn ${turn.id} carried no usable usage receipt`);
324
+ }
325
+ if (usable === 0) {
326
+ return {
327
+ inputTokens: null,
328
+ outputTokens: null,
329
+ cacheReadTokens: null,
330
+ cacheCreationTokens: null,
331
+ reasoningOutputTokens: null,
332
+ providerActiveDurationMs: provider.total,
333
+ toolDurationMs: tool.total,
334
+ coverage: "UNAVAILABLE",
335
+ missingRanges: missing,
336
+ perModel: []
337
+ };
338
+ }
339
+ const complete = missing.length === 0 && provider.complete && tool.complete;
340
+ return {
341
+ inputTokens,
342
+ outputTokens,
343
+ cacheReadTokens: cacheReadReported ? cacheReadTokens : null,
344
+ cacheCreationTokens: cacheCreationReported ? cacheCreationTokens : null,
345
+ reasoningOutputTokens: reasoningReported ? reasoningOutputTokens : null,
346
+ providerActiveDurationMs: provider.total,
347
+ toolDurationMs: tool.total,
348
+ coverage: complete ? "COMPLETE" : "PARTIAL",
349
+ missingRanges: missing,
350
+ perModel
351
+ };
352
+ }
353
+
354
+ // lib/session-bridge.ts
355
+ var MAX_FINAL_RESPONSE_BYTES = 2 * 1024 * 1024;
356
+ var HEARTBEAT_MIN_INTERVAL_MS = 3e4;
357
+ var SessionBridge = class {
358
+ constructor(deps) {
359
+ this.deps = deps;
360
+ }
361
+ deps;
362
+ sequence = 0;
363
+ receipts = [];
364
+ providerTurns = /* @__PURE__ */ new Map();
365
+ toolIntervals = /* @__PURE__ */ new Map();
366
+ observedRanges = [];
367
+ /**
368
+ * control-room AC2.1/AC2.2 — the LAST model the provider was observed
369
+ * running. Last, not first: a session may legitimately switch models
370
+ * mid-flight and the model that ran is the one that ran. Null until a line
371
+ * names one; the heartbeat then omits the field entirely, so an unobserved
372
+ * model can never overwrite a proven one server-side.
373
+ */
374
+ observedModelId = null;
375
+ observingSince = null;
376
+ ackedParts = /* @__PURE__ */ new Map();
377
+ terminalParts = /* @__PURE__ */ new Set();
378
+ lastHeartbeatAt = 0;
379
+ unrecognizedEvents = 0;
380
+ capability = null;
381
+ inactive = false;
382
+ /**
383
+ * AGE-649 — the newest non-empty assistant message observed, kept so the
384
+ * session's own OUTPUT survives the close. Held in memory only: this is a
385
+ * projection of an event the host already sees, never a second capture
386
+ * channel, and it is recorded even when TRACE capture is off (which is the
387
+ * whole point — capture-off is the MVP default, and without this a closed
388
+ * session keeps its telemetry and loses what it actually concluded).
389
+ */
390
+ lastAssistantMessage = null;
391
+ /**
392
+ * True after a heartbeat came back 409 SESSION_NOT_ACTIVE — the session is
393
+ * terminal server-side. The watch host uses this as its end signal when no
394
+ * lifecycle hook can reach it; network loss never sets it.
395
+ */
396
+ get sessionInactive() {
397
+ return this.inactive;
398
+ }
399
+ /** Server-acknowledged parts so far — the host stamps this into host.json. */
400
+ get ackedPartCount() {
401
+ return this.ackedParts.size;
402
+ }
403
+ /** The injected MCP tool caller (host convenience — same credential). */
404
+ get tool() {
405
+ return this.deps.callTool;
406
+ }
407
+ now() {
408
+ return (this.deps.monotonic ?? (() => performance.now()))();
409
+ }
410
+ wall() {
411
+ return (this.deps.wallClock ?? (() => /* @__PURE__ */ new Date()))();
412
+ }
413
+ fetch() {
414
+ return this.deps.fetchImpl ?? fetch;
415
+ }
416
+ /** Begin (or resume) continuous observation — opens an observed range. */
417
+ startObserving() {
418
+ if (this.observingSince === null) this.observingSince = this.now();
419
+ }
420
+ /** A capture gap (stream drop, provider restart): closes the range. */
421
+ recordGap(reason) {
422
+ if (this.observingSince !== null) {
423
+ this.observedRanges.push({ from: this.observingSince, to: this.now() });
424
+ this.observingSince = null;
425
+ }
426
+ this.record({ kind: "error", payload: { captureGap: reason } });
427
+ }
428
+ /** Record the provider capability snapshot (§15.3) as an observable event. */
429
+ recordCapabilities(snapshot) {
430
+ this.capability = snapshot;
431
+ this.record({ kind: "session", payload: { capabilities: snapshot } });
432
+ }
433
+ get capabilities() {
434
+ return this.capability;
435
+ }
436
+ countUnrecognized() {
437
+ this.unrecognizedEvents += 1;
438
+ }
439
+ /**
440
+ * Append one observable event: sequence + wall timestamp stamped here, the
441
+ * whole line REDACTED before it becomes durable, usage receipts collected
442
+ * for the rollup (deduped downstream by provider event identity).
443
+ */
444
+ record(event) {
445
+ const full = {
446
+ version: 1,
447
+ sequence: this.sequence++,
448
+ at: event.at ?? this.wall().toISOString(),
449
+ provider: this.deps.provider,
450
+ ...event.providerEventId ? { providerEventId: event.providerEventId } : {},
451
+ kind: event.kind,
452
+ payload: this.deps.redactor.value(event.payload)
453
+ };
454
+ if (this.deps.traceCapture !== false) {
455
+ this.deps.spool.append(
456
+ this.deps.redactor.text(serializeSessionEvent(full))
457
+ );
458
+ }
459
+ if (full.kind === "assistant_message") {
460
+ const text2 = full.payload?.text;
461
+ if (typeof text2 === "string" && text2.trim().length > 0) {
462
+ this.lastAssistantMessage = {
463
+ text: text2,
464
+ at: full.at,
465
+ sequence: full.sequence
466
+ };
467
+ }
468
+ }
469
+ if (full.kind === "usage") {
470
+ const payload = full.payload;
471
+ if ((payload?.kind === "delta" || payload?.kind === "cumulative") && typeof payload.inputTokens === "number" && typeof payload.outputTokens === "number") {
472
+ this.receipts.push({
473
+ eventId: full.providerEventId ?? `seq:${full.sequence}`,
474
+ turnId: payload.turnId ?? null,
475
+ kind: payload.kind,
476
+ inputTokens: payload.inputTokens,
477
+ outputTokens: payload.outputTokens,
478
+ ...typeof payload.cacheReadTokens === "number" ? { cacheReadTokens: payload.cacheReadTokens } : {},
479
+ ...typeof payload.cacheCreationTokens === "number" ? { cacheCreationTokens: payload.cacheCreationTokens } : {},
480
+ // TPM Slice 2 (AC2.4/AC2.7): the receipt's own model and reasoning
481
+ // split, when the mapper reported them — grouped receipts, never
482
+ // estimates.
483
+ ...typeof payload.reasoningOutputTokens === "number" ? { reasoningOutputTokens: payload.reasoningOutputTokens } : {},
484
+ ...typeof payload.modelId === "string" && payload.modelId.trim() ? { modelId: payload.modelId.trim() } : {},
485
+ at: this.now()
486
+ });
487
+ this.persistUsageSnapshot();
488
+ }
489
+ }
490
+ return full;
491
+ }
492
+ /** Best-effort spool-side snapshot of the current rollup (provider receipts). */
493
+ persistUsageSnapshot() {
494
+ try {
495
+ writeFileSync2(
496
+ join(this.deps.spool.directory, "usage.json"),
497
+ JSON.stringify({
498
+ rollup: this.usageRollup(),
499
+ updatedAt: this.wall().toISOString()
500
+ }),
501
+ { mode: 384 }
502
+ );
503
+ } catch {
504
+ }
505
+ }
506
+ /** Record the model a transcript line named (pure accumulation, no I/O). */
507
+ observeModel(modelId) {
508
+ const next = modelId.trim();
509
+ if (next) this.observedModelId = next;
510
+ }
511
+ /** What the host has observed running, for tests and the close-time record. */
512
+ get modelId() {
513
+ return this.observedModelId;
514
+ }
515
+ /**
516
+ * control-room AC3.7 — record an interval whose bounds were OBSERVED rather
517
+ * than measured on this process's clock. The Claude path replays timestamps
518
+ * the transcript already carries, so `this.now()` (which the mark* pair
519
+ * below uses for the live Codex path) would time the tail, not the turn.
520
+ */
521
+ recordInterval(interval) {
522
+ const target = interval.kind === "turn" ? this.providerTurns : this.toolIntervals;
523
+ target.set(interval.id, {
524
+ id: interval.id,
525
+ startedAt: interval.startedAt,
526
+ endedAt: interval.endedAt
527
+ });
528
+ }
529
+ /**
530
+ * An interval opened and never closed — a tool that never returned, a host
531
+ * killed mid-turn. Recorded WITHOUT an end so `aggregateSessionUsage` names
532
+ * the gap and degrades coverage to PARTIAL, instead of the total quietly
533
+ * omitting it and reading as complete.
534
+ */
535
+ recordUnclosedInterval(kind, id) {
536
+ const target = kind === "turn" ? this.providerTurns : this.toolIntervals;
537
+ if (!target.has(id)) target.set(id, { id, startedAt: 0, endedAt: null });
538
+ }
539
+ markTurnStarted(id) {
540
+ this.providerTurns.set(id, { id, startedAt: this.now(), endedAt: null });
541
+ }
542
+ markTurnEnded(id) {
543
+ const turn = this.providerTurns.get(id);
544
+ if (turn) turn.endedAt = this.now();
545
+ }
546
+ markToolStarted(id) {
547
+ this.toolIntervals.set(id, { id, startedAt: this.now(), endedAt: null });
548
+ }
549
+ markToolEnded(id) {
550
+ const interval = this.toolIntervals.get(id);
551
+ if (interval) interval.endedAt = this.now();
552
+ }
553
+ /** The current REST bearer (function form resolves per request). */
554
+ bearerOf() {
555
+ return typeof this.deps.bearer === "function" ? this.deps.bearer() : this.deps.bearer;
556
+ }
557
+ /** ≤ one heartbeat per 30s window (AC42); failures are silent (retry next). */
558
+ async maybeHeartbeat() {
559
+ const now = this.now();
560
+ if (now - this.lastHeartbeatAt < HEARTBEAT_MIN_INTERVAL_MS) return;
561
+ await this.postHeartbeat(now);
562
+ }
563
+ /**
564
+ * TPM Slice 2 (AC2.5): the flush receipt — an immediate beat that ignores
565
+ * the 30-second window, posted before a task-changing re-align so the OLD
566
+ * alignment's open interval absorbs everything observed so far. Host-side
567
+ * ordering only: a killed host's mid-switch smear stays bounded by one
568
+ * beat window, which the CLI disclosure names.
569
+ */
570
+ async flushUsageNow() {
571
+ return this.postHeartbeat(this.now());
572
+ }
573
+ /** @returns true when the server acknowledged the beat (HTTP ok). */
574
+ async postHeartbeat(now) {
575
+ this.lastHeartbeatAt = now;
576
+ const bearer = this.bearerOf();
577
+ try {
578
+ const response = await this.fetch()(
579
+ new URL("/api/agent-sessions/heartbeat", this.deps.jentrixBaseUrl),
580
+ {
581
+ method: "POST",
582
+ headers: {
583
+ authorization: `Bearer ${bearer}`,
584
+ "content-type": "application/json"
585
+ },
586
+ body: JSON.stringify({
587
+ sessionId: this.deps.sessionId,
588
+ // control-room AC2.1: the heartbeat is the host's "what I
589
+ // observed" channel. No new timer, no new route, no new tool —
590
+ // and host-attested by construction, since the route writes only
591
+ // the authenticated operator's own open session.
592
+ ...this.observedModelId ? { modelId: this.observedModelId } : {},
593
+ // control-room AC4.1: the LIVE usage receipt on the same beat.
594
+ // Sent only once a receipt has actually been observed — an empty
595
+ // rollup would overwrite the session's totals with nulls and
596
+ // report UNAVAILABLE for a session that had already reported.
597
+ ...this.receipts.length > 0 ? { usage: this.usageRollup() } : {}
598
+ })
599
+ }
600
+ );
601
+ if (response.status === 401) {
602
+ void this.deps.onUnauthorized?.(bearer)?.catch(() => void 0);
603
+ }
604
+ if (response.status === 409) {
605
+ const body = await response.text().catch(() => "");
606
+ if (body.includes("SESSION_NOT_ACTIVE")) this.inactive = true;
607
+ }
608
+ return response.ok;
609
+ } catch {
610
+ return false;
611
+ }
612
+ }
613
+ /**
614
+ * Upload every pending spool part. Returns the still-pending count — a
615
+ * non-zero result is "capture pending", printed prominently and encoded in
616
+ * the CLI exit code (§20). A redacted-slot refusal (terminal, §12.3) keeps
617
+ * the local file forever and is reported as a named gap.
618
+ */
619
+ async flushParts() {
620
+ if (this.deps.traceCapture === false) return { pending: 0, terminal: 0 };
621
+ let pending = 0;
622
+ for (const part of this.deps.spool.pendingParts()) {
623
+ if (this.terminalParts.has(part.part)) continue;
624
+ const bearer = this.bearerOf();
625
+ try {
626
+ const response = await this.fetch()(
627
+ new URL(
628
+ `/api/agent-sessions/${this.deps.sessionId}/parts`,
629
+ this.deps.jentrixBaseUrl
630
+ ),
631
+ {
632
+ method: "POST",
633
+ headers: {
634
+ authorization: `Bearer ${bearer}`,
635
+ "content-type": "application/json"
636
+ },
637
+ body: JSON.stringify({
638
+ part: part.part,
639
+ body: this.deps.spool.readPart(part.part)
640
+ })
641
+ }
642
+ );
643
+ if (response.ok) {
644
+ const ack = await response.json();
645
+ const acked = typeof ack.checksum === "string" ? ack.checksum : part.checksum;
646
+ this.ackedParts.set(part.part, acked);
647
+ this.deps.spool.deleteAcknowledged(part.part, acked, { force: true });
648
+ this.deps.spool.advancePast(part.part);
649
+ continue;
650
+ }
651
+ if (response.status === 401) {
652
+ void this.deps.onUnauthorized?.(bearer)?.catch(() => void 0);
653
+ }
654
+ const body = await response.text().catch(() => "");
655
+ if (response.status === 409 && body.includes("ARTIFACT_PART_REDACTED")) {
656
+ this.terminalParts.add(part.part);
657
+ this.deps.log?.(
658
+ `trace part ${part.part}: slot terminally redacted \u2014 local spool retained`
659
+ );
660
+ continue;
661
+ }
662
+ pending += 1;
663
+ } catch {
664
+ pending += 1;
665
+ }
666
+ }
667
+ return { pending, terminal: this.terminalParts.size };
668
+ }
669
+ /**
670
+ * AGE-649 — push the session's FINAL RESPONSE as a typed artifact.
671
+ *
672
+ * Why this exists: with TRACE capture off (the MVP default) a closed session
673
+ * keeps its telemetry and its typed artifacts, and nothing at all holds what
674
+ * the agent concluded. The RUN_SUMMARY cannot carry it — that document is a
675
+ * deterministic server projection and model prose is banned from it (M20.1
676
+ * AC31) — so the output lands as its own artifact, on the same typed-push
677
+ * boundary an operator's `jentrix push report` uses. One ingestion function,
678
+ * both redaction passes, checksum after redaction.
679
+ *
680
+ * Ordering is load-bearing: `COMPLETED` is a SEALED status for typed pushes,
681
+ * so this runs BEFORE `complete_agent_session`, never after.
682
+ *
683
+ * Absence stays absence. A session where the host observed no assistant text
684
+ * (capture never bound, a Codex thread that only ran tools) gets NO artifact
685
+ * rather than an empty one — the same rule the usage rollup follows for
686
+ * tokens. Failure never fails the close: the artifact is a bonus record, and
687
+ * losing it must not cost the operator their session completion.
688
+ *
689
+ * @returns the artifact id, or null when there was nothing to push.
690
+ */
691
+ async pushFinalResponse() {
692
+ const last = this.lastAssistantMessage;
693
+ if (!last) return null;
694
+ const body = this.finalResponseBody(last);
695
+ const bearer = this.bearerOf();
696
+ try {
697
+ const response = await this.fetch()(
698
+ new URL(
699
+ `/api/agent-sessions/${this.deps.sessionId}/artifacts`,
700
+ this.deps.jentrixBaseUrl
701
+ ),
702
+ {
703
+ method: "POST",
704
+ headers: {
705
+ authorization: `Bearer ${bearer}`,
706
+ "content-type": "application/json"
707
+ },
708
+ body: JSON.stringify({
709
+ // `report` → REPORT → the Execution layer, which is the session's
710
+ // own layer. No new push kind and no new ArtifactType: the seven
711
+ // kinds are frozen vocabulary and this is a report the agent wrote.
712
+ kind: "report",
713
+ title: `Final response \u2014 session ${this.deps.sessionId.slice(-8)}`,
714
+ body
715
+ })
716
+ }
717
+ );
718
+ if (response.ok) {
719
+ const ack = await response.json().catch(() => null);
720
+ return ack?.artifactId ?? null;
721
+ }
722
+ if (response.status === 401) {
723
+ void this.deps.onUnauthorized?.(bearer)?.catch(() => void 0);
724
+ }
725
+ this.deps.log?.(
726
+ `final response: not stored (HTTP ${response.status}) \u2014 the session's closing output was not captured`
727
+ );
728
+ return null;
729
+ } catch (error) {
730
+ this.deps.log?.(
731
+ `final response: not stored (${error instanceof Error ? error.message : "unknown"}) \u2014 the session's closing output was not captured`
732
+ );
733
+ return null;
734
+ }
735
+ }
736
+ /**
737
+ * The stored document. Self-describing on purpose: a reader has to be able to
738
+ * tell this apart from the RUN_SUMMARY sitting beside it, and has to know it
739
+ * is verbatim provider output rather than anything the server derived.
740
+ *
741
+ * Bounded here as well as server-side, and a truncation SAYS so — an artifact
742
+ * silently missing its tail is worse than one that names the cut.
743
+ */
744
+ finalResponseBody(last) {
745
+ const header = [
746
+ `# Final response \u2014 session ${this.deps.sessionId}`,
747
+ "",
748
+ `The last assistant message this session's host observed before close (event ${last.sequence}, ${last.at}).`,
749
+ "Verbatim provider output \u2014 redacted on this machine and again on arrival.",
750
+ "This is model prose, not a server projection: the RUN_SUMMARY artifact is the deterministic record of what the session did.",
751
+ "",
752
+ "---",
753
+ ""
754
+ ].join("\n");
755
+ const room = MAX_FINAL_RESPONSE_BYTES - Buffer.byteLength(header, "utf8");
756
+ if (Buffer.byteLength(last.text, "utf8") <= room) return header + last.text;
757
+ const notice = "\n\n[truncated \u2014 the response exceeded the artifact limit]";
758
+ const kept = Buffer.from(last.text, "utf8").subarray(0, Math.max(0, room - Buffer.byteLength(notice, "utf8"))).toString("utf8").replace(/�+$/, "");
759
+ return header + kept + notice;
760
+ }
761
+ /** The §12.5 rollup over everything observed so far. */
762
+ usageRollup() {
763
+ const ranges = [...this.observedRanges];
764
+ if (this.observingSince !== null) {
765
+ ranges.push({ from: this.observingSince, to: this.now() });
766
+ }
767
+ return aggregateSessionUsage({
768
+ receipts: this.receipts,
769
+ observedRanges: ranges,
770
+ providerTurns: [...this.providerTurns.values()],
771
+ toolIntervals: [...this.toolIntervals.values()]
772
+ });
773
+ }
774
+ /**
775
+ * Close the session: final flush, server-verified manifest from the ACKED
776
+ * checksums, rollup, then `complete_agent_session` under CAS. Returns the
777
+ * server's verdict plus the local pending count — the CLI exits non-zero
778
+ * while anything is pending (§20).
779
+ */
780
+ async complete(opts) {
781
+ const { pending } = await this.flushParts();
782
+ const finalResponseArtifactId = await this.pushFinalResponse();
783
+ const rollup = this.usageRollup();
784
+ const current = await this.deps.callTool("get_agent_session", {
785
+ sessionId: this.deps.sessionId
786
+ });
787
+ const traceOff = this.deps.traceCapture === false;
788
+ const manifest = traceOff ? void 0 : {
789
+ parts: [...this.ackedParts.entries()].sort(([a], [b]) => a - b).map(([part, checksum]) => ({ part, checksum }))
790
+ };
791
+ const captureError = opts.captureError ?? (traceOff ? null : pending > 0 ? `capture pending: ${pending} trace part(s) not yet acknowledged` : this.unrecognizedEvents > 0 ? `${this.unrecognizedEvents} provider event(s) had shapes this adapter does not observe` : null);
792
+ const result = await this.deps.callTool("complete_agent_session", {
793
+ sessionId: this.deps.sessionId,
794
+ outcome: opts.outcome,
795
+ endBranch: opts.end.branch,
796
+ endHead: opts.end.head,
797
+ endDirty: opts.end.dirty,
798
+ captureError,
799
+ ...manifest ? { manifest } : {},
800
+ usage: {
801
+ inputTokens: rollup.inputTokens,
802
+ outputTokens: rollup.outputTokens,
803
+ cacheReadTokens: rollup.cacheReadTokens,
804
+ cacheCreationTokens: rollup.cacheCreationTokens,
805
+ // TPM Slice 2 (AC2.6/AC2.7): the close corrects session TOTALS —
806
+ // reasoning included, perModel deliberately NOT sent (the server
807
+ // writes no segments at close; residuals stay disclosed).
808
+ reasoningOutputTokens: rollup.reasoningOutputTokens,
809
+ providerActiveDurationMs: rollup.providerActiveDurationMs,
810
+ toolDurationMs: rollup.toolDurationMs,
811
+ coverage: rollup.coverage,
812
+ ...rollup.missingRanges.length ? { missingRanges: rollup.missingRanges.slice(0, 200) } : {}
813
+ },
814
+ expectedUpdatedAt: current.updatedAt
815
+ });
816
+ try {
817
+ unlinkSync(join(this.deps.spool.directory, "usage.json"));
818
+ } catch {
819
+ }
820
+ return {
821
+ status: result.status ?? opts.outcome,
822
+ captureComplete: Boolean(result.captureComplete),
823
+ summaryArtifactId: result.summaryArtifactId ?? null,
824
+ finalResponseArtifactId,
825
+ pendingParts: pending
826
+ };
827
+ }
828
+ };
829
+
830
+ // lib/session-claude-transcript.ts
831
+ function baseEvent(entry, kind, payload, idSuffix = "") {
832
+ return {
833
+ version: SESSION_EVENT_VERSION,
834
+ at: entry.timestamp ?? (/* @__PURE__ */ new Date(0)).toISOString(),
835
+ provider: "claude",
836
+ ...entry.uuid ? { providerEventId: `${entry.uuid}${idSuffix}` } : {},
837
+ kind,
838
+ payload
839
+ };
840
+ }
841
+ function textOf(content) {
842
+ if (typeof content === "string") return content;
843
+ if (!Array.isArray(content)) return "";
844
+ return content.filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n");
845
+ }
846
+ function mapClaudeTranscriptLine(line) {
847
+ let entry;
848
+ try {
849
+ entry = JSON.parse(line);
850
+ } catch {
851
+ return { events: [], unrecognized: true };
852
+ }
853
+ if (entry?.type !== "user" && entry?.type !== "assistant") {
854
+ return { events: [], unrecognized: false };
855
+ }
856
+ const events = [];
857
+ const content = entry.message?.content;
858
+ if (entry.type === "user") {
859
+ const blocks2 = Array.isArray(content) ? content : [];
860
+ const toolResults = blocks2.filter((b) => b.type === "tool_result");
861
+ for (const block of toolResults) {
862
+ events.push(
863
+ baseEvent(
864
+ entry,
865
+ "tool_result",
866
+ {
867
+ toolUseId: block.tool_use_id ?? null,
868
+ isError: Boolean(block.is_error),
869
+ content: block.content ?? null
870
+ },
871
+ `:result:${block.tool_use_id ?? ""}`
872
+ )
873
+ );
874
+ }
875
+ const text3 = textOf(content);
876
+ if (text3) {
877
+ events.push(baseEvent(entry, "user_message", { text: text3 }));
878
+ }
879
+ return {
880
+ events,
881
+ unrecognized: false,
882
+ ...timingOf(entry, {
883
+ toolEnds: toolResults.map((block) => block.tool_use_id).filter((id) => typeof id === "string")
884
+ })
885
+ };
886
+ }
887
+ const modelId = typeof entry.message?.model === "string" && entry.message.model.trim() ? entry.message.model.trim() : void 0;
888
+ const blocks = Array.isArray(content) ? content : [];
889
+ const text2 = textOf(content);
890
+ if (text2) {
891
+ events.push(baseEvent(entry, "assistant_message", { text: text2 }));
892
+ }
893
+ for (const block of blocks) {
894
+ if (block.type === "tool_use") {
895
+ events.push(
896
+ baseEvent(
897
+ entry,
898
+ "tool_call",
899
+ { toolUseId: block.id ?? null, name: block.name ?? null, input: block.input ?? null },
900
+ `:tool:${block.id ?? ""}`
901
+ )
902
+ );
903
+ }
904
+ }
905
+ const usage = entry.message?.usage;
906
+ if (usage && (typeof usage.input_tokens === "number" || typeof usage.cache_creation_input_tokens === "number" || typeof usage.cache_read_input_tokens === "number" || typeof usage.output_tokens === "number")) {
907
+ const hasCacheFields = typeof usage.cache_read_input_tokens === "number" || typeof usage.cache_creation_input_tokens === "number";
908
+ events.push(
909
+ baseEvent(
910
+ entry,
911
+ "usage",
912
+ {
913
+ kind: "delta",
914
+ inputTokens: (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0),
915
+ outputTokens: usage.output_tokens ?? 0,
916
+ ...hasCacheFields ? {
917
+ cacheReadTokens: usage.cache_read_input_tokens ?? 0,
918
+ cacheCreationTokens: usage.cache_creation_input_tokens ?? 0
919
+ } : {},
920
+ // TPM Slice 2 (AC2.4): the model that produced THIS receipt — the
921
+ // same entry stamps both, which is what makes per-model grouping a
922
+ // grouped receipt rather than an estimate. Anthropic does not split
923
+ // reasoning tokens out, so no reasoningOutputTokens here (absent,
924
+ // never 0).
925
+ ...modelId ? { modelId } : {}
926
+ },
927
+ ":usage"
928
+ )
929
+ );
930
+ }
931
+ return {
932
+ events,
933
+ unrecognized: false,
934
+ ...modelId ? { modelId } : {},
935
+ ...timingOf(entry, {
936
+ toolStarts: blocks.filter((block) => block.type === "tool_use").map((block) => block.id).filter((id) => typeof id === "string")
937
+ })
938
+ };
939
+ }
940
+ function timingOf(entry, parts) {
941
+ const at = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
942
+ if (!Number.isFinite(at)) return {};
943
+ return {
944
+ timing: {
945
+ at,
946
+ id: entry.uuid ?? String(at),
947
+ role: entry.type === "assistant" ? "assistant" : "user",
948
+ ...parts.toolStarts?.length ? { toolStarts: parts.toolStarts } : {},
949
+ ...parts.toolEnds?.length ? { toolEnds: parts.toolEnds } : {}
950
+ }
951
+ };
952
+ }
953
+
954
+ // lib/session-claude-timing.ts
955
+ var ClaudeTimingTracker = class {
956
+ /**
957
+ * When the provider was last handed control: the newest user message or
958
+ * tool result. Null before the first one — an assistant entry with no
959
+ * preceding boundary (a resumed transcript whose head we never saw) yields
960
+ * NO interval rather than an invented one starting at zero.
961
+ */
962
+ boundaryAt = null;
963
+ openTools = /* @__PURE__ */ new Map();
964
+ /** Feed one line; returns every interval this line CLOSED. */
965
+ observe(line) {
966
+ const closed = [];
967
+ for (const toolUseId of line.toolEnds ?? []) {
968
+ const startedAt = this.openTools.get(toolUseId);
969
+ if (startedAt === void 0) continue;
970
+ this.openTools.delete(toolUseId);
971
+ closed.push({
972
+ kind: "tool",
973
+ id: `tool:${toolUseId}`,
974
+ startedAt,
975
+ endedAt: line.at
976
+ });
977
+ }
978
+ if (line.role === "assistant") {
979
+ if (this.boundaryAt !== null) {
980
+ closed.push({
981
+ kind: "turn",
982
+ id: `turn:${line.id}`,
983
+ startedAt: this.boundaryAt,
984
+ endedAt: line.at
985
+ });
986
+ }
987
+ for (const toolUseId of line.toolStarts ?? []) {
988
+ this.openTools.set(toolUseId, line.at);
989
+ }
990
+ this.boundaryAt = (line.toolStarts?.length ?? 0) > 0 ? null : line.at;
991
+ return closed;
992
+ }
993
+ this.boundaryAt = line.at;
994
+ return closed;
995
+ }
996
+ /**
997
+ * Tool calls still open at close — a killed host, a tool that never
998
+ * returned. Reported so the aggregator can NAME the gap and degrade
999
+ * coverage to PARTIAL rather than quietly summing a shorter total.
1000
+ */
1001
+ unclosedToolIds() {
1002
+ return [...this.openTools.keys()].map((id) => `tool:${id}`);
1003
+ }
1004
+ };
1005
+
1006
+ // lib/session-codex-events.ts
1007
+ function make(kind, payload, providerEventId) {
1008
+ return {
1009
+ version: SESSION_EVENT_VERSION,
1010
+ at: (/* @__PURE__ */ new Date(0)).toISOString(),
1011
+ // stamped by the bridge at observation time
1012
+ provider: "codex",
1013
+ ...providerEventId ? { providerEventId } : {},
1014
+ kind,
1015
+ payload
1016
+ };
1017
+ }
1018
+ function mapCodexThreadEvent(raw) {
1019
+ const event = raw ?? {};
1020
+ switch (event.type) {
1021
+ case "thread.started":
1022
+ return {
1023
+ event: make("session", { threadId: event.thread_id ?? null }),
1024
+ ...event.thread_id ? { threadId: event.thread_id } : {},
1025
+ unrecognized: false
1026
+ };
1027
+ case "turn.started":
1028
+ return { event: null, unrecognized: false };
1029
+ case "turn_context": {
1030
+ const model = (event.turn_context?.model ?? event.model)?.trim();
1031
+ return {
1032
+ event: null,
1033
+ ...model ? { modelId: model } : {},
1034
+ unrecognized: false
1035
+ };
1036
+ }
1037
+ case "turn.completed":
1038
+ return {
1039
+ event: event.usage ? make("usage", {
1040
+ kind: "delta",
1041
+ inputTokens: event.usage.input_tokens ?? 0,
1042
+ outputTokens: event.usage.output_tokens ?? 0,
1043
+ ...typeof event.usage.cached_input_tokens === "number" ? { cacheReadTokens: event.usage.cached_input_tokens } : {},
1044
+ // TPM Slice 2 (AC2.7): real spend visibility OpenAI reports
1045
+ // and Anthropic does not split out — mapped only when present.
1046
+ ...typeof event.usage.reasoning_output_tokens === "number" ? {
1047
+ reasoningOutputTokens: event.usage.reasoning_output_tokens
1048
+ } : {}
1049
+ }) : null,
1050
+ unrecognized: false
1051
+ };
1052
+ case "turn.failed":
1053
+ return {
1054
+ event: make("error", {
1055
+ message: event.error?.message ?? "turn failed"
1056
+ }),
1057
+ unrecognized: false
1058
+ };
1059
+ case "item.completed": {
1060
+ const item = event.item ?? {};
1061
+ const id = item.id;
1062
+ switch (item.type) {
1063
+ case "agent_message":
1064
+ return {
1065
+ event: make("assistant_message", { text: item.text ?? "" }, id),
1066
+ unrecognized: false
1067
+ };
1068
+ case "command_execution":
1069
+ return {
1070
+ event: make(
1071
+ "command",
1072
+ {
1073
+ command: item.command ?? null,
1074
+ exitCode: item.exit_code ?? null,
1075
+ output: item.aggregated_output ?? null
1076
+ },
1077
+ id
1078
+ ),
1079
+ unrecognized: false
1080
+ };
1081
+ case "file_change":
1082
+ return {
1083
+ event: make("file_change", { changes: item.changes ?? null }, id),
1084
+ unrecognized: false
1085
+ };
1086
+ case "mcp_tool_call":
1087
+ return {
1088
+ event: make(
1089
+ "tool_call",
1090
+ {
1091
+ name: item.name ?? null,
1092
+ input: item.arguments ?? null,
1093
+ status: item.status ?? null
1094
+ },
1095
+ id
1096
+ ),
1097
+ unrecognized: false
1098
+ };
1099
+ case "reasoning":
1100
+ return { event: null, unrecognized: false };
1101
+ default:
1102
+ return { event: null, unrecognized: true };
1103
+ }
1104
+ }
1105
+ default:
1106
+ return { event: null, unrecognized: true };
1107
+ }
1108
+ }
1109
+
1110
+ // lib/session-codex-hooks.ts
1111
+ function text(value) {
1112
+ return typeof value === "string" && value.trim() ? value : null;
1113
+ }
1114
+ function mapCodexHook(event, payload) {
1115
+ const modelId = text(payload.model);
1116
+ switch (event) {
1117
+ case "SessionStart":
1118
+ case "SessionEnd":
1119
+ case "PreCompact":
1120
+ case "PostCompact":
1121
+ return {
1122
+ events: [
1123
+ {
1124
+ kind: "session",
1125
+ payload: {
1126
+ lifecycle: event,
1127
+ sessionId: payload.session_id ?? null
1128
+ }
1129
+ }
1130
+ ],
1131
+ modelId
1132
+ };
1133
+ case "UserPromptSubmit": {
1134
+ const prompt = text(payload.prompt);
1135
+ return {
1136
+ events: prompt ? [{ kind: "user_message", payload: { text: prompt } }] : [],
1137
+ modelId
1138
+ };
1139
+ }
1140
+ case "PostToolUse": {
1141
+ const name = text(payload.tool_name) ?? "unknown";
1142
+ const events = [
1143
+ {
1144
+ kind: "tool_call",
1145
+ payload: { name, input: payload.tool_input ?? null }
1146
+ }
1147
+ ];
1148
+ if ("tool_response" in payload) {
1149
+ events.push({
1150
+ kind: "tool_result",
1151
+ payload: { name, result: payload.tool_response }
1152
+ });
1153
+ }
1154
+ return { events, modelId };
1155
+ }
1156
+ case "Stop": {
1157
+ const message = text(payload.last_assistant_message);
1158
+ return {
1159
+ events: message ? [{ kind: "assistant_message", payload: { text: message } }] : [],
1160
+ modelId
1161
+ };
1162
+ }
1163
+ default:
1164
+ return { events: [], modelId };
1165
+ }
1166
+ }
1167
+
1168
+ // lib/session-redact.ts
1169
+ var REDACTED = "\u2039redacted\u203A";
1170
+ var SECRET_PATTERNS = [
1171
+ /\btm[or]?_[A-Za-z0-9_-]{16,}\b/g,
1172
+ /\bgh[posru]_[A-Za-z0-9]{20,}\b/g,
1173
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
1174
+ /\bwhsec_[A-Za-z0-9]{16,}\b/g,
1175
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
1176
+ /\bxox[abpsr]-[A-Za-z0-9-]{10,}\b/g,
1177
+ /\bAIza[A-Za-z0-9_-]{30,}\b/g,
1178
+ /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
1179
+ /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g,
1180
+ /\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}\b/g,
1181
+ /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,
1182
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
1183
+ /\b(Authorization\s*[:=]\s*Bearer\s+)[A-Za-z0-9._~+/=-]{12,}/gi
1184
+ ];
1185
+ var SECRET_ENV_NAMES = [
1186
+ "STACKS_TOKEN",
1187
+ "STACKS_BOOTSTRAP_TOKEN",
1188
+ "STACKS_WEBHOOK_SECRET",
1189
+ "STACKS_WORKLOAD_SVID",
1190
+ "ANTHROPIC_API_KEY",
1191
+ "OPENAI_API_KEY",
1192
+ "GITHUB_TOKEN",
1193
+ "AWS_SECRET_ACCESS_KEY",
1194
+ "R2_SECRET_ACCESS_KEY"
1195
+ ];
1196
+ function createSessionRedactor(opts = {}) {
1197
+ const env = opts.env ?? process.env;
1198
+ const literals = [
1199
+ ...opts.literals ?? [],
1200
+ ...SECRET_ENV_NAMES.map((name) => env[name]).filter(
1201
+ (v) => typeof v === "string" && v.length >= 6
1202
+ )
1203
+ ];
1204
+ const home = opts.homedir?.replace(/\/$/, "");
1205
+ function text2(input) {
1206
+ let out = input;
1207
+ for (const literal of literals) {
1208
+ out = out.split(literal).join(REDACTED);
1209
+ }
1210
+ for (const pattern of SECRET_PATTERNS) {
1211
+ out = out.replace(pattern, REDACTED);
1212
+ }
1213
+ if (home && home.length > 1) {
1214
+ out = out.split(home).join("~");
1215
+ }
1216
+ return out;
1217
+ }
1218
+ function value(input) {
1219
+ if (typeof input === "string") return text2(input);
1220
+ if (Array.isArray(input)) return input.map(value);
1221
+ if (input && typeof input === "object") {
1222
+ const out = {};
1223
+ for (const [k, v] of Object.entries(input)) {
1224
+ out[k] = value(v);
1225
+ }
1226
+ return out;
1227
+ }
1228
+ return input;
1229
+ }
1230
+ return { text: text2, value };
1231
+ }
1232
+
1233
+ // lib/session-spool.ts
1234
+ import { createHash } from "node:crypto";
1235
+ import {
1236
+ appendFileSync,
1237
+ closeSync,
1238
+ mkdirSync as mkdirSync2,
1239
+ openSync,
1240
+ readdirSync,
1241
+ readFileSync as readFileSync2,
1242
+ renameSync as renameSync2,
1243
+ statSync,
1244
+ unlinkSync as unlinkSync2,
1245
+ writeFileSync as writeFileSync3,
1246
+ writeSync
1247
+ } from "node:fs";
1248
+ import { join as join2 } from "node:path";
1249
+ function writeHostMarker(sessionDir, marker) {
1250
+ mkdirSync2(sessionDir, { recursive: true, mode: 448 });
1251
+ const body = {
1252
+ ...marker,
1253
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
1254
+ };
1255
+ writeFileSync3(join2(sessionDir, "host.json"), JSON.stringify(body), {
1256
+ mode: 384
1257
+ });
1258
+ }
1259
+ function markHostTranscript(sessionDir, seen) {
1260
+ const path = join2(sessionDir, "host.json");
1261
+ let marker;
1262
+ try {
1263
+ marker = JSON.parse(readFileSync2(path, "utf8"));
1264
+ } catch {
1265
+ return;
1266
+ }
1267
+ marker.transcriptSeen = seen;
1268
+ writeFileSync3(path, JSON.stringify(marker), { mode: 384 });
1269
+ }
1270
+ function markHostFlushed(sessionDir, ackedParts) {
1271
+ const path = join2(sessionDir, "host.json");
1272
+ let marker;
1273
+ try {
1274
+ marker = JSON.parse(readFileSync2(path, "utf8"));
1275
+ } catch {
1276
+ return;
1277
+ }
1278
+ marker.ackedParts = ackedParts;
1279
+ marker.lastFlushAt = (/* @__PURE__ */ new Date()).toISOString();
1280
+ writeFileSync3(path, JSON.stringify(marker), { mode: 384 });
1281
+ }
1282
+ function markHostExited(sessionDir, exitCode) {
1283
+ const path = join2(sessionDir, "host.json");
1284
+ let marker;
1285
+ try {
1286
+ marker = JSON.parse(readFileSync2(path, "utf8"));
1287
+ } catch {
1288
+ return;
1289
+ }
1290
+ marker.exitedAt = (/* @__PURE__ */ new Date()).toISOString();
1291
+ marker.exitCode = exitCode;
1292
+ writeFileSync3(path, JSON.stringify(marker), { mode: 384 });
1293
+ }
1294
+ var SPOOL_PART_ROTATE_BYTES = 6 * 1024 * 1024;
1295
+ var PART_FILE = /^part-(\d{6})\.ndjson$/;
1296
+ var SessionSpool = class {
1297
+ dir;
1298
+ currentPart;
1299
+ constructor(root, sessionId) {
1300
+ this.dir = join2(root, sessionId);
1301
+ mkdirSync2(this.dir, { recursive: true, mode: 448 });
1302
+ const existing = this.listPartNumbers();
1303
+ this.currentPart = existing.length ? Math.max(...existing) : 0;
1304
+ }
1305
+ get directory() {
1306
+ return this.dir;
1307
+ }
1308
+ partPath(part) {
1309
+ return join2(this.dir, `part-${String(part).padStart(6, "0")}.ndjson`);
1310
+ }
1311
+ listPartNumbers() {
1312
+ return readdirSync(this.dir).map((name) => PART_FILE.exec(name)).filter((m) => m !== null).map((m) => Number(m[1]));
1313
+ }
1314
+ /**
1315
+ * Append one ALREADY-REDACTED NDJSON line durably (0600, fsync'd). Rotates
1316
+ * to the next part when the current one would cross the ingestion cap.
1317
+ */
1318
+ append(redactedLine) {
1319
+ const path = this.partPath(this.currentPart);
1320
+ let size = 0;
1321
+ try {
1322
+ size = statSync(path).size;
1323
+ } catch {
1324
+ }
1325
+ if (size > 0 && size + Buffer.byteLength(redactedLine) > SPOOL_PART_ROTATE_BYTES) {
1326
+ this.currentPart += 1;
1327
+ }
1328
+ const target = this.partPath(this.currentPart);
1329
+ const fd = openSync(target, "a", 384);
1330
+ try {
1331
+ writeSync(fd, redactedLine);
1332
+ } finally {
1333
+ closeSync(fd);
1334
+ }
1335
+ }
1336
+ /**
1337
+ * Advance past a flushed (acked + deleted) part. Ingestion slots are
1338
+ * append-only — same part + different checksum is a permanent CONFLICT —
1339
+ * so a slot the server acknowledged must never be reused for new events.
1340
+ */
1341
+ advancePast(part) {
1342
+ if (part >= this.currentPart) this.currentPart = part + 1;
1343
+ }
1344
+ /** Cheap append without rotation checks (tests / recovery merges). */
1345
+ appendRaw(part, redactedLine) {
1346
+ appendFileSync(this.partPath(part), redactedLine, { mode: 384 });
1347
+ if (part > this.currentPart) this.currentPart = part;
1348
+ }
1349
+ /** Every pending part with its convergence checksum, ordered by number. */
1350
+ pendingParts() {
1351
+ return this.listPartNumbers().sort((a, b) => a - b).map((part) => {
1352
+ const path = this.partPath(part);
1353
+ const body = readFileSync2(path, "utf8");
1354
+ return {
1355
+ part,
1356
+ path,
1357
+ byteSize: Buffer.byteLength(body),
1358
+ checksum: createHash("sha256").update(body, "utf8").digest("hex")
1359
+ };
1360
+ });
1361
+ }
1362
+ /** Read one part's redacted text for upload. */
1363
+ readPart(part) {
1364
+ return readFileSync2(this.partPath(part), "utf8");
1365
+ }
1366
+ /**
1367
+ * Delete a part ONLY on a server acknowledgement of this exact content.
1368
+ * `acknowledgedChecksum` is the STORED checksum from the server's ack; when
1369
+ * the server's re-redaction changed the bytes, the caller records the acked
1370
+ * checksum into its manifest first, then confirms deletion explicitly with
1371
+ * `force`. An audit stub can never satisfy this — a refusal keeps the file.
1372
+ */
1373
+ deleteAcknowledged(part, acknowledgedChecksum, opts = {}) {
1374
+ const path = this.partPath(part);
1375
+ let body;
1376
+ try {
1377
+ body = readFileSync2(path, "utf8");
1378
+ } catch {
1379
+ return false;
1380
+ }
1381
+ const localChecksum = createHash("sha256").update(body, "utf8").digest("hex");
1382
+ if (localChecksum !== acknowledgedChecksum && !opts.force) {
1383
+ return false;
1384
+ }
1385
+ const tomb = `${path}.acked`;
1386
+ renameSync2(path, tomb);
1387
+ unlinkSync2(tomb);
1388
+ return true;
1389
+ }
1390
+ };
1391
+
1392
+ // lib/session-host.ts
1393
+ function defaultSpoolRoot() {
1394
+ return join3(homedir(), ".config", "stacks", "session-spool");
1395
+ }
1396
+ function bearerSourceOf(plan, log) {
1397
+ return plan.configPath ? createConfigBearerSource({
1398
+ configPath: plan.configPath,
1399
+ fallback: plan.bearer,
1400
+ log
1401
+ }) : staticBearerSource(plan.bearer);
1402
+ }
1403
+ function sessionCallTool(mcpUrl, bearerSource, sessionId) {
1404
+ const attempt = async (bearer, name, args) => {
1405
+ const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
1406
+ requestInit: {
1407
+ headers: {
1408
+ Authorization: `Bearer ${bearer}`,
1409
+ "X-Stacks-Session-Id": sessionId
1410
+ }
1411
+ }
1412
+ });
1413
+ const client = new Client({
1414
+ name: "stacks-session-host",
1415
+ version: RUNNER_VERSION
1416
+ });
1417
+ await client.connect(transport, { timeout: 6e4 });
1418
+ try {
1419
+ const res = await client.callTool({ name, arguments: args }, void 0, {
1420
+ timeout: 6e4
1421
+ });
1422
+ if (res.isError) {
1423
+ const text2 = Array.isArray(res.content) && res.content[0] && "text" in res.content[0] ? res.content[0].text : JSON.stringify(res.content);
1424
+ throw new Error(`${name} failed: ${text2}`);
1425
+ }
1426
+ return res.structuredContent ?? {};
1427
+ } finally {
1428
+ await client.close();
1429
+ }
1430
+ };
1431
+ return async (name, args) => {
1432
+ const bearer = bearerSource.get();
1433
+ try {
1434
+ return await attempt(bearer, name, args);
1435
+ } catch (error) {
1436
+ if (!isUnauthorizedishError(error)) throw error;
1437
+ const next = await bearerSource.refresh(bearer);
1438
+ if (!next || next === bearer) throw error;
1439
+ return attempt(next, name, args);
1440
+ }
1441
+ };
1442
+ }
1443
+ function claudeHookSettings(runnerBin, sessionDir) {
1444
+ const hook = (event) => [
1445
+ {
1446
+ hooks: [
1447
+ {
1448
+ type: "command",
1449
+ // argv carries only the runner binary, the session DIRECTORY, and
1450
+ // the event name — never credentials or transcript content (§15.1).
1451
+ command: `${runnerBin} session-hook --dir ${JSON.stringify(sessionDir)} --event ${event}`
1452
+ }
1453
+ ]
1454
+ }
1455
+ ];
1456
+ return {
1457
+ hooks: {
1458
+ SessionStart: hook("SessionStart"),
1459
+ UserPromptSubmit: hook("UserPromptSubmit"),
1460
+ Stop: hook("Stop"),
1461
+ SessionEnd: hook("SessionEnd")
1462
+ }
1463
+ };
1464
+ }
1465
+ async function endRepoState(repoRoot) {
1466
+ const run = (args) => new Promise((resolve) => {
1467
+ const child = spawn("git", args, { cwd: repoRoot });
1468
+ let stdout = "";
1469
+ child.stdout?.on("data", (chunk) => stdout += String(chunk));
1470
+ child.once("error", () => resolve({ code: 1, stdout: "" }));
1471
+ child.once("exit", (code) => resolve({ code: code ?? 1, stdout }));
1472
+ });
1473
+ const [branch, head, status] = await Promise.all([
1474
+ run(["symbolic-ref", "--short", "-q", "HEAD"]),
1475
+ run(["rev-parse", "HEAD"]),
1476
+ run(["status", "--porcelain"])
1477
+ ]);
1478
+ return {
1479
+ branch: branch.code === 0 ? branch.stdout.trim() || null : null,
1480
+ head: head.code === 0 ? head.stdout.trim() || null : null,
1481
+ dirty: status.code === 0 ? status.stdout.trim().length > 0 : null
1482
+ };
1483
+ }
1484
+ async function runClaudeSessionHost(plan, deps = {}) {
1485
+ const log = deps.log ?? ((line) => process.stderr.write(`${line}
1486
+ `));
1487
+ const spoolRoot = plan.spoolRoot ?? defaultSpoolRoot();
1488
+ const spool = new SessionSpool(spoolRoot, plan.sessionId);
1489
+ const sessionDir = spool.directory;
1490
+ writeHostMarker(sessionDir, {
1491
+ pid: process.pid,
1492
+ provider: plan.provider,
1493
+ mode: plan.mode === "watch" ? "watch" : "launch",
1494
+ captureTrace: plan.captureTrace !== false,
1495
+ // F1/P3: the transcript this host is bound to — what a compaction hook
1496
+ // matches on to find its session without guessing from cwd.
1497
+ ...plan.transcriptPath ? { transcriptPath: plan.transcriptPath } : {}
1498
+ });
1499
+ const traceCapture = plan.captureTrace !== false;
1500
+ const bearerSource = bearerSourceOf(plan, log);
1501
+ const bridge = new SessionBridge({
1502
+ jentrixBaseUrl: plan.jentrixBaseUrl,
1503
+ bearer: () => bearerSource.get(),
1504
+ onUnauthorized: (failed) => bearerSource.refresh(failed),
1505
+ sessionId: plan.sessionId,
1506
+ provider: plan.provider,
1507
+ spool,
1508
+ redactor: createSessionRedactor({ homedir: homedir() }),
1509
+ callTool: deps.callTool ?? sessionCallTool(plan.mcpUrl, bearerSource, plan.sessionId),
1510
+ fetchImpl: deps.fetchImpl,
1511
+ traceCapture,
1512
+ log
1513
+ });
1514
+ bridge.recordCapabilities(
1515
+ plan.provider === "codex" ? {
1516
+ provider: "codex",
1517
+ providerVersion: null,
1518
+ observable: [
1519
+ "session",
1520
+ "user_message",
1521
+ "assistant_message",
1522
+ "tool_call",
1523
+ "tool_result"
1524
+ ],
1525
+ notObservable: ["command", "file_change", "plan", "usage", "error"]
1526
+ } : {
1527
+ provider: "claude",
1528
+ providerVersion: null,
1529
+ observable: [
1530
+ "session",
1531
+ "user_message",
1532
+ "assistant_message",
1533
+ "tool_call",
1534
+ "tool_result",
1535
+ "usage",
1536
+ "error"
1537
+ ],
1538
+ notObservable: ["command", "file_change", "plan"]
1539
+ }
1540
+ );
1541
+ bridge.startObserving();
1542
+ const watch = plan.mode === "watch";
1543
+ let child = null;
1544
+ if (!watch) {
1545
+ const runnerBin = process.argv[1] ?? "stacks-runner";
1546
+ const settingsPath = join3(sessionDir, "claude-hooks.json");
1547
+ writeFileSync4(
1548
+ settingsPath,
1549
+ JSON.stringify(claudeHookSettings(runnerBin, sessionDir), null, 2),
1550
+ { mode: 384 }
1551
+ );
1552
+ const args = ["--settings", settingsPath];
1553
+ if (plan.resumeProviderSessionId) {
1554
+ args.push("--resume", plan.resumeProviderSessionId);
1555
+ }
1556
+ child = (deps.spawnImpl ?? spawn)(plan.executablePath ?? "claude", args, {
1557
+ cwd: plan.repoRoot,
1558
+ stdio: "inherit"
1559
+ });
1560
+ }
1561
+ const hookDir = plan.hookDir ?? sessionDir;
1562
+ let hookOffset = 0;
1563
+ if (watch && plan.hookDir && !plan.importHistory) {
1564
+ try {
1565
+ hookOffset = readFileSync3(join3(hookDir, "hooks.ndjson"), "utf8").length;
1566
+ } catch {
1567
+ }
1568
+ }
1569
+ let transcriptPath = watch ? plan.transcriptPath ?? null : null;
1570
+ let transcriptOffset = 0;
1571
+ const timing = new ClaudeTimingTracker();
1572
+ let transcriptSeen = false;
1573
+ let transcriptWarned = false;
1574
+ const hostStartedMs = Date.now();
1575
+ const noteTranscriptSeen = () => {
1576
+ if (!transcriptSeen) {
1577
+ transcriptSeen = true;
1578
+ markHostTranscript(sessionDir, true);
1579
+ }
1580
+ };
1581
+ if (watch && transcriptPath && !plan.importHistory) {
1582
+ try {
1583
+ transcriptOffset = statSync2(transcriptPath).size;
1584
+ noteTranscriptSeen();
1585
+ } catch {
1586
+ }
1587
+ }
1588
+ let bound = watch;
1589
+ let sessionEnded = false;
1590
+ let lastPeriodicFlushAt = 0;
1591
+ const poll = async () => {
1592
+ const endRequestPath = join3(sessionDir, "end-request.json");
1593
+ if (!sessionEnded && existsSync(endRequestPath)) {
1594
+ try {
1595
+ unlinkSync3(endRequestPath);
1596
+ } catch {
1597
+ }
1598
+ sessionEnded = true;
1599
+ }
1600
+ const { lines, offset } = readHookLines(hookDir, hookOffset);
1601
+ hookOffset = offset;
1602
+ for (const line of lines) {
1603
+ if (plan.provider === "codex" && line.payload.session_id !== plan.providerSessionId) {
1604
+ continue;
1605
+ }
1606
+ if (plan.provider === "codex") {
1607
+ const mapped = mapCodexHook(line.event, line.payload);
1608
+ if (mapped.modelId) bridge.observeModel(mapped.modelId);
1609
+ for (const event of mapped.events) bridge.record(event);
1610
+ }
1611
+ if (line.event === "SessionStart" && line.payload.session_id && !bound) {
1612
+ bound = true;
1613
+ transcriptPath = line.payload.transcript_path ?? null;
1614
+ try {
1615
+ await bridge.tool("attach_agent_session", {
1616
+ sessionId: plan.sessionId,
1617
+ provider: plan.provider,
1618
+ connection: { kind: "local", installationId: plan.installationId },
1619
+ providerSessionId: line.payload.session_id,
1620
+ idempotencyKey: `bind:${plan.sessionId}:${line.payload.session_id}`
1621
+ });
1622
+ log(
1623
+ `Capture connected \xB7 provider session ${line.payload.session_id}`
1624
+ );
1625
+ } catch (error) {
1626
+ log(
1627
+ `capture: provider binding failed (${error instanceof Error ? error.message : "unknown"})`
1628
+ );
1629
+ }
1630
+ }
1631
+ if (line.event === "SessionEnd" || line.event === "Stop") {
1632
+ await bridge.flushParts().catch(() => void 0);
1633
+ markHostFlushed(sessionDir, bridge.ackedPartCount);
1634
+ }
1635
+ if (line.event === "SessionEnd") sessionEnded = true;
1636
+ }
1637
+ if (plan.provider === "claude" && transcriptPath) {
1638
+ try {
1639
+ const size = statSync2(transcriptPath).size;
1640
+ noteTranscriptSeen();
1641
+ if (size > transcriptOffset) {
1642
+ const buffer = readFileSync3(transcriptPath);
1643
+ const body = buffer.subarray(transcriptOffset).toString("utf8");
1644
+ transcriptOffset = buffer.byteLength;
1645
+ for (const rawLine of body.split("\n").filter(Boolean)) {
1646
+ const mapped = mapClaudeTranscriptLine(rawLine);
1647
+ if (mapped.unrecognized) bridge.countUnrecognized();
1648
+ if (mapped.modelId) bridge.observeModel(mapped.modelId);
1649
+ if (mapped.timing) {
1650
+ for (const interval of timing.observe(mapped.timing)) {
1651
+ bridge.recordInterval(interval);
1652
+ }
1653
+ }
1654
+ for (const event of mapped.events) bridge.record(event);
1655
+ }
1656
+ }
1657
+ } catch {
1658
+ if (!transcriptSeen && !transcriptWarned && Date.now() - hostStartedMs > 6e4) {
1659
+ transcriptWarned = true;
1660
+ markHostTranscript(sessionDir, false);
1661
+ log(
1662
+ `capture: transcript never appeared at ${transcriptPath} \u2014 observing no events; usage will be unavailable. End the session and re-align to rebind.`
1663
+ );
1664
+ }
1665
+ }
1666
+ }
1667
+ const flushRequestPath = join3(sessionDir, "flush-request.json");
1668
+ if (existsSync(flushRequestPath)) {
1669
+ const acked = await bridge.flushUsageNow().catch(() => false);
1670
+ if (acked) {
1671
+ try {
1672
+ unlinkSync3(flushRequestPath);
1673
+ } catch {
1674
+ }
1675
+ }
1676
+ }
1677
+ const nowMs = Date.now();
1678
+ if (!sessionEnded && nowMs - lastPeriodicFlushAt >= 15e3) {
1679
+ lastPeriodicFlushAt = nowMs;
1680
+ await bridge.flushParts().catch(() => void 0);
1681
+ markHostFlushed(sessionDir, bridge.ackedPartCount);
1682
+ }
1683
+ await bridge.maybeHeartbeat();
1684
+ };
1685
+ const timer = setInterval(() => {
1686
+ void poll();
1687
+ }, 2e3);
1688
+ const exitCode = await (watch ? (
1689
+ // Watch mode: live capture beside the operator's own provider process.
1690
+ // End signals: the SessionEnd lifecycle hook, an end request from
1691
+ // `jentrix session end`, or a heartbeat 409 (session terminal
1692
+ // server-side — the out-of-band case the hooks can never deliver).
1693
+ new Promise((resolve) => {
1694
+ const check = setInterval(() => {
1695
+ if (sessionEnded || bridge.sessionInactive) {
1696
+ clearInterval(check);
1697
+ resolve(0);
1698
+ }
1699
+ }, 1e3);
1700
+ })
1701
+ ) : new Promise((resolve) => {
1702
+ child.once("error", () => resolve(1));
1703
+ child.once(
1704
+ "exit",
1705
+ (code, signal) => resolve(code ?? (signal ? 130 : 0))
1706
+ );
1707
+ }));
1708
+ clearInterval(timer);
1709
+ await poll().catch(() => void 0);
1710
+ await bridge.flushParts().catch(() => void 0);
1711
+ for (const id of timing.unclosedToolIds()) {
1712
+ bridge.recordUnclosedInterval("tool", id);
1713
+ }
1714
+ const end = await endRepoState(plan.repoRoot);
1715
+ const result = await bridge.complete({
1716
+ outcome: exitCode === 0 ? "COMPLETED" : "INTERRUPTED",
1717
+ end
1718
+ }).catch((error) => {
1719
+ log(
1720
+ `capture: completion failed (${error instanceof Error ? error.message : "unknown"}) \u2014 spool retained for retry`
1721
+ );
1722
+ return null;
1723
+ });
1724
+ if (!result) {
1725
+ markHostExited(sessionDir, 1);
1726
+ return 1;
1727
+ }
1728
+ const output = result.finalResponseArtifactId ? ` \xB7 output ${result.finalResponseArtifactId}` : " \xB7 output not observed";
1729
+ log(
1730
+ !traceCapture ? `Session ${plan.sessionId} closed \xB7 TRACE capture off (typed artifacts only) \xB7 summary ${result.summaryArtifactId ?? "\u2014"}${output}` : result.captureComplete ? `Session ${plan.sessionId} closed \xB7 capture complete \xB7 summary ${result.summaryArtifactId ?? "\u2014"}${output}` : `Session ${plan.sessionId} closed \xB7 CAPTURE PENDING (${result.pendingParts} part(s)) \u2014 re-run \`jentrix session status ${plan.sessionId}\``
1731
+ );
1732
+ const finalCode = result.captureComplete || !traceCapture ? exitCode : exitCode || 1;
1733
+ markHostExited(sessionDir, finalCode);
1734
+ return finalCode;
1735
+ }
1736
+ async function runCodexSessionHost(plan, deps = {}) {
1737
+ const log = deps.log ?? ((line) => process.stderr.write(`${line}
1738
+ `));
1739
+ const spoolRoot = plan.spoolRoot ?? defaultSpoolRoot();
1740
+ const spool = new SessionSpool(spoolRoot, plan.sessionId);
1741
+ writeHostMarker(spool.directory, {
1742
+ pid: process.pid,
1743
+ provider: "codex",
1744
+ mode: "launch"
1745
+ });
1746
+ const codexBearerSource = bearerSourceOf(plan, log);
1747
+ const bridge = new SessionBridge({
1748
+ jentrixBaseUrl: plan.jentrixBaseUrl,
1749
+ bearer: () => codexBearerSource.get(),
1750
+ onUnauthorized: (failed) => codexBearerSource.refresh(failed),
1751
+ sessionId: plan.sessionId,
1752
+ provider: "codex",
1753
+ spool,
1754
+ redactor: createSessionRedactor({ homedir: homedir() }),
1755
+ callTool: sessionCallTool(plan.mcpUrl, codexBearerSource, plan.sessionId),
1756
+ fetchImpl: deps.fetchImpl,
1757
+ log
1758
+ });
1759
+ bridge.recordCapabilities({
1760
+ provider: "codex",
1761
+ providerVersion: null,
1762
+ observable: [
1763
+ "session",
1764
+ "assistant_message",
1765
+ "tool_call",
1766
+ "command",
1767
+ "file_change",
1768
+ "usage",
1769
+ "error"
1770
+ ],
1771
+ notObservable: ["plan", "tool_result"]
1772
+ });
1773
+ bridge.startObserving();
1774
+ const { Codex } = await import("@openai/codex-sdk");
1775
+ const codex = new Codex(
1776
+ plan.executablePath ? { codexPathOverride: plan.executablePath } : {}
1777
+ );
1778
+ const thread = plan.resumeProviderSessionId ? codex.resumeThread(plan.resumeProviderSessionId, {
1779
+ workingDirectory: plan.repoRoot,
1780
+ skipGitRepoCheck: true
1781
+ }) : codex.startThread({
1782
+ workingDirectory: plan.repoRoot,
1783
+ skipGitRepoCheck: true
1784
+ });
1785
+ let bound = Boolean(plan.resumeProviderSessionId);
1786
+ let currentModel = null;
1787
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1788
+ const ask = (prompt) => new Promise((resolve) => {
1789
+ rl.question(prompt, (answer) => resolve(answer));
1790
+ rl.once("close", () => resolve(null));
1791
+ });
1792
+ log("Codex connected session \u2014 empty line or Ctrl-D ends the session.");
1793
+ let outcome = "COMPLETED";
1794
+ try {
1795
+ for (; ; ) {
1796
+ const input = await ask("codex> ");
1797
+ if (input === null || input.trim() === "") break;
1798
+ const turnId = `turn:${Date.now()}`;
1799
+ bridge.markTurnStarted(turnId);
1800
+ bridge.record({ kind: "user_message", payload: { text: input } });
1801
+ try {
1802
+ const { events } = await thread.runStreamed(input);
1803
+ for await (const raw of events) {
1804
+ const mapped = mapCodexThreadEvent(raw);
1805
+ if (mapped.unrecognized) bridge.countUnrecognized();
1806
+ if (mapped.modelId) {
1807
+ currentModel = mapped.modelId;
1808
+ bridge.observeModel(mapped.modelId);
1809
+ }
1810
+ if (mapped.threadId && !bound) {
1811
+ bound = true;
1812
+ try {
1813
+ await bridge.tool("attach_agent_session", {
1814
+ sessionId: plan.sessionId,
1815
+ provider: "codex",
1816
+ connection: {
1817
+ kind: "local",
1818
+ installationId: plan.installationId
1819
+ },
1820
+ providerSessionId: mapped.threadId,
1821
+ idempotencyKey: `bind:${plan.sessionId}:${mapped.threadId}`
1822
+ });
1823
+ log(`Capture connected \xB7 provider thread ${mapped.threadId}`);
1824
+ } catch (error) {
1825
+ log(
1826
+ `capture: provider binding failed (${error instanceof Error ? error.message : "unknown"})`
1827
+ );
1828
+ }
1829
+ }
1830
+ if (mapped.event) {
1831
+ const recorded = bridge.record({
1832
+ ...mapped.event,
1833
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1834
+ payload: mapped.event.kind === "usage" ? {
1835
+ ...mapped.event.payload,
1836
+ turnId,
1837
+ // TPM Slice 2 (AC2.4): the model the runtime last named
1838
+ // for this thread rides the receipt — absent when no
1839
+ // turn_context was ever observed (null-model bucket,
1840
+ // disclosed, never guessed).
1841
+ ...currentModel ? { modelId: currentModel } : {}
1842
+ } : mapped.event.payload
1843
+ });
1844
+ if (recorded.kind === "assistant_message" && typeof recorded.payload?.text === "string") {
1845
+ process.stdout.write(
1846
+ `${recorded.payload.text}
1847
+ `
1848
+ );
1849
+ }
1850
+ }
1851
+ }
1852
+ } catch (error) {
1853
+ outcome = "INTERRUPTED";
1854
+ bridge.recordGap(
1855
+ `provider turn failed: ${error instanceof Error ? error.message : "unknown"}`
1856
+ );
1857
+ log("codex turn failed \u2014 session will close as INTERRUPTED");
1858
+ break;
1859
+ }
1860
+ bridge.markTurnEnded(turnId);
1861
+ await bridge.flushParts().catch(() => void 0);
1862
+ await bridge.maybeHeartbeat();
1863
+ }
1864
+ } finally {
1865
+ rl.close();
1866
+ }
1867
+ const end = await endRepoState(plan.repoRoot);
1868
+ const result = await bridge.complete({ outcome, end }).catch(() => null);
1869
+ if (!result) {
1870
+ markHostExited(spool.directory, 1);
1871
+ return 1;
1872
+ }
1873
+ log(
1874
+ result.captureComplete ? `Session ${plan.sessionId} closed \xB7 capture complete \xB7 output ${result.finalResponseArtifactId ?? "not observed"}` : `Session ${plan.sessionId} closed \xB7 CAPTURE PENDING (${result.pendingParts} part(s))`
1875
+ );
1876
+ const finalCode = result.captureComplete ? 0 : 1;
1877
+ markHostExited(spool.directory, finalCode);
1878
+ return finalCode;
1879
+ }
1880
+ async function runSessionHost(plan, deps = {}) {
1881
+ return plan.mode === "watch" || plan.provider === "claude" ? runClaudeSessionHost(plan, deps) : runCodexSessionHost(plan, deps);
1882
+ }
1883
+ export {
1884
+ appendHookEvent,
1885
+ claudeHookSettings,
1886
+ defaultSpoolRoot,
1887
+ readHookLines,
1888
+ runClaudeSessionHost,
1889
+ runCodexSessionHost,
1890
+ runSessionHost,
1891
+ safeParse,
1892
+ sessionCallTool
1893
+ };
1894
+ //# sourceMappingURL=session-host-2Y7475EG.js.map