@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.
- package/README.md +385 -0
- package/dist/chunk-A6NTXRVS.js +7 -0
- package/dist/chunk-A6NTXRVS.js.map +7 -0
- package/dist/chunk-N2UNVXHV.js +184 -0
- package/dist/chunk-N2UNVXHV.js.map +7 -0
- package/dist/chunk-PMRDMBQR.js +35 -0
- package/dist/chunk-PMRDMBQR.js.map +7 -0
- package/dist/local-probes-24XPDSVY.js +495 -0
- package/dist/local-probes-24XPDSVY.js.map +7 -0
- package/dist/runner-cli.js +288 -0
- package/dist/runner-cli.js.map +7 -0
- package/dist/runner-config-THT646ZG.js +15 -0
- package/dist/runner-config-THT646ZG.js.map +7 -0
- package/dist/runner-setup-JHWM6BYD.js +153 -0
- package/dist/runner-setup-JHWM6BYD.js.map +7 -0
- package/dist/session-hook-log-KNO6C4BF.js +11 -0
- package/dist/session-hook-log-KNO6C4BF.js.map +7 -0
- package/dist/session-host-2Y7475EG.js +1894 -0
- package/dist/session-host-2Y7475EG.js.map +7 -0
- package/dist/workflow-runner.js +6414 -0
- package/dist/workflow-runner.js.map +7 -0
- package/package.json +49 -0
- package/rubrics/executor-evidence.md +51 -0
- package/rubrics/reviewer-disposition.md +81 -0
|
@@ -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
|