@inixiative/agent-session 0.2.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,6 +1,8 @@
1
- import { TurnState } from "./turn-state";
2
- import { retainEvidence } from "./retained-evidence";
3
- import { isDeepStrictEqual } from "node:util";
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';
4
6
  // ---------------------------------------------------------------------------
5
7
  // CodexSession — long-lived OpenAI Codex CLI process with full event capture
6
8
  // ---------------------------------------------------------------------------
@@ -33,19 +35,20 @@ import { isDeepStrictEqual } from "node:util";
33
35
  import type {
34
36
  BeforeSendHook,
35
37
  HarnessSession,
38
+ NativeInterruptOutcome,
39
+ SessionArtifact,
36
40
  SessionEvent,
37
41
  SessionEventHandler,
38
42
  SessionResult,
39
- SessionArtifact,
40
- } from "./harness-session";
43
+ } from './harness-session';
41
44
 
42
45
  // Re-export types so importers can stay on one path (mirrors claude-code-session).
43
46
  export type {
47
+ SessionArtifact,
44
48
  SessionEvent,
45
49
  SessionEventKind,
46
50
  SessionResult,
47
- SessionArtifact,
48
- } from "./harness-session";
51
+ } from './harness-session';
49
52
 
50
53
  // ---------------------------------------------------------------------------
51
54
  // Shared subprocess shape (mirrors ClaudeCodeSession.PipedSubprocess)
