@inixiative/agent-session 0.1.0 → 0.3.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.
@@ -1,3 +1,8 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { codexLimitSnapshot, type LimitSnapshot, mergeLimits } from './limits';
3
+ import { retainEvidence } from './retained-evidence';
4
+ import { TRANSPORTS } from './transport';
5
+ import { TurnState } from './turn-state';
1
6
  // ---------------------------------------------------------------------------
2
7
  // CodexSession — long-lived OpenAI Codex CLI process with full event capture
3
8
  // ---------------------------------------------------------------------------
@@ -8,7 +13,7 @@
8
13
  // the shared SessionEvent taxonomy and resolving a turn on completion.
9
14
  //
10
15
  // CodexMcpSession (default) — `codex mcp-server` (stdio MCP JSON-RPC)
11
- // CodexAppServerSession (experimental) — `codex app-server` (WebSocket JSON-RPC)
16
+ // CodexAppServerSession (experimental) — `codex app-server` (stdio JSON-RPC)
12
17
  //
13
18
  // Both DISABLE codex's own approvals + sandbox (the union of
14
19
  // `--dangerously-bypass-approvals-and-sandbox`) so OUR container is the only
@@ -30,19 +35,20 @@
30
35
  import type {
31
36
  BeforeSendHook,
32
37
  HarnessSession,
38
+ NativeInterruptOutcome,
39
+ SessionArtifact,
33
40
  SessionEvent,
34
41
  SessionEventHandler,
35
42
  SessionResult,
36
- SessionArtifact,
37
- } from "./harness-session";
43
+ } from './harness-session';
38
44
 
39
45
  // Re-export types so importers can stay on one path (mirrors claude-code-session).
40
46
  export type {
47
+ SessionArtifact,
41
48
  SessionEvent,
42
49
  SessionEventKind,
43
50
  SessionResult,
44
- SessionArtifact,
45
- } from "./harness-session";
51
+ } from './harness-session';
46
52
 
47
53
  // ---------------------------------------------------------------------------
48
54
  // Shared subprocess shape (mirrors ClaudeCodeSession.PipedSubprocess)
