create-principles-disciple 1.145.42 → 1.146.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/_release/product-identity.json +2 -2
  2. package/console/dist/server/index.js +8 -0
  3. package/console/dist/server/routes/examiner-executor.d.ts +260 -0
  4. package/console/dist/server/routes/examiner-executor.js +628 -0
  5. package/console/dist/server/routes/examiner.d.ts +52 -0
  6. package/console/dist/server/routes/examiner.js +299 -0
  7. package/console/dist/ui/App.js +6 -3
  8. package/console/dist/ui/api.d.ts +138 -0
  9. package/console/dist/ui/api.js +56 -0
  10. package/console/dist/ui/components/layout/app-sidebar.d.ts +10 -0
  11. package/console/dist/ui/components/layout/app-sidebar.js +10 -2
  12. package/console/dist/ui/i18n/en.json +63 -0
  13. package/console/dist/ui/i18n/zh-CN.json +63 -0
  14. package/console/dist/ui/pages/examiner/ExaminerPage.d.ts +15 -0
  15. package/console/dist/ui/pages/examiner/ExaminerPage.js +158 -0
  16. package/console/dist/ui/pages/principles/PrincipleDetailPage.js +2 -1
  17. package/console/dist/ui/utils/runtime-mode.d.ts +11 -0
  18. package/console/dist/ui/utils/runtime-mode.js +11 -0
  19. package/console/dist/web/assets/app.css +44 -0
  20. package/console/dist/web/assets/app.js +33 -31
  21. package/console/lab/lib/challenge-actor.mjs +394 -0
  22. package/console/lab/lib/challenge-arms.mjs +102 -0
  23. package/console/lab/lib/challenge-behavior-report.mjs +2484 -0
  24. package/console/lab/lib/challenge-compile.mjs +211 -0
  25. package/console/lab/lib/challenge-freeze.mjs +215 -0
  26. package/console/lab/lib/challenge-isolation-probe.mjs +550 -0
  27. package/console/lab/lib/challenge-ledger.mjs +206 -0
  28. package/console/lab/lib/challenge-openclaw-host.mjs +630 -0
  29. package/console/lab/lib/challenge-runner.mjs +868 -0
  30. package/console/lab/lib/challenge-stage-r-gate.mjs +458 -0
  31. package/console/lab/lib/challenge-statistics.mjs +591 -0
  32. package/console/lab/lib/claim-evidence-env.mjs +652 -0
  33. package/console/lab/lib/claim-evidence-scorer.mjs +784 -0
  34. package/console/lab/lib/examiner-injection-evidence.mjs +367 -0
  35. package/console/lab/lib/examiner-study.mjs +1203 -0
  36. package/console/lab/lib/examiner-v07-cases.mjs +187 -0
  37. package/console/lab/lib/examiner-v2-cases.mjs +61 -0
  38. package/console/lab/lib/fixture.mjs +382 -0
  39. package/console/lab/lib/g2.mjs +146 -0
  40. package/console/lab/lib/host-actor.mjs +987 -0
  41. package/console/lab/lib/sidecar.mjs +273 -0
  42. package/package.json +1 -1
  43. package/pd-cli/package.json +1 -1
  44. package/release-manager/package.json +1 -1
@@ -0,0 +1,987 @@
1
+ // PRI-966 G2 — Codex HOST transport for the real-actor study (host-native Luna).
2
+ //
3
+ // This is the HOST-NATIVE execution path, deliberately distinct from the
4
+ // pi-ai EXTERNAL path in lib/actor.mjs:
5
+ //
6
+ // external (actor.mjs): PD owns every round through PiAiRuntimeAdapter +
7
+ // budgetedFetch — an HTTP/USD fail-closed ledger IS the cost boundary.
8
+ // host (this file): the model executes inside the OWNER'S Codex CLI. PD
9
+ // only spawns the official headless interface (`codex exec --json`,
10
+ // then `codex exec resume <session-id>`) as a research-side subprocess.
11
+ //
12
+ // Because host calls never cross PD's fetch boundary, the P1-01 HTTP ledger
13
+ // and the USD cost guard DO NOT APPLY here and MUST NOT be simulated. The
14
+ // enforceable boundary is the PROCESS boundary: every spawn is reserved
15
+ // against a hierarchical process ledger (opportunity → arm → study) BEFORE
16
+ // the child exists; token totals are OBSERVED from the host's own event
17
+ // stream (token_count) and stop the NEXT spawn when exceeded — they can never
18
+ // abort a round already executing on the host. Both facts are recorded as
19
+ // such: exec/process caps are hard pre-send limits, token totals are
20
+ // observed feedback limits (rc-9 — no silent, no mislabeled guarantees).
21
+ //
22
+ // Session discipline (per prereg deliveryInvariance/actorIsolation):
23
+ // - ONE host session per OPPORTUNITY (rounds within an opportunity share
24
+ // the session so observations accumulate; a new opportunity starts a
25
+ // fresh session so no task text leaks across opportunities);
26
+ // - the session id comes ONLY from the host's own event stream
27
+ // (thread_settings_applied / item_completed / token_count thread_id) —
28
+ // `resume --last` is FORBIDDEN (shared CODEX_HOME means "last" can pick
29
+ // a user session); without a session id the next round fails loudly;
30
+ // - per-arm scratch root is the spawn cwd (-C): no shared working state.
31
+ //
32
+ // Model identity (task §3.2): requested `-m` vs the host's OWN report
33
+ // (thread_settings_applied.thread_settings.model). Mismatch or absence is
34
+ // never papered over: the snapshot carries MODEL_IDENTITY_MISMATCH /
35
+ // MODEL_IDENTITY_UNVERIFIED and the runner must exclude such arms from
36
+ // formal results (dataQuality UNCERTAIN). An offline spawn-stub test proves
37
+ // the wiring, NOT connectivity with a real model.
38
+
39
+ import { spawn, spawnSync } from 'node:child_process';
40
+ import fs from 'node:fs';
41
+ import os from 'node:os';
42
+ import path from 'node:path';
43
+
44
+ /** Thrown BEFORE a child process is created when any ledger level refuses.
45
+ * Carries the refusing scope and its observed/cap values for audit. */
46
+ export class HostBudgetExhaustedError extends Error {
47
+ constructor({ scope, limit, used, cap, reason }) {
48
+ super(
49
+ `host process budget exhausted at '${scope}' (limit: ${limit}, used: ${JSON.stringify(used)}, cap: ${limit === 'tokens' ? cap : JSON.stringify(cap)}); ` +
50
+ `refusing the NEXT exec spawn (pre-spawn hard limit)${reason ? ` — ${reason}` : ''}`,
51
+ );
52
+ this.name = 'HostBudgetExhaustedError';
53
+ this.code = 'host_budget_exhausted';
54
+ this.scope = scope;
55
+ this.limit = limit;
56
+ this.used = used;
57
+ this.cap = cap;
58
+ }
59
+ }
60
+
61
+ function nonNegativeInt(value) {
62
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
63
+ }
64
+
65
+ /**
66
+ * Hierarchical PROCESS budget ledger for host exec calls. Levels never reset;
67
+ * checks walk the whole ancestor chain (plus the active child) BEFORE any
68
+ * commit. Token/wall-clock totals are observed feedback (checked pre-spawn,
69
+ * recorded post-round); exec and session counts are true pre-spawn hard caps.
70
+ */
71
+ export function createHostProcessLedger({ scope, caps = {}, parent = null } = {}) {
72
+ if (typeof scope !== 'string' || scope.length === 0) {
73
+ throw new Error('createHostProcessLedger: scope is required');
74
+ }
75
+ if (parent !== null && typeof parent.reserveExec !== 'function') {
76
+ throw new Error('createHostProcessLedger: parent must be a host process ledger');
77
+ }
78
+ const limitCaps = {
79
+ maxExecProcesses: nonNegativeInt(caps.maxExecProcesses),
80
+ maxSessions: nonNegativeInt(caps.maxSessions),
81
+ maxTokens: nonNegativeInt(caps.maxTokens),
82
+ wallMsBudgetMs: nonNegativeInt(caps.wallMsBudgetMs),
83
+ };
84
+ for (const [name, value] of Object.entries(caps)) {
85
+ if (!(name in limitCaps) && value !== undefined) {
86
+ throw new Error(`createHostProcessLedger(${scope}): unknown cap '${name}' — host caps are maxExecProcesses|maxSessions|maxTokens|wallMsBudgetMs`);
87
+ }
88
+ }
89
+ let activeChild = null;
90
+ const used = { execCount: 0, sessionCount: 0, tokensObserved: 0, wallMsObserved: 0 };
91
+
92
+ const chain = () => {
93
+ const nodes = activeChild !== null ? [activeChild] : [];
94
+ for (let node = ledger; node !== null; node = node.parentScope) nodes.push(node);
95
+ return nodes;
96
+ };
97
+
98
+ const ledger = {
99
+ scope,
100
+ caps: limitCaps,
101
+ parentScope: parent,
102
+ used() {
103
+ return { ...used };
104
+ },
105
+ setActiveChild(child) {
106
+ if (child !== null && child.parentScope !== ledger) {
107
+ throw new Error(`createHostProcessLedger: setActiveChild expects a direct child of '${scope}'`);
108
+ }
109
+ activeChild = child;
110
+ },
111
+ clearActiveChild() {
112
+ activeChild = null;
113
+ },
114
+ /** Pre-spawn check + commit. `isNewSession` also consumes a session slot. */
115
+ reserveExec({ isNewSession = false, reason = null } = {}) {
116
+ for (const node of chain()) {
117
+ const c = node.caps;
118
+ const u = node.used();
119
+ if (c.maxExecProcesses !== null && u.execCount + 1 > c.maxExecProcesses) {
120
+ throw new HostBudgetExhaustedError({ scope: node.scope, limit: 'exec', used: u, cap: c.maxExecProcesses, reason: reason ?? node.reason });
121
+ }
122
+ if (isNewSession && c.maxSessions !== null && u.sessionCount + 1 > c.maxSessions) {
123
+ throw new HostBudgetExhaustedError({ scope: node.scope, limit: 'sessions', used: u, cap: c.maxSessions, reason: reason ?? node.reason });
124
+ }
125
+ if (c.maxTokens !== null && u.tokensObserved >= c.maxTokens) {
126
+ throw new HostBudgetExhaustedError({
127
+ scope: node.scope, limit: 'tokens', used: u, cap: c.maxTokens,
128
+ reason: 'observed token total already at/over budget — next round not started (the host may still consume tokens for a round already in flight; that is recorded, never hidden)',
129
+ });
130
+ }
131
+ if (c.wallMsBudgetMs !== null && u.wallMsObserved >= c.wallMsBudgetMs) {
132
+ throw new HostBudgetExhaustedError({ scope: node.scope, limit: 'wallMs', used: u, cap: c.wallMsBudgetMs, reason: reason ?? node.reason });
133
+ }
134
+ }
135
+ for (const node of chain()) {
136
+ node.commitExec(isNewSession);
137
+ }
138
+ },
139
+ /** Post-round observed feedback, committed up the whole chain. */
140
+ recordUsage({ tokensTotal = null, wallMs = 0 } = {}) {
141
+ if (tokensTotal !== null && (!Number.isSafeInteger(tokensTotal) || tokensTotal < 0)) {
142
+ throw new Error(`createHostProcessLedger(${scope}): recordUsage tokensTotal must be a non-negative safe integer or null`);
143
+ }
144
+ for (const node of chain()) {
145
+ node.commitUsage(tokensTotal, wallMs);
146
+ }
147
+ },
148
+ snapshot() {
149
+ const levels = [];
150
+ for (let cur = ledger; cur !== null; cur = cur.parentScope) {
151
+ levels.push({ scope: cur.scope, caps: cur.caps, used: cur.used() });
152
+ }
153
+ return { levels, used: { ...used } };
154
+ },
155
+ // internal
156
+ commitExec(isNewSession) {
157
+ used.execCount += 1;
158
+ if (isNewSession) used.sessionCount += 1;
159
+ },
160
+ commitUsage(tokensTotal, wallMs) {
161
+ if (tokensTotal !== null) used.tokensObserved += tokensTotal;
162
+ used.wallMsObserved += wallMs;
163
+ },
164
+ };
165
+ ledger.reason = caps.reason ?? null;
166
+ return ledger;
167
+ }
168
+
169
+ const EMPTY_TOKENS = { inputTokens: null, cachedInputTokens: null, cacheWriteInputTokens: null, outputTokens: null, reasoningOutputTokens: null, totalTokens: null };
170
+
171
+ /** Normalize one host usage block (token_count.info.last_token_usage) into
172
+ * lab vocabulary. Missing fields stay null — NEVER zero (rc-3/P1-03). */
173
+ function normalizeUsage(lastTokenUsage) {
174
+ if (lastTokenUsage === null || typeof lastTokenUsage !== 'object') {
175
+ return { ...EMPTY_TOKENS, usageSource: 'unavailable' };
176
+ }
177
+ const pick = (key, altKeys = []) => {
178
+ for (const k of [key, ...altKeys]) {
179
+ const v = lastTokenUsage[k];
180
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
181
+ }
182
+ return null;
183
+ };
184
+ const normalized = {
185
+ inputTokens: pick('input_tokens'),
186
+ cachedInputTokens: pick('cached_input_tokens'),
187
+ cacheWriteInputTokens: pick('cache_write_input_tokens'),
188
+ outputTokens: pick('output_tokens'),
189
+ reasoningOutputTokens: pick('reasoning_output_tokens'),
190
+ totalTokens: pick('total_tokens'),
191
+ };
192
+ const anyPresent = Object.values(normalized).some((v) => v !== null);
193
+ return { ...normalized, usageSource: anyPresent ? 'host_token_count' : 'unavailable' };
194
+ }
195
+
196
+ function textFromAgentMessage(item) {
197
+ if (item === null || typeof item !== 'object' || item.type !== 'AgentMessage') return null;
198
+ const content = Array.isArray(item.content) ? item.content : [];
199
+ const parts = [];
200
+ for (const c of content) {
201
+ if (c !== null && typeof c === 'object') {
202
+ const text = typeof c.text === 'string' ? c.text : null;
203
+ if (text !== null) parts.push(text);
204
+ }
205
+ }
206
+ return parts.length > 0 ? parts.join('') : null;
207
+ }
208
+
209
+ /** Extract the opportunity id from the runner's task id form
210
+ * `${armId}#${opportunityId}#turn${n}` (opportunity ids may contain ':'). */
211
+ export function opportunityIdFromTaskId(taskId) {
212
+ if (typeof taskId !== 'string') return null;
213
+ const parts = taskId.split('#');
214
+ return parts.length >= 2 && parts[1].length > 0 ? parts[1] : null;
215
+ }
216
+
217
+ /**
218
+ * Rewrite pass over a TypeBox/JSON schema toward the OpenAI Structured
219
+ * Outputs subset. Official rules (developers.openai.com structured-outputs,
220
+ * supported-schemas): ALL fields must be listed in `required` (optionality
221
+ * is emulated with null-unions), every object sets
222
+ * `additionalProperties: false`, and the supported type set is
223
+ * String/Number/Boolean/Integer/Object/Array/Enum/anyOf — `const` is NOT
224
+ * supported, so `{const: X, type: "string"}` branches are rewritten to the
225
+ * equivalent `{type: "string", enum: [X]}`. `$schema`/`$id` are dropped.
226
+ *
227
+ * This pass alone does NOT widen `required` — use
228
+ * `normalizeSchemaForOpenAiStrictFull` for the real host rounds, which adds
229
+ * the strict-optionality pass (all properties required, originally-optional
230
+ * ones nullable) and pairs with `stripNullFields` to restore the original
231
+ * omitted-semantics before validateActorAction. Pure; never mutates input.
232
+ */
233
+ export function normalizeSchemaForOpenAiStrict(schema) {
234
+ if (Array.isArray(schema)) {
235
+ return schema.map((item) => normalizeSchemaForOpenAiStrict(item));
236
+ }
237
+ if (schema === null || typeof schema !== 'object') {
238
+ return schema;
239
+ }
240
+ const out = {};
241
+ for (const [key, value] of Object.entries(schema)) {
242
+ if (key === 'properties' && value !== null && typeof value === 'object' && !Array.isArray(value)) {
243
+ out.properties = Object.fromEntries(
244
+ Object.entries(value).map(([name, sub]) => [name, normalizeSchemaForOpenAiStrict(sub)]),
245
+ );
246
+ } else if (['anyOf', 'oneOf'].includes(key) && Array.isArray(value)) {
247
+ out[key] = value.map((sub) => normalizeSchemaForOpenAiStrict(sub));
248
+ } else if (key === '$schema' || key === '$id') {
249
+ continue; // irrelevant (and sometimes unwelcome) to the API
250
+ } else {
251
+ out[key] = value;
252
+ }
253
+ }
254
+ // const is outside the supported keyword set; a single-value enum is its
255
+ // exact equivalent and IS supported.
256
+ if (out.const !== undefined) {
257
+ out.enum = [out.const];
258
+ delete out.const;
259
+ }
260
+ if (out.type === 'object') {
261
+ if (out.required === undefined) {
262
+ out.required = [];
263
+ }
264
+ if (out.additionalProperties === undefined) {
265
+ out.additionalProperties = false;
266
+ }
267
+ }
268
+ return out;
269
+ }
270
+
271
+ /** Make one property schema nullable, in the form the official docs use:
272
+ * enums gain `null` in the value list, string types become
273
+ * `["<type>", "null"]`, type arrays gain "null", complex nodes gain an
274
+ * anyOf `{type: "null"}` branch (the docs' linked-list pattern). */
275
+ function makeNullable(nodeSchema) {
276
+ const s = nodeSchema;
277
+ if (s === null || typeof s !== 'object' || Array.isArray(s)) return s;
278
+ if (Array.isArray(s.enum) && !s.enum.includes(null)) {
279
+ return { ...s, enum: [...s.enum, null] };
280
+ }
281
+ if (typeof s.type === 'string') {
282
+ return { ...s, type: [s.type, 'null'] };
283
+ }
284
+ if (Array.isArray(s.type) && !s.type.includes('null')) {
285
+ return { ...s, type: [...s.type, 'null'] };
286
+ }
287
+ if (Array.isArray(s.anyOf)) {
288
+ return { ...s, anyOf: [...s.anyOf, { type: 'null' }] };
289
+ }
290
+ return { anyOf: [{ ...s }, { type: 'null' }] };
291
+ }
292
+
293
+ /**
294
+ * Full normalization used by REAL host rounds: the rewrite pass above plus
295
+ * the strict-optionality pass — every object's `required` lists ALL of its
296
+ * properties, and properties that were NOT required in the original schema
297
+ * become nullable (strict mode's only way to express optionality).
298
+ *
299
+ * The ACTION CONTRACT IS UNCHANGED on the wire round-trip: optional fields
300
+ * arrive null on the wire and `stripNullFields` deletes them again before
301
+ * validateActorAction, restoring the exact omitted-semantics. Pure;
302
+ * idempotent; never mutates the input.
303
+ */
304
+ export function normalizeSchemaForOpenAiStrictFull(schema) {
305
+ const rewritten = normalizeSchemaForOpenAiStrict(schema);
306
+ const walk = (node) => {
307
+ if (Array.isArray(node)) return node.map(walk);
308
+ if (node === null || typeof node !== 'object') return node;
309
+ const out = {};
310
+ for (const [key, value] of Object.entries(node)) {
311
+ if (key === 'properties' && value !== null && typeof value === 'object' && !Array.isArray(value)) {
312
+ const originalRequired = Array.isArray(node.required) ? node.required : [];
313
+ out.properties = Object.fromEntries(
314
+ Object.entries(value).map(([name, sub]) => [
315
+ name,
316
+ originalRequired.includes(name) ? walk(sub) : makeNullable(walk(sub)),
317
+ ]),
318
+ );
319
+ } else if (['anyOf', 'oneOf'].includes(key) && Array.isArray(value)) {
320
+ out[key] = value.map(walk);
321
+ } else {
322
+ out[key] = value;
323
+ }
324
+ }
325
+ if (out.type === 'object' && out.properties !== undefined) {
326
+ out.required = Object.keys(out.properties);
327
+ }
328
+ return out;
329
+ };
330
+ return walk(rewritten);
331
+ }
332
+
333
+ /**
334
+ * Restore the original omitted-semantics on a model reply BEFORE the lab's
335
+ * action validation: strict schemas force optional fields to be present
336
+ * (as null) on the wire; the action contract expresses optionality by
337
+ * ABSENCE. Recursively deletes properties whose value is exactly null.
338
+ * Non-object values pass through untouched.
339
+ */
340
+ export function stripNullFields(value) {
341
+ if (Array.isArray(value)) {
342
+ return value.map((item) => stripNullFields(item));
343
+ }
344
+ if (value === null || typeof value !== 'object') {
345
+ return value;
346
+ }
347
+ const out = {};
348
+ for (const [key, v] of Object.entries(value)) {
349
+ if (v === null) continue; // optional field the model null-filled → omitted
350
+ out[key] = stripNullFields(v);
351
+ }
352
+ return out;
353
+ }
354
+
355
+ /**
356
+ * Read-back contract for host rollout evidence (read-only, id-exact).
357
+ *
358
+ * `codex exec --json` stdout carries NO identity/model event — the model
359
+ * identity lives only in the rollout file the host itself writes under
360
+ * `$CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl`. This reader is
361
+ * the audited bridge, under a strict contract that makes cross-session
362
+ * confusion impossible:
363
+ *
364
+ * - the file is matched by its session_meta.session_id EQUALING the
365
+ * session id that came from OUR OWN spawn's event stream — never by
366
+ * timestamp proximity, cwd similarity or "most recent file";
367
+ * - at most one file may match; zero or ambiguous matches are reported as
368
+ * `found: false` / `ambiguous: true` and NEVER guessed;
369
+ * - the reader extracts only identity (thread_settings_applied.model),
370
+ * usage (token_usage_record) and the terminal error (task_complete
371
+ * error payload) — it NEVER fabricates actions or marks a run verified.
372
+ *
373
+ * Diagnostic/audit use; the formal identity status in hostSnapshot keeps
374
+ * deriving from whatever evidence channels were actually wired (stdout
375
+ * events and/or this read-back when enabled per round).
376
+ */
377
+ export function readRolloutEvidenceForSession({ codexHome, sessionId, now = new Date() } = {}) {
378
+ if (typeof sessionId !== 'string' || sessionId.trim().length === 0) {
379
+ throw new Error('readRolloutEvidenceForSession: sessionId is required');
380
+ }
381
+ const home = codexHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
382
+ const sessionsRoot = path.join(home, 'sessions');
383
+ // search today and yesterday (UTC) — a round can straddle midnight
384
+ const days = [0, -1].map((offset) => {
385
+ const d = new Date(now.getTime() + offset * 86_400_000);
386
+ return [String(d.getUTCFullYear()), String(d.getUTCMonth() + 1).padStart(2, '0'), String(d.getUTCDate()).padStart(2, '0')];
387
+ });
388
+ const candidates = [];
389
+ for (const [y, m, d] of days) {
390
+ const dir = path.join(sessionsRoot, y, m, d);
391
+ let entries;
392
+ try {
393
+ entries = fs.readdirSync(dir);
394
+ } catch {
395
+ continue; // day directory absent
396
+ }
397
+ for (const name of entries) {
398
+ if (name.startsWith('rollout-') && name.endsWith('.jsonl')) candidates.push(path.join(dir, name));
399
+ }
400
+ }
401
+ const matches = [];
402
+ for (const file of candidates) {
403
+ let content;
404
+ try {
405
+ content = fs.readFileSync(file, 'utf8');
406
+ } catch {
407
+ continue;
408
+ }
409
+ // the session id must appear as OUR session's own metadata; a cheap
410
+ // pre-filter before JSON parsing (the id is unique enough for that)
411
+ if (!content.includes(sessionId)) continue;
412
+ const evidence = { file, sessionId: null, model: null, modelProviderId: null, usage: null, terminalError: null, settingsSeen: false };
413
+ for (const line of content.split('\n')) {
414
+ const trimmed = line.trim();
415
+ if (trimmed.length === 0) continue;
416
+ let ev;
417
+ try {
418
+ ev = JSON.parse(trimmed);
419
+ } catch {
420
+ continue; // torn/foreign line — not ours to judge
421
+ }
422
+ const payload = ev?.payload !== undefined ? ev.payload : ev;
423
+ if (ev?.type === 'session_meta' || payload?.session_id) {
424
+ if (typeof payload.session_id === 'string' && evidence.sessionId === null) evidence.sessionId = payload.session_id;
425
+ }
426
+ if (payload?.type === 'thread_settings_applied' && payload.thread_settings) {
427
+ evidence.settingsSeen = true;
428
+ evidence.model = payload.thread_settings.model ?? evidence.model;
429
+ evidence.modelProviderId = payload.thread_settings.model_provider_id ?? evidence.modelProviderId;
430
+ }
431
+ if (ev?.type === 'token_usage_record') {
432
+ // last record wins; fields mirror what the stream would carry
433
+ evidence.usage = {
434
+ inputTokens: payload.input_tokens ?? payload.usage?.input_tokens ?? null,
435
+ cachedInputTokens: payload.cached_input_tokens ?? payload.usage?.cached_input_tokens ?? null,
436
+ cacheWriteInputTokens: payload.cache_write_input_tokens ?? payload.usage?.cache_write_input_tokens ?? null,
437
+ outputTokens: payload.output_tokens ?? payload.usage?.output_tokens ?? null,
438
+ reasoningOutputTokens: payload.reasoning_output_tokens ?? payload.usage?.reasoning_output_tokens ?? null,
439
+ totalTokens: payload.total_tokens ?? payload.usage?.total_tokens ?? null,
440
+ usageSource: 'rollout_token_usage_record',
441
+ };
442
+ }
443
+ if (payload?.type === 'task_complete' && payload.error) {
444
+ evidence.terminalError = String(payload.error?.message ?? JSON.stringify(payload.error)).slice(0, 400);
445
+ }
446
+ }
447
+ if (evidence.sessionId === sessionId) matches.push(evidence);
448
+ }
449
+ if (matches.length === 0) {
450
+ return { found: false, ambiguous: false, reason: `no rollout file under ${sessionsRoot} carries session_meta.session_id === ${sessionId}` };
451
+ }
452
+ if (matches.length > 1) {
453
+ return { found: false, ambiguous: true, reason: `${matches.length} rollout files claim session ${sessionId} — refusing to guess`, files: matches.map((m) => m.file) };
454
+ }
455
+ return { found: true, ambiguous: false, ...matches[0] };
456
+ }
457
+
458
+ /**
459
+ * Create the Codex host transport. One instance serves ONE arm: rounds are
460
+ * delivered per call; a change of opportunityId in request.taskId starts a
461
+ * fresh host session (opportunity isolation). Implements the same
462
+ * `complete(request)` contract as every other lab transport.
463
+ *
464
+ * Fail-closed at creation: the codex binary must answer `--version` (local
465
+ * process probe, no model call) and every parameter must validate — a
466
+ * missing CLI or invalid config means no spawn can ever be attempted.
467
+ */
468
+ export function createCodexHostTransport({
469
+ model,
470
+ codexBin = 'codex',
471
+ scratchRoot,
472
+ profile = null,
473
+ threadSource = 'pl-lab',
474
+ timeoutMsPerCall = 180_000,
475
+ outputSchemaFile = null,
476
+ outputLastMessageFile = null,
477
+ codexHome = null,
478
+ rolloutReadback = true,
479
+ extraArgs = [],
480
+ opportunityCaps = {},
481
+ hostLedger = null,
482
+ spawnProcess = spawn,
483
+ versionProbe = null,
484
+ readRollout = readRolloutEvidenceForSession,
485
+ } = {}) {
486
+ if (typeof model !== 'string' || model.trim().length === 0) {
487
+ throw new Error('createCodexHostTransport: model (requested model id, e.g. gpt-6-luna) is required');
488
+ }
489
+ if (typeof scratchRoot !== 'string' || scratchRoot.trim().length === 0) {
490
+ throw new Error('createCodexHostTransport: scratchRoot (per-arm spawn cwd) is required');
491
+ }
492
+ if (!Number.isFinite(timeoutMsPerCall) || timeoutMsPerCall <= 0) {
493
+ throw new Error('createCodexHostTransport: timeoutMsPerCall must be a positive finite number');
494
+ }
495
+ if (hostLedger !== null && typeof hostLedger.reserveExec !== 'function') {
496
+ throw new Error('createCodexHostTransport: hostLedger must be a host process ledger');
497
+ }
498
+ if (outputSchemaFile !== null && (typeof outputSchemaFile !== 'string' || outputSchemaFile.trim().length === 0)) {
499
+ throw new Error('createCodexHostTransport: outputSchemaFile must be a non-empty path or null');
500
+ }
501
+ if (outputLastMessageFile !== null && (typeof outputLastMessageFile !== 'string' || outputLastMessageFile.trim().length === 0)) {
502
+ throw new Error('createCodexHostTransport: outputLastMessageFile must be a non-empty path or null');
503
+ }
504
+
505
+ // Fail-closed local probe: no codex binary → refuse transport creation
506
+ // before any round could ever be attempted. (--version is a local process,
507
+ // not a model call. The probe result doubles as the recorded cliVersion.)
508
+ const probe = versionProbe
509
+ ? versionProbe(codexBin)
510
+ : (() => {
511
+ try {
512
+ return spawnSync(codexBin, ['--version'], { encoding: 'utf8', timeout: 15_000 });
513
+ } catch (err) {
514
+ return { error: err, status: null };
515
+ }
516
+ })();
517
+ const probedVersion = typeof probe?.stdout === 'string' && probe.stdout.trim().length > 0 ? probe.stdout.trim().split('\n')[0] : null;
518
+ if (probe?.error || (typeof probe?.status === 'number' && probe.status !== 0)) {
519
+ throw new Error(`createCodexHostTransport: codex CLI probe failed (${codexBin} --version): ${probe?.error?.message ?? `exit ${probe?.status}`}. No host rounds can run without the owner's Codex CLI (fail-closed).`);
520
+ }
521
+
522
+ const identity = {
523
+ requestedModel: model,
524
+ observedModel: null,
525
+ observedModelProviderId: null,
526
+ evidenceSource: null,
527
+ cliVersion: probedVersion,
528
+ status: 'MODEL_IDENTITY_UNVERIFIED', // until the HOST reports its own model
529
+ };
530
+ let currentOpportunityId = null;
531
+ let sessionId = null;
532
+ let roundSeq = 0;
533
+ let malformedLines = 0;
534
+ let toolCallsObserved = 0;
535
+ let rounds = 0;
536
+ /** Per-opportunity child ledgers (token feedback aggregation). */
537
+ const oppLedgers = new Map();
538
+ /** Completed round records, traceable to session + turn. */
539
+ const roundRecords = [];
540
+ /** session records: one per host session actually started. */
541
+ const sessionRecords = [];
542
+
543
+ const oppLedgerFor = (opportunityId) => {
544
+ if (hostLedger === null) return null;
545
+ if (!oppLedgers.has(opportunityId)) {
546
+ oppLedgers.set(opportunityId, createHostProcessLedger({
547
+ scope: `opp:${opportunityId}`,
548
+ caps: opportunityCaps,
549
+ parent: hostLedger,
550
+ }));
551
+ }
552
+ return oppLedgers.get(opportunityId);
553
+ };
554
+
555
+ function buildArgs(forResume) {
556
+ // `codex exec resume <SESSION_ID> [OPTIONS] [PROMPT]` — resume (and its
557
+ // session id) sits immediately after the subcommand; shared options and
558
+ // the prompt follow. First rounds use `codex exec [OPTIONS] [PROMPT]`.
559
+ const rest = [
560
+ '--json', '--skip-git-repo-check', '-C', scratchRoot, '-s', 'read-only', '--thread-source', threadSource, '-m', model,
561
+ ];
562
+ if (profile !== null) rest.push('-p', profile);
563
+ if (outputSchemaFile !== null) rest.push('--output-schema', outputSchemaFile);
564
+ if (outputLastMessageFile !== null) rest.push('--output-last-message', outputLastMessageFile);
565
+ rest.push(...extraArgs);
566
+ return forResume ? ['exec', 'resume', sessionId, ...rest] : ['exec', ...rest];
567
+ }
568
+
569
+ /** Collect ONE child's stdout until close; returns parsed events + raw info. */
570
+ function runChild(args, promptText) {
571
+ return new Promise((resolve, reject) => {
572
+ const events = [];
573
+ let stdoutBuf = '';
574
+ let stderrText = '';
575
+ let child;
576
+ let settled = false;
577
+ let timedOut = false;
578
+ const startedAt = Date.now();
579
+ // PROMPT VIA STDIN (rc): `codex exec -` reads the instructions from
580
+ // stdin. Windows CreateProcess quoting is unreliable for multi-line
581
+ // argv values (a multi-line prompt arg reaches codex truncated, which
582
+ // then waits on stdin and exits 1) — the pipe is the exact, tested
583
+ // channel. `-` marks the prompt position explicitly on both exec and
584
+ // `exec resume`.
585
+ const childArgs = [...args, '-'];
586
+ try {
587
+ child = spawnProcess(codexBin, childArgs, { cwd: scratchRoot, stdio: ['pipe', 'pipe', 'pipe'] });
588
+ } catch (err) {
589
+ reject(Object.assign(new Error(`codex spawn failed: ${err?.message ?? String(err)}`), { code: 'spawn_failed', wallMs: 0 }));
590
+ return;
591
+ }
592
+ child.stdin?.on('error', () => { /* EPIPE if codex exits early — the close handler reports the real failure */ });
593
+ child.stdin?.write(promptText, 'utf8');
594
+ child.stdin?.end();
595
+ const timer = setTimeout(() => {
596
+ timedOut = true;
597
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
598
+ }, timeoutMsPerCall);
599
+ child.stdout?.on('data', (chunk) => {
600
+ stdoutBuf += chunk.toString('utf8');
601
+ let idx;
602
+ while ((idx = stdoutBuf.indexOf('\n')) >= 0) {
603
+ const line = stdoutBuf.slice(0, idx).trim();
604
+ stdoutBuf = stdoutBuf.slice(idx + 1);
605
+ if (line.length === 0) continue; // empty lines are legal
606
+ try {
607
+ events.push(JSON.parse(line));
608
+ } catch {
609
+ malformedLines += 1; // torn/partial or non-JSON line — counted, never parsed-as-truth
610
+ }
611
+ }
612
+ });
613
+ child.stderr?.on('data', (chunk) => {
614
+ stderrText += chunk.toString('utf8');
615
+ });
616
+ const finish = (err) => {
617
+ if (settled) return;
618
+ settled = true;
619
+ clearTimeout(timer);
620
+ if (err) reject(err);
621
+ else resolve({ events, exitCode: child.exitCode, wallMs: Date.now() - startedAt, stderrText, timedOut });
622
+ };
623
+ const failWith = (code, message) => {
624
+ const err = Object.assign(new Error(message), { code });
625
+ err.wallMs = Date.now() - startedAt;
626
+ err.stderrTail = stderrText.slice(0, 300);
627
+ finish(err);
628
+ };
629
+ child.on('error', (err) => failWith('spawn_failed', `codex process error: ${err?.message ?? String(err)}`));
630
+ child.on('close', (code) => {
631
+ if (timedOut) {
632
+ failWith('timeout', `codex exec timed out after ${timeoutMsPerCall}ms and was killed (fail-loud, no partial round is graded as success)`);
633
+ return;
634
+ }
635
+ if (code !== 0) {
636
+ failWith('nonzero_exit', `codex exec exited ${code}: ${stderrText.slice(0, 300)}`);
637
+ return;
638
+ }
639
+ finish(null);
640
+ });
641
+ });
642
+ }
643
+
644
+ /** Consume ONE round's event stream. TWO real wire formats exist and BOTH
645
+ * must be understood (the 2026-10-23 smoke failed on exactly this):
646
+ *
647
+ * 1. `codex exec --json` STDOUT format (official, documented): top-level
648
+ * snake_case events — {"type":"thread.started","thread_id":…},
649
+ * {"type":"turn.completed","usage":{input_tokens,…}},
650
+ * {"type":"item.completed","item":{"type":"agent_message","text":…}},
651
+ * {"type":"error","message":…}. No identity/model event exists here.
652
+ * 2. ROLLOUT internal format (what lands in ~/.codex/sessions):
653
+ * {"type":"event_msg","payload":{type:"thread_settings_applied",…}},
654
+ * item payloads use CamelCase (AgentMessage/custom_tool_call).
655
+ *
656
+ * Returns the final agent text, per-round usage, thread ids, any error
657
+ * event text, and whether the stream carried any recognized event at all
658
+ * (an absent stream is an auditable fact, never silently accepted). */
659
+ function consumeEvents(events) {
660
+ let lastAgentText = null;
661
+ let errorEventText = null;
662
+ let recognizedEvents = 0;
663
+ // last-wins per turn: cumulative figures (token_count.info.total_token_usage,
664
+ // session-level totals) must never be summed per round — the round delta
665
+ // lives in last_token_usage / turn.completed.usage.
666
+ const usageByTurn = new Map();
667
+ const threadIds = [];
668
+ for (const ev of events) {
669
+ const payload = ev !== null && typeof ev === 'object' && ev.payload !== undefined ? ev.payload : ev;
670
+ if (payload === null || typeof payload !== 'object') continue;
671
+ const type = payload.type;
672
+ // ── stdout official format (top-level snake_case) ──
673
+ if (type === 'thread.started' || type === 'session_configured') {
674
+ recognizedEvents += 1;
675
+ if (typeof payload.thread_id === 'string') threadIds.push(payload.thread_id);
676
+ if (typeof payload.session_id === 'string') threadIds.push(payload.session_id);
677
+ } else if (type === 'turn.completed' || type === 'turn.failed') {
678
+ recognizedEvents += 1;
679
+ if (typeof payload.thread_id === 'string') threadIds.push(payload.thread_id);
680
+ const turnKey = payload.turn_id ?? `turn.${type}`;
681
+ usageByTurn.set(turnKey, payload.usage ?? null);
682
+ if (type === 'turn.failed' && payload.error) {
683
+ errorEventText = String(payload.error?.message ?? JSON.stringify(payload.error)).slice(0, 400);
684
+ }
685
+ } else if (type === 'item.completed' || type === 'item.updated' || type === 'item.started') {
686
+ recognizedEvents += 1;
687
+ if (typeof payload.thread_id === 'string') threadIds.push(payload.thread_id);
688
+ const item = payload.item;
689
+ if (item !== null && typeof item === 'object') {
690
+ const itemType = String(item.type ?? item.item_type ?? '');
691
+ if (itemType === 'agent_message' || itemType === 'AgentMessage') {
692
+ const text = itemType === 'agent_message'
693
+ ? (typeof item.text === 'string' && item.text.length > 0 ? item.text : null)
694
+ : textFromAgentMessage(item);
695
+ if (text !== null) lastAgentText = text; // last agent message wins
696
+ } else if (/tool|command|search|file_change|exec/i.test(itemType)) {
697
+ toolCallsObserved += 1; // protocol deviation signal — recorded, never silent
698
+ }
699
+ }
700
+ } else if (type === 'error') {
701
+ recognizedEvents += 1;
702
+ errorEventText = String(payload.message ?? JSON.stringify(payload)).slice(0, 400);
703
+ if (typeof payload.thread_id === 'string') threadIds.push(payload.thread_id);
704
+ } else if (type === 'thread_settings_applied') {
705
+ // ── rollout format (also the ONLY identity source) ──
706
+ recognizedEvents += 1;
707
+ threadIds.push(payload.thread_id);
708
+ const settings = payload.thread_settings;
709
+ if (settings !== null && typeof settings === 'object') {
710
+ if (typeof settings.model === 'string' && settings.model.length > 0) identity.observedModel = settings.model;
711
+ if (typeof settings.model_provider_id === 'string') identity.observedModelProviderId = settings.model_provider_id;
712
+ }
713
+ } else if (type === 'item_completed') {
714
+ recognizedEvents += 1;
715
+ if (typeof payload.thread_id === 'string') threadIds.push(payload.thread_id);
716
+ if (payload.item !== null && typeof payload.item === 'object') {
717
+ if (payload.item.type === 'custom_tool_call' || payload.item.type === 'local_custom_tool_call' || payload.item.type === 'function_call') {
718
+ toolCallsObserved += 1; // protocol deviation signal — recorded, never silent
719
+ }
720
+ const text = textFromAgentMessage(payload.item);
721
+ if (text !== null) lastAgentText = text; // last AgentMessage wins
722
+ }
723
+ } else if (type === 'token_count') {
724
+ recognizedEvents += 1;
725
+ if (typeof payload.thread_id === 'string') threadIds.push(payload.thread_id);
726
+ const info = payload.info;
727
+ const last = info !== null && typeof info === 'object' ? info.last_token_usage ?? null : null;
728
+ if (payload.turn_id !== undefined && payload.turn_id !== null) {
729
+ usageByTurn.set(payload.turn_id, last);
730
+ } else {
731
+ usageByTurn.set('(no-turn-id)', last);
732
+ }
733
+ } else if (type === 'task_started' || type === 'task_complete') {
734
+ recognizedEvents += 1;
735
+ if (typeof payload.thread_id === 'string') threadIds.push(payload.thread_id);
736
+ if (typeof payload.turn_id === 'string' && type === 'task_complete') {
737
+ if (!usageByTurn.has(payload.turn_id)) usageByTurn.set(payload.turn_id, null);
738
+ }
739
+ }
740
+ }
741
+ // Round usage = the LAST turn seen in this exec call (one exec == one
742
+ // agent round in the lab protocol). Missing token_count → unavailable.
743
+ let roundUsage = { ...EMPTY_TOKENS, usageSource: 'unavailable' };
744
+ if (usageByTurn.size > 0) {
745
+ const [turnId, last] = [...usageByTurn.entries()][usageByTurn.size - 1];
746
+ roundUsage = { ...normalizeUsage(last), turnId };
747
+ }
748
+ return { lastAgentText, roundUsage, threadIds: [...new Set(threadIds)], errorEventText, recognizedEvents };
749
+ }
750
+
751
+ return {
752
+ kind: 'codex-host',
753
+ /** Same contract as every lab transport: one agent round in, one action
754
+ * payload out. Throws labeled errors; never fakes a payload. */
755
+ async complete(request) {
756
+ const opportunityId = opportunityIdFromTaskId(request.taskId);
757
+ if (opportunityId === null) {
758
+ throw Object.assign(new Error(`codex-host transport: request.taskId '${String(request.taskId)}' does not carry an opportunity segment — opportunity isolation cannot be proven`), { code: 'invalid_request' });
759
+ }
760
+ const startingNewSession = opportunityId !== currentOpportunityId || sessionId === null;
761
+ if (startingNewSession) {
762
+ // finalize the previous opportunity's child bookkeeping
763
+ if (currentOpportunityId !== null) {
764
+ const prev = oppLedgers.get(currentOpportunityId);
765
+ hostLedger?.clearActiveChild();
766
+ prev?.clearActiveChild();
767
+ }
768
+ sessionId = null;
769
+ currentOpportunityId = opportunityId;
770
+ }
771
+ const oppLedger = oppLedgerFor(opportunityId);
772
+ const isNewSession = sessionId === null;
773
+ // Pre-spawn reservation across opportunity → arm → study. Exec count
774
+ // and session count are HARD limits; observed token/wall totals gate
775
+ // the NEXT round (recorded feedback, never a claim about the current).
776
+ hostLedger?.setActiveChild(oppLedger);
777
+ try {
778
+ (oppLedger ?? hostLedger)?.reserveExec({ isNewSession, reason: request.taskId ?? 'host round' });
779
+ } catch (err) {
780
+ hostLedger?.clearActiveChild();
781
+ throw err;
782
+ }
783
+
784
+ const promptText = `${request.systemPrompt}\n\n${request.userPrompt}`;
785
+ // Old-file poisoning guard for --output-last-message: the file must be
786
+ // produced by THIS round. Delete any pre-existing copy before the
787
+ // spawn; a file that exists after the child exits was written by it.
788
+ if (outputLastMessageFile !== null && fs.existsSync(outputLastMessageFile)) {
789
+ fs.rmSync(outputLastMessageFile, { force: true });
790
+ }
791
+ // first round of a session = plain exec; any later round = resume
792
+ const args = buildArgs(!isNewSession);
793
+ roundSeq += 1;
794
+ const roundNo = roundSeq;
795
+ let result;
796
+ try {
797
+ result = await runChild(args, promptText);
798
+ } catch (err) {
799
+ hostLedger?.clearActiveChild();
800
+ // A spawn attempt consumed a process slot (OS-level spawn failures
801
+ // excepted). Wall-clock of a timed-out/killed child stays on the
802
+ // record — it ran on the host even though it produced no round.
803
+ if (err?.code !== 'spawn_failed') {
804
+ (oppLedger ?? hostLedger)?.recordUsage({ tokensTotal: null, wallMs: err?.wallMs ?? 0 });
805
+ }
806
+ roundRecords.push({ roundNo, opportunityId, sessionId, error: err?.message ?? String(err), usage: null, stderrText: err?.stderrTail ?? null });
807
+ throw err;
808
+ }
809
+
810
+ const { lastAgentText: streamAgentText, roundUsage: streamUsage, threadIds, errorEventText: errorEventTextFromStream, recognizedEvents } = consumeEvents(result.events);
811
+ // --output-last-message (official interface): when present and non-empty
812
+ // it is the AUTHORITATIVE final message (the file was deleted before
813
+ // the spawn, so its existence proves this round produced it). The
814
+ // stream text is the fallback channel.
815
+ let lastAgentText = streamAgentText;
816
+ let finalMessageSource = 'stdout-stream';
817
+ if (outputLastMessageFile !== null) {
818
+ try {
819
+ const fileText = fs.readFileSync(outputLastMessageFile, 'utf8');
820
+ if (fileText.trim().length > 0) {
821
+ lastAgentText = fileText;
822
+ finalMessageSource = 'output-last-message-file';
823
+ }
824
+ } catch { /* absent/empty → stream channel (or the explicit failure below) */ }
825
+ finally {
826
+ try { fs.rmSync(outputLastMessageFile, { force: true }); } catch { /* best-effort cleanup */ }
827
+ }
828
+ }
829
+ // errorEventText is MUTABLE after this point: the rollout read-back may
830
+ // surface the terminal error when the stdout stream carried none.
831
+ let errorEventText = errorEventTextFromStream;
832
+ if (isNewSession) {
833
+ const resolved = threadIds[0] ?? null;
834
+ if (resolved === null) {
835
+ hostLedger?.clearActiveChild();
836
+ (oppLedger ?? hostLedger)?.recordUsage({ tokensTotal: null, wallMs: result.wallMs });
837
+ roundRecords.push({
838
+ roundNo,
839
+ opportunityId,
840
+ sessionId: null,
841
+ error: 'no session/thread id in host event stream',
842
+ usage: streamUsage,
843
+ stderrText: result.stderrText.slice(0, 300),
844
+ streamAudit: { parsedEvents: result.events.length, recognizedEvents, errorEventText, exitCode: result.exitCode },
845
+ });
846
+ throw Object.assign(new Error(
847
+ `codex-host transport: host event stream carried no thread/session id (parsed ${result.events.length} events, ${recognizedEvents} recognized, exit ${result.exitCode}${errorEventText ? `, stream error: ${errorEventText.slice(0, 160)}` : ''}) — refusing to guess or use resume --last (shared CODEX_HOME); next rounds cannot continue this session (fail-loud). Read-only diagnosis: check the host's own rollout under CODEX_HOME/sessions for this invocation.`,
848
+ ), { code: 'host_session_unavailable' });
849
+ }
850
+ sessionId = resolved;
851
+ // Identity read-back under the strict id-exact contract: the stdout
852
+ // stream has no model event, so the host's OWN rollout file — matched
853
+ // by session_meta.session_id === OUR stream-provided id — supplies
854
+ // the identity (and, when the stream carried no usage, the round's
855
+ // token total). A failed read-back keeps UNVERIFIED honestly.
856
+ if (identity.observedModel === null && rolloutReadback) {
857
+ try {
858
+ const evidence = readRollout({ codexHome, sessionId });
859
+ if (evidence.found && typeof evidence.model === 'string' && evidence.model.length > 0) {
860
+ identity.observedModel = evidence.model;
861
+ identity.observedModelProviderId = evidence.modelProviderId ?? identity.observedModelProviderId;
862
+ identity.evidenceSource = 'rollout-readback (session_meta id match)';
863
+ }
864
+ if (streamUsage.usageSource === 'unavailable' && evidence.found && evidence.usage) {
865
+ streamUsage.inputTokens = evidence.usage.inputTokens;
866
+ streamUsage.cachedInputTokens = evidence.usage.cachedInputTokens;
867
+ streamUsage.cacheWriteInputTokens = evidence.usage.cacheWriteInputTokens;
868
+ streamUsage.outputTokens = evidence.usage.outputTokens;
869
+ streamUsage.reasoningOutputTokens = evidence.usage.reasoningOutputTokens;
870
+ streamUsage.totalTokens = evidence.usage.totalTokens;
871
+ streamUsage.usageSource = evidence.usage.usageSource;
872
+ }
873
+ if (evidence.found && evidence.terminalError && !errorEventText) {
874
+ errorEventText = evidence.terminalError;
875
+ }
876
+ } catch { // read-back is best-effort: identity simply stays UNVERIFIED
877
+ }
878
+ }
879
+ sessionRecords.push({
880
+ sessionId,
881
+ opportunityId,
882
+ requestedModel: model,
883
+ observedModel: identity.observedModel,
884
+ identitySource: identity.evidenceSource ?? (identity.observedModel ? 'stdout-stream' : null),
885
+ identityStatus: identity.observedModel === null
886
+ ? 'MODEL_IDENTITY_UNVERIFIED'
887
+ : identity.observedModel === model ? 'VERIFIED' : 'MODEL_IDENTITY_MISMATCH',
888
+ cliVersion: identity.cliVersion,
889
+ startedAt: new Date().toISOString(),
890
+ });
891
+ } else if (identity.observedModel === null && rolloutReadback && sessionId) {
892
+ // later rounds of a session where round 1 had no stream identity
893
+ try {
894
+ const evidence = readRollout({ codexHome, sessionId });
895
+ if (evidence.found && typeof evidence.model === 'string' && evidence.model.length > 0) {
896
+ identity.observedModel = evidence.model;
897
+ identity.observedModelProviderId = evidence.modelProviderId ?? identity.observedModelProviderId;
898
+ identity.evidenceSource = 'rollout-readback (session_meta id match)';
899
+ }
900
+ } catch { /* stays UNVERIFIED */ }
901
+ }
902
+ const tokensTotal = streamUsage.totalTokens;
903
+ (oppLedger ?? hostLedger)?.recordUsage({ tokensTotal, wallMs: result.wallMs });
904
+ hostLedger?.clearActiveChild();
905
+ rounds += 1;
906
+ roundRecords.push({
907
+ roundNo,
908
+ opportunityId,
909
+ sessionId,
910
+ usage: streamUsage,
911
+ exitCode: result.exitCode,
912
+ wallMs: result.wallMs,
913
+ stderrText: result.stderrText.slice(0, 300),
914
+ streamAudit: { parsedEvents: result.events.length, recognizedEvents, errorEventText, finalMessageSource },
915
+ });
916
+ if (typeof lastAgentText !== 'string' || lastAgentText.trim().length === 0) {
917
+ throw Object.assign(new Error(
918
+ `codex-host transport: no agent message on any channel (stream events: ${result.events.length} parsed/${recognizedEvents} recognized; output-last-message file: ${outputLastMessageFile !== null ? 'configured' : 'not configured'}; exit ${result.exitCode}) — refusing to fabricate an action`,
919
+ ), { code: 'no_response' });
920
+ }
921
+ let payload;
922
+ try {
923
+ payload = JSON.parse(lastAgentText.trim());
924
+ } catch {
925
+ throw Object.assign(new Error(`codex-host transport: final agent message is not valid JSON (source: ${finalMessageSource}; first 120 chars: ${lastAgentText.trim().slice(0, 120)})`), { code: 'invalid_output' });
926
+ }
927
+ // Strict schemas force optional fields to arrive as explicit nulls on
928
+ // the wire; the action contract expresses optionality by absence.
929
+ // Restore it HERE — before the payload reaches validateActorAction.
930
+ payload = stripNullFields(payload);
931
+ return {
932
+ payload,
933
+ runId: `${sessionId}#round${roundNo}`,
934
+ hostRound: {
935
+ sessionId,
936
+ roundNo,
937
+ opportunityId,
938
+ usage: streamUsage,
939
+ exitCode: result.exitCode,
940
+ wallMs: result.wallMs,
941
+ toolCallsObserved,
942
+ malformedLines,
943
+ },
944
+ };
945
+ },
946
+ /** Post-run audit snapshot: identity, sessions, rounds, observed usage,
947
+ * process-ledger chain. This is OBSERVED feedback + hard-cap accounting,
948
+ * never an HTTP/USD ledger. */
949
+ hostSnapshot() {
950
+ const tokens = { ...EMPTY_TOKENS, inputTokens: 0, cachedInputTokens: 0, cacheWriteInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, roundsWithUsage: 0, roundsWithoutUsage: 0, totalsAvailable: null };
951
+ for (const r of roundRecords) {
952
+ const u = r.usage;
953
+ if (u !== null && u !== undefined && u.usageSource !== 'unavailable') {
954
+ tokens.inputTokens += u.inputTokens ?? 0;
955
+ tokens.cachedInputTokens += u.cachedInputTokens ?? 0;
956
+ tokens.cacheWriteInputTokens += u.cacheWriteInputTokens ?? 0;
957
+ tokens.outputTokens += u.outputTokens ?? 0;
958
+ tokens.reasoningOutputTokens += u.reasoningOutputTokens ?? 0;
959
+ tokens.totalTokens += u.totalTokens ?? 0;
960
+ tokens.roundsWithUsage += 1;
961
+ } else {
962
+ tokens.roundsWithoutUsage += 1;
963
+ }
964
+ }
965
+ tokens.totalsAvailable = roundRecords.length > 0 && tokens.roundsWithoutUsage === 0;
966
+ return {
967
+ requestedModel: model,
968
+ identityStatus: identity.observedModel === null
969
+ ? 'MODEL_IDENTITY_UNVERIFIED'
970
+ : identity.observedModel === model ? 'VERIFIED' : 'MODEL_IDENTITY_MISMATCH',
971
+ observedModel: identity.observedModel,
972
+ observedModelProviderId: identity.observedModelProviderId,
973
+ identityEvidenceSource: identity.evidenceSource,
974
+ cliVersion: identity.cliVersion,
975
+ sessions: sessionRecords.slice(),
976
+ rounds,
977
+ execProcesses: roundRecords.length,
978
+ toolCallsObserved,
979
+ malformedLines,
980
+ tokens,
981
+ roundRecords: roundRecords.slice(),
982
+ ledger: hostLedger ? hostLedger.snapshot() : null,
983
+ budgetSemantics: 'process-level hard caps pre-spawn; token totals are OBSERVED host feedback gating the next spawn — not a pre-send USD/HTTP limit',
984
+ };
985
+ },
986
+ };
987
+ }