@@ -104,6 +107,8 @@ export interface CodexSessionConfig {
104
107
  * `thread/resume` on app-server. Only app-server supports cold resume by ID.
105
108
  */
106
109
  externalSessionId?: string;
110
+ /** Environment merged over process.env (e.g. CODEX_HOME for a profile). */
111
+ env?: Record<string, string | undefined>;
107
112
  /**
108
113
  * Override for the process spawner. Defaults to Bun.spawn. Tests inject a fake
109
114
  * subprocess that emulates the codex JSON-RPC protocol; the docker-spawn helper
@@ -117,7 +122,7 @@ export interface CodexSessionConfig {
117
122
  // ---------------------------------------------------------------------------
118
123
 
119
124
  interface QueuedTurn {
120
- onAdmission?: import("./harness-session").SessionSendOptions["onAdmission"];
125
+ onAdmission?: import('./harness-session').SessionSendOptions['onAdmission'];
121
126
  evidence: TurnState;
122
127
  message: string;
123
128
  timeout: number;
@@ -126,7 +131,7 @@ interface QueuedTurn {
126
131
  }
127
132
 
128
133
  // codex reasoning-effort levels (model_reasoning_effort), low→high.
129
- const VALID_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"];
134
+ const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh'];
130
135
 
131
136
  // ---------------------------------------------------------------------------
132
137
  // Base class — shared queue, event log, classification helpers, lifecycle
@@ -147,10 +152,11 @@ abstract class BaseCodexSession implements HarnessSession {
147
152
  protected _defaultTimeout: number;
148
153
  protected _baseContext?: string;
149
154
  protected _spawn?: CodexSpawn;
155
+ protected _env?: Record<string, string | undefined>;
150
156
 
151
157
  // -- Process --
152
158
  protected _proc: PipedSubprocess | null = null;
153
- protected _stderr = "";
159
+ protected _stderr = '';
154
160
 
155
161
  // -- Session state --
156
162
  protected _externalSessionId?: string;
@@ -183,27 +189,28 @@ abstract class BaseCodexSession implements HarnessSession {
183
189
  protected _seenTerminals = new Set<string>();
184
190
  protected _inflight: QueuedTurn | null = null;
185
191
  protected _turnEvents: SessionEvent[] = [];
186
- protected _resultText = "";
192
+ protected _resultText = '';
187
193
  protected _turnTimer: ReturnType<typeof setTimeout> | null = null;
188
194
 
189
195
  constructor(config?: CodexSessionConfig) {
190
- const bin = config?.bin ?? "codex";
191
- if (!/^[a-zA-Z0-9_.\/\\-]+$/.test(bin)) {
196
+ const bin = config?.bin ?? 'codex';
197
+ if (!/^[a-zA-Z0-9_./\\-]+$/.test(bin)) {
192
198
  throw new Error(`Invalid codex CLI binary path: "${bin}"`);
193
199
  }
194
200
  if (config?.effort && !VALID_EFFORTS.includes(config.effort)) {
195
201
  throw new Error(
196
- `Invalid codex effort "${config.effort}". Valid: ${VALID_EFFORTS.join(", ")}`,
202
+ `Invalid codex effort "${config.effort}". Valid: ${VALID_EFFORTS.join(', ')}`,
197
203
  );
198
204
  }
199
205
  this._bin = bin;
200
- this._model = config?.model ?? "gpt-5.5";
206
+ this._model = config?.model ?? 'gpt-5.5';
201
207
  this._effort = config?.effort;
202
208
  this._cwd = config?.cwd ?? process.cwd();
203
209
  this._defaultTimeout = config?.timeout ?? 600_000;
204
210
  this._baseContext = config?.baseContext;
205
211
  this._externalSessionId = config?.externalSessionId;
206
212
  this._spawn = config?.spawn;
213
+ this._env = config?.env;
207
214
  this._startedAt = Date.now();
208
215
  }
209
216
 
@@ -211,14 +218,28 @@ abstract class BaseCodexSession implements HarnessSession {
211
218
  // Accessors (identical to ClaudeCodeSession)
212
219
  // ---------------------------------------------------------------------------
213
220
 
214
- get accounting() { return "observed-only" as const; }
215
- get attempts() { return this._attempts.map(a => a.snapshot()); }
216
- get diagnostics() { return Object.freeze({ observerFailures: Object.freeze({ ...this._observerFailures }) }); }
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
+ }
217
230
 
218
- get alive(): boolean { return this._alive; }
219
- get externalSessionId(): string | undefined { return this._externalSessionId; }
220
- get events(): readonly SessionEvent[] { return Object.freeze([...this._eventLog]); }
221
- get turns(): number { return this._turns; }
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
+ }
222
243
  get totalTokens(): Readonly<{ input: number; output: number }> {
223
244
  return { ...this._totalTokens };
224
245
  }
@@ -251,7 +272,7 @@ abstract class BaseCodexSession implements HarnessSession {
251
272
  */
252
273
  async push(payload: { kind: string; text: string }): Promise<void> {
253
274
  this._emit({
254
- kind: "error",
275
+ kind: 'error',
255
276
  timestamp: Date.now(),
256
277
  text: `push_ignored: kind=${payload.kind} — codex turn has no OOB channel`,
257
278
  raw: payload,
@@ -264,7 +285,10 @@ abstract class BaseCodexSession implements HarnessSession {
264
285
 
265
286
  interrupt(): void {
266
287
  if (!this._inflight) return;
267
- this._rejectInflight(new Error("Local waiter interrupted; native cancellation unacknowledged"), "interrupt-request");
288
+ this._rejectInflight(
289
+ new Error('Local waiter interrupted; native cancellation unacknowledged'),
290
+ 'interrupt-request',
291
+ );
268
292
  }
269
293
 
270
294
  kill(): void {
@@ -276,30 +300,40 @@ abstract class BaseCodexSession implements HarnessSession {
276
300
  this._turnTimer = null;
277
301
  }
278
302
 
279
- this._rejectInflight(new Error("Session killed; native outcome may remain unknown"), "killed");
280
- this._rejectQueue(new Error("Session killed"));
281
- 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'));
282
306
  this._pending.clear();
283
307
 
284
- try { this._proc.stdin.end(); } catch { /* already closed */ }
285
- 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
+ }
286
318
  this._proc = null;
287
319
 
288
- this._emit({ kind: "session_end", timestamp: Date.now() });
320
+ this._emit({ kind: 'session_end', timestamp: Date.now() });
289
321
  }
290
322
 
291
323
  artifact(): SessionArtifact {
292
324
  return {
293
325
  externalSessionId: this._externalSessionId,
294
- attempts: this.attempts, accounting: "observed-only", diagnostics: this.diagnostics,
326
+ attempts: this.attempts,
327
+ accounting: 'observed-only',
328
+ diagnostics: this.diagnostics,
295
329
  events: [...this._eventLog],
296
330
  startedAt: this._startedAt,
297
331
  endedAt: this._alive ? undefined : Date.now(),
298
332
  turns: this._turns,
299
333
  totalTokens: { ...this._totalTokens },
300
- toolCalls: this._eventLog.filter((e) => e.kind === "tool_use").length,
301
- toolResults: this._eventLog.filter((e) => e.kind === "tool_result").length,
302
- 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,
303
337
  };
304
338
  }
305
339
 
@@ -308,30 +342,36 @@ abstract class BaseCodexSession implements HarnessSession {
308
342
  // ---------------------------------------------------------------------------
309
343
 
310
344
  async start(): Promise<void> {
311
- if (this._attempts.some(a => a.dispatch === "attempted" && a.nativeOutcome === "unknown")) throw new Error("Native outcome unresolved; automatic resume is blocked");
312
- 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');
313
348
 
314
349
  const args = this._buildSpawnArgs();
315
350
  const env: Record<string, string | undefined> = {
316
351
  ...process.env,
317
- DISABLE_AUTOUPDATER: "1",
352
+ ...this._env,
353
+ DISABLE_AUTOUPDATER: '1',
318
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];
319
359
 
320
360
  if (this._spawn) {
321
361
  this._proc = this._spawn([this._bin, ...args], { cwd: this._cwd, env });
322
362
  } else {
323
363
  this._proc = Bun.spawn([this._bin, ...args], {
324
364
  cwd: this._cwd,
325
- stdin: "pipe",
326
- stdout: "pipe",
327
- stderr: "pipe",
365
+ stdin: 'pipe',
366
+ stdout: 'pipe',
367
+ stderr: 'pipe',
328
368
  env,
329
369
  }) as unknown as PipedSubprocess;
330
370
  }
331
371
 
332
372
  this._alive = true;
333
373
  this._endEmitted = false;
334
- this._emit({ kind: "session_start", timestamp: Date.now() });
374
+ this._emit({ kind: 'session_start', timestamp: Date.now() });
335
375
 
336
376
  const proc = this._proc;
337
377
  const stdoutDone = this._readStdout(proc);
@@ -345,10 +385,10 @@ abstract class BaseCodexSession implements HarnessSession {
345
385
  ? `Process exited (code ${code}): ${this._stderr.trim().slice(0, 500)}`
346
386
  : `Process exited with code ${code}`;
347
387
  this._rejectInflight(new Error(errMsg));
348
- this._rejectQueue(new Error("Session ended"));
349
- 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'));
350
390
  this._pending.clear();
351
- this._emit({ kind: "session_end", timestamp: Date.now(), transportOutcome: "failed" });
391
+ this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
352
392
  });
353
393
 
354
394
  await this._handshake();
@@ -360,17 +400,25 @@ abstract class BaseCodexSession implements HarnessSession {
360
400
 
361
401
  async send(
362
402
  message: string,
363
- opts?: import("./harness-session").SessionSendOptions,
403
+ opts?: import('./harness-session').SessionSendOptions,
364
404
  ): Promise<SessionResult> {
365
- if (this._attempts.some(a => a.dispatch === "attempted" && a.nativeOutcome === "unknown" && (!this._alive || a.localOutcome !== "pending"))) {
366
- const blocked = new TurnState(); this._attempts.push(blocked);
367
- throw blocked.fail(new Error("Native outcome unresolved; send not dispatched"), "blocked");
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');
368
416
  }
369
417
  if (!this._proc && this._externalSessionId) {
370
418
  await this.start();
371
419
  }
372
- if (!this._proc) throw new Error("Session not started — call start() first");
373
- 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');
374
422
 
375
423
  const timeout = opts?.timeout ?? this._defaultTimeout;
376
424
 
@@ -386,10 +434,24 @@ abstract class BaseCodexSession implements HarnessSession {
386
434
  }
387
435
 
388
436
  return new Promise<SessionResult>((resolve, reject) => {
389
- const evidence = new TurnState(); this._attempts.push(evidence);
390
- const turn: QueuedTurn = { evidence, message: transformed, timeout, resolve, reject, onAdmission: opts?.onAdmission };
391
- if (!this._alive || (this._inflight && this._inflight.evidence.localOutcome !== "pending")) {
392
- reject(evidence.fail(new Error("Previous native work unresolved; send not dispatched"), "blocked")); return;
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;
393
455
  }
394
456
  if (!this._inflight) {
395
457
  this._dispatchTurn(turn);
@@ -403,28 +465,45 @@ abstract class BaseCodexSession implements HarnessSession {
403
465
  // Private — turn dispatch + queue (mirrors ClaudeCodeSession)
404
466
  // ---------------------------------------------------------------------------
405
467
 
406
- readonly admissionProtocol = "prewrite-v1" as const;
407
- inspectAttempt(admissionId: string) { return this._attempts.find(attempt => attempt.admissionId === admissionId)?.snapshot(); }
468
+ readonly admissionProtocol = 'prewrite-v1' as const;
469
+ inspectAttempt(admissionId: string) {
470
+ return this._attempts.find((attempt) => attempt.admissionId === admissionId)?.snapshot();
471
+ }
408
472
 
409
473
  private _dispatchTurn(turn: QueuedTurn): void {
410
474
  this._inflight = turn;
411
475
  if (turn.onAdmission) {
412
- Promise.resolve().then(() => turn.onAdmission!(turn.evidence.snapshot())).then(() => {
413
- if (this._inflight === turn && this._alive && turn.evidence.localOutcome === "pending") this._writeTurn(turn);
414
- else if (this._inflight === turn && turn.evidence.dispatch === "not-dispatched") { this._inflight = null; this._processNextTurn(); }
415
- }).catch(error => {
416
- turn.reject(turn.evidence.fail(error instanceof Error ? error : new Error(String(error)), "registration"));
417
- if (this._inflight === turn) { this._inflight = null; this._processNextTurn(); }
418
- });
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
+ });
419
498
  return;
420
499
  }
421
500
  this._writeTurn(turn);
422
501
  }
423
502
 
424
503
  private _writeTurn(turn: QueuedTurn): void {
425
- turn.evidence.dispatch = "attempted";
504
+ turn.evidence.dispatch = 'attempted';
426
505
  this._turnEvents = turn.evidence.events;
427
- this._resultText = "";
506
+ this._resultText = '';
428
507
 
429
508
  // Send via the subclass's wire protocol. The returned promise resolves when
430
509
  // the tools/call (or turn) completes; that resolves the turn.
@@ -441,15 +520,15 @@ abstract class BaseCodexSession implements HarnessSession {
441
520
  .catch((err: Error) => {
442
521
  if (this._inflight !== turn) return;
443
522
  turn.evidence.rpcSettled = true;
444
- if (turn.evidence.rpcOutcome === "pending") turn.evidence.rpcOutcome = "unknown";
445
- this._rejectInflight(err, err instanceof NativeValidationError ? "validation" : "rpc");
523
+ if (turn.evidence.rpcOutcome === 'pending') turn.evidence.rpcOutcome = 'unknown';
524
+ this._rejectInflight(err, err instanceof NativeValidationError ? 'validation' : 'rpc');
446
525
  this._releaseKnownTurn();
447
526
  });
448
527
 
449
528
  if (turn.timeout > 0) {
450
529
  this._turnTimer = setTimeout(() => {
451
530
  this._turnTimer = null;
452
- this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`), "timeout");
531
+ this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`), 'timeout');
453
532
  }, turn.timeout);
454
533
  }
455
534
  }
@@ -465,23 +544,26 @@ abstract class BaseCodexSession implements HarnessSession {
465
544
  const a = this._inflight.evidence;
466
545
  a.content = this._resultText;
467
546
  a.tokens = tokens ?? a.tokens;
468
- if (a.localOutcome === "pending") {
469
- a.localOutcome = "resolved";
547
+ if (a.localOutcome === 'pending') {
548
+ a.localOutcome = 'resolved';
470
549
  this._inflight.resolve(a.result(this._externalSessionId));
471
550
  }
472
- if (a.nativeOutcome === "unknown") this._rejectQueue(new Error("Native terminal missing; queued send not dispatched"));
551
+ if (a.nativeOutcome === 'unknown')
552
+ this._rejectQueue(new Error('Native terminal missing; queued send not dispatched'));
473
553
  this._releaseKnownTurn();
474
554
  }
475
555
 
476
556
  private _releaseKnownTurn(): void {
477
557
  const a = this._inflight?.evidence;
478
- if (!a || !this._canReleaseAttempt(a) || a.nativeOutcome === "unknown") return;
558
+ if (!a || !this._canReleaseAttempt(a) || a.nativeOutcome === 'unknown') return;
479
559
  this._turns++;
480
560
  this._inflight = null;
481
561
  this._processNextTurn();
482
562
  }
483
563
 
484
- protected _canReleaseAttempt(attempt: TurnState): boolean { return attempt.rpcSettled; }
564
+ protected _canReleaseAttempt(attempt: TurnState): boolean {
565
+ return attempt.rpcSettled;
566
+ }
485
567
 
486
568
  private _processNextTurn(): void {
487
569
  if (!this._inflight && this._queue.length > 0 && this._alive) {
@@ -490,26 +572,33 @@ abstract class BaseCodexSession implements HarnessSession {
490
572
  }
491
573
  }
492
574
 
493
- protected _rejectInflight(err: Error, reason: import("./harness-session").SessionAttempt["localFailure"] = "transport"): void {
575
+ protected _rejectInflight(
576
+ err: Error,
577
+ reason: import('./harness-session').SessionAttempt['localFailure'] = 'transport',
578
+ ): void {
494
579
  if (!this._inflight) return;
495
- if (this._turnTimer) { clearTimeout(this._turnTimer); this._turnTimer = null; }
580
+ if (this._turnTimer) {
581
+ clearTimeout(this._turnTimer);
582
+ this._turnTimer = null;
583
+ }
496
584
  const a = this._inflight.evidence;
497
- if ((reason === "transport" || reason === "killed") && a.transportOutcome === "open") a.transportOutcome = reason === "killed" ? "closed" : "failed";
498
- if (a.localOutcome === "pending") this._inflight.reject(a.fail(err, reason));
499
- this._rejectQueue(new Error("Previous native work unresolved; queued send not dispatched"));
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'));
500
589
  }
501
590
 
502
591
  private _transportFailure(err: Error): void {
503
592
  this._alive = false;
504
593
  this._rejectInflight(err);
505
594
  this._rejectQueue(err);
506
- this._emit({ kind: "session_end", timestamp: Date.now(), transportOutcome: "failed" });
595
+ this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
507
596
  for (const p of this._pending.values()) p.reject(err);
508
597
  this._pending.clear();
509
598
  }
510
599
 
511
600
  protected _rejectQueue(err: Error): void {
512
- for (const turn of this._queue) turn.reject(turn.evidence.fail(err, "blocked"));
601
+ for (const turn of this._queue) turn.reject(turn.evidence.fail(err, 'blocked'));
513
602
  this._queue = [];
514
603
  }
515
604
 
@@ -520,13 +609,13 @@ abstract class BaseCodexSession implements HarnessSession {
520
609
  private async _readStdout(proc: PipedSubprocess): Promise<void> {
521
610
  const reader = proc.stdout.getReader();
522
611
  const decoder = new TextDecoder();
523
- let buffer = "";
612
+ let buffer = '';
524
613
  try {
525
614
  while (true) {
526
615
  const { done, value } = await reader.read();
527
616
  if (done) break;
528
617
  buffer += decoder.decode(value, { stream: true });
529
- const lines = buffer.split("\n");
618
+ const lines = buffer.split('\n');
530
619
  buffer = lines.pop()!;
531
620
  for (const line of lines) {
532
621
  if (!line.trim()) continue;
@@ -534,7 +623,14 @@ abstract class BaseCodexSession implements HarnessSession {
534
623
  }
535
624
  }
536
625
  if (buffer.trim() && (this._proc === proc || !this._proc)) this._processLine(buffer);
537
- if (this._proc === proc && this._alive) this._transportFailure(new Error(this._stderr.trim() ? `Native stdout closed: ${this._stderr.trim().slice(0, 500)}` : "Native stdout closed"));
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
+ );
538
634
  } catch (err) {
539
635
  if (this._proc === proc) this._transportFailure(err as Error);
540
636
  }
@@ -549,7 +645,9 @@ abstract class BaseCodexSession implements HarnessSession {
549
645
  if (done) break;
550
646
  this._stderr += decoder.decode(value, { stream: true });
551
647
  }
552
- } catch { /* ignore */ }
648
+ } catch {
649
+ /* ignore */
650
+ }
553
651
  }
554
652
 
555
653
  // ---------------------------------------------------------------------------
@@ -565,23 +663,32 @@ abstract class BaseCodexSession implements HarnessSession {
565
663
  }
566
664
  const raw = object(msg);
567
665
  if (!raw) return;
568
- if (typeof raw.method === "string" && (typeof raw.id === "string" || typeof raw.id === "number") && this._serverRequest(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;
569
672
 
570
673
  // JSON-RPC response to one of our requests (has matching `id` + result/error).
571
- if (typeof raw.id === "number" && (("result" in raw) || ("error" in raw))) {
674
+ if (typeof raw.id === 'number' && ('result' in raw || 'error' in raw)) {
572
675
  const pending = this._pending.get(raw.id);
573
676
  if (pending) {
574
677
  this._pending.delete(raw.id);
575
678
  const a = this._inflight?.evidence;
576
- if (a?.identity.rpcRequestId === raw.id) a.rpcOutcome = raw.error ? "failed" : "resolved";
577
- if ("error" in raw && raw.error) {
679
+ if (a?.identity.rpcRequestId === raw.id) a.rpcOutcome = raw.error ? 'failed' : 'resolved';
680
+ if ('error' in raw && raw.error) {
578
681
  const e = raw.error as Record<string, unknown>;
579
682
  pending.reject(new Error((e.message as string) ?? JSON.stringify(e)));
580
683
  } else {
581
684
  // Apply protocol identity synchronously before later lines in this
582
685
  // same stdout chunk. Awaiting the promise first can lose a terminal.
583
- try { this._observeRpcResult(raw.id, raw.result); pending.resolve(raw.result); }
584
- catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))); }
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
+ }
585
692
  }
586
693
  return;
587
694
  }
@@ -590,15 +697,23 @@ abstract class BaseCodexSession implements HarnessSession {
590
697
  const classified = this._classify(raw);
591
698
  for (const event of classified) {
592
699
  const a = this._inflight?.evidence;
593
- if (!a || event.unattributedReason) { this._emit(event); continue; }
700
+ if (!a || event.unattributedReason) {
701
+ this._emit(event);
702
+ continue;
703
+ }
594
704
  const e = a.record(event);
595
705
  this._resultText = a.content;
596
- if (e.nativeOutcome && e.terminal) { a.nativeOutcome = e.nativeOutcome; a.terminal = e.terminal; }
597
- if (e.tokens) { this._totalTokens.input += e.tokens.input; this._totalTokens.output += e.tokens.output; }
706
+ if (e.nativeOutcome && e.terminal) {
707
+ a.nativeOutcome = e.nativeOutcome;
708
+ a.terminal = e.terminal;
709
+ }
710
+ if (e.tokens) {
711
+ this._totalTokens.input += e.tokens.input;
712
+ this._totalTokens.output += e.tokens.output;
713
+ }
598
714
  this._emit(e);
599
715
  }
600
716
  this._releaseKnownTurn();
601
-
602
717
  }
603
718
 
604
719
  // ---------------------------------------------------------------------------
@@ -607,7 +722,7 @@ abstract class BaseCodexSession implements HarnessSession {
607
722
 
608
723
  protected _emit(event: SessionEvent): void {
609
724
  event = retainEvidence(event);
610
- if (event.kind === "session_end") {
725
+ if (event.kind === 'session_end') {
611
726
  if (this._endEmitted) return;
612
727
  this._endEmitted = true;
613
728
  }
@@ -615,10 +730,14 @@ abstract class BaseCodexSession implements HarnessSession {
615
730
  for (const handler of this._handlers) {
616
731
  try {
617
732
  const observation: unknown = handler(event);
618
- if (observation && typeof (observation as PromiseLike<unknown>).then === "function") {
619
- void Promise.resolve(observation).catch(() => { this._observerFailures.asynchronous++; });
733
+ if (observation && typeof (observation as PromiseLike<unknown>).then === 'function') {
734
+ void Promise.resolve(observation).catch(() => {
735
+ this._observerFailures.asynchronous++;
736
+ });
620
737
  }
621
- } catch { this._observerFailures.synchronous++; }
738
+ } catch {
739
+ this._observerFailures.synchronous++;
740
+ }
622
741
  }
623
742
  }
624
743
 
@@ -628,11 +747,11 @@ abstract class BaseCodexSession implements HarnessSession {
628
747
 
629
748
  protected _rpcRequest(method: string, params?: unknown): Promise<unknown> {
630
749
  const id = ++this._rpcId;
631
- if ((method === "tools/call" || method === "turn/start") && this._inflight) {
750
+ if ((method === 'tools/call' || method === 'turn/start') && this._inflight) {
632
751
  this._inflight.evidence.identity = { ...this._inflight.evidence.identity, rpcRequestId: id };
633
- this._inflight.evidence.rpcOutcome = "pending";
752
+ this._inflight.evidence.rpcOutcome = 'pending';
634
753
  }
635
- const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
754
+ const payload = `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`;
636
755
  return new Promise<unknown>((resolve, reject) => {
637
756
  this._pending.set(id, { resolve, reject });
638
757
  try {
@@ -645,10 +764,12 @@ abstract class BaseCodexSession implements HarnessSession {
645
764
  });
646
765
  }
647
766
 
648
- protected _serverRequest(_request: Record<string, unknown>): boolean { return false; }
767
+ protected _serverRequest(_request: Record<string, unknown>): boolean {
768
+ return false;
769
+ }
649
770
 
650
771
  protected _rpcNotify(method: string, params?: unknown): void {
651
- const payload = JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n";
772
+ const payload = `${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`;
652
773
  this._proc!.stdin.write(payload);
653
774
  this._proc!.stdin.flush();
654
775
  }
@@ -672,11 +793,9 @@ abstract class BaseCodexSession implements HarnessSession {
672
793
 
673
794
  /** Shared fork constructor — subclass passes its own ctor. */
674
795
  fork(opts?: { cwd?: string; baseContext?: string }): HarnessSession {
675
- if (this._inflight) throw new Error("Cannot fork while native ownership is unresolved");
796
+ if (this._inflight) throw new Error('Cannot fork while native ownership is unresolved');
676
797
  if (!this._externalSessionId) {
677
- throw new Error(
678
- "Cannot fork — no thread ID yet (send at least one message first)",
679
- );
798
+ throw new Error('Cannot fork — no thread ID yet (send at least one message first)');
680
799
  }
681
800
  const Ctor = this.constructor as new (c?: CodexSessionConfig) => BaseCodexSession;
682
801
  const forked = new Ctor({
@@ -688,6 +807,7 @@ abstract class BaseCodexSession implements HarnessSession {
688
807
  baseContext: opts?.baseContext ?? this._baseContext,
689
808
  externalSessionId: this._externalSessionId,
690
809
  spawn: this._spawn,
810
+ env: this._env,
691
811
  });
692
812
  forked._forking = true;
693
813
  return forked;
@@ -708,6 +828,9 @@ abstract class BaseCodexSession implements HarnessSession {
708
828
  // multi-turn + fork).
709
829
 
710
830
  export class CodexMcpSession extends BaseCodexSession {
831
+ get transport() {
832
+ return TRANSPORTS['codex-mcp'];
833
+ }
711
834
  private _mcpAdmission?: string;
712
835
  private _mcpCalls = new Map<string, { invocation: Record<string, unknown>; ended: boolean }>();
713
836
  protected _buildSpawnArgs(): string[] {
@@ -715,32 +838,34 @@ export class CodexMcpSession extends BaseCodexSession {
715
838
  // --dangerously-bypass-approvals-and-sandbox) so OUR container is the jail.
716
839
  // model_reasoning_effort sets the effort level; passed as TOML-ish `-c` values.
717
840
  const args = [
718
- "mcp-server",
719
- "-c", `sandbox_mode="danger-full-access"`,
720
- "-c", `approval_policy="never"`,
841
+ 'mcp-server',
842
+ '-c',
843
+ `sandbox_mode="danger-full-access"`,
844
+ '-c',
845
+ `approval_policy="never"`,
721
846
  ];
722
847
  if (this._effort) {
723
- args.push("-c", `model_reasoning_effort="${this._effort}"`);
848
+ args.push('-c', `model_reasoning_effort="${this._effort}"`);
724
849
  }
725
850
  return args;
726
851
  }
727
852
 
728
853
  protected async _handshake(): Promise<void> {
729
- await this._rpcRequest("initialize", {
730
- protocolVersion: "2025-06-18",
854
+ await this._rpcRequest('initialize', {
855
+ protocolVersion: '2025-06-18',
731
856
  capabilities: {},
732
- clientInfo: { name: "inixiative-bench", version: "0.1.0" },
857
+ clientInfo: { name: 'inixiative-bench', version: '0.1.0' },
733
858
  });
734
- this._rpcNotify("notifications/initialized");
859
+ this._rpcNotify('notifications/initialized');
735
860
  // tools/list confirms the `codex` + `codex-reply` tools are present.
736
- await this._rpcRequest("tools/list", {});
861
+ await this._rpcRequest('tools/list', {});
737
862
  }
738
863
 
739
864
  protected async _sendTurn(
740
865
  message: string,
741
866
  ): Promise<{ input: number; output: number } | undefined> {
742
867
  const isReply = this._externalSessionId !== undefined;
743
- const name = isReply ? "codex-reply" : "codex";
868
+ const name = isReply ? 'codex-reply' : 'codex';
744
869
  const args: Record<string, unknown> = isReply
745
870
  ? { prompt: message, threadId: this._externalSessionId }
746
871
  : {
@@ -748,34 +873,34 @@ export class CodexMcpSession extends BaseCodexSession {
748
873
  model: this._model,
749
874
  cwd: this._cwd,
750
875
  // Belt-and-suspenders: also disable per-call (matches spawn `-c` flags).
751
- sandbox: "danger-full-access",
752
- "approval-policy": "never",
753
- ...(this._effort
754
- ? { config: { model_reasoning_effort: this._effort } }
755
- : {}),
876
+ sandbox: 'danger-full-access',
877
+ 'approval-policy': 'never',
878
+ ...(this._effort ? { config: { model_reasoning_effort: this._effort } } : {}),
756
879
  };
757
880
 
758
- const result = (await this._rpcRequest("tools/call", {
881
+ const result = (await this._rpcRequest('tools/call', {
759
882
  name,
760
883
  arguments: args,
761
884
  })) as Record<string, unknown> | undefined;
762
885
 
763
886
  // Capture the threadId for multi-turn (codex-reply) + fork.
764
- const structured = result?.structuredContent as
765
- | Record<string, unknown>
766
- | undefined;
887
+ const structured = result?.structuredContent as Record<string, unknown> | undefined;
767
888
  const threadId = structured?.threadId as string | undefined;
768
- if (threadId && this._externalSessionId && threadId !== this._externalSessionId) throw new Error("MCP response changed the native resume binding");
889
+ if (threadId && this._externalSessionId && threadId !== this._externalSessionId)
890
+ throw new Error('MCP response changed the native resume binding');
769
891
  if (threadId && !this._externalSessionId) {
770
892
  this._externalSessionId = threadId;
771
893
  }
772
- if (threadId && this._inflight) this._inflight.evidence.identity = { ...this._inflight.evidence.identity, threadId };
894
+ if (threadId && this._inflight)
895
+ this._inflight.evidence.identity = { ...this._inflight.evidence.identity, threadId };
773
896
  // Final text: prefer structuredContent.content, else the tool result content.
774
897
  const finalText =
775
- (structured?.content as string | undefined) ??
776
- this._extractToolText(result?.content);
777
- if (result?.isError === true) throw new Error(finalText ?? "MCP tools/call failed");
778
- if (finalText) { this._resultText = finalText; if (this._inflight) this._inflight.evidence.content = 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
+ }
779
904
 
780
905
  // Usage, when the call result reports it. Streamed token_count events are
781
906
  // already accounted by the base loop; the call result is the authoritative
@@ -784,7 +909,12 @@ export class CodexMcpSession extends BaseCodexSession {
784
909
  // token event was seen this turn.
785
910
  const sawStreamedTokens = this._turnEvents.some((e) => e.tokens);
786
911
  const usage = structured?.usage as Record<string, number> | undefined;
787
- if (usage && !sawStreamedTokens && typeof (usage.input_tokens ?? usage.inputTokens) === "number" && typeof (usage.output_tokens ?? usage.outputTokens) === "number") {
912
+ if (
913
+ usage &&
914
+ !sawStreamedTokens &&
915
+ typeof (usage.input_tokens ?? usage.inputTokens) === 'number' &&
916
+ typeof (usage.output_tokens ?? usage.outputTokens) === 'number'
917
+ ) {
788
918
  return {
789
919
  input: (usage.input_tokens ?? usage.inputTokens ?? 0) as number,
790
920
  output: (usage.output_tokens ?? usage.outputTokens ?? 0) as number,
@@ -798,85 +928,160 @@ export class CodexMcpSession extends BaseCodexSession {
798
928
  const parts: string[] = [];
799
929
  for (const block of content) {
800
930
  const b = block as Record<string, unknown>;
801
- 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);
802
932
  }
803
- return parts.length ? parts.join("\n") : undefined;
933
+ return parts.length ? parts.join('\n') : undefined;
804
934
  }
805
935
 
806
936
  protected _classify(msg: Record<string, unknown>): SessionEvent[] {
807
937
  // Streamed events arrive as JSON-RPC notifications: method "codex/event"
808
938
  // with params carrying a `msg` of a tagged type.
809
- if (msg.method !== "codex/event") return [];
939
+ if (msg.method !== 'codex/event') return [];
810
940
  const params = msg.params as Record<string, unknown> | undefined;
811
941
  const ev = (params?.msg ?? params) as Record<string, unknown> | undefined;
812
942
  if (!ev) return [];
813
943
  const a = this._inflight?.evidence;
814
- const str = (v: unknown) => typeof v === "string" ? v : undefined;
815
- const nativeSessionId = str(ev.session_id), threadId = str(ev.thread_id);
816
- const turnId = str(ev.turn_id), envelopeId = str(params?.id);
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);
817
949
  const item = ev.item as Record<string, unknown> | undefined;
818
- const unowned = (reason: NonNullable<SessionEvent["unattributedReason"]>): SessionEvent[] => [{
819
- kind: "native_status", timestamp: Date.now(), correlation: "unknown", unattributedReason: reason,
820
- nativeSessionId, threadId, turnId, callId: str(ev.call_id), itemId: str(item?.id) ?? str(ev.item_id),
821
- // Keep the envelope too: params.id may explain a rejected correlation.
822
- // Unowned MCP calls retain identity/type diagnostics, never unrelated tool payloads.
823
- raw: ev.type === "mcp_tool_call_begin" || ev.type === "mcp_tool_call_end" ? { type: ev.type } : msg,
824
- }];
825
- if (threadId && this._externalSessionId && threadId !== this._externalSessionId) return unowned("foreign-session");
826
- if (nativeSessionId && a?.identity.nativeSessionId && nativeSessionId !== a.identity.nativeSessionId) return unowned("foreign-session");
827
- if ((envelopeId && this._seenTerminals.has(envelopeId)) || (turnId && this._seenTerminals.has(turnId))) {
828
- return unowned(ev.type === "task_complete" ? "duplicate-terminal" : "after-terminal");
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');
829
982
  }
830
983
  if (!a) {
831
- if (ev.type === "task_complete" && turnId) this._seenTerminals.add(turnId);
832
- return unowned("no-admission");
984
+ if (ev.type === 'task_complete' && turnId) this._seenTerminals.add(turnId);
985
+ return unowned('no-admission');
833
986
  }
834
- if (ev.type === "session_configured") {
987
+ if (ev.type === 'session_configured') {
835
988
  if (!this._externalSessionId && threadId) this._externalSessionId = threadId;
836
989
  a.identity = { ...a.identity, nativeSessionId, threadId };
837
990
  }
838
- if (ev.type !== "session_configured" && a.identity.turnId && envelopeId && envelopeId !== a.identity.turnId) return unowned("foreign-turn");
839
- if (threadId && a.identity.threadId && threadId !== a.identity.threadId) return unowned("foreign-session");
840
- if (ev.type === "task_started" && turnId && !a.identity.turnId) a.identity = { ...a.identity, turnId, correlation: "native-turn" };
841
- if (turnId && turnId !== a.identity.turnId) return unowned("foreign-turn");
842
- if (ev.type === "task_complete") {
843
- if (!turnId || turnId !== a.identity.turnId) return unowned("unrecognized-event");
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');
844
1005
  this._seenTerminals.add(turnId);
845
- return [{ ...a.identity, kind: "result", timestamp: Date.now(), nativeOutcome: "completed", terminal: { type: "task_complete", turnId }, raw: ev }];
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
+ ];
846
1016
  }
847
- const identity = { ...a.identity, threadId: threadId ?? a.identity.threadId, turnId: turnId ?? a.identity.turnId,
848
- callId: str(ev.call_id), itemId: str(item?.id) ?? str(ev.item_id) };
849
- if (ev.type === "mcp_tool_call_begin" || ev.type === "mcp_tool_call_end") {
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') {
850
1025
  const invocation = object(ev.invocation);
851
- if (!identity.callId || !invocation || typeof invocation.server !== "string" || !invocation.server
852
- || typeof invocation.tool !== "string" || !invocation.tool) return unowned("malformed-tool");
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');
853
1035
  if (this._mcpAdmission !== a.identity.admissionId) {
854
- this._mcpAdmission = a.identity.admissionId; this._mcpCalls.clear();
1036
+ this._mcpAdmission = a.identity.admissionId;
1037
+ this._mcpCalls.clear();
855
1038
  }
856
1039
  // Compare the original argument value, including absence. Never join a result
857
1040
  // for another server/tool or silently substitute arguments from its envelope.
858
- const join = { server: invocation.server, tool: invocation.tool, arguments: invocation.arguments };
1041
+ const join = {
1042
+ server: invocation.server,
1043
+ tool: invocation.tool,
1044
+ arguments: invocation.arguments,
1045
+ };
859
1046
  const prior = this._mcpCalls.get(identity.callId);
860
- const tool = { toolName: `mcp__${invocation.server}__${invocation.tool}`, toolServer: invocation.server, toolMethod: invocation.tool };
861
- if (ev.type === "mcp_tool_call_begin") {
862
- if (prior) return unowned("duplicate-tool");
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');
863
1054
  this._mcpCalls.set(identity.callId, { invocation: structuredClone(join), ended: false });
864
1055
  const args = publicMcpArguments(invocation.arguments);
865
- return [{ ...identity, ...tool, kind: "tool_use", timestamp: Date.now(), ...args }];
1056
+ return [{ ...identity, ...tool, kind: 'tool_use', timestamp: Date.now(), ...args }];
866
1057
  }
867
- if (!prior || !isDeepStrictEqual(prior.invocation, join)) return unowned("unmatched-tool");
868
- if (prior.ended) return unowned("duplicate-tool");
1058
+ if (!prior || !isDeepStrictEqual(prior.invocation, join)) return unowned('unmatched-tool');
1059
+ if (prior.ended) return unowned('duplicate-tool');
869
1060
  const result = publicMcpResult(ev.result);
870
- if (!result) return unowned("malformed-tool");
1061
+ if (!result) return unowned('malformed-tool');
871
1062
  prior.ended = true;
872
- return [{ ...identity, ...tool, kind: "tool_result", timestamp: Date.now(), ...result }];
1063
+ return [{ ...identity, ...tool, kind: 'tool_result', timestamp: Date.now(), ...result }];
873
1064
  }
874
- if (["session_configured", "task_started", "item_started", "item_completed"].includes(String(ev.type))) {
875
- return [{ ...identity, kind: "native_status", timestamp: Date.now(), nativeOutcome: a.nativeOutcome, raw: ev }];
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
+ ];
876
1079
  }
877
1080
  // Notifications without a turn ID are associated only with the single owned RPC.
878
- const classified = classifyCodexEvent(ev, "type");
879
- return classified.length ? classified.map(event => ({ ...identity, ...event })) : unowned("unrecognized-event");
1081
+ const classified = classifyCodexEvent(ev, 'type');
1082
+ return classified.length
1083
+ ? classified.map((event) => ({ ...identity, ...event }))
1084
+ : unowned('unrecognized-event');
880
1085
  }
881
1086
  }
882
1087
 
@@ -892,102 +1097,330 @@ export class CodexMcpSession extends BaseCodexSession {
892
1097
  class NativeValidationError extends Error {}
893
1098
 
894
1099
  export class CodexAppServerSession extends BaseCodexSession {
895
- readonly appServerProtocol = "owned-thread-v1";
896
- private readonly _options: NonNullable<CodexSessionConfig["appServer"]>;
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']>;
897
1234
  private readonly _onThreadReady?: (binding: string) => void | Promise<void>;
898
1235
  private _startedOnce = false;
899
1236
  private _threadReady = false;
900
1237
  private _nativeSessionId?: string;
901
1238
  private _appAdmission?: string;
902
1239
  private _turnDone?: () => void;
903
- private _items = new Map<string, { type: string; input?: unknown; completed: boolean; server?: string; tool?: string; text?: string }>();
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
+ >();
904
1251
 
905
1252
  constructor(config?: CodexSessionConfig) {
906
1253
  super(config);
907
- const {onThreadReady,...options}=config?.appServer??{};
908
- this._onThreadReady=onThreadReady;
1254
+ const { onThreadReady, ...options } = config?.appServer ?? {};
1255
+ this._onThreadReady = onThreadReady;
909
1256
  this._options = retainEvidence(options);
910
1257
  const required = this._options.requiredMcpServer;
911
- if (required && (!/^[a-zA-Z0-9_-]+$/.test(required.name) || !required.tools.length || required.tools.some(t => !/^[a-zA-Z0-9_-]+$/.test(t))))
912
- throw new NativeValidationError("Invalid required MCP inventory");
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');
913
1265
  }
914
1266
 
915
- override fork(): HarnessSession { throw Error("Native app-server fork is not implemented; a same-thread wrapper cannot create a native fork or inherit a process capability"); }
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
+ }
916
1272
 
917
1273
  override async start(): Promise<void> {
918
- if (this._inflight) throw Error("Native call ownership unresolved; automatic resume is blocked");
919
- if (this._startedOnce && !this._alive) throw Error("App-server restart requires a new owned instance and preflight");
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');
920
1278
  this._startedOnce = true;
921
- try { await super.start(); }
922
- catch(error) { this.kill(); throw error; }
1279
+ try {
1280
+ await super.start();
1281
+ } catch (error) {
1282
+ this.kill();
1283
+ throw error;
1284
+ }
923
1285
  }
924
1286
 
925
1287
  protected override _serverRequest(request: Record<string, unknown>): boolean {
926
1288
  // JSON-RPC's explicit method-not-supported refusal also covers unknown future
927
1289
  // requests. Never approve native work or echo private request parameters.
928
- const payload = JSON.stringify({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Client interaction is unsupported by this adapter" } }) + "\n";
929
- const params=object(request.params),a=this._inflight?.evidence;
930
- const identity=a?.identity.turnId && params?.threadId===a.identity.threadId && params?.turnId===a.identity.turnId
931
- ? {...a.identity,admissionId:a.admissionId} : {unattributedReason:"unrecognized-event" as const};
932
- try { this._proc!.stdin.write(payload); this._proc!.stdin.flush(); }
933
- catch { this._emit({ ...identity, kind: "native_status", timestamp: Date.now(), raw: { type: "server-request-refusal", status: "write-failed" } }); return true; }
934
- this._emit({ ...identity, kind: "native_status", timestamp: Date.now(), raw: { type: "server-request-refusal", status: "refused" } });
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
+ });
935
1321
  return true;
936
1322
  }
937
1323
 
938
- private _refuse(reason: string, status = "unknown"): never {
1324
+ private _refuse(reason: string, status = 'unknown'): never {
939
1325
  const a = this._inflight?.evidence;
940
- this._emit({ ...(a ? { admissionId: a.admissionId, ...a.identity } : {}), kind: "native_status", timestamp: Date.now(),
941
- raw: { type: "thread-refused", reason, status } });
942
- throw new NativeValidationError(`Native thread validation refused: ${reason} (${status}); no turn was sent`);
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
+ );
943
1335
  }
944
1336
 
945
1337
  private async _readiness(): Promise<void> {
946
1338
  const required = this._options.requiredMcpServer;
947
1339
  if (!required) return;
948
1340
  let cursor: string | undefined;
949
- const seen = new Set<string>(); let found: Record<string, unknown> | undefined;
1341
+ const seen = new Set<string>();
1342
+ let found: Record<string, unknown> | undefined;
950
1343
  // Pagination only, never polling/retrying a starting or failed native server.
951
1344
  for (let page = 0; page < 32; page++) {
952
- const result = object(await this._rpcRequest("mcpServerStatus/list", { threadId: this._externalSessionId, detail: "toolsAndAuthOnly", limit: 100, ...(cursor ? { cursor } : {}) }));
953
- if (!Array.isArray(result?.data)) this._refuse("mcp-inventory-malformed");
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');
954
1354
  for (const entry of result.data) {
955
1355
  const server = object(entry);
956
- if (server?.name === required.name) { if (found) this._refuse("mcp-inventory-duplicate"); found = server; }
1356
+ if (server?.name === required.name) {
1357
+ if (found) this._refuse('mcp-inventory-duplicate');
1358
+ found = server;
1359
+ }
957
1360
  }
958
1361
  if (result.nextCursor == null) {
959
1362
  const tools = object(found?.tools);
960
- if (!found || found.runtimeStatus !== "connected" || !tools || required.tools.some(name => object(tools[name])?.name !== name))
961
- this._refuse("mcp-inventory-unavailable");
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');
962
1369
  const a = this._inflight!.evidence;
963
- this._emit({ ...a.identity, admissionId: a.admissionId, kind: "native_status", timestamp: Date.now(), raw: { type: "mcp-ready", status: "connected", server: required.name, tools: [...required.tools] } });
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
+ });
964
1382
  return;
965
1383
  }
966
- if (typeof result.nextCursor !== "string" || !result.nextCursor || seen.has(result.nextCursor)) this._refuse("mcp-inventory-cursor");
967
- cursor = result.nextCursor; seen.add(cursor);
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);
968
1392
  }
969
- this._refuse("mcp-inventory-page-limit");
1393
+ this._refuse('mcp-inventory-page-limit');
970
1394
  }
971
1395
 
972
1396
  protected override _canReleaseAttempt(a: TurnState): boolean {
973
1397
  // An observer/write failure may occur after delivery. A terminal alone does
974
1398
  // not settle the unanswered turn/start call or authorize another admission.
975
- return a.rpcSettled && (a.rpcOutcome === "resolved" || a.rpcOutcome === "failed");
1399
+ return a.rpcSettled && (a.rpcOutcome === 'resolved' || a.rpcOutcome === 'failed');
976
1400
  }
977
1401
 
978
1402
  protected _buildSpawnArgs(): string[] {
979
- return ["app-server", "--listen", "stdio://"];
1403
+ return ['app-server', '--listen', 'stdio://'];
980
1404
  }
981
1405
 
982
1406
  protected async _handshake(): Promise<void> {
983
1407
  this._threadReady = false;
984
- const initialized = object(await this._rpcRequest("initialize", {
985
- capabilities: {},
986
- clientInfo: { name: "inixiative-bench", version: "0.1.0" },
987
- }));
988
- if (this._options.requireConfiguration && ["userAgent","platformFamily","platformOs","codexHome"].some(key=>typeof initialized?.[key]!=="string" || !initialized[key]))
989
- throw new NativeValidationError("Native initialize response lacks required configuration; no thread or turn was sent");
990
- this._rpcNotify("initialized");
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');
991
1424
  }
992
1425
 
993
1426
  protected async _sendTurn(
@@ -996,54 +1429,123 @@ export class CodexAppServerSession extends BaseCodexSession {
996
1429
  const a = this._inflight!.evidence;
997
1430
  if (!this._threadReady) {
998
1431
  const binding = this._externalSessionId;
999
- const method = binding ? "thread/resume" : "thread/start";
1000
- const response = object(await this._rpcRequest(method, {
1001
- ...(binding ? { threadId: binding } : {}),
1002
- model: this._model,
1003
- cwd: this._cwd,
1004
- approvalPolicy: "never",
1005
- sandbox: "danger-full-access",
1006
- ...(this._options.config ? { config: this._options.config } : {}),
1007
- }));
1008
- const thread = object(response?.thread), id = thread?.id;
1009
- if (typeof id !== "string" || !id || (binding && id !== binding)) this._refuse("binding-mismatch");
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');
1010
1447
  // Resume may rejoin a live thread. Never steer known pre-existing work.
1011
1448
  // The installed schema requires status. Missing/unknown state cannot
1012
1449
  // authorize work, even if this response otherwise identifies our thread.
1013
- if (object(thread?.status)?.type !== "idle"
1014
- || (Array.isArray(thread?.turns) && thread.turns.some(turn => object(turn)?.status === "inProgress")))
1015
- this._refuse("not-idle", ["active", "notLoaded", "systemError"].includes(String(object(thread?.status)?.type)) ? String(object(thread?.status)?.type) : "unknown");
1016
- if (this._options.requireConfiguration && (typeof response?.model !== "string" || !response.model || typeof response.modelProvider !== "string" || !response.modelProvider
1017
- || response.cwd !== this._cwd || response.approvalPolicy !== "never" || response.approvalsReviewer !== "user" || object(response.sandbox)?.type !== "dangerFullAccess"
1018
- || !Array.isArray(thread?.turns) || thread.turns.some(value=>!this._turn(value)))) this._refuse("configuration-missing-or-incompatible");
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');
1019
1475
  this._externalSessionId = id;
1020
- this._nativeSessionId = typeof thread?.sessionId === "string" && thread.sessionId ? thread.sessionId : undefined;
1021
- a.identity = { ...a.identity, threadId: id, ...(this._nativeSessionId ? {nativeSessionId:this._nativeSessionId} : {}) };
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
+ };
1022
1483
  if (this._onThreadReady) {
1023
- try { await this._onThreadReady(id); }
1024
- catch (cause) { throw new NativeValidationError("Native binding persistence failed before turn/start", {cause}); }
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
+ }
1025
1491
  }
1026
1492
  this._threadReady = true;
1027
1493
  // Configuration belongs to this process/thread, never to the next turn.
1028
1494
  // Do not copy historical turns, instructions, paths or arbitrary config.
1029
- this._emit({ kind: "native_status", timestamp: Date.now(), threadId: id,
1030
- nativeSessionId: this._nativeSessionId, unattributedReason: "session-configuration", raw: { type: "thread-configured", method, threadId: id,
1031
- ...(object(thread?.status)?.type === "idle" ? { status: "idle" } : {}),
1032
- ...(typeof response?.model === "string" ? { model: response.model } : {}),
1033
- ...(response?.reasoningEffort === null || VALID_EFFORTS.includes(String(response?.reasoningEffort)) ? { reasoningEffort: response?.reasoningEffort } : {}),
1034
- history: { source: method, turns: Array.isArray(thread?.turns) ? thread.turns.slice(0,200).flatMap(value => {const t=this._turn(value);return t?[{id:t.id,status:t.status}]:[];}) : [],
1035
- available: Array.isArray(thread?.turns), hasMore: !!response?.turnsBackwardsCursor || (Array.isArray(thread?.turns) && thread.turns.length>200) } } });
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
+ });
1036
1526
  }
1037
- a.identity = { ...a.identity, threadId: this._externalSessionId, ...(this._nativeSessionId ? { nativeSessionId: this._nativeSessionId } : {}) };
1527
+ a.identity = {
1528
+ ...a.identity,
1529
+ threadId: this._externalSessionId,
1530
+ ...(this._nativeSessionId ? { nativeSessionId: this._nativeSessionId } : {}),
1531
+ };
1038
1532
  await this._readiness();
1039
1533
  // Timeout/kill during create/resume must not cause a later model write.
1040
- if (this._inflight?.evidence !== a || a.localOutcome !== "pending" || !this._alive) throw Error("Local admission closed before turn/start; no turn was sent");
1041
- a.identity = { ...a.identity, threadId: this._externalSessionId, ...(this._nativeSessionId ? { nativeSessionId: this._nativeSessionId } : {}) };
1042
- this._appAdmission = a.admissionId; this._items.clear();
1043
- const completed = new Promise<void>(resolve => { this._turnDone = resolve; });
1044
- await this._rpcRequest("turn/start", {
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', {
1045
1547
  threadId: this._externalSessionId,
1046
- input: [{ type: "text", text: message }],
1548
+ input: [{ type: 'text', text: message }],
1047
1549
  ...(this._effort ? { effort: this._effort } : {}),
1048
1550
  });
1049
1551
  await completed; // Native terminal and RPC acknowledgment are both required.
@@ -1054,121 +1556,273 @@ export class CodexAppServerSession extends BaseCodexSession {
1054
1556
  const a = this._inflight?.evidence;
1055
1557
  if (!a || this._appAdmission !== a.admissionId || a.identity.rpcRequestId !== id) return;
1056
1558
  const turn = this._turn(object(result)?.turn);
1057
- if (!turn || (a.identity.turnId && turn.id !== a.identity.turnId)
1058
- || (!a.identity.turnId && this._seenTerminals.has(turn.id))) throw new NativeValidationError("Native turn/start response missing or conflicts with owned turn");
1059
- a.identity = { ...a.identity, turnId: turn.id, correlation: "native-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' };
1060
1568
  }
1061
1569
 
1062
1570
  private _turn(value: unknown): { id: string; status: string; error?: unknown } | undefined {
1063
1571
  const turn = object(value);
1064
- if (!turn || typeof turn.id !== "string" || !turn.id || !Array.isArray(turn.items)
1065
- || !["inProgress", "completed", "failed", "interrupted"].includes(String(turn.status))) return;
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;
1066
1580
  return turn as { id: string; status: string; error?: unknown };
1067
1581
  }
1068
1582
 
1069
1583
  protected _classify(msg: Record<string, unknown>): SessionEvent[] {
1070
- const method = typeof msg.method === "string" ? msg.method : undefined;
1584
+ const method = typeof msg.method === 'string' ? msg.method : undefined;
1071
1585
  if (!method) return [];
1072
- const params = object(msg.params), turn = this._turn(params?.turn), item = object(params?.item);
1073
- const str = (value: unknown) => typeof value === "string" && value ? value : undefined;
1074
- const threadId = str(params?.threadId), turnId = turn?.id ?? str(params?.turnId), itemId = str(item?.id) ?? str(params?.itemId);
1075
- const unowned = (reason: NonNullable<SessionEvent["unattributedReason"]>): SessionEvent[] => [{
1076
- kind: "native_status", timestamp: Date.now(), threadId, turnId, itemId, correlation: "unknown", unattributedReason: reason,
1077
- raw: { method: ["turn/started", "turn/completed", "item/started", "item/completed", "thread/tokenUsage/updated", "item/agentMessage/delta", "item/commandExecution/outputDelta"].includes(method) ? method : "unrecognized",
1078
- ...(item ? { itemType: ["agentMessage", "commandExecution", "reasoning", "mcpToolCall"].includes(String(item.type)) ? item.type : "unsupported" } : {}) },
1079
- }];
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
+ ];
1080
1630
  const a = this._inflight?.evidence;
1081
- if (!threadId || threadId !== this._externalSessionId) return unowned("foreign-session");
1082
- if (!a || this._appAdmission !== a.admissionId) return unowned("no-admission");
1083
- if (turnId && this._seenTerminals.has(turnId)) return unowned(method === "turn/completed" ? "duplicate-terminal" : "after-terminal");
1084
- if (method === "turn/started" && turn?.status === "inProgress" && !a.identity.turnId)
1085
- a.identity = { ...a.identity, threadId, turnId: turn.id, correlation: "native-turn" };
1086
- if (!turnId || turnId !== a.identity.turnId) return unowned("foreign-turn");
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');
1087
1638
  const identity = { ...a.identity, threadId, turnId, ...(itemId ? { itemId } : {}) };
1088
- const status = (): SessionEvent[] => [{ ...identity, kind: "native_status", timestamp: Date.now(), raw: { method } }];
1089
- if (method === "turn/completed") {
1090
- if (!turn || turn.status === "inProgress") return unowned("unrecognized-event");
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');
1091
1644
  const error = object(turn.error);
1092
- if (turn.error != null && typeof error?.message !== "string") return unowned("unrecognized-event");
1645
+ if (turn.error != null && typeof error?.message !== 'string')
1646
+ return unowned('unrecognized-event');
1093
1647
  this._seenTerminals.add(turnId);
1094
1648
  this._turnDone?.();
1095
1649
  this._turnDone = undefined;
1096
- return [{ ...identity, kind: "result", timestamp: Date.now(), text: a.content,
1097
- nativeOutcome: turn.status === "completed" && !error ? "completed" : "failed",
1098
- terminal: { type: method, turnId, subtype: turn.status, ...(error ? { reason: error.message as string } : {}) } }];
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
+ ];
1099
1665
  }
1100
- if (method === "thread/tokenUsage/updated") {
1666
+ if (method === 'thread/tokenUsage/updated') {
1101
1667
  const last = object(object(params?.tokenUsage)?.last);
1102
- if (typeof last?.inputTokens !== "number" || typeof last?.outputTokens !== "number"
1103
- || !Number.isSafeInteger(last.inputTokens) || !Number.isSafeInteger(last.outputTokens) || last.inputTokens < 0 || last.outputTokens < 0) return unowned("unrecognized-event");
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');
1104
1677
  // `last` is a snapshot, not an additive delta. Total accounting happens once
1105
1678
  // when the original native turn and RPC settle; absent usage stays absent.
1106
1679
  a.tokens = { input: last.inputTokens, output: last.outputTokens };
1107
1680
  return status();
1108
1681
  }
1109
- if (method === "item/agentMessage/delta" || method === "item/commandExecution/outputDelta") {
1110
- if (!itemId || typeof params?.delta !== "string") return unowned("unrecognized-event");
1682
+ if (method === 'item/agentMessage/delta' || method === 'item/commandExecution/outputDelta') {
1683
+ if (!itemId || typeof params?.delta !== 'string') return unowned('unrecognized-event');
1111
1684
  const prior = this._items.get(itemId);
1112
- if (prior?.completed) return unowned("after-terminal");
1113
- if (method === "item/agentMessage/delta") {
1114
- if (prior && prior.type !== "agentMessage") return unowned("unmatched-tool");
1115
- this._items.set(itemId, { type: "agentMessage", completed: false, text: (prior?.text ?? "") + params.delta });
1116
- return [{ ...identity, kind: "text_delta", timestamp: Date.now(), text: params.delta }];
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 }];
1117
1694
  }
1118
- if (prior?.type !== "commandExecution") return unowned("unmatched-tool");
1119
- return [{ ...identity, kind: "native_status", timestamp: Date.now(), toolName: "shell", text: params.delta }];
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
+ ];
1120
1705
  }
1121
- if (method === "item/completed" || method === "item/started") {
1122
- if (!itemId || !item || typeof item.type !== "string") return unowned("unrecognized-event");
1123
- if (item.type === "agentMessage") {
1124
- if (method !== "item/completed") return status();
1125
- if (this._items.get(itemId)?.completed) return unowned("after-terminal");
1126
- if (typeof item.text !== "string" || (item.phase != null && !["commentary", "final_answer"].includes(String(item.phase)))) return unowned("unrecognized-event");
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');
1127
1716
  this._items.set(itemId, { type: item.type, completed: true });
1128
- return [{ ...identity, kind: "text", timestamp: Date.now(), text: item.text }];
1717
+ return [{ ...identity, kind: 'text', timestamp: Date.now(), text: item.text }];
1129
1718
  }
1130
- if (item.type === "commandExecution") {
1131
- if (typeof item.command !== "string") return unowned("malformed-tool");
1719
+ if (item.type === 'commandExecution') {
1720
+ if (typeof item.command !== 'string') return unowned('malformed-tool');
1132
1721
  const prior = this._items.get(itemId);
1133
- if (method === "item/started") {
1134
- if (prior) return unowned("duplicate-tool");
1722
+ if (method === 'item/started') {
1723
+ if (prior) return unowned('duplicate-tool');
1135
1724
  this._items.set(itemId, { type: item.type, input: item.command, completed: false });
1136
- return [{ ...identity, kind: "tool_use", timestamp: Date.now(), toolName: "shell", toolInput: { command: item.command } }];
1725
+ return [
1726
+ {
1727
+ ...identity,
1728
+ kind: 'tool_use',
1729
+ timestamp: Date.now(),
1730
+ toolName: 'shell',
1731
+ toolInput: { command: item.command },
1732
+ },
1733
+ ];
1137
1734
  }
1138
- if (!prior || prior.type !== item.type || prior.input !== item.command) return unowned("unmatched-tool");
1139
- if (prior.completed) return unowned("duplicate-tool");
1140
- if (!["completed", "failed", "declined"].includes(String(item.status))) return unowned("malformed-tool");
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');
1141
1740
  prior.completed = true;
1142
- return [{ ...identity, kind: "tool_result", timestamp: Date.now(), toolName: "shell",
1143
- ...(typeof item.aggregatedOutput === "string" ? { toolOutput: item.aggregatedOutput } : { toolOutputOmitted: true }),
1144
- toolError: item.status !== "completed" || (typeof item.exitCode === "number" && item.exitCode !== 0) }];
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
+ ];
1145
1755
  }
1146
- if (item.type === "mcpToolCall") {
1147
- if (typeof item.server !== "string" || !item.server || typeof item.tool !== "string" || !item.tool) return unowned("malformed-tool");
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');
1148
1764
  const prior = this._items.get(itemId);
1149
- const tool = { toolServer: item.server, toolMethod: item.tool, toolName: `mcp__${item.server}__${item.tool}` };
1150
- if (method === "item/started") {
1151
- if (prior) return unowned("duplicate-tool");
1152
- if (item.status !== "inProgress") return unowned("malformed-tool");
1153
- this._items.set(itemId, { type: item.type, server: item.server, tool: item.tool, input: retainEvidence(item.arguments), completed: false });
1154
- return [{ ...identity, ...tool, kind: "tool_use", timestamp: Date.now(), ...publicMcpArguments(item.arguments) }];
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
+ ];
1155
1789
  }
1156
- if (!prior || prior.type !== item.type || prior.server !== item.server || prior.tool !== item.tool || !isDeepStrictEqual(prior.input, item.arguments)) return unowned("unmatched-tool");
1157
- if (prior.completed) return unowned("duplicate-tool");
1158
- if (!["completed", "failed"].includes(String(item.status))) return unowned("malformed-tool");
1159
- const error = object(item.error), result = object(item.result);
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);
1160
1803
  const output = result ? publicMcpResult({ Ok: { content: result.content } }) : undefined;
1161
- if (item.error != null && typeof error?.message !== "string") return unowned("malformed-tool");
1804
+ if (item.error != null && typeof error?.message !== 'string')
1805
+ return unowned('malformed-tool');
1162
1806
  prior.completed = true;
1163
- return [{ ...identity, ...tool, kind: "tool_result", timestamp: Date.now(),
1164
- ...(output ?? (error ? { toolOutput: error.message as string } : { toolOutputOmitted: true })),
1165
- toolError: item.status === "failed" || !!error,
1166
- ...(result && Object.keys(result).some(k => k !== "content" && result[k] != null) ? { toolOutputOmitted: 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
+ ];
1167
1821
  }
1168
1822
  // Reasoning and unsupported items retain safe identity/type diagnostics only.
1169
- return unowned("unrecognized-event");
1823
+ return unowned('unrecognized-event');
1170
1824
  }
1171
- return method === "turn/started" ? status() : unowned("unrecognized-event");
1825
+ return method === 'turn/started' ? status() : unowned('unrecognized-event');
1172
1826
  }
1173
1827
  }
1174
1828
 
@@ -1186,101 +1840,134 @@ export class CodexAppServerSession extends BaseCodexSession {
1186
1840
  // token_count / usage → tokens (attached to a result event)
1187
1841
 
1188
1842
  const object = (value: unknown): Record<string, unknown> | undefined =>
1189
- value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
1843
+ value !== null && typeof value === 'object' && !Array.isArray(value)
1844
+ ? (value as Record<string, unknown>)
1845
+ : undefined;
1190
1846
 
1191
- function publicMcpArguments(value: unknown): Pick<SessionEvent, "toolInput" | "toolInputOmitted"> {
1192
- if (!object(value)) return { toolInputOmitted: "Native arguments absent or not an object" };
1847
+ function publicMcpArguments(value: unknown): Pick<SessionEvent, 'toolInput' | 'toolInputOmitted'> {
1848
+ if (!object(value)) return { toolInputOmitted: 'Native arguments absent or not an object' };
1193
1849
  let omitted = false;
1194
1850
  const clean = (v: unknown, depth = 0): unknown => {
1195
- if (depth > 32) { omitted = true; return null; }
1196
- if (v === null || ["string", "number", "boolean"].includes(typeof v)) return v;
1197
- if (Array.isArray(v)) return v.map(x => clean(x, depth + 1));
1198
- if (!object(v)) { omitted = true; return null; }
1199
- return Object.fromEntries(Object.entries(v as Record<string, unknown>).flatMap(([key, item]) => {
1200
- if (/^(?:__proto__|constructor|prototype|env|environment|authorization|credentials?|api[-_]?key|access[-_]?token|refresh[-_]?token|password|secret|reasoning|thinking)$/i.test(key)) { omitted = true; return []; }
1201
- return [[key, clean(item, depth + 1)]];
1202
- }));
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
+ );
1203
1874
  };
1204
1875
  const toolInput = clean(value) as Record<string, unknown>;
1205
- return { toolInput, ...(omitted ? { toolInputOmitted: "Non-public or unsupported argument fields omitted" } : {}) };
1876
+ return {
1877
+ toolInput,
1878
+ ...(omitted ? { toolInputOmitted: 'Non-public or unsupported argument fields omitted' } : {}),
1879
+ };
1206
1880
  }
1207
1881
 
1208
1882
  /** Rust Result<CallToolResult,String> from protocol.rs. This is legacy event
1209
1883
  * normalization, not the app-server ThreadItem schema. Keep all public text
1210
1884
  * blocks (including the bridge's JSON receipt), not arbitrary envelope fields. */
1211
- function publicMcpResult(value: unknown): Pick<SessionEvent, "toolOutput" | "toolError" | "toolOutputOmitted"> | undefined {
1885
+ function publicMcpResult(
1886
+ value: unknown,
1887
+ ): Pick<SessionEvent, 'toolOutput' | 'toolError' | 'toolOutputOmitted'> | undefined {
1212
1888
  const result = object(value);
1213
1889
  if (!result || Object.keys(result).length !== 1) return;
1214
- if (typeof result.Err === "string") return { toolOutput: result.Err, toolError: true };
1890
+ if (typeof result.Err === 'string') return { toolOutput: result.Err, toolError: true };
1215
1891
  const ok = object(result.Ok);
1216
- if (!ok || !Array.isArray(ok.content) || (ok.isError !== undefined && typeof ok.isError !== "boolean")) return;
1217
- let omitted = Object.keys(ok).some(k => k !== "content" && k !== "isError");
1218
- const content = ok.content.map(value => {
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) => {
1219
1900
  const block = object(value);
1220
- if (block?.type === "text" && typeof block.text === "string") {
1221
- if (Object.keys(block).some(k => k !== "type" && k !== "text")) omitted = true;
1222
- return { type: "text", text: block.text };
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 };
1223
1904
  }
1224
1905
  omitted = true;
1225
1906
  // Unknown shape values are not safe labels. Known non-text types retain type only.
1226
- return { type: ["image", "audio", "resource", "resource_link"].includes(String(block?.type)) ? block!.type : "unsupported" };
1907
+ return {
1908
+ type: ['image', 'audio', 'resource', 'resource_link'].includes(String(block?.type))
1909
+ ? block!.type
1910
+ : 'unsupported',
1911
+ };
1227
1912
  });
1228
- return { toolOutput: JSON.stringify({ content, ...(ok.isError !== undefined ? { isError: ok.isError } : {}) }),
1229
- ...(ok.isError !== undefined ? { toolError: ok.isError as boolean } : {}), ...(omitted ? { toolOutputOmitted: true } : {}) };
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
+ };
1230
1921
  }
1231
1922
 
1232
- function classifyCodexEvent(
1233
- ev: Record<string, unknown>,
1234
- tag: string,
1235
- ): SessionEvent[] {
1923
+ function classifyCodexEvent(ev: Record<string, unknown>, tag: string): SessionEvent[] {
1236
1924
  const ts = Date.now();
1237
- const type = String(ev[tag] ?? "").toLowerCase();
1925
+ const type = String(ev[tag] ?? '').toLowerCase();
1238
1926
  const events: SessionEvent[] = [];
1239
1927
 
1240
1928
  // Agent message → text.
1241
- if (type === "agent_message" || type === "agentmessage" || type === "agent_message_delta") {
1929
+ if (type === 'agent_message' || type === 'agentmessage' || type === 'agent_message_delta') {
1242
1930
  const text = (ev.message ?? ev.text ?? ev.delta) as string | undefined;
1243
- if (text) events.push({ kind: "text", timestamp: ts, text, raw: ev });
1931
+ if (text) events.push({ kind: 'text', timestamp: ts, text, raw: ev });
1244
1932
  return events;
1245
1933
  }
1246
1934
 
1247
1935
  // Reasoning → thinking.
1248
1936
  if (
1249
- type === "reasoning" ||
1250
- type === "agent_reasoning" ||
1251
- type === "agentreasoning" ||
1252
- type === "agent_reasoning_delta"
1937
+ type === 'reasoning' ||
1938
+ type === 'agent_reasoning' ||
1939
+ type === 'agentreasoning' ||
1940
+ type === 'agent_reasoning_delta'
1253
1941
  ) {
1254
1942
  const text = (ev.text ?? ev.reasoning ?? ev.delta ?? ev.summary) as string | undefined;
1255
- if (text) events.push({ kind: "thinking", timestamp: ts, text, raw: ev });
1943
+ if (text) events.push({ kind: 'thinking', timestamp: ts, text, raw: ev });
1256
1944
  return events;
1257
1945
  }
1258
1946
 
1259
1947
  // Command execution → tool_use, plus tool_result if output is present.
1260
1948
  if (
1261
- type === "command_execution" ||
1262
- type === "commandexecution" ||
1263
- type === "exec_command_begin" ||
1264
- type === "exec_command_end"
1949
+ type === 'command_execution' ||
1950
+ type === 'commandexecution' ||
1951
+ type === 'exec_command_begin' ||
1952
+ type === 'exec_command_end'
1265
1953
  ) {
1266
1954
  const command = (ev.command ?? ev.cmd) as string | string[] | undefined;
1267
- const cmdStr = Array.isArray(command) ? command.join(" ") : command;
1268
- if (type !== "exec_command_end") events.push({
1269
- kind: "tool_use",
1270
- timestamp: ts,
1271
- toolName: "shell",
1272
- toolInput: cmdStr ? { command: cmdStr } : (ev as Record<string, unknown>),
1273
- raw: ev,
1274
- });
1275
- const output = (ev.output ?? ev.stdout ?? ev.aggregated_output) as
1276
- | string
1277
- | 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;
1278
1965
  const exitCode = (ev.exit_code ?? ev.exitCode) as number | undefined;
1279
1966
  if (output !== undefined || exitCode !== undefined) {
1280
1967
  events.push({
1281
- kind: "tool_result",
1968
+ kind: 'tool_result',
1282
1969
  timestamp: ts,
1283
- toolOutput: output ?? "",
1970
+ toolOutput: output ?? '',
1284
1971
  toolError: exitCode !== undefined && exitCode !== 0,
1285
1972
  raw: ev,
1286
1973
  });
@@ -1290,19 +1977,17 @@ function classifyCodexEvent(
1290
1977
 
1291
1978
  // Token usage → carried on a result-less event so accounting picks it up.
1292
1979
  if (
1293
- type === "token_count" ||
1294
- type === "token_usage" ||
1295
- type === "usage" ||
1296
- type === "tokenusage"
1980
+ type === 'token_count' ||
1981
+ type === 'token_usage' ||
1982
+ type === 'usage' ||
1983
+ type === 'tokenusage'
1297
1984
  ) {
1298
1985
  const u = (ev.info ?? ev.usage ?? ev) as Record<string, unknown>;
1299
- const input =
1300
- (u.input_tokens ?? u.inputTokens ?? u.total_input_tokens ?? 0) as number;
1301
- const output =
1302
- (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;
1303
1988
  if (input || output) {
1304
1989
  events.push({
1305
- kind: "result",
1990
+ kind: 'result',
1306
1991
  timestamp: ts,
1307
1992
  tokens: { input, output },
1308
1993
  raw: ev,
@@ -1312,9 +1997,9 @@ function classifyCodexEvent(
1312
1997
  }
1313
1998
 
1314
1999
  // Errors.
1315
- if (type === "error" || type === "stream_error") {
2000
+ if (type === 'error' || type === 'stream_error') {
1316
2001
  events.push({
1317
- kind: "error",
2002
+ kind: 'error',
1318
2003
  timestamp: ts,
1319
2004
  text: (ev.message ?? ev.error ?? JSON.stringify(ev)) as string,
1320
2005
  raw: ev,