@@ -67,6 +73,15 @@ export type CodexSpawn = (
67
73
  // ---------------------------------------------------------------------------
68
74
 
69
75
  export interface CodexSessionConfig {
76
+ /** Explicit app-server integration. Never applied by the default MCP engine.
77
+ * Config is native launch data, not event/recording data. */
78
+ appServer?: {
79
+ /** Persist the actual returned binding before model admission; failure blocks work. */
80
+ onThreadReady?: (binding: string) => void | Promise<void>;
81
+ requireConfiguration?: boolean;
82
+ config?: Readonly<Record<string, unknown>>;
83
+ requiredMcpServer?: { readonly name: string; readonly tools: readonly string[] };
84
+ };
70
85
  /** Path to codex CLI binary. Defaults to "codex". */
71
86
  bin?: string;
72
87
  /** Model. Defaults to "gpt-5.5". */
@@ -88,10 +103,12 @@ export interface CodexSessionConfig {
88
103
  baseContext?: string;
89
104
  /**
90
105
  * codex's native thread ID (the value the `codex` tool returns). When set, the
91
- * first send() continues that thread via `codex-reply` — used for fork and
92
- * crash recovery. Also set by a SessionAdapter resuming a mapped Foundry thread.
106
+ * first send() uses `codex-reply` on MCP (loaded threads only), or
107
+ * `thread/resume` on app-server. Only app-server supports cold resume by ID.
93
108
  */
94
109
  externalSessionId?: string;
110
+ /** Environment merged over process.env (e.g. CODEX_HOME for a profile). */
111
+ env?: Record<string, string | undefined>;
95
112
  /**
96
113
  * Override for the process spawner. Defaults to Bun.spawn. Tests inject a fake
97
114
  * subprocess that emulates the codex JSON-RPC protocol; the docker-spawn helper
@@ -105,6 +122,8 @@ export interface CodexSessionConfig {
105
122
  // ---------------------------------------------------------------------------
106
123
 
107
124
  interface QueuedTurn {
125
+ onAdmission?: import('./harness-session').SessionSendOptions['onAdmission'];
126
+ evidence: TurnState;
108
127
  message: string;
109
128
  timeout: number;
110
129
  resolve: (result: SessionResult) => void;
@@ -112,7 +131,7 @@ interface QueuedTurn {
112
131
  }
113
132
 
114
133
  // codex reasoning-effort levels (model_reasoning_effort), low→high.
115
- const VALID_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"];
134
+ const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh'];
116
135
 
117
136
  // ---------------------------------------------------------------------------
118
137
  // Base class — shared queue, event log, classification helpers, lifecycle
@@ -133,16 +152,19 @@ abstract class BaseCodexSession implements HarnessSession {
133
152
  protected _defaultTimeout: number;
134
153
  protected _baseContext?: string;
135
154
  protected _spawn?: CodexSpawn;
155
+ protected _env?: Record<string, string | undefined>;
136
156
 
137
157
  // -- Process --
138
158
  protected _proc: PipedSubprocess | null = null;
139
- protected _stderr = "";
159
+ protected _stderr = '';
140
160
 
141
161
  // -- Session state --
142
162
  protected _externalSessionId?: string;
143
163
  protected _alive = false;
164
+ private _endEmitted = false;
144
165
  protected _eventLog: SessionEvent[] = [];
145
166
  protected _handlers: SessionEventHandler[] = [];
167
+ private _observerFailures = { synchronous: 0, asynchronous: 0 };
146
168
  protected _beforeSendHooks: BeforeSendHook[] = [];
147
169
  protected _turns = 0;
148
170
  protected _totalTokens = { input: 0, output: 0 };
@@ -163,29 +185,32 @@ abstract class BaseCodexSession implements HarnessSession {
163
185
 
164
186
  // -- Turn queue (identical to ClaudeCodeSession) --
165
187
  protected _queue: QueuedTurn[] = [];
188
+ protected _attempts: TurnState[] = [];
189
+ protected _seenTerminals = new Set<string>();
166
190
  protected _inflight: QueuedTurn | null = null;
167
191
  protected _turnEvents: SessionEvent[] = [];
168
- protected _resultText = "";
192
+ protected _resultText = '';
169
193
  protected _turnTimer: ReturnType<typeof setTimeout> | null = null;
170
194
 
171
195
  constructor(config?: CodexSessionConfig) {
172
- const bin = config?.bin ?? "codex";
173
- if (!/^[a-zA-Z0-9_.\/\\-]+$/.test(bin)) {
196
+ const bin = config?.bin ?? 'codex';
197
+ if (!/^[a-zA-Z0-9_./\\-]+$/.test(bin)) {
174
198
  throw new Error(`Invalid codex CLI binary path: "${bin}"`);
175
199
  }
176
200
  if (config?.effort && !VALID_EFFORTS.includes(config.effort)) {
177
201
  throw new Error(
178
- `Invalid codex effort "${config.effort}". Valid: ${VALID_EFFORTS.join(", ")}`,
202
+ `Invalid codex effort "${config.effort}". Valid: ${VALID_EFFORTS.join(', ')}`,
179
203
  );
180
204
  }
181
205
  this._bin = bin;
182
- this._model = config?.model ?? "gpt-5.5";
206
+ this._model = config?.model ?? 'gpt-5.5';
183
207
  this._effort = config?.effort;
184
208
  this._cwd = config?.cwd ?? process.cwd();
185
209
  this._defaultTimeout = config?.timeout ?? 600_000;
186
210
  this._baseContext = config?.baseContext;
187
211
  this._externalSessionId = config?.externalSessionId;
188
212
  this._spawn = config?.spawn;
213
+ this._env = config?.env;
189
214
  this._startedAt = Date.now();
190
215
  }
191
216
 
@@ -193,10 +218,28 @@ abstract class BaseCodexSession implements HarnessSession {
193
218
  // Accessors (identical to ClaudeCodeSession)
194
219
  // ---------------------------------------------------------------------------
195
220
 
196
- get alive(): boolean { return this._alive; }
197
- get externalSessionId(): string | undefined { return this._externalSessionId; }
198
- get events(): readonly SessionEvent[] { return this._eventLog; }
199
- get turns(): number { return this._turns; }
221
+ get accounting() {
222
+ return 'observed-only' as const;
223
+ }
224
+ get attempts() {
225
+ return this._attempts.map((a) => a.snapshot());
226
+ }
227
+ get diagnostics() {
228
+ return Object.freeze({ observerFailures: Object.freeze({ ...this._observerFailures }) });
229
+ }
230
+
231
+ get alive(): boolean {
232
+ return this._alive;
233
+ }
234
+ get externalSessionId(): string | undefined {
235
+ return this._externalSessionId;
236
+ }
237
+ get events(): readonly SessionEvent[] {
238
+ return Object.freeze([...this._eventLog]);
239
+ }
240
+ get turns(): number {
241
+ return this._turns;
242
+ }
200
243
  get totalTokens(): Readonly<{ input: number; output: number }> {
201
244
  return { ...this._totalTokens };
202
245
  }
@@ -229,7 +272,7 @@ abstract class BaseCodexSession implements HarnessSession {
229
272
  */
230
273
  async push(payload: { kind: string; text: string }): Promise<void> {
231
274
  this._emit({
232
- kind: "error",
275
+ kind: 'error',
233
276
  timestamp: Date.now(),
234
277
  text: `push_ignored: kind=${payload.kind} — codex turn has no OOB channel`,
235
278
  raw: payload,
@@ -242,7 +285,10 @@ abstract class BaseCodexSession implements HarnessSession {
242
285
 
243
286
  interrupt(): void {
244
287
  if (!this._inflight) return;
245
- this._rejectInflight(new Error("Turn interrupted"));
288
+ this._rejectInflight(
289
+ new Error('Local waiter interrupted; native cancellation unacknowledged'),
290
+ 'interrupt-request',
291
+ );
246
292
  }
247
293
 
248
294
  kill(): void {
@@ -254,29 +300,40 @@ abstract class BaseCodexSession implements HarnessSession {
254
300
  this._turnTimer = null;
255
301
  }
256
302
 
257
- this._rejectInflight(new Error("Session killed"));
258
- this._rejectQueue(new Error("Session killed"));
259
- for (const p of this._pending.values()) p.reject(new Error("Session killed"));
303
+ this._rejectInflight(new Error('Session killed; native outcome may remain unknown'), 'killed');
304
+ this._rejectQueue(new Error('Session killed'));
305
+ for (const p of this._pending.values()) p.reject(new Error('Session killed'));
260
306
  this._pending.clear();
261
307
 
262
- try { this._proc.stdin.end(); } catch { /* already closed */ }
263
- try { this._proc.kill(); } catch { /* already dead */ }
308
+ try {
309
+ this._proc.stdin.end();
310
+ } catch {
311
+ /* already closed */
312
+ }
313
+ try {
314
+ this._proc.kill();
315
+ } catch {
316
+ /* already dead */
317
+ }
264
318
  this._proc = null;
265
319
 
266
- this._emit({ kind: "session_end", timestamp: Date.now() });
320
+ this._emit({ kind: 'session_end', timestamp: Date.now() });
267
321
  }
268
322
 
269
323
  artifact(): SessionArtifact {
270
324
  return {
271
325
  externalSessionId: this._externalSessionId,
326
+ attempts: this.attempts,
327
+ accounting: 'observed-only',
328
+ diagnostics: this.diagnostics,
272
329
  events: [...this._eventLog],
273
330
  startedAt: this._startedAt,
274
331
  endedAt: this._alive ? undefined : Date.now(),
275
332
  turns: this._turns,
276
333
  totalTokens: { ...this._totalTokens },
277
- toolCalls: this._eventLog.filter((e) => e.kind === "tool_use").length,
278
- toolResults: this._eventLog.filter((e) => e.kind === "tool_result").length,
279
- errors: this._eventLog.filter((e) => e.kind === "error").length,
334
+ toolCalls: this._eventLog.filter((e) => e.kind === 'tool_use').length,
335
+ toolResults: this._eventLog.filter((e) => e.kind === 'tool_result').length,
336
+ errors: this._eventLog.filter((e) => e.kind === 'error').length,
280
337
  };
281
338
  }
282
339
 
@@ -285,43 +342,53 @@ abstract class BaseCodexSession implements HarnessSession {
285
342
  // ---------------------------------------------------------------------------
286
343
 
287
344
  async start(): Promise<void> {
288
- if (this._proc) throw new Error("Session already started");
345
+ if (this._attempts.some((a) => a.dispatch === 'attempted' && a.nativeOutcome === 'unknown'))
346
+ throw new Error('Native outcome unresolved; automatic resume is blocked');
347
+ if (this._proc) throw new Error('Session already started');
289
348
 
290
349
  const args = this._buildSpawnArgs();
291
350
  const env: Record<string, string | undefined> = {
292
351
  ...process.env,
293
- DISABLE_AUTOUPDATER: "1",
352
+ ...this._env,
353
+ DISABLE_AUTOUPDATER: '1',
294
354
  };
355
+ // Subscription transport: an inherited API key never selects metered billing.
356
+ delete env.OPENAI_API_KEY;
357
+ delete env.CODEX_API_KEY;
358
+ for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key];
295
359
 
296
360
  if (this._spawn) {
297
361
  this._proc = this._spawn([this._bin, ...args], { cwd: this._cwd, env });
298
362
  } else {
299
363
  this._proc = Bun.spawn([this._bin, ...args], {
300
364
  cwd: this._cwd,
301
- stdin: "pipe",
302
- stdout: "pipe",
303
- stderr: "pipe",
365
+ stdin: 'pipe',
366
+ stdout: 'pipe',
367
+ stderr: 'pipe',
304
368
  env,
305
369
  }) as unknown as PipedSubprocess;
306
370
  }
307
371
 
308
372
  this._alive = true;
309
- this._emit({ kind: "session_start", timestamp: Date.now() });
373
+ this._endEmitted = false;
374
+ this._emit({ kind: 'session_start', timestamp: Date.now() });
310
375
 
311
- this._readStdout();
376
+ const proc = this._proc;
377
+ const stdoutDone = this._readStdout(proc);
312
378
  this._readStderr();
313
379
 
314
- this._proc.exited.then((code) => {
315
- if (!this._alive) return;
380
+ proc.exited.then(async (code) => {
381
+ if (this._proc !== proc || !this._alive) return;
316
382
  this._alive = false;
383
+ await stdoutDone; // Drain buffered terminal evidence before interpreting process exit.
317
384
  const errMsg = this._stderr.trim()
318
385
  ? `Process exited (code ${code}): ${this._stderr.trim().slice(0, 500)}`
319
386
  : `Process exited with code ${code}`;
320
387
  this._rejectInflight(new Error(errMsg));
321
- this._rejectQueue(new Error("Session ended"));
322
- for (const p of this._pending.values()) p.reject(new Error("Session ended"));
388
+ this._rejectQueue(new Error('Session ended'));
389
+ for (const p of this._pending.values()) p.reject(new Error('Session ended'));
323
390
  this._pending.clear();
324
- this._emit({ kind: "session_end", timestamp: Date.now() });
391
+ this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
325
392
  });
326
393
 
327
394
  await this._handshake();
@@ -333,13 +400,25 @@ abstract class BaseCodexSession implements HarnessSession {
333
400
 
334
401
  async send(
335
402
  message: string,
336
- opts?: { timeout?: number },
403
+ opts?: import('./harness-session').SessionSendOptions,
337
404
  ): Promise<SessionResult> {
405
+ if (
406
+ this._attempts.some(
407
+ (a) =>
408
+ a.dispatch === 'attempted' &&
409
+ a.nativeOutcome === 'unknown' &&
410
+ (!this._alive || a.localOutcome !== 'pending'),
411
+ )
412
+ ) {
413
+ const blocked = new TurnState();
414
+ this._attempts.push(blocked);
415
+ throw blocked.fail(new Error('Native outcome unresolved; send not dispatched'), 'blocked');
416
+ }
338
417
  if (!this._proc && this._externalSessionId) {
339
418
  await this.start();
340
419
  }
341
- if (!this._proc) throw new Error("Session not started — call start() first");
342
- if (!this._alive) throw new Error("Session ended");
420
+ if (!this._proc) throw new Error('Session not started — call start() first');
421
+ if (!this._alive) throw new Error('Session ended');
343
422
 
344
423
  const timeout = opts?.timeout ?? this._defaultTimeout;
345
424
 
@@ -355,7 +434,25 @@ abstract class BaseCodexSession implements HarnessSession {
355
434
  }
356
435
 
357
436
  return new Promise<SessionResult>((resolve, reject) => {
358
- const turn: QueuedTurn = { message: transformed, timeout, resolve, reject };
437
+ const evidence = new TurnState();
438
+ this._attempts.push(evidence);
439
+ const turn: QueuedTurn = {
440
+ evidence,
441
+ message: transformed,
442
+ timeout,
443
+ resolve,
444
+ reject,
445
+ onAdmission: opts?.onAdmission,
446
+ };
447
+ if (!this._alive || (this._inflight && this._inflight.evidence.localOutcome !== 'pending')) {
448
+ reject(
449
+ evidence.fail(
450
+ new Error('Previous native work unresolved; send not dispatched'),
451
+ 'blocked',
452
+ ),
453
+ );
454
+ return;
455
+ }
359
456
  if (!this._inflight) {
360
457
  this._dispatchTurn(turn);
361
458
  } else {
@@ -368,16 +465,52 @@ abstract class BaseCodexSession implements HarnessSession {
368
465
  // Private — turn dispatch + queue (mirrors ClaudeCodeSession)
369
466
  // ---------------------------------------------------------------------------
370
467
 
468
+ readonly admissionProtocol = 'prewrite-v1' as const;
469
+ inspectAttempt(admissionId: string) {
470
+ return this._attempts.find((attempt) => attempt.admissionId === admissionId)?.snapshot();
471
+ }
472
+
371
473
  private _dispatchTurn(turn: QueuedTurn): void {
372
474
  this._inflight = turn;
373
- this._turnEvents = [];
374
- this._resultText = "";
475
+ if (turn.onAdmission) {
476
+ Promise.resolve()
477
+ .then(() => turn.onAdmission!(turn.evidence.snapshot()))
478
+ .then(() => {
479
+ if (this._inflight === turn && this._alive && turn.evidence.localOutcome === 'pending')
480
+ this._writeTurn(turn);
481
+ else if (this._inflight === turn && turn.evidence.dispatch === 'not-dispatched') {
482
+ this._inflight = null;
483
+ this._processNextTurn();
484
+ }
485
+ })
486
+ .catch((error) => {
487
+ turn.reject(
488
+ turn.evidence.fail(
489
+ error instanceof Error ? error : new Error(String(error)),
490
+ 'registration',
491
+ ),
492
+ );
493
+ if (this._inflight === turn) {
494
+ this._inflight = null;
495
+ this._processNextTurn();
496
+ }
497
+ });
498
+ return;
499
+ }
500
+ this._writeTurn(turn);
501
+ }
502
+
503
+ private _writeTurn(turn: QueuedTurn): void {
504
+ turn.evidence.dispatch = 'attempted';
505
+ this._turnEvents = turn.evidence.events;
506
+ this._resultText = '';
375
507
 
376
508
  // Send via the subclass's wire protocol. The returned promise resolves when
377
509
  // the tools/call (or turn) completes; that resolves the turn.
378
510
  this._sendTurn(turn.message)
379
511
  .then((tokens) => {
380
- if (this._inflight !== turn) return; // interrupted / timed out
512
+ if (this._inflight !== turn) return;
513
+ turn.evidence.rpcSettled = true;
381
514
  if (tokens) {
382
515
  this._totalTokens.input += tokens.input;
383
516
  this._totalTokens.output += tokens.output;
@@ -386,14 +519,16 @@ abstract class BaseCodexSession implements HarnessSession {
386
519
  })
387
520
  .catch((err: Error) => {
388
521
  if (this._inflight !== turn) return;
389
- this._rejectInflight(err);
390
- this._processNextTurn();
522
+ turn.evidence.rpcSettled = true;
523
+ if (turn.evidence.rpcOutcome === 'pending') turn.evidence.rpcOutcome = 'unknown';
524
+ this._rejectInflight(err, err instanceof NativeValidationError ? 'validation' : 'rpc');
525
+ this._releaseKnownTurn();
391
526
  });
392
527
 
393
528
  if (turn.timeout > 0) {
394
529
  this._turnTimer = setTimeout(() => {
395
530
  this._turnTimer = null;
396
- this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`));
531
+ this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`), 'timeout');
397
532
  }, turn.timeout);
398
533
  }
399
534
  }
@@ -406,38 +541,64 @@ abstract class BaseCodexSession implements HarnessSession {
406
541
  this._turnTimer = null;
407
542
  }
408
543
 
544
+ const a = this._inflight.evidence;
545
+ a.content = this._resultText;
546
+ a.tokens = tokens ?? a.tokens;
547
+ if (a.localOutcome === 'pending') {
548
+ a.localOutcome = 'resolved';
549
+ this._inflight.resolve(a.result(this._externalSessionId));
550
+ }
551
+ if (a.nativeOutcome === 'unknown')
552
+ this._rejectQueue(new Error('Native terminal missing; queued send not dispatched'));
553
+ this._releaseKnownTurn();
554
+ }
555
+
556
+ private _releaseKnownTurn(): void {
557
+ const a = this._inflight?.evidence;
558
+ if (!a || !this._canReleaseAttempt(a) || a.nativeOutcome === 'unknown') return;
409
559
  this._turns++;
410
- const result: SessionResult = {
411
- content: this._resultText,
412
- events: [...this._turnEvents],
413
- tokens: tokens ?? this._turnEvents.find((e) => e.tokens)?.tokens,
414
- externalSessionId: this._externalSessionId,
415
- };
416
- this._inflight.resolve(result);
417
560
  this._inflight = null;
418
-
419
561
  this._processNextTurn();
420
562
  }
421
563
 
564
+ protected _canReleaseAttempt(attempt: TurnState): boolean {
565
+ return attempt.rpcSettled;
566
+ }
567
+
422
568
  private _processNextTurn(): void {
423
- if (this._queue.length > 0 && this._alive) {
569
+ if (!this._inflight && this._queue.length > 0 && this._alive) {
424
570
  const next = this._queue.shift()!;
425
571
  this._dispatchTurn(next);
426
572
  }
427
573
  }
428
574
 
429
- protected _rejectInflight(err: Error): void {
575
+ protected _rejectInflight(
576
+ err: Error,
577
+ reason: import('./harness-session').SessionAttempt['localFailure'] = 'transport',
578
+ ): void {
430
579
  if (!this._inflight) return;
431
580
  if (this._turnTimer) {
432
581
  clearTimeout(this._turnTimer);
433
582
  this._turnTimer = null;
434
583
  }
435
- this._inflight.reject(err);
436
- this._inflight = null;
584
+ const a = this._inflight.evidence;
585
+ if ((reason === 'transport' || reason === 'killed') && a.transportOutcome === 'open')
586
+ a.transportOutcome = reason === 'killed' ? 'closed' : 'failed';
587
+ if (a.localOutcome === 'pending') this._inflight.reject(a.fail(err, reason));
588
+ this._rejectQueue(new Error('Previous native work unresolved; queued send not dispatched'));
589
+ }
590
+
591
+ private _transportFailure(err: Error): void {
592
+ this._alive = false;
593
+ this._rejectInflight(err);
594
+ this._rejectQueue(err);
595
+ this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
596
+ for (const p of this._pending.values()) p.reject(err);
597
+ this._pending.clear();
437
598
  }
438
599
 
439
600
  protected _rejectQueue(err: Error): void {
440
- for (const turn of this._queue) turn.reject(err);
601
+ for (const turn of this._queue) turn.reject(turn.evidence.fail(err, 'blocked'));
441
602
  this._queue = [];
442
603
  }
443
604
 
@@ -445,25 +606,33 @@ abstract class BaseCodexSession implements HarnessSession {
445
606
  // Private — stdout/stderr readers (mirrors ClaudeCodeSession)
446
607
  // ---------------------------------------------------------------------------
447
608
 
448
- private async _readStdout(): Promise<void> {
449
- const reader = this._proc!.stdout.getReader();
609
+ private async _readStdout(proc: PipedSubprocess): Promise<void> {
610
+ const reader = proc.stdout.getReader();
450
611
  const decoder = new TextDecoder();
451
- let buffer = "";
612
+ let buffer = '';
452
613
  try {
453
614
  while (true) {
454
615
  const { done, value } = await reader.read();
455
616
  if (done) break;
456
617
  buffer += decoder.decode(value, { stream: true });
457
- const lines = buffer.split("\n");
618
+ const lines = buffer.split('\n');
458
619
  buffer = lines.pop()!;
459
620
  for (const line of lines) {
460
621
  if (!line.trim()) continue;
461
- this._processLine(line);
622
+ if (this._proc === proc || !this._proc) this._processLine(line);
462
623
  }
463
624
  }
464
- if (buffer.trim()) this._processLine(buffer);
625
+ if (buffer.trim() && (this._proc === proc || !this._proc)) this._processLine(buffer);
626
+ if (this._proc === proc && this._alive)
627
+ this._transportFailure(
628
+ new Error(
629
+ this._stderr.trim()
630
+ ? `Native stdout closed: ${this._stderr.trim().slice(0, 500)}`
631
+ : 'Native stdout closed',
632
+ ),
633
+ );
465
634
  } catch (err) {
466
- this._rejectInflight(err as Error);
635
+ if (this._proc === proc) this._transportFailure(err as Error);
467
636
  }
468
637
  }
469
638
 
@@ -476,7 +645,9 @@ abstract class BaseCodexSession implements HarnessSession {
476
645
  if (done) break;
477
646
  this._stderr += decoder.decode(value, { stream: true });
478
647
  }
479
- } catch { /* ignore */ }
648
+ } catch {
649
+ /* ignore */
650
+ }
480
651
  }
481
652
 
482
653
  // ---------------------------------------------------------------------------
@@ -490,36 +661,59 @@ abstract class BaseCodexSession implements HarnessSession {
490
661
  } catch {
491
662
  return;
492
663
  }
493
- const raw = msg as Record<string, unknown>;
664
+ const raw = object(msg);
665
+ if (!raw) return;
666
+ if (
667
+ typeof raw.method === 'string' &&
668
+ (typeof raw.id === 'string' || typeof raw.id === 'number') &&
669
+ this._serverRequest(raw)
670
+ )
671
+ return;
494
672
 
495
673
  // JSON-RPC response to one of our requests (has matching `id` + result/error).
496
- if (typeof raw.id === "number" && (("result" in raw) || ("error" in raw))) {
674
+ if (typeof raw.id === 'number' && ('result' in raw || 'error' in raw)) {
497
675
  const pending = this._pending.get(raw.id);
498
676
  if (pending) {
499
677
  this._pending.delete(raw.id);
500
- if ("error" in raw && raw.error) {
678
+ const a = this._inflight?.evidence;
679
+ if (a?.identity.rpcRequestId === raw.id) a.rpcOutcome = raw.error ? 'failed' : 'resolved';
680
+ if ('error' in raw && raw.error) {
501
681
  const e = raw.error as Record<string, unknown>;
502
682
  pending.reject(new Error((e.message as string) ?? JSON.stringify(e)));
503
683
  } else {
504
- pending.resolve(raw.result);
684
+ // Apply protocol identity synchronously before later lines in this
685
+ // same stdout chunk. Awaiting the promise first can lose a terminal.
686
+ try {
687
+ this._observeRpcResult(raw.id, raw.result);
688
+ pending.resolve(raw.result);
689
+ } catch (error) {
690
+ pending.reject(error instanceof Error ? error : new Error(String(error)));
691
+ }
505
692
  }
506
693
  return;
507
694
  }
508
695
  }
509
696
 
510
- // Otherwise it's a notification (streamed event) — classify it.
511
697
  const classified = this._classify(raw);
512
698
  for (const event of classified) {
513
- this._emit(event);
514
- this._turnEvents.push(event);
515
- if (event.kind === "result" || event.kind === "text") {
516
- if (event.text) this._resultText = event.text;
699
+ const a = this._inflight?.evidence;
700
+ if (!a || event.unattributedReason) {
701
+ this._emit(event);
702
+ continue;
703
+ }
704
+ const e = a.record(event);
705
+ this._resultText = a.content;
706
+ if (e.nativeOutcome && e.terminal) {
707
+ a.nativeOutcome = e.nativeOutcome;
708
+ a.terminal = e.terminal;
517
709
  }
518
- if (event.tokens) {
519
- this._totalTokens.input += event.tokens.input;
520
- this._totalTokens.output += event.tokens.output;
710
+ if (e.tokens) {
711
+ this._totalTokens.input += e.tokens.input;
712
+ this._totalTokens.output += e.tokens.output;
521
713
  }
714
+ this._emit(e);
522
715
  }
716
+ this._releaseKnownTurn();
523
717
  }
524
718
 
525
719
  // ---------------------------------------------------------------------------
@@ -527,15 +721,22 @@ abstract class BaseCodexSession implements HarnessSession {
527
721
  // ---------------------------------------------------------------------------
528
722
 
529
723
  protected _emit(event: SessionEvent): void {
724
+ event = retainEvidence(event);
725
+ if (event.kind === 'session_end') {
726
+ if (this._endEmitted) return;
727
+ this._endEmitted = true;
728
+ }
530
729
  this._eventLog.push(event);
531
730
  for (const handler of this._handlers) {
532
731
  try {
533
- handler(event);
534
- } catch (err) {
535
- console.warn(
536
- `[${this.constructor.name}] handler error:`,
537
- (err as Error).message,
538
- );
732
+ const observation: unknown = handler(event);
733
+ if (observation && typeof (observation as PromiseLike<unknown>).then === 'function') {
734
+ void Promise.resolve(observation).catch(() => {
735
+ this._observerFailures.asynchronous++;
736
+ });
737
+ }
738
+ } catch {
739
+ this._observerFailures.synchronous++;
539
740
  }
540
741
  }
541
742
  }
@@ -546,7 +747,11 @@ abstract class BaseCodexSession implements HarnessSession {
546
747
 
547
748
  protected _rpcRequest(method: string, params?: unknown): Promise<unknown> {
548
749
  const id = ++this._rpcId;
549
- const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
750
+ if ((method === 'tools/call' || method === 'turn/start') && this._inflight) {
751
+ this._inflight.evidence.identity = { ...this._inflight.evidence.identity, rpcRequestId: id };
752
+ this._inflight.evidence.rpcOutcome = 'pending';
753
+ }
754
+ const payload = `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`;
550
755
  return new Promise<unknown>((resolve, reject) => {
551
756
  this._pending.set(id, { resolve, reject });
552
757
  try {
@@ -559,8 +764,12 @@ abstract class BaseCodexSession implements HarnessSession {
559
764
  });
560
765
  }
561
766
 
767
+ protected _serverRequest(_request: Record<string, unknown>): boolean {
768
+ return false;
769
+ }
770
+
562
771
  protected _rpcNotify(method: string, params?: unknown): void {
563
- const payload = JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n";
772
+ const payload = `${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`;
564
773
  this._proc!.stdin.write(payload);
565
774
  this._proc!.stdin.flush();
566
775
  }
@@ -580,12 +789,13 @@ abstract class BaseCodexSession implements HarnessSession {
580
789
  /** Map a streamed notification to SessionEvents. */
581
790
  protected abstract _classify(msg: Record<string, unknown>): SessionEvent[];
582
791
 
792
+ protected _observeRpcResult(_id: number, _result: unknown): void {}
793
+
583
794
  /** Shared fork constructor — subclass passes its own ctor. */
584
795
  fork(opts?: { cwd?: string; baseContext?: string }): HarnessSession {
796
+ if (this._inflight) throw new Error('Cannot fork while native ownership is unresolved');
585
797
  if (!this._externalSessionId) {
586
- throw new Error(
587
- "Cannot fork — no thread ID yet (send at least one message first)",
588
- );
798
+ throw new Error('Cannot fork — no thread ID yet (send at least one message first)');
589
799
  }
590
800
  const Ctor = this.constructor as new (c?: CodexSessionConfig) => BaseCodexSession;
591
801
  const forked = new Ctor({
@@ -597,6 +807,7 @@ abstract class BaseCodexSession implements HarnessSession {
597
807
  baseContext: opts?.baseContext ?? this._baseContext,
598
808
  externalSessionId: this._externalSessionId,
599
809
  spawn: this._spawn,
810
+ env: this._env,
600
811
  });
601
812
  forked._forking = true;
602
813
  return forked;
@@ -617,37 +828,44 @@ abstract class BaseCodexSession implements HarnessSession {
617
828
  // multi-turn + fork).
618
829
 
619
830
  export class CodexMcpSession extends BaseCodexSession {
831
+ get transport() {
832
+ return TRANSPORTS['codex-mcp'];
833
+ }
834
+ private _mcpAdmission?: string;
835
+ private _mcpCalls = new Map<string, { invocation: Record<string, unknown>; ended: boolean }>();
620
836
  protected _buildSpawnArgs(): string[] {
621
837
  // Disable codex's own approvals + sandbox (the union of
622
838
  // --dangerously-bypass-approvals-and-sandbox) so OUR container is the jail.
623
839
  // model_reasoning_effort sets the effort level; passed as TOML-ish `-c` values.
624
840
  const args = [
625
- "mcp-server",
626
- "-c", `sandbox_mode="danger-full-access"`,
627
- "-c", `approval_policy="never"`,
841
+ 'mcp-server',
842
+ '-c',
843
+ `sandbox_mode="danger-full-access"`,
844
+ '-c',
845
+ `approval_policy="never"`,
628
846
  ];
629
847
  if (this._effort) {
630
- args.push("-c", `model_reasoning_effort="${this._effort}"`);
848
+ args.push('-c', `model_reasoning_effort="${this._effort}"`);
631
849
  }
632
850
  return args;
633
851
  }
634
852
 
635
853
  protected async _handshake(): Promise<void> {
636
- await this._rpcRequest("initialize", {
637
- protocolVersion: "2025-06-18",
854
+ await this._rpcRequest('initialize', {
855
+ protocolVersion: '2025-06-18',
638
856
  capabilities: {},
639
- clientInfo: { name: "inixiative-bench", version: "0.1.0" },
857
+ clientInfo: { name: 'inixiative-bench', version: '0.1.0' },
640
858
  });
641
- this._rpcNotify("notifications/initialized");
859
+ this._rpcNotify('notifications/initialized');
642
860
  // tools/list confirms the `codex` + `codex-reply` tools are present.
643
- await this._rpcRequest("tools/list", {});
861
+ await this._rpcRequest('tools/list', {});
644
862
  }
645
863
 
646
864
  protected async _sendTurn(
647
865
  message: string,
648
866
  ): Promise<{ input: number; output: number } | undefined> {
649
867
  const isReply = this._externalSessionId !== undefined;
650
- const name = isReply ? "codex-reply" : "codex";
868
+ const name = isReply ? 'codex-reply' : 'codex';
651
869
  const args: Record<string, unknown> = isReply
652
870
  ? { prompt: message, threadId: this._externalSessionId }
653
871
  : {
@@ -655,31 +873,34 @@ export class CodexMcpSession extends BaseCodexSession {
655
873
  model: this._model,
656
874
  cwd: this._cwd,
657
875
  // Belt-and-suspenders: also disable per-call (matches spawn `-c` flags).
658
- sandbox: "danger-full-access",
659
- "approval-policy": "never",
660
- ...(this._effort
661
- ? { config: { model_reasoning_effort: this._effort } }
662
- : {}),
876
+ sandbox: 'danger-full-access',
877
+ 'approval-policy': 'never',
878
+ ...(this._effort ? { config: { model_reasoning_effort: this._effort } } : {}),
663
879
  };
664
880
 
665
- const result = (await this._rpcRequest("tools/call", {
881
+ const result = (await this._rpcRequest('tools/call', {
666
882
  name,
667
883
  arguments: args,
668
884
  })) as Record<string, unknown> | undefined;
669
885
 
670
886
  // Capture the threadId for multi-turn (codex-reply) + fork.
671
- const structured = result?.structuredContent as
672
- | Record<string, unknown>
673
- | undefined;
887
+ const structured = result?.structuredContent as Record<string, unknown> | undefined;
674
888
  const threadId = structured?.threadId as string | undefined;
889
+ if (threadId && this._externalSessionId && threadId !== this._externalSessionId)
890
+ throw new Error('MCP response changed the native resume binding');
675
891
  if (threadId && !this._externalSessionId) {
676
892
  this._externalSessionId = threadId;
677
893
  }
894
+ if (threadId && this._inflight)
895
+ this._inflight.evidence.identity = { ...this._inflight.evidence.identity, threadId };
678
896
  // Final text: prefer structuredContent.content, else the tool result content.
679
897
  const finalText =
680
- (structured?.content as string | undefined) ??
681
- this._extractToolText(result?.content);
682
- if (finalText) this._resultText = finalText;
898
+ (structured?.content as string | undefined) ?? this._extractToolText(result?.content);
899
+ if (result?.isError === true) throw new Error(finalText ?? 'MCP tools/call failed');
900
+ if (finalText) {
901
+ this._resultText = finalText;
902
+ if (this._inflight) this._inflight.evidence.content = finalText;
903
+ }
683
904
 
684
905
  // Usage, when the call result reports it. Streamed token_count events are
685
906
  // already accounted by the base loop; the call result is the authoritative
@@ -688,7 +909,12 @@ export class CodexMcpSession extends BaseCodexSession {
688
909
  // token event was seen this turn.
689
910
  const sawStreamedTokens = this._turnEvents.some((e) => e.tokens);
690
911
  const usage = structured?.usage as Record<string, number> | undefined;
691
- if (usage && !sawStreamedTokens) {
912
+ if (
913
+ usage &&
914
+ !sawStreamedTokens &&
915
+ typeof (usage.input_tokens ?? usage.inputTokens) === 'number' &&
916
+ typeof (usage.output_tokens ?? usage.outputTokens) === 'number'
917
+ ) {
692
918
  return {
693
919
  input: (usage.input_tokens ?? usage.inputTokens ?? 0) as number,
694
920
  output: (usage.output_tokens ?? usage.outputTokens ?? 0) as number,
@@ -702,126 +928,901 @@ export class CodexMcpSession extends BaseCodexSession {
702
928
  const parts: string[] = [];
703
929
  for (const block of content) {
704
930
  const b = block as Record<string, unknown>;
705
- if (b.type === "text" && typeof b.text === "string") parts.push(b.text);
931
+ if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
706
932
  }
707
- return parts.length ? parts.join("\n") : undefined;
933
+ return parts.length ? parts.join('\n') : undefined;
708
934
  }
709
935
 
710
936
  protected _classify(msg: Record<string, unknown>): SessionEvent[] {
711
937
  // Streamed events arrive as JSON-RPC notifications: method "codex/event"
712
938
  // with params carrying a `msg` of a tagged type.
713
- if (msg.method !== "codex/event") return [];
939
+ if (msg.method !== 'codex/event') return [];
714
940
  const params = msg.params as Record<string, unknown> | undefined;
715
941
  const ev = (params?.msg ?? params) as Record<string, unknown> | undefined;
716
942
  if (!ev) return [];
717
- return classifyCodexEvent(ev, "type");
943
+ const a = this._inflight?.evidence;
944
+ const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
945
+ const nativeSessionId = str(ev.session_id),
946
+ threadId = str(ev.thread_id);
947
+ const turnId = str(ev.turn_id),
948
+ envelopeId = str(params?.id);
949
+ const item = ev.item as Record<string, unknown> | undefined;
950
+ const unowned = (reason: NonNullable<SessionEvent['unattributedReason']>): SessionEvent[] => [
951
+ {
952
+ kind: 'native_status',
953
+ timestamp: Date.now(),
954
+ correlation: 'unknown',
955
+ unattributedReason: reason,
956
+ nativeSessionId,
957
+ threadId,
958
+ turnId,
959
+ callId: str(ev.call_id),
960
+ itemId: str(item?.id) ?? str(ev.item_id),
961
+ // Keep the envelope too: params.id may explain a rejected correlation.
962
+ // Unowned MCP calls retain identity/type diagnostics, never unrelated tool payloads.
963
+ raw:
964
+ ev.type === 'mcp_tool_call_begin' || ev.type === 'mcp_tool_call_end'
965
+ ? { type: ev.type }
966
+ : msg,
967
+ },
968
+ ];
969
+ if (threadId && this._externalSessionId && threadId !== this._externalSessionId)
970
+ return unowned('foreign-session');
971
+ if (
972
+ nativeSessionId &&
973
+ a?.identity.nativeSessionId &&
974
+ nativeSessionId !== a.identity.nativeSessionId
975
+ )
976
+ return unowned('foreign-session');
977
+ if (
978
+ (envelopeId && this._seenTerminals.has(envelopeId)) ||
979
+ (turnId && this._seenTerminals.has(turnId))
980
+ ) {
981
+ return unowned(ev.type === 'task_complete' ? 'duplicate-terminal' : 'after-terminal');
982
+ }
983
+ if (!a) {
984
+ if (ev.type === 'task_complete' && turnId) this._seenTerminals.add(turnId);
985
+ return unowned('no-admission');
986
+ }
987
+ if (ev.type === 'session_configured') {
988
+ if (!this._externalSessionId && threadId) this._externalSessionId = threadId;
989
+ a.identity = { ...a.identity, nativeSessionId, threadId };
990
+ }
991
+ if (
992
+ ev.type !== 'session_configured' &&
993
+ a.identity.turnId &&
994
+ envelopeId &&
995
+ envelopeId !== a.identity.turnId
996
+ )
997
+ return unowned('foreign-turn');
998
+ if (threadId && a.identity.threadId && threadId !== a.identity.threadId)
999
+ return unowned('foreign-session');
1000
+ if (ev.type === 'task_started' && turnId && !a.identity.turnId)
1001
+ a.identity = { ...a.identity, turnId, correlation: 'native-turn' };
1002
+ if (turnId && turnId !== a.identity.turnId) return unowned('foreign-turn');
1003
+ if (ev.type === 'task_complete') {
1004
+ if (!turnId || turnId !== a.identity.turnId) return unowned('unrecognized-event');
1005
+ this._seenTerminals.add(turnId);
1006
+ return [
1007
+ {
1008
+ ...a.identity,
1009
+ kind: 'result',
1010
+ timestamp: Date.now(),
1011
+ nativeOutcome: 'completed',
1012
+ terminal: { type: 'task_complete', turnId },
1013
+ raw: ev,
1014
+ },
1015
+ ];
1016
+ }
1017
+ const identity = {
1018
+ ...a.identity,
1019
+ threadId: threadId ?? a.identity.threadId,
1020
+ turnId: turnId ?? a.identity.turnId,
1021
+ callId: str(ev.call_id),
1022
+ itemId: str(item?.id) ?? str(ev.item_id),
1023
+ };
1024
+ if (ev.type === 'mcp_tool_call_begin' || ev.type === 'mcp_tool_call_end') {
1025
+ const invocation = object(ev.invocation);
1026
+ if (
1027
+ !identity.callId ||
1028
+ !invocation ||
1029
+ typeof invocation.server !== 'string' ||
1030
+ !invocation.server ||
1031
+ typeof invocation.tool !== 'string' ||
1032
+ !invocation.tool
1033
+ )
1034
+ return unowned('malformed-tool');
1035
+ if (this._mcpAdmission !== a.identity.admissionId) {
1036
+ this._mcpAdmission = a.identity.admissionId;
1037
+ this._mcpCalls.clear();
1038
+ }
1039
+ // Compare the original argument value, including absence. Never join a result
1040
+ // for another server/tool or silently substitute arguments from its envelope.
1041
+ const join = {
1042
+ server: invocation.server,
1043
+ tool: invocation.tool,
1044
+ arguments: invocation.arguments,
1045
+ };
1046
+ const prior = this._mcpCalls.get(identity.callId);
1047
+ const tool = {
1048
+ toolName: `mcp__${invocation.server}__${invocation.tool}`,
1049
+ toolServer: invocation.server,
1050
+ toolMethod: invocation.tool,
1051
+ };
1052
+ if (ev.type === 'mcp_tool_call_begin') {
1053
+ if (prior) return unowned('duplicate-tool');
1054
+ this._mcpCalls.set(identity.callId, { invocation: structuredClone(join), ended: false });
1055
+ const args = publicMcpArguments(invocation.arguments);
1056
+ return [{ ...identity, ...tool, kind: 'tool_use', timestamp: Date.now(), ...args }];
1057
+ }
1058
+ if (!prior || !isDeepStrictEqual(prior.invocation, join)) return unowned('unmatched-tool');
1059
+ if (prior.ended) return unowned('duplicate-tool');
1060
+ const result = publicMcpResult(ev.result);
1061
+ if (!result) return unowned('malformed-tool');
1062
+ prior.ended = true;
1063
+ return [{ ...identity, ...tool, kind: 'tool_result', timestamp: Date.now(), ...result }];
1064
+ }
1065
+ if (
1066
+ ['session_configured', 'task_started', 'item_started', 'item_completed'].includes(
1067
+ String(ev.type),
1068
+ )
1069
+ ) {
1070
+ return [
1071
+ {
1072
+ ...identity,
1073
+ kind: 'native_status',
1074
+ timestamp: Date.now(),
1075
+ nativeOutcome: a.nativeOutcome,
1076
+ raw: ev,
1077
+ },
1078
+ ];
1079
+ }
1080
+ // Notifications without a turn ID are associated only with the single owned RPC.
1081
+ const classified = classifyCodexEvent(ev, 'type');
1082
+ return classified.length
1083
+ ? classified.map((event) => ({ ...identity, ...event }))
1084
+ : unowned('unrecognized-event');
718
1085
  }
719
1086
  }
720
1087
 
721
1088
  // ---------------------------------------------------------------------------
722
- // CodexAppServerSession (EXPERIMENTAL) — `codex app-server` over WebSocket
1089
+ // CodexAppServerSession (EXPERIMENTAL) — `codex app-server` over stdio
723
1090
  // ---------------------------------------------------------------------------
724
1091
  //
725
- // EXPERIMENTAL: the app-server protocol is newer and less battle-tested than
726
- // mcp-server. Prefer CodexMcpSession unless you specifically need app-server.
727
- //
728
- // `codex app-server --listen ws://127.0.0.1:<port>` — localhost needs no auth
729
- // token. JSON-RPC 2.0 with slash-delimited methods:
730
- // initialize → initialized → thread/start (returns thread.id) → turn/start
731
- // Consume turn/started, item/started, item/completed, turn/completed
732
- // notifications (ThreadItem types agentMessage / reasoning / commandExecution;
733
- // ThreadTokenUsage). Mapped to SessionEvents.
734
- //
735
- // We connect over the spawned process's stdin/stdout JSON-RPC for parity with
736
- // the rest of the harness (the WebSocket listen address is for external
737
- // clients; the stdio channel carries the same JSON-RPC frames). The base
738
- // class's read/write loop is reused unchanged.
1092
+ // Wire contract: installed codex-cli 0.153.4 v2 JSON schema. Controlled tests
1093
+ // establish source behavior, not a real create/resume/native-completion capture.
1094
+ // No default switch. Full history hydration, native fork and cancellation remain
1095
+ // separate integration work; a local interrupt never acknowledges native stop.
1096
+
1097
+ class NativeValidationError extends Error {}
739
1098
 
740
1099
  export class CodexAppServerSession extends BaseCodexSession {
741
- /** Resolves when the in-flight turn's `turn/completed` arrives. */
742
- private _turnDone?: {
743
- resolve: (t: { input: number; output: number } | undefined) => void;
744
- reject: (e: Error) => void;
745
- };
1100
+ get transport() {
1101
+ return TRANSPORTS['codex-app-server'];
1102
+ }
1103
+ readonly appServerProtocol = 'owned-thread-v1';
1104
+ private _limits?: LimitSnapshot;
1105
+
1106
+ /** Latest account limits observed on this session (stream or poll). */
1107
+ get limits(): LimitSnapshot | undefined {
1108
+ return this._limits;
1109
+ }
1110
+
1111
+ private _observeLimits(snapshot: LimitSnapshot): SessionEvent {
1112
+ this._limits = mergeLimits(this._limits, snapshot);
1113
+ return {
1114
+ kind: 'rate_limit',
1115
+ timestamp: Date.now(),
1116
+ correlation: 'unknown',
1117
+ unattributedReason: 'account-status',
1118
+ limits: this._limits,
1119
+ };
1120
+ }
1121
+
1122
+ /** account/rateLimits/read — no model turn. Requires a started session. */
1123
+ async readLimits(opts?: { timeoutMs?: number }): Promise<LimitSnapshot | undefined> {
1124
+ if (!this._proc || !this._alive) throw Error('Session not running');
1125
+ let timer: ReturnType<typeof setTimeout> | undefined;
1126
+ const result = await Promise.race([
1127
+ this._rpcRequest('account/rateLimits/read', { excludeResetCreditDetails: true }),
1128
+ new Promise<never>((_, reject) => {
1129
+ timer = setTimeout(
1130
+ () => reject(Error('account/rateLimits/read timed out')),
1131
+ opts?.timeoutMs ?? 10_000,
1132
+ );
1133
+ }),
1134
+ ]).finally(() => clearTimeout(timer));
1135
+ const snapshot = codexLimitSnapshot(result, 'poll');
1136
+ if (snapshot) this._emit(this._observeLimits(snapshot));
1137
+ return snapshot;
1138
+ }
1139
+
1140
+ /**
1141
+ * turn/interrupt for the owned in-flight turn. "acknowledged" once the turn's
1142
+ * own turn/completed arrives; the send() then settles through that terminal.
1143
+ */
1144
+ async interruptNative(opts?: { timeoutMs?: number }): Promise<NativeInterruptOutcome> {
1145
+ const a = this._inflight?.evidence;
1146
+ if (
1147
+ a?.dispatch !== 'attempted' ||
1148
+ a.nativeOutcome !== 'unknown' ||
1149
+ !a.identity.turnId ||
1150
+ !this._externalSessionId
1151
+ )
1152
+ return 'no-turn';
1153
+ const deadline = Date.now() + (opts?.timeoutMs ?? 5_000);
1154
+ let timer: ReturnType<typeof setTimeout> | undefined;
1155
+ try {
1156
+ await Promise.race([
1157
+ this._rpcRequest('turn/interrupt', {
1158
+ threadId: this._externalSessionId,
1159
+ turnId: a.identity.turnId,
1160
+ }),
1161
+ new Promise<never>((_, reject) => {
1162
+ timer = setTimeout(
1163
+ () => reject(Error('turn/interrupt timed out')),
1164
+ Math.max(1, deadline - Date.now()),
1165
+ );
1166
+ }),
1167
+ ]);
1168
+ } catch {
1169
+ return 'unacknowledged';
1170
+ } finally {
1171
+ clearTimeout(timer);
1172
+ }
1173
+ while (a.nativeOutcome === 'unknown' && this._alive && Date.now() < deadline)
1174
+ await Bun.sleep(10);
1175
+ return a.nativeOutcome === 'unknown' ? 'unacknowledged' : 'acknowledged';
1176
+ }
1177
+
1178
+ /** Mid-turn steer (turn/steer) into the owned in-flight turn; otherwise records push_ignored. */
1179
+ override async push(payload: { kind: string; text: string }): Promise<void> {
1180
+ const a = this._inflight?.evidence;
1181
+ const ignored = (reason: string) =>
1182
+ this._emit({
1183
+ kind: 'error',
1184
+ timestamp: Date.now(),
1185
+ text: `push_ignored: kind=${payload.kind} — ${reason}`,
1186
+ raw: { kind: payload.kind },
1187
+ });
1188
+ if (
1189
+ a?.dispatch !== 'attempted' ||
1190
+ a.nativeOutcome !== 'unknown' ||
1191
+ !a.identity.turnId ||
1192
+ !this._externalSessionId
1193
+ )
1194
+ return ignored('no owned in-flight turn');
1195
+ let timer: ReturnType<typeof setTimeout> | undefined,
1196
+ timedOut = false;
1197
+ try {
1198
+ await Promise.race([
1199
+ this._rpcRequest('turn/steer', {
1200
+ threadId: this._externalSessionId,
1201
+ expectedTurnId: a.identity.turnId,
1202
+ input: [{ type: 'text', text: payload.text }],
1203
+ }),
1204
+ new Promise<never>((_, reject) => {
1205
+ timer = setTimeout(() => {
1206
+ timedOut = true;
1207
+ reject(Error('turn/steer unanswered'));
1208
+ }, 10_000);
1209
+ }),
1210
+ ]);
1211
+ this._emit({
1212
+ ...a.identity,
1213
+ admissionId: a.admissionId,
1214
+ kind: 'native_status',
1215
+ timestamp: Date.now(),
1216
+ raw: { type: 'push-steered', kind: payload.kind },
1217
+ });
1218
+ } catch (error) {
1219
+ // A refusal is the runtime's answer; no answer leaves delivery unknown, never "ignored".
1220
+ if (timedOut || !this._alive)
1221
+ this._emit({
1222
+ ...a.identity,
1223
+ admissionId: a.admissionId,
1224
+ kind: 'native_status',
1225
+ timestamp: Date.now(),
1226
+ raw: { type: 'push-unknown', kind: payload.kind },
1227
+ });
1228
+ else ignored(`turn/steer refused: ${(error as Error).message.slice(0, 200)}`);
1229
+ } finally {
1230
+ clearTimeout(timer);
1231
+ }
1232
+ }
1233
+ private readonly _options: NonNullable<CodexSessionConfig['appServer']>;
1234
+ private readonly _onThreadReady?: (binding: string) => void | Promise<void>;
1235
+ private _startedOnce = false;
1236
+ private _threadReady = false;
1237
+ private _nativeSessionId?: string;
1238
+ private _appAdmission?: string;
1239
+ private _turnDone?: () => void;
1240
+ private _items = new Map<
1241
+ string,
1242
+ {
1243
+ type: string;
1244
+ input?: unknown;
1245
+ completed: boolean;
1246
+ server?: string;
1247
+ tool?: string;
1248
+ text?: string;
1249
+ }
1250
+ >();
1251
+
1252
+ constructor(config?: CodexSessionConfig) {
1253
+ super(config);
1254
+ const { onThreadReady, ...options } = config?.appServer ?? {};
1255
+ this._onThreadReady = onThreadReady;
1256
+ this._options = retainEvidence(options);
1257
+ const required = this._options.requiredMcpServer;
1258
+ if (
1259
+ required &&
1260
+ (!/^[a-zA-Z0-9_-]+$/.test(required.name) ||
1261
+ !required.tools.length ||
1262
+ required.tools.some((t) => !/^[a-zA-Z0-9_-]+$/.test(t)))
1263
+ )
1264
+ throw new NativeValidationError('Invalid required MCP inventory');
1265
+ }
1266
+
1267
+ override fork(): HarnessSession {
1268
+ throw Error(
1269
+ 'Native app-server fork is not implemented; a same-thread wrapper cannot create a native fork or inherit a process capability',
1270
+ );
1271
+ }
1272
+
1273
+ override async start(): Promise<void> {
1274
+ if (this._inflight)
1275
+ throw Error('Native call ownership unresolved; automatic resume is blocked');
1276
+ if (this._startedOnce && !this._alive)
1277
+ throw Error('App-server restart requires a new owned instance and preflight');
1278
+ this._startedOnce = true;
1279
+ try {
1280
+ await super.start();
1281
+ } catch (error) {
1282
+ this.kill();
1283
+ throw error;
1284
+ }
1285
+ }
1286
+
1287
+ protected override _serverRequest(request: Record<string, unknown>): boolean {
1288
+ // JSON-RPC's explicit method-not-supported refusal also covers unknown future
1289
+ // requests. Never approve native work or echo private request parameters.
1290
+ const payload = `${JSON.stringify({
1291
+ jsonrpc: '2.0',
1292
+ id: request.id,
1293
+ error: { code: -32601, message: 'Client interaction is unsupported by this adapter' },
1294
+ })}\n`;
1295
+ const params = object(request.params),
1296
+ a = this._inflight?.evidence;
1297
+ const identity =
1298
+ a?.identity.turnId &&
1299
+ params?.threadId === a.identity.threadId &&
1300
+ params?.turnId === a.identity.turnId
1301
+ ? { ...a.identity, admissionId: a.admissionId }
1302
+ : { unattributedReason: 'unrecognized-event' as const };
1303
+ try {
1304
+ this._proc!.stdin.write(payload);
1305
+ this._proc!.stdin.flush();
1306
+ } catch {
1307
+ this._emit({
1308
+ ...identity,
1309
+ kind: 'native_status',
1310
+ timestamp: Date.now(),
1311
+ raw: { type: 'server-request-refusal', status: 'write-failed' },
1312
+ });
1313
+ return true;
1314
+ }
1315
+ this._emit({
1316
+ ...identity,
1317
+ kind: 'native_status',
1318
+ timestamp: Date.now(),
1319
+ raw: { type: 'server-request-refusal', status: 'refused' },
1320
+ });
1321
+ return true;
1322
+ }
1323
+
1324
+ private _refuse(reason: string, status = 'unknown'): never {
1325
+ const a = this._inflight?.evidence;
1326
+ this._emit({
1327
+ ...(a ? { admissionId: a.admissionId, ...a.identity } : {}),
1328
+ kind: 'native_status',
1329
+ timestamp: Date.now(),
1330
+ raw: { type: 'thread-refused', reason, status },
1331
+ });
1332
+ throw new NativeValidationError(
1333
+ `Native thread validation refused: ${reason} (${status}); no turn was sent`,
1334
+ );
1335
+ }
1336
+
1337
+ private async _readiness(): Promise<void> {
1338
+ const required = this._options.requiredMcpServer;
1339
+ if (!required) return;
1340
+ let cursor: string | undefined;
1341
+ const seen = new Set<string>();
1342
+ let found: Record<string, unknown> | undefined;
1343
+ // Pagination only, never polling/retrying a starting or failed native server.
1344
+ for (let page = 0; page < 32; page++) {
1345
+ const result = object(
1346
+ await this._rpcRequest('mcpServerStatus/list', {
1347
+ threadId: this._externalSessionId,
1348
+ detail: 'toolsAndAuthOnly',
1349
+ limit: 100,
1350
+ ...(cursor ? { cursor } : {}),
1351
+ }),
1352
+ );
1353
+ if (!Array.isArray(result?.data)) this._refuse('mcp-inventory-malformed');
1354
+ for (const entry of result.data) {
1355
+ const server = object(entry);
1356
+ if (server?.name === required.name) {
1357
+ if (found) this._refuse('mcp-inventory-duplicate');
1358
+ found = server;
1359
+ }
1360
+ }
1361
+ if (result.nextCursor == null) {
1362
+ const tools = object(found?.tools);
1363
+ if (
1364
+ found?.runtimeStatus !== 'connected' ||
1365
+ !tools ||
1366
+ required.tools.some((name) => object(tools[name])?.name !== name)
1367
+ )
1368
+ this._refuse('mcp-inventory-unavailable');
1369
+ const a = this._inflight!.evidence;
1370
+ this._emit({
1371
+ ...a.identity,
1372
+ admissionId: a.admissionId,
1373
+ kind: 'native_status',
1374
+ timestamp: Date.now(),
1375
+ raw: {
1376
+ type: 'mcp-ready',
1377
+ status: 'connected',
1378
+ server: required.name,
1379
+ tools: [...required.tools],
1380
+ },
1381
+ });
1382
+ return;
1383
+ }
1384
+ if (
1385
+ typeof result.nextCursor !== 'string' ||
1386
+ !result.nextCursor ||
1387
+ seen.has(result.nextCursor)
1388
+ )
1389
+ this._refuse('mcp-inventory-cursor');
1390
+ cursor = result.nextCursor;
1391
+ seen.add(cursor);
1392
+ }
1393
+ this._refuse('mcp-inventory-page-limit');
1394
+ }
1395
+
1396
+ protected override _canReleaseAttempt(a: TurnState): boolean {
1397
+ // An observer/write failure may occur after delivery. A terminal alone does
1398
+ // not settle the unanswered turn/start call or authorize another admission.
1399
+ return a.rpcSettled && (a.rpcOutcome === 'resolved' || a.rpcOutcome === 'failed');
1400
+ }
746
1401
 
747
1402
  protected _buildSpawnArgs(): string[] {
748
- // app-server on localhost needs no auth token. Approvals/sandbox are set
749
- // per-thread in thread/start (approvalPolicy / sandbox below).
750
- return ["app-server", "--listen", "ws://127.0.0.1:0"];
1403
+ return ['app-server', '--listen', 'stdio://'];
751
1404
  }
752
1405
 
753
1406
  protected async _handshake(): Promise<void> {
754
- await this._rpcRequest("initialize", {
755
- protocolVersion: "2025-06-18",
756
- capabilities: {},
757
- clientInfo: { name: "inixiative-bench", version: "0.1.0" },
758
- });
759
- this._rpcNotify("initialized");
1407
+ this._threadReady = false;
1408
+ const initialized = object(
1409
+ await this._rpcRequest('initialize', {
1410
+ capabilities: {},
1411
+ clientInfo: { name: 'inixiative-bench', version: '0.1.0' },
1412
+ }),
1413
+ );
1414
+ if (
1415
+ this._options.requireConfiguration &&
1416
+ ['userAgent', 'platformFamily', 'platformOs', 'codexHome'].some(
1417
+ (key) => typeof initialized?.[key] !== 'string' || !initialized[key],
1418
+ )
1419
+ )
1420
+ throw new NativeValidationError(
1421
+ 'Native initialize response lacks required configuration; no thread or turn was sent',
1422
+ );
1423
+ this._rpcNotify('initialized');
760
1424
  }
761
1425
 
762
1426
  protected async _sendTurn(
763
1427
  message: string,
764
1428
  ): Promise<{ input: number; output: number } | undefined> {
765
- // Start (or reuse) a thread. Disable codex's approvals + sandbox so OUR
766
- // container is the only jail.
767
- if (!this._externalSessionId) {
768
- const started = (await this._rpcRequest("thread/start", {
769
- model: this._model,
770
- cwd: this._cwd,
771
- approvalPolicy: "never",
772
- sandbox: "danger-full-access",
773
- ...(this._effort ? { modelReasoningEffort: this._effort } : {}),
774
- })) as Record<string, unknown> | undefined;
775
- const thread = started?.thread as Record<string, unknown> | undefined;
776
- const id = (thread?.id ?? started?.threadId) as string | undefined;
777
- if (id) this._externalSessionId = id;
778
- }
779
-
780
- const completed = new Promise<{ input: number; output: number } | undefined>(
781
- (resolve, reject) => {
782
- this._turnDone = { resolve, reject };
783
- },
784
- );
785
-
786
- // turn/start streams turn/started, item/*, turn/completed back as
787
- // notifications, consumed in _classify; turn/completed resolves the turn.
788
- await this._rpcRequest("turn/start", {
1429
+ const a = this._inflight!.evidence;
1430
+ if (!this._threadReady) {
1431
+ const binding = this._externalSessionId;
1432
+ const method = binding ? 'thread/resume' : 'thread/start';
1433
+ const response = object(
1434
+ await this._rpcRequest(method, {
1435
+ ...(binding ? { threadId: binding } : {}),
1436
+ model: this._model,
1437
+ cwd: this._cwd,
1438
+ approvalPolicy: 'never',
1439
+ sandbox: 'danger-full-access',
1440
+ ...(this._options.config ? { config: this._options.config } : {}),
1441
+ }),
1442
+ );
1443
+ const thread = object(response?.thread),
1444
+ id = thread?.id;
1445
+ if (typeof id !== 'string' || !id || (binding && id !== binding))
1446
+ this._refuse('binding-mismatch');
1447
+ // Resume may rejoin a live thread. Never steer known pre-existing work.
1448
+ // The installed schema requires status. Missing/unknown state cannot
1449
+ // authorize work, even if this response otherwise identifies our thread.
1450
+ if (
1451
+ object(thread?.status)?.type !== 'idle' ||
1452
+ (Array.isArray(thread?.turns) &&
1453
+ thread.turns.some((turn) => object(turn)?.status === 'inProgress'))
1454
+ )
1455
+ this._refuse(
1456
+ 'not-idle',
1457
+ ['active', 'notLoaded', 'systemError'].includes(String(object(thread?.status)?.type))
1458
+ ? String(object(thread?.status)?.type)
1459
+ : 'unknown',
1460
+ );
1461
+ if (
1462
+ this._options.requireConfiguration &&
1463
+ (typeof response?.model !== 'string' ||
1464
+ !response.model ||
1465
+ typeof response.modelProvider !== 'string' ||
1466
+ !response.modelProvider ||
1467
+ response.cwd !== this._cwd ||
1468
+ response.approvalPolicy !== 'never' ||
1469
+ response.approvalsReviewer !== 'user' ||
1470
+ object(response.sandbox)?.type !== 'dangerFullAccess' ||
1471
+ !Array.isArray(thread?.turns) ||
1472
+ thread.turns.some((value) => !this._turn(value)))
1473
+ )
1474
+ this._refuse('configuration-missing-or-incompatible');
1475
+ this._externalSessionId = id;
1476
+ this._nativeSessionId =
1477
+ typeof thread?.sessionId === 'string' && thread.sessionId ? thread.sessionId : undefined;
1478
+ a.identity = {
1479
+ ...a.identity,
1480
+ threadId: id,
1481
+ ...(this._nativeSessionId ? { nativeSessionId: this._nativeSessionId } : {}),
1482
+ };
1483
+ if (this._onThreadReady) {
1484
+ try {
1485
+ await this._onThreadReady(id);
1486
+ } catch (cause) {
1487
+ throw new NativeValidationError('Native binding persistence failed before turn/start', {
1488
+ cause,
1489
+ });
1490
+ }
1491
+ }
1492
+ this._threadReady = true;
1493
+ // Configuration belongs to this process/thread, never to the next turn.
1494
+ // Do not copy historical turns, instructions, paths or arbitrary config.
1495
+ this._emit({
1496
+ kind: 'native_status',
1497
+ timestamp: Date.now(),
1498
+ threadId: id,
1499
+ nativeSessionId: this._nativeSessionId,
1500
+ unattributedReason: 'session-configuration',
1501
+ raw: {
1502
+ type: 'thread-configured',
1503
+ method,
1504
+ threadId: id,
1505
+ ...(object(thread?.status)?.type === 'idle' ? { status: 'idle' } : {}),
1506
+ ...(typeof response?.model === 'string' ? { model: response.model } : {}),
1507
+ ...(response?.reasoningEffort === null ||
1508
+ VALID_EFFORTS.includes(String(response?.reasoningEffort))
1509
+ ? { reasoningEffort: response?.reasoningEffort }
1510
+ : {}),
1511
+ history: {
1512
+ source: method,
1513
+ turns: Array.isArray(thread?.turns)
1514
+ ? thread.turns.slice(0, 200).flatMap((value) => {
1515
+ const t = this._turn(value);
1516
+ return t ? [{ id: t.id, status: t.status }] : [];
1517
+ })
1518
+ : [],
1519
+ available: Array.isArray(thread?.turns),
1520
+ hasMore:
1521
+ !!response?.turnsBackwardsCursor ||
1522
+ (Array.isArray(thread?.turns) && thread.turns.length > 200),
1523
+ },
1524
+ },
1525
+ });
1526
+ }
1527
+ a.identity = {
1528
+ ...a.identity,
1529
+ threadId: this._externalSessionId,
1530
+ ...(this._nativeSessionId ? { nativeSessionId: this._nativeSessionId } : {}),
1531
+ };
1532
+ await this._readiness();
1533
+ // Timeout/kill during create/resume must not cause a later model write.
1534
+ if (this._inflight?.evidence !== a || a.localOutcome !== 'pending' || !this._alive)
1535
+ throw Error('Local admission closed before turn/start; no turn was sent');
1536
+ a.identity = {
1537
+ ...a.identity,
1538
+ threadId: this._externalSessionId,
1539
+ ...(this._nativeSessionId ? { nativeSessionId: this._nativeSessionId } : {}),
1540
+ };
1541
+ this._appAdmission = a.admissionId;
1542
+ this._items.clear();
1543
+ const completed = new Promise<void>((resolve) => {
1544
+ this._turnDone = resolve;
1545
+ });
1546
+ await this._rpcRequest('turn/start', {
789
1547
  threadId: this._externalSessionId,
790
- input: message,
1548
+ input: [{ type: 'text', text: message }],
1549
+ ...(this._effort ? { effort: this._effort } : {}),
791
1550
  });
1551
+ await completed; // Native terminal and RPC acknowledgment are both required.
1552
+ return a.tokens;
1553
+ }
792
1554
 
793
- return completed;
1555
+ protected override _observeRpcResult(id: number, result: unknown): void {
1556
+ const a = this._inflight?.evidence;
1557
+ if (!a || this._appAdmission !== a.admissionId || a.identity.rpcRequestId !== id) return;
1558
+ const turn = this._turn(object(result)?.turn);
1559
+ if (
1560
+ !turn ||
1561
+ (a.identity.turnId && turn.id !== a.identity.turnId) ||
1562
+ (!a.identity.turnId && this._seenTerminals.has(turn.id))
1563
+ )
1564
+ throw new NativeValidationError(
1565
+ 'Native turn/start response missing or conflicts with owned turn',
1566
+ );
1567
+ a.identity = { ...a.identity, turnId: turn.id, correlation: 'native-turn' };
1568
+ }
1569
+
1570
+ private _turn(value: unknown): { id: string; status: string; error?: unknown } | undefined {
1571
+ const turn = object(value);
1572
+ if (
1573
+ !turn ||
1574
+ typeof turn.id !== 'string' ||
1575
+ !turn.id ||
1576
+ !Array.isArray(turn.items) ||
1577
+ !['inProgress', 'completed', 'failed', 'interrupted'].includes(String(turn.status))
1578
+ )
1579
+ return;
1580
+ return turn as { id: string; status: string; error?: unknown };
794
1581
  }
795
1582
 
796
1583
  protected _classify(msg: Record<string, unknown>): SessionEvent[] {
797
- const method = msg.method as string | undefined;
1584
+ const method = typeof msg.method === 'string' ? msg.method : undefined;
798
1585
  if (!method) return [];
799
- const params = (msg.params ?? {}) as Record<string, unknown>;
800
-
801
- if (method === "turn/completed") {
802
- const usage = (params.usage ?? params.tokenUsage) as
803
- | Record<string, number>
804
- | undefined;
805
- const tokens = usage
806
- ? {
807
- input: (usage.inputTokens ?? usage.input_tokens ?? 0) as number,
808
- output: (usage.outputTokens ?? usage.output_tokens ?? 0) as number,
809
- }
810
- : undefined;
811
- this._turnDone?.resolve(tokens);
1586
+ if (method === 'account/rateLimits/updated') {
1587
+ const snapshot = codexLimitSnapshot(msg.params, 'stream');
1588
+ return snapshot ? [this._observeLimits(snapshot)] : [];
1589
+ }
1590
+ const params = object(msg.params),
1591
+ turn = this._turn(params?.turn),
1592
+ item = object(params?.item);
1593
+ const str = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
1594
+ const threadId = str(params?.threadId),
1595
+ turnId = turn?.id ?? str(params?.turnId),
1596
+ itemId = str(item?.id) ?? str(params?.itemId);
1597
+ const unowned = (reason: NonNullable<SessionEvent['unattributedReason']>): SessionEvent[] => [
1598
+ {
1599
+ kind: 'native_status',
1600
+ timestamp: Date.now(),
1601
+ threadId,
1602
+ turnId,
1603
+ itemId,
1604
+ correlation: 'unknown',
1605
+ unattributedReason: reason,
1606
+ raw: {
1607
+ method: [
1608
+ 'turn/started',
1609
+ 'turn/completed',
1610
+ 'item/started',
1611
+ 'item/completed',
1612
+ 'thread/tokenUsage/updated',
1613
+ 'item/agentMessage/delta',
1614
+ 'item/commandExecution/outputDelta',
1615
+ ].includes(method)
1616
+ ? method
1617
+ : 'unrecognized',
1618
+ ...(item
1619
+ ? {
1620
+ itemType: ['agentMessage', 'commandExecution', 'reasoning', 'mcpToolCall'].includes(
1621
+ String(item.type),
1622
+ )
1623
+ ? item.type
1624
+ : 'unsupported',
1625
+ }
1626
+ : {}),
1627
+ },
1628
+ },
1629
+ ];
1630
+ const a = this._inflight?.evidence;
1631
+ if (!threadId || threadId !== this._externalSessionId) return unowned('foreign-session');
1632
+ if (!a || this._appAdmission !== a.admissionId) return unowned('no-admission');
1633
+ if (turnId && this._seenTerminals.has(turnId))
1634
+ return unowned(method === 'turn/completed' ? 'duplicate-terminal' : 'after-terminal');
1635
+ if (method === 'turn/started' && turn?.status === 'inProgress' && !a.identity.turnId)
1636
+ a.identity = { ...a.identity, threadId, turnId: turn.id, correlation: 'native-turn' };
1637
+ if (!turnId || turnId !== a.identity.turnId) return unowned('foreign-turn');
1638
+ const identity = { ...a.identity, threadId, turnId, ...(itemId ? { itemId } : {}) };
1639
+ const status = (): SessionEvent[] => [
1640
+ { ...identity, kind: 'native_status', timestamp: Date.now(), raw: { method } },
1641
+ ];
1642
+ if (method === 'turn/completed') {
1643
+ if (!turn || turn.status === 'inProgress') return unowned('unrecognized-event');
1644
+ const error = object(turn.error);
1645
+ if (turn.error != null && typeof error?.message !== 'string')
1646
+ return unowned('unrecognized-event');
1647
+ this._seenTerminals.add(turnId);
1648
+ this._turnDone?.();
812
1649
  this._turnDone = undefined;
813
- // Note: no `tokens` on this event — _sendTurn returns them to _dispatchTurn,
814
- // which does the accounting once. Putting tokens here too would double-count.
815
- return [{ kind: "result", timestamp: Date.now(), text: this._resultText, raw: msg }];
1650
+ return [
1651
+ {
1652
+ ...identity,
1653
+ kind: 'result',
1654
+ timestamp: Date.now(),
1655
+ text: a.content,
1656
+ nativeOutcome: turn.status === 'completed' && !error ? 'completed' : 'failed',
1657
+ terminal: {
1658
+ type: method,
1659
+ turnId,
1660
+ subtype: turn.status,
1661
+ ...(error ? { reason: error.message as string } : {}),
1662
+ },
1663
+ },
1664
+ ];
816
1665
  }
817
-
818
- if (method === "item/completed" || method === "item/started") {
819
- const item = (params.item ?? params) as Record<string, unknown>;
820
- return classifyCodexEvent(item, "type");
1666
+ if (method === 'thread/tokenUsage/updated') {
1667
+ const last = object(object(params?.tokenUsage)?.last);
1668
+ if (
1669
+ typeof last?.inputTokens !== 'number' ||
1670
+ typeof last?.outputTokens !== 'number' ||
1671
+ !Number.isSafeInteger(last.inputTokens) ||
1672
+ !Number.isSafeInteger(last.outputTokens) ||
1673
+ last.inputTokens < 0 ||
1674
+ last.outputTokens < 0
1675
+ )
1676
+ return unowned('unrecognized-event');
1677
+ // `last` is a snapshot, not an additive delta. Total accounting happens once
1678
+ // when the original native turn and RPC settle; absent usage stays absent.
1679
+ a.tokens = { input: last.inputTokens, output: last.outputTokens };
1680
+ return status();
821
1681
  }
822
-
823
- // turn/started and other lifecycle notifications carry no turn content.
824
- return [];
1682
+ if (method === 'item/agentMessage/delta' || method === 'item/commandExecution/outputDelta') {
1683
+ if (!itemId || typeof params?.delta !== 'string') return unowned('unrecognized-event');
1684
+ const prior = this._items.get(itemId);
1685
+ if (prior?.completed) return unowned('after-terminal');
1686
+ if (method === 'item/agentMessage/delta') {
1687
+ if (prior && prior.type !== 'agentMessage') return unowned('unmatched-tool');
1688
+ this._items.set(itemId, {
1689
+ type: 'agentMessage',
1690
+ completed: false,
1691
+ text: (prior?.text ?? '') + params.delta,
1692
+ });
1693
+ return [{ ...identity, kind: 'text_delta', timestamp: Date.now(), text: params.delta }];
1694
+ }
1695
+ if (prior?.type !== 'commandExecution') return unowned('unmatched-tool');
1696
+ return [
1697
+ {
1698
+ ...identity,
1699
+ kind: 'native_status',
1700
+ timestamp: Date.now(),
1701
+ toolName: 'shell',
1702
+ text: params.delta,
1703
+ },
1704
+ ];
1705
+ }
1706
+ if (method === 'item/completed' || method === 'item/started') {
1707
+ if (!itemId || !item || typeof item.type !== 'string') return unowned('unrecognized-event');
1708
+ if (item.type === 'agentMessage') {
1709
+ if (method !== 'item/completed') return status();
1710
+ if (this._items.get(itemId)?.completed) return unowned('after-terminal');
1711
+ if (
1712
+ typeof item.text !== 'string' ||
1713
+ (item.phase != null && !['commentary', 'final_answer'].includes(String(item.phase)))
1714
+ )
1715
+ return unowned('unrecognized-event');
1716
+ this._items.set(itemId, { type: item.type, completed: true });
1717
+ return [{ ...identity, kind: 'text', timestamp: Date.now(), text: item.text }];
1718
+ }
1719
+ if (item.type === 'commandExecution') {
1720
+ if (typeof item.command !== 'string') return unowned('malformed-tool');
1721
+ const prior = this._items.get(itemId);
1722
+ if (method === 'item/started') {
1723
+ if (prior) return unowned('duplicate-tool');
1724
+ this._items.set(itemId, { type: item.type, input: item.command, completed: false });
1725
+ return [
1726
+ {
1727
+ ...identity,
1728
+ kind: 'tool_use',
1729
+ timestamp: Date.now(),
1730
+ toolName: 'shell',
1731
+ toolInput: { command: item.command },
1732
+ },
1733
+ ];
1734
+ }
1735
+ if (!prior || prior.type !== item.type || prior.input !== item.command)
1736
+ return unowned('unmatched-tool');
1737
+ if (prior.completed) return unowned('duplicate-tool');
1738
+ if (!['completed', 'failed', 'declined'].includes(String(item.status)))
1739
+ return unowned('malformed-tool');
1740
+ prior.completed = true;
1741
+ return [
1742
+ {
1743
+ ...identity,
1744
+ kind: 'tool_result',
1745
+ timestamp: Date.now(),
1746
+ toolName: 'shell',
1747
+ ...(typeof item.aggregatedOutput === 'string'
1748
+ ? { toolOutput: item.aggregatedOutput }
1749
+ : { toolOutputOmitted: true }),
1750
+ toolError:
1751
+ item.status !== 'completed' ||
1752
+ (typeof item.exitCode === 'number' && item.exitCode !== 0),
1753
+ },
1754
+ ];
1755
+ }
1756
+ if (item.type === 'mcpToolCall') {
1757
+ if (
1758
+ typeof item.server !== 'string' ||
1759
+ !item.server ||
1760
+ typeof item.tool !== 'string' ||
1761
+ !item.tool
1762
+ )
1763
+ return unowned('malformed-tool');
1764
+ const prior = this._items.get(itemId);
1765
+ const tool = {
1766
+ toolServer: item.server,
1767
+ toolMethod: item.tool,
1768
+ toolName: `mcp__${item.server}__${item.tool}`,
1769
+ };
1770
+ if (method === 'item/started') {
1771
+ if (prior) return unowned('duplicate-tool');
1772
+ if (item.status !== 'inProgress') return unowned('malformed-tool');
1773
+ this._items.set(itemId, {
1774
+ type: item.type,
1775
+ server: item.server,
1776
+ tool: item.tool,
1777
+ input: retainEvidence(item.arguments),
1778
+ completed: false,
1779
+ });
1780
+ return [
1781
+ {
1782
+ ...identity,
1783
+ ...tool,
1784
+ kind: 'tool_use',
1785
+ timestamp: Date.now(),
1786
+ ...publicMcpArguments(item.arguments),
1787
+ },
1788
+ ];
1789
+ }
1790
+ if (
1791
+ !prior ||
1792
+ prior.type !== item.type ||
1793
+ prior.server !== item.server ||
1794
+ prior.tool !== item.tool ||
1795
+ !isDeepStrictEqual(prior.input, item.arguments)
1796
+ )
1797
+ return unowned('unmatched-tool');
1798
+ if (prior.completed) return unowned('duplicate-tool');
1799
+ if (!['completed', 'failed'].includes(String(item.status)))
1800
+ return unowned('malformed-tool');
1801
+ const error = object(item.error),
1802
+ result = object(item.result);
1803
+ const output = result ? publicMcpResult({ Ok: { content: result.content } }) : undefined;
1804
+ if (item.error != null && typeof error?.message !== 'string')
1805
+ return unowned('malformed-tool');
1806
+ prior.completed = true;
1807
+ return [
1808
+ {
1809
+ ...identity,
1810
+ ...tool,
1811
+ kind: 'tool_result',
1812
+ timestamp: Date.now(),
1813
+ ...(output ??
1814
+ (error ? { toolOutput: error.message as string } : { toolOutputOmitted: true })),
1815
+ toolError: item.status === 'failed' || !!error,
1816
+ ...(result && Object.keys(result).some((k) => k !== 'content' && result[k] != null)
1817
+ ? { toolOutputOmitted: true }
1818
+ : {}),
1819
+ },
1820
+ ];
1821
+ }
1822
+ // Reasoning and unsupported items retain safe identity/type diagnostics only.
1823
+ return unowned('unrecognized-event');
1824
+ }
1825
+ return method === 'turn/started' ? status() : unowned('unrecognized-event');
825
1826
  }
826
1827
  }
827
1828
 
@@ -838,58 +1839,135 @@ export class CodexAppServerSession extends BaseCodexSession {
838
1839
  // command_execution / commandExecution → tool_use (+ tool_result when done)
839
1840
  // token_count / usage → tokens (attached to a result event)
840
1841
 
841
- function classifyCodexEvent(
842
- ev: Record<string, unknown>,
843
- tag: string,
844
- ): SessionEvent[] {
1842
+ const object = (value: unknown): Record<string, unknown> | undefined =>
1843
+ value !== null && typeof value === 'object' && !Array.isArray(value)
1844
+ ? (value as Record<string, unknown>)
1845
+ : undefined;
1846
+
1847
+ function publicMcpArguments(value: unknown): Pick<SessionEvent, 'toolInput' | 'toolInputOmitted'> {
1848
+ if (!object(value)) return { toolInputOmitted: 'Native arguments absent or not an object' };
1849
+ let omitted = false;
1850
+ const clean = (v: unknown, depth = 0): unknown => {
1851
+ if (depth > 32) {
1852
+ omitted = true;
1853
+ return null;
1854
+ }
1855
+ if (v === null || ['string', 'number', 'boolean'].includes(typeof v)) return v;
1856
+ if (Array.isArray(v)) return v.map((x) => clean(x, depth + 1));
1857
+ if (!object(v)) {
1858
+ omitted = true;
1859
+ return null;
1860
+ }
1861
+ return Object.fromEntries(
1862
+ Object.entries(v as Record<string, unknown>).flatMap(([key, item]) => {
1863
+ if (
1864
+ /^(?:__proto__|constructor|prototype|env|environment|authorization|credentials?|api[-_]?key|access[-_]?token|refresh[-_]?token|password|secret|reasoning|thinking)$/i.test(
1865
+ key,
1866
+ )
1867
+ ) {
1868
+ omitted = true;
1869
+ return [];
1870
+ }
1871
+ return [[key, clean(item, depth + 1)]];
1872
+ }),
1873
+ );
1874
+ };
1875
+ const toolInput = clean(value) as Record<string, unknown>;
1876
+ return {
1877
+ toolInput,
1878
+ ...(omitted ? { toolInputOmitted: 'Non-public or unsupported argument fields omitted' } : {}),
1879
+ };
1880
+ }
1881
+
1882
+ /** Rust Result<CallToolResult,String> from protocol.rs. This is legacy event
1883
+ * normalization, not the app-server ThreadItem schema. Keep all public text
1884
+ * blocks (including the bridge's JSON receipt), not arbitrary envelope fields. */
1885
+ function publicMcpResult(
1886
+ value: unknown,
1887
+ ): Pick<SessionEvent, 'toolOutput' | 'toolError' | 'toolOutputOmitted'> | undefined {
1888
+ const result = object(value);
1889
+ if (!result || Object.keys(result).length !== 1) return;
1890
+ if (typeof result.Err === 'string') return { toolOutput: result.Err, toolError: true };
1891
+ const ok = object(result.Ok);
1892
+ if (
1893
+ !ok ||
1894
+ !Array.isArray(ok.content) ||
1895
+ (ok.isError !== undefined && typeof ok.isError !== 'boolean')
1896
+ )
1897
+ return;
1898
+ let omitted = Object.keys(ok).some((k) => k !== 'content' && k !== 'isError');
1899
+ const content = ok.content.map((value) => {
1900
+ const block = object(value);
1901
+ if (block?.type === 'text' && typeof block.text === 'string') {
1902
+ if (Object.keys(block).some((k) => k !== 'type' && k !== 'text')) omitted = true;
1903
+ return { type: 'text', text: block.text };
1904
+ }
1905
+ omitted = true;
1906
+ // Unknown shape values are not safe labels. Known non-text types retain type only.
1907
+ return {
1908
+ type: ['image', 'audio', 'resource', 'resource_link'].includes(String(block?.type))
1909
+ ? block!.type
1910
+ : 'unsupported',
1911
+ };
1912
+ });
1913
+ return {
1914
+ toolOutput: JSON.stringify({
1915
+ content,
1916
+ ...(ok.isError !== undefined ? { isError: ok.isError } : {}),
1917
+ }),
1918
+ ...(ok.isError !== undefined ? { toolError: ok.isError as boolean } : {}),
1919
+ ...(omitted ? { toolOutputOmitted: true } : {}),
1920
+ };
1921
+ }
1922
+
1923
+ function classifyCodexEvent(ev: Record<string, unknown>, tag: string): SessionEvent[] {
845
1924
  const ts = Date.now();
846
- const type = String(ev[tag] ?? "").toLowerCase();
1925
+ const type = String(ev[tag] ?? '').toLowerCase();
847
1926
  const events: SessionEvent[] = [];
848
1927
 
849
1928
  // Agent message → text.
850
- if (type === "agent_message" || type === "agentmessage" || type === "agent_message_delta") {
1929
+ if (type === 'agent_message' || type === 'agentmessage' || type === 'agent_message_delta') {
851
1930
  const text = (ev.message ?? ev.text ?? ev.delta) as string | undefined;
852
- if (text) events.push({ kind: "text", timestamp: ts, text, raw: ev });
1931
+ if (text) events.push({ kind: 'text', timestamp: ts, text, raw: ev });
853
1932
  return events;
854
1933
  }
855
1934
 
856
1935
  // Reasoning → thinking.
857
1936
  if (
858
- type === "reasoning" ||
859
- type === "agent_reasoning" ||
860
- type === "agentreasoning" ||
861
- type === "agent_reasoning_delta"
1937
+ type === 'reasoning' ||
1938
+ type === 'agent_reasoning' ||
1939
+ type === 'agentreasoning' ||
1940
+ type === 'agent_reasoning_delta'
862
1941
  ) {
863
1942
  const text = (ev.text ?? ev.reasoning ?? ev.delta ?? ev.summary) as string | undefined;
864
- if (text) events.push({ kind: "thinking", timestamp: ts, text, raw: ev });
1943
+ if (text) events.push({ kind: 'thinking', timestamp: ts, text, raw: ev });
865
1944
  return events;
866
1945
  }
867
1946
 
868
1947
  // Command execution → tool_use, plus tool_result if output is present.
869
1948
  if (
870
- type === "command_execution" ||
871
- type === "commandexecution" ||
872
- type === "exec_command_begin" ||
873
- type === "exec_command_end"
1949
+ type === 'command_execution' ||
1950
+ type === 'commandexecution' ||
1951
+ type === 'exec_command_begin' ||
1952
+ type === 'exec_command_end'
874
1953
  ) {
875
1954
  const command = (ev.command ?? ev.cmd) as string | string[] | undefined;
876
- const cmdStr = Array.isArray(command) ? command.join(" ") : command;
877
- events.push({
878
- kind: "tool_use",
879
- timestamp: ts,
880
- toolName: "shell",
881
- toolInput: cmdStr ? { command: cmdStr } : (ev as Record<string, unknown>),
882
- raw: ev,
883
- });
884
- const output = (ev.output ?? ev.stdout ?? ev.aggregated_output) as
885
- | string
886
- | undefined;
1955
+ const cmdStr = Array.isArray(command) ? command.join(' ') : command;
1956
+ if (type !== 'exec_command_end')
1957
+ events.push({
1958
+ kind: 'tool_use',
1959
+ timestamp: ts,
1960
+ toolName: 'shell',
1961
+ toolInput: cmdStr ? { command: cmdStr } : (ev as Record<string, unknown>),
1962
+ raw: ev,
1963
+ });
1964
+ const output = (ev.output ?? ev.stdout ?? ev.aggregated_output) as string | undefined;
887
1965
  const exitCode = (ev.exit_code ?? ev.exitCode) as number | undefined;
888
1966
  if (output !== undefined || exitCode !== undefined) {
889
1967
  events.push({
890
- kind: "tool_result",
1968
+ kind: 'tool_result',
891
1969
  timestamp: ts,
892
- toolOutput: output ?? "",
1970
+ toolOutput: output ?? '',
893
1971
  toolError: exitCode !== undefined && exitCode !== 0,
894
1972
  raw: ev,
895
1973
  });
@@ -899,19 +1977,17 @@ function classifyCodexEvent(
899
1977
 
900
1978
  // Token usage → carried on a result-less event so accounting picks it up.
901
1979
  if (
902
- type === "token_count" ||
903
- type === "token_usage" ||
904
- type === "usage" ||
905
- type === "tokenusage"
1980
+ type === 'token_count' ||
1981
+ type === 'token_usage' ||
1982
+ type === 'usage' ||
1983
+ type === 'tokenusage'
906
1984
  ) {
907
1985
  const u = (ev.info ?? ev.usage ?? ev) as Record<string, unknown>;
908
- const input =
909
- (u.input_tokens ?? u.inputTokens ?? u.total_input_tokens ?? 0) as number;
910
- const output =
911
- (u.output_tokens ?? u.outputTokens ?? u.total_output_tokens ?? 0) as number;
1986
+ const input = (u.input_tokens ?? u.inputTokens ?? u.total_input_tokens ?? 0) as number;
1987
+ const output = (u.output_tokens ?? u.outputTokens ?? u.total_output_tokens ?? 0) as number;
912
1988
  if (input || output) {
913
1989
  events.push({
914
- kind: "result",
1990
+ kind: 'result',
915
1991
  timestamp: ts,
916
1992
  tokens: { input, output },
917
1993
  raw: ev,
@@ -921,9 +1997,9 @@ function classifyCodexEvent(
921
1997
  }
922
1998
 
923
1999
  // Errors.
924
- if (type === "error" || type === "stream_error") {
2000
+ if (type === 'error' || type === 'stream_error') {
925
2001
  events.push({
926
- kind: "error",
2002
+ kind: 'error',
927
2003
  timestamp: ts,
928
2004
  text: (ev.message ?? ev.error ?? JSON.stringify(ev)) as string,
929
2005
  raw: ev,