@inixiative/agent-session 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,6 @@
1
+ import { TurnState } from "./turn-state";
2
+ import { retainEvidence } from "./retained-evidence";
3
+ import { isDeepStrictEqual } from "node:util";
1
4
  // ---------------------------------------------------------------------------
2
5
  // CodexSession — long-lived OpenAI Codex CLI process with full event capture
3
6
  // ---------------------------------------------------------------------------
@@ -8,7 +11,7 @@
8
11
  // the shared SessionEvent taxonomy and resolving a turn on completion.
9
12
  //
10
13
  // CodexMcpSession (default) — `codex mcp-server` (stdio MCP JSON-RPC)
11
- // CodexAppServerSession (experimental) — `codex app-server` (WebSocket JSON-RPC)
14
+ // CodexAppServerSession (experimental) — `codex app-server` (stdio JSON-RPC)
12
15
  //
13
16
  // Both DISABLE codex's own approvals + sandbox (the union of
14
17
  // `--dangerously-bypass-approvals-and-sandbox`) so OUR container is the only
@@ -67,6 +70,15 @@ export type CodexSpawn = (
67
70
  // ---------------------------------------------------------------------------
68
71
 
69
72
  export interface CodexSessionConfig {
73
+ /** Explicit app-server integration. Never applied by the default MCP engine.
74
+ * Config is native launch data, not event/recording data. */
75
+ appServer?: {
76
+ /** Persist the actual returned binding before model admission; failure blocks work. */
77
+ onThreadReady?: (binding: string) => void | Promise<void>;
78
+ requireConfiguration?: boolean;
79
+ config?: Readonly<Record<string, unknown>>;
80
+ requiredMcpServer?: { readonly name: string; readonly tools: readonly string[] };
81
+ };
70
82
  /** Path to codex CLI binary. Defaults to "codex". */
71
83
  bin?: string;
72
84
  /** Model. Defaults to "gpt-5.5". */
@@ -88,8 +100,8 @@ export interface CodexSessionConfig {
88
100
  baseContext?: string;
89
101
  /**
90
102
  * codex's native thread ID (the value the `codex` tool returns). When set, the
91
- * first send() continues that thread via `codex-reply` — used for fork and
92
- * crash recovery. Also set by a SessionAdapter resuming a mapped Foundry thread.
103
+ * first send() uses `codex-reply` on MCP (loaded threads only), or
104
+ * `thread/resume` on app-server. Only app-server supports cold resume by ID.
93
105
  */
94
106
  externalSessionId?: string;
95
107
  /**
@@ -105,6 +117,8 @@ export interface CodexSessionConfig {
105
117
  // ---------------------------------------------------------------------------
106
118
 
107
119
  interface QueuedTurn {
120
+ onAdmission?: import("./harness-session").SessionSendOptions["onAdmission"];
121
+ evidence: TurnState;
108
122
  message: string;
109
123
  timeout: number;
110
124
  resolve: (result: SessionResult) => void;
@@ -141,8 +155,10 @@ abstract class BaseCodexSession implements HarnessSession {
141
155
  // -- Session state --
142
156
  protected _externalSessionId?: string;
143
157
  protected _alive = false;
158
+ private _endEmitted = false;
144
159
  protected _eventLog: SessionEvent[] = [];
145
160
  protected _handlers: SessionEventHandler[] = [];
161
+ private _observerFailures = { synchronous: 0, asynchronous: 0 };
146
162
  protected _beforeSendHooks: BeforeSendHook[] = [];
147
163
  protected _turns = 0;
148
164
  protected _totalTokens = { input: 0, output: 0 };
@@ -163,6 +179,8 @@ abstract class BaseCodexSession implements HarnessSession {
163
179
 
164
180
  // -- Turn queue (identical to ClaudeCodeSession) --
165
181
  protected _queue: QueuedTurn[] = [];
182
+ protected _attempts: TurnState[] = [];
183
+ protected _seenTerminals = new Set<string>();
166
184
  protected _inflight: QueuedTurn | null = null;
167
185
  protected _turnEvents: SessionEvent[] = [];
168
186
  protected _resultText = "";
@@ -193,9 +211,13 @@ abstract class BaseCodexSession implements HarnessSession {
193
211
  // Accessors (identical to ClaudeCodeSession)
194
212
  // ---------------------------------------------------------------------------
195
213
 
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 }) }); }
217
+
196
218
  get alive(): boolean { return this._alive; }
197
219
  get externalSessionId(): string | undefined { return this._externalSessionId; }
198
- get events(): readonly SessionEvent[] { return this._eventLog; }
220
+ get events(): readonly SessionEvent[] { return Object.freeze([...this._eventLog]); }
199
221
  get turns(): number { return this._turns; }
200
222
  get totalTokens(): Readonly<{ input: number; output: number }> {
201
223
  return { ...this._totalTokens };
@@ -242,7 +264,7 @@ abstract class BaseCodexSession implements HarnessSession {
242
264
 
243
265
  interrupt(): void {
244
266
  if (!this._inflight) return;
245
- this._rejectInflight(new Error("Turn interrupted"));
267
+ this._rejectInflight(new Error("Local waiter interrupted; native cancellation unacknowledged"), "interrupt-request");
246
268
  }
247
269
 
248
270
  kill(): void {
@@ -254,7 +276,7 @@ abstract class BaseCodexSession implements HarnessSession {
254
276
  this._turnTimer = null;
255
277
  }
256
278
 
257
- this._rejectInflight(new Error("Session killed"));
279
+ this._rejectInflight(new Error("Session killed; native outcome may remain unknown"), "killed");
258
280
  this._rejectQueue(new Error("Session killed"));
259
281
  for (const p of this._pending.values()) p.reject(new Error("Session killed"));
260
282
  this._pending.clear();
@@ -269,6 +291,7 @@ abstract class BaseCodexSession implements HarnessSession {
269
291
  artifact(): SessionArtifact {
270
292
  return {
271
293
  externalSessionId: this._externalSessionId,
294
+ attempts: this.attempts, accounting: "observed-only", diagnostics: this.diagnostics,
272
295
  events: [...this._eventLog],
273
296
  startedAt: this._startedAt,
274
297
  endedAt: this._alive ? undefined : Date.now(),
@@ -285,6 +308,7 @@ abstract class BaseCodexSession implements HarnessSession {
285
308
  // ---------------------------------------------------------------------------
286
309
 
287
310
  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");
288
312
  if (this._proc) throw new Error("Session already started");
289
313
 
290
314
  const args = this._buildSpawnArgs();
@@ -306,14 +330,17 @@ abstract class BaseCodexSession implements HarnessSession {
306
330
  }
307
331
 
308
332
  this._alive = true;
333
+ this._endEmitted = false;
309
334
  this._emit({ kind: "session_start", timestamp: Date.now() });
310
335
 
311
- this._readStdout();
336
+ const proc = this._proc;
337
+ const stdoutDone = this._readStdout(proc);
312
338
  this._readStderr();
313
339
 
314
- this._proc.exited.then((code) => {
315
- if (!this._alive) return;
340
+ proc.exited.then(async (code) => {
341
+ if (this._proc !== proc || !this._alive) return;
316
342
  this._alive = false;
343
+ await stdoutDone; // Drain buffered terminal evidence before interpreting process exit.
317
344
  const errMsg = this._stderr.trim()
318
345
  ? `Process exited (code ${code}): ${this._stderr.trim().slice(0, 500)}`
319
346
  : `Process exited with code ${code}`;
@@ -321,7 +348,7 @@ abstract class BaseCodexSession implements HarnessSession {
321
348
  this._rejectQueue(new Error("Session ended"));
322
349
  for (const p of this._pending.values()) p.reject(new Error("Session ended"));
323
350
  this._pending.clear();
324
- this._emit({ kind: "session_end", timestamp: Date.now() });
351
+ this._emit({ kind: "session_end", timestamp: Date.now(), transportOutcome: "failed" });
325
352
  });
326
353
 
327
354
  await this._handshake();
@@ -333,8 +360,12 @@ abstract class BaseCodexSession implements HarnessSession {
333
360
 
334
361
  async send(
335
362
  message: string,
336
- opts?: { timeout?: number },
363
+ opts?: import("./harness-session").SessionSendOptions,
337
364
  ): 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");
368
+ }
338
369
  if (!this._proc && this._externalSessionId) {
339
370
  await this.start();
340
371
  }
@@ -355,7 +386,11 @@ abstract class BaseCodexSession implements HarnessSession {
355
386
  }
356
387
 
357
388
  return new Promise<SessionResult>((resolve, reject) => {
358
- const turn: QueuedTurn = { message: transformed, timeout, 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;
393
+ }
359
394
  if (!this._inflight) {
360
395
  this._dispatchTurn(turn);
361
396
  } else {
@@ -368,16 +403,35 @@ abstract class BaseCodexSession implements HarnessSession {
368
403
  // Private — turn dispatch + queue (mirrors ClaudeCodeSession)
369
404
  // ---------------------------------------------------------------------------
370
405
 
406
+ readonly admissionProtocol = "prewrite-v1" as const;
407
+ inspectAttempt(admissionId: string) { return this._attempts.find(attempt => attempt.admissionId === admissionId)?.snapshot(); }
408
+
371
409
  private _dispatchTurn(turn: QueuedTurn): void {
372
410
  this._inflight = turn;
373
- this._turnEvents = [];
411
+ 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
+ });
419
+ return;
420
+ }
421
+ this._writeTurn(turn);
422
+ }
423
+
424
+ private _writeTurn(turn: QueuedTurn): void {
425
+ turn.evidence.dispatch = "attempted";
426
+ this._turnEvents = turn.evidence.events;
374
427
  this._resultText = "";
375
428
 
376
429
  // Send via the subclass's wire protocol. The returned promise resolves when
377
430
  // the tools/call (or turn) completes; that resolves the turn.
378
431
  this._sendTurn(turn.message)
379
432
  .then((tokens) => {
380
- if (this._inflight !== turn) return; // interrupted / timed out
433
+ if (this._inflight !== turn) return;
434
+ turn.evidence.rpcSettled = true;
381
435
  if (tokens) {
382
436
  this._totalTokens.input += tokens.input;
383
437
  this._totalTokens.output += tokens.output;
@@ -386,14 +440,16 @@ abstract class BaseCodexSession implements HarnessSession {
386
440
  })
387
441
  .catch((err: Error) => {
388
442
  if (this._inflight !== turn) return;
389
- this._rejectInflight(err);
390
- this._processNextTurn();
443
+ turn.evidence.rpcSettled = true;
444
+ if (turn.evidence.rpcOutcome === "pending") turn.evidence.rpcOutcome = "unknown";
445
+ this._rejectInflight(err, err instanceof NativeValidationError ? "validation" : "rpc");
446
+ this._releaseKnownTurn();
391
447
  });
392
448
 
393
449
  if (turn.timeout > 0) {
394
450
  this._turnTimer = setTimeout(() => {
395
451
  this._turnTimer = null;
396
- this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`));
452
+ this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`), "timeout");
397
453
  }, turn.timeout);
398
454
  }
399
455
  }
@@ -406,38 +462,54 @@ abstract class BaseCodexSession implements HarnessSession {
406
462
  this._turnTimer = null;
407
463
  }
408
464
 
465
+ const a = this._inflight.evidence;
466
+ a.content = this._resultText;
467
+ a.tokens = tokens ?? a.tokens;
468
+ if (a.localOutcome === "pending") {
469
+ a.localOutcome = "resolved";
470
+ this._inflight.resolve(a.result(this._externalSessionId));
471
+ }
472
+ if (a.nativeOutcome === "unknown") this._rejectQueue(new Error("Native terminal missing; queued send not dispatched"));
473
+ this._releaseKnownTurn();
474
+ }
475
+
476
+ private _releaseKnownTurn(): void {
477
+ const a = this._inflight?.evidence;
478
+ if (!a || !this._canReleaseAttempt(a) || a.nativeOutcome === "unknown") return;
409
479
  this._turns++;
410
- const result: SessionResult = {
411
- content: this._resultText,
412
- events: [...this._turnEvents],
413
- tokens: tokens ?? this._turnEvents.find((e) => e.tokens)?.tokens,
414
- externalSessionId: this._externalSessionId,
415
- };
416
- this._inflight.resolve(result);
417
480
  this._inflight = null;
418
-
419
481
  this._processNextTurn();
420
482
  }
421
483
 
484
+ protected _canReleaseAttempt(attempt: TurnState): boolean { return attempt.rpcSettled; }
485
+
422
486
  private _processNextTurn(): void {
423
- if (this._queue.length > 0 && this._alive) {
487
+ if (!this._inflight && this._queue.length > 0 && this._alive) {
424
488
  const next = this._queue.shift()!;
425
489
  this._dispatchTurn(next);
426
490
  }
427
491
  }
428
492
 
429
- protected _rejectInflight(err: Error): void {
493
+ protected _rejectInflight(err: Error, reason: import("./harness-session").SessionAttempt["localFailure"] = "transport"): void {
430
494
  if (!this._inflight) return;
431
- if (this._turnTimer) {
432
- clearTimeout(this._turnTimer);
433
- this._turnTimer = null;
434
- }
435
- this._inflight.reject(err);
436
- this._inflight = null;
495
+ if (this._turnTimer) { clearTimeout(this._turnTimer); this._turnTimer = null; }
496
+ 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"));
500
+ }
501
+
502
+ private _transportFailure(err: Error): void {
503
+ this._alive = false;
504
+ this._rejectInflight(err);
505
+ this._rejectQueue(err);
506
+ this._emit({ kind: "session_end", timestamp: Date.now(), transportOutcome: "failed" });
507
+ for (const p of this._pending.values()) p.reject(err);
508
+ this._pending.clear();
437
509
  }
438
510
 
439
511
  protected _rejectQueue(err: Error): void {
440
- for (const turn of this._queue) turn.reject(err);
512
+ for (const turn of this._queue) turn.reject(turn.evidence.fail(err, "blocked"));
441
513
  this._queue = [];
442
514
  }
443
515
 
@@ -445,8 +517,8 @@ abstract class BaseCodexSession implements HarnessSession {
445
517
  // Private — stdout/stderr readers (mirrors ClaudeCodeSession)
446
518
  // ---------------------------------------------------------------------------
447
519
 
448
- private async _readStdout(): Promise<void> {
449
- const reader = this._proc!.stdout.getReader();
520
+ private async _readStdout(proc: PipedSubprocess): Promise<void> {
521
+ const reader = proc.stdout.getReader();
450
522
  const decoder = new TextDecoder();
451
523
  let buffer = "";
452
524
  try {
@@ -458,12 +530,13 @@ abstract class BaseCodexSession implements HarnessSession {
458
530
  buffer = lines.pop()!;
459
531
  for (const line of lines) {
460
532
  if (!line.trim()) continue;
461
- this._processLine(line);
533
+ if (this._proc === proc || !this._proc) this._processLine(line);
462
534
  }
463
535
  }
464
- if (buffer.trim()) this._processLine(buffer);
536
+ 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"));
465
538
  } catch (err) {
466
- this._rejectInflight(err as Error);
539
+ if (this._proc === proc) this._transportFailure(err as Error);
467
540
  }
468
541
  }
469
542
 
@@ -490,36 +563,42 @@ abstract class BaseCodexSession implements HarnessSession {
490
563
  } catch {
491
564
  return;
492
565
  }
493
- const raw = msg as Record<string, unknown>;
566
+ const raw = object(msg);
567
+ if (!raw) return;
568
+ if (typeof raw.method === "string" && (typeof raw.id === "string" || typeof raw.id === "number") && this._serverRequest(raw)) return;
494
569
 
495
570
  // JSON-RPC response to one of our requests (has matching `id` + result/error).
496
571
  if (typeof raw.id === "number" && (("result" in raw) || ("error" in raw))) {
497
572
  const pending = this._pending.get(raw.id);
498
573
  if (pending) {
499
574
  this._pending.delete(raw.id);
575
+ const a = this._inflight?.evidence;
576
+ if (a?.identity.rpcRequestId === raw.id) a.rpcOutcome = raw.error ? "failed" : "resolved";
500
577
  if ("error" in raw && raw.error) {
501
578
  const e = raw.error as Record<string, unknown>;
502
579
  pending.reject(new Error((e.message as string) ?? JSON.stringify(e)));
503
580
  } else {
504
- pending.resolve(raw.result);
581
+ // Apply protocol identity synchronously before later lines in this
582
+ // 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))); }
505
585
  }
506
586
  return;
507
587
  }
508
588
  }
509
589
 
510
- // Otherwise it's a notification (streamed event) — classify it.
511
590
  const classified = this._classify(raw);
512
591
  for (const event of classified) {
513
- this._emit(event);
514
- this._turnEvents.push(event);
515
- if (event.kind === "result" || event.kind === "text") {
516
- if (event.text) this._resultText = event.text;
517
- }
518
- if (event.tokens) {
519
- this._totalTokens.input += event.tokens.input;
520
- this._totalTokens.output += event.tokens.output;
521
- }
592
+ const a = this._inflight?.evidence;
593
+ if (!a || event.unattributedReason) { this._emit(event); continue; }
594
+ const e = a.record(event);
595
+ 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; }
598
+ this._emit(e);
522
599
  }
600
+ this._releaseKnownTurn();
601
+
523
602
  }
524
603
 
525
604
  // ---------------------------------------------------------------------------
@@ -527,16 +606,19 @@ abstract class BaseCodexSession implements HarnessSession {
527
606
  // ---------------------------------------------------------------------------
528
607
 
529
608
  protected _emit(event: SessionEvent): void {
609
+ event = retainEvidence(event);
610
+ if (event.kind === "session_end") {
611
+ if (this._endEmitted) return;
612
+ this._endEmitted = true;
613
+ }
530
614
  this._eventLog.push(event);
531
615
  for (const handler of this._handlers) {
532
616
  try {
533
- handler(event);
534
- } catch (err) {
535
- console.warn(
536
- `[${this.constructor.name}] handler error:`,
537
- (err as Error).message,
538
- );
539
- }
617
+ const observation: unknown = handler(event);
618
+ if (observation && typeof (observation as PromiseLike<unknown>).then === "function") {
619
+ void Promise.resolve(observation).catch(() => { this._observerFailures.asynchronous++; });
620
+ }
621
+ } catch { this._observerFailures.synchronous++; }
540
622
  }
541
623
  }
542
624
 
@@ -546,6 +628,10 @@ abstract class BaseCodexSession implements HarnessSession {
546
628
 
547
629
  protected _rpcRequest(method: string, params?: unknown): Promise<unknown> {
548
630
  const id = ++this._rpcId;
631
+ if ((method === "tools/call" || method === "turn/start") && this._inflight) {
632
+ this._inflight.evidence.identity = { ...this._inflight.evidence.identity, rpcRequestId: id };
633
+ this._inflight.evidence.rpcOutcome = "pending";
634
+ }
549
635
  const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
550
636
  return new Promise<unknown>((resolve, reject) => {
551
637
  this._pending.set(id, { resolve, reject });
@@ -559,6 +645,8 @@ abstract class BaseCodexSession implements HarnessSession {
559
645
  });
560
646
  }
561
647
 
648
+ protected _serverRequest(_request: Record<string, unknown>): boolean { return false; }
649
+
562
650
  protected _rpcNotify(method: string, params?: unknown): void {
563
651
  const payload = JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n";
564
652
  this._proc!.stdin.write(payload);
@@ -580,8 +668,11 @@ abstract class BaseCodexSession implements HarnessSession {
580
668
  /** Map a streamed notification to SessionEvents. */
581
669
  protected abstract _classify(msg: Record<string, unknown>): SessionEvent[];
582
670
 
671
+ protected _observeRpcResult(_id: number, _result: unknown): void {}
672
+
583
673
  /** Shared fork constructor — subclass passes its own ctor. */
584
674
  fork(opts?: { cwd?: string; baseContext?: string }): HarnessSession {
675
+ if (this._inflight) throw new Error("Cannot fork while native ownership is unresolved");
585
676
  if (!this._externalSessionId) {
586
677
  throw new Error(
587
678
  "Cannot fork — no thread ID yet (send at least one message first)",
@@ -617,6 +708,8 @@ abstract class BaseCodexSession implements HarnessSession {
617
708
  // multi-turn + fork).
618
709
 
619
710
  export class CodexMcpSession extends BaseCodexSession {
711
+ private _mcpAdmission?: string;
712
+ private _mcpCalls = new Map<string, { invocation: Record<string, unknown>; ended: boolean }>();
620
713
  protected _buildSpawnArgs(): string[] {
621
714
  // Disable codex's own approvals + sandbox (the union of
622
715
  // --dangerously-bypass-approvals-and-sandbox) so OUR container is the jail.
@@ -672,14 +765,17 @@ export class CodexMcpSession extends BaseCodexSession {
672
765
  | Record<string, unknown>
673
766
  | undefined;
674
767
  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");
675
769
  if (threadId && !this._externalSessionId) {
676
770
  this._externalSessionId = threadId;
677
771
  }
772
+ if (threadId && this._inflight) this._inflight.evidence.identity = { ...this._inflight.evidence.identity, threadId };
678
773
  // Final text: prefer structuredContent.content, else the tool result content.
679
774
  const finalText =
680
775
  (structured?.content as string | undefined) ??
681
776
  this._extractToolText(result?.content);
682
- if (finalText) this._resultText = finalText;
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; }
683
779
 
684
780
  // Usage, when the call result reports it. Streamed token_count events are
685
781
  // already accounted by the base loop; the call result is the authoritative
@@ -688,7 +784,7 @@ export class CodexMcpSession extends BaseCodexSession {
688
784
  // token event was seen this turn.
689
785
  const sawStreamedTokens = this._turnEvents.some((e) => e.tokens);
690
786
  const usage = structured?.usage as Record<string, number> | undefined;
691
- if (usage && !sawStreamedTokens) {
787
+ if (usage && !sawStreamedTokens && typeof (usage.input_tokens ?? usage.inputTokens) === "number" && typeof (usage.output_tokens ?? usage.outputTokens) === "number") {
692
788
  return {
693
789
  input: (usage.input_tokens ?? usage.inputTokens ?? 0) as number,
694
790
  output: (usage.output_tokens ?? usage.outputTokens ?? 0) as number,
@@ -714,114 +810,365 @@ export class CodexMcpSession extends BaseCodexSession {
714
810
  const params = msg.params as Record<string, unknown> | undefined;
715
811
  const ev = (params?.msg ?? params) as Record<string, unknown> | undefined;
716
812
  if (!ev) return [];
717
- return classifyCodexEvent(ev, "type");
813
+ 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);
817
+ 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");
829
+ }
830
+ if (!a) {
831
+ if (ev.type === "task_complete" && turnId) this._seenTerminals.add(turnId);
832
+ return unowned("no-admission");
833
+ }
834
+ if (ev.type === "session_configured") {
835
+ if (!this._externalSessionId && threadId) this._externalSessionId = threadId;
836
+ a.identity = { ...a.identity, nativeSessionId, threadId };
837
+ }
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");
844
+ this._seenTerminals.add(turnId);
845
+ return [{ ...a.identity, kind: "result", timestamp: Date.now(), nativeOutcome: "completed", terminal: { type: "task_complete", turnId }, raw: ev }];
846
+ }
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") {
850
+ 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");
853
+ if (this._mcpAdmission !== a.identity.admissionId) {
854
+ this._mcpAdmission = a.identity.admissionId; this._mcpCalls.clear();
855
+ }
856
+ // Compare the original argument value, including absence. Never join a result
857
+ // for another server/tool or silently substitute arguments from its envelope.
858
+ const join = { server: invocation.server, tool: invocation.tool, arguments: invocation.arguments };
859
+ 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");
863
+ this._mcpCalls.set(identity.callId, { invocation: structuredClone(join), ended: false });
864
+ const args = publicMcpArguments(invocation.arguments);
865
+ return [{ ...identity, ...tool, kind: "tool_use", timestamp: Date.now(), ...args }];
866
+ }
867
+ if (!prior || !isDeepStrictEqual(prior.invocation, join)) return unowned("unmatched-tool");
868
+ if (prior.ended) return unowned("duplicate-tool");
869
+ const result = publicMcpResult(ev.result);
870
+ if (!result) return unowned("malformed-tool");
871
+ prior.ended = true;
872
+ return [{ ...identity, ...tool, kind: "tool_result", timestamp: Date.now(), ...result }];
873
+ }
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 }];
876
+ }
877
+ // 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");
718
880
  }
719
881
  }
720
882
 
721
883
  // ---------------------------------------------------------------------------
722
- // CodexAppServerSession (EXPERIMENTAL) — `codex app-server` over WebSocket
884
+ // CodexAppServerSession (EXPERIMENTAL) — `codex app-server` over stdio
723
885
  // ---------------------------------------------------------------------------
724
886
  //
725
- // EXPERIMENTAL: the app-server protocol is newer and less battle-tested than
726
- // mcp-server. Prefer CodexMcpSession unless you specifically need app-server.
727
- //
728
- // `codex app-server --listen ws://127.0.0.1:<port>` — localhost needs no auth
729
- // token. JSON-RPC 2.0 with slash-delimited methods:
730
- // initialize → initialized → thread/start (returns thread.id) → turn/start
731
- // Consume turn/started, item/started, item/completed, turn/completed
732
- // notifications (ThreadItem types agentMessage / reasoning / commandExecution;
733
- // ThreadTokenUsage). Mapped to SessionEvents.
734
- //
735
- // We connect over the spawned process's stdin/stdout JSON-RPC for parity with
736
- // the rest of the harness (the WebSocket listen address is for external
737
- // clients; the stdio channel carries the same JSON-RPC frames). The base
738
- // class's read/write loop is reused unchanged.
887
+ // Wire contract: installed codex-cli 0.153.4 v2 JSON schema. Controlled tests
888
+ // establish source behavior, not a real create/resume/native-completion capture.
889
+ // No default switch. Full history hydration, native fork and cancellation remain
890
+ // separate integration work; a local interrupt never acknowledges native stop.
891
+
892
+ class NativeValidationError extends Error {}
739
893
 
740
894
  export class CodexAppServerSession extends BaseCodexSession {
741
- /** Resolves when the in-flight turn's `turn/completed` arrives. */
742
- private _turnDone?: {
743
- resolve: (t: { input: number; output: number } | undefined) => void;
744
- reject: (e: Error) => void;
745
- };
895
+ readonly appServerProtocol = "owned-thread-v1";
896
+ private readonly _options: NonNullable<CodexSessionConfig["appServer"]>;
897
+ private readonly _onThreadReady?: (binding: string) => void | Promise<void>;
898
+ private _startedOnce = false;
899
+ private _threadReady = false;
900
+ private _nativeSessionId?: string;
901
+ private _appAdmission?: string;
902
+ private _turnDone?: () => void;
903
+ private _items = new Map<string, { type: string; input?: unknown; completed: boolean; server?: string; tool?: string; text?: string }>();
904
+
905
+ constructor(config?: CodexSessionConfig) {
906
+ super(config);
907
+ const {onThreadReady,...options}=config?.appServer??{};
908
+ this._onThreadReady=onThreadReady;
909
+ this._options = retainEvidence(options);
910
+ 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");
913
+ }
914
+
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"); }
916
+
917
+ 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");
920
+ this._startedOnce = true;
921
+ try { await super.start(); }
922
+ catch(error) { this.kill(); throw error; }
923
+ }
924
+
925
+ protected override _serverRequest(request: Record<string, unknown>): boolean {
926
+ // JSON-RPC's explicit method-not-supported refusal also covers unknown future
927
+ // 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" } });
935
+ return true;
936
+ }
937
+
938
+ private _refuse(reason: string, status = "unknown"): never {
939
+ 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`);
943
+ }
944
+
945
+ private async _readiness(): Promise<void> {
946
+ const required = this._options.requiredMcpServer;
947
+ if (!required) return;
948
+ let cursor: string | undefined;
949
+ const seen = new Set<string>(); let found: Record<string, unknown> | undefined;
950
+ // Pagination only, never polling/retrying a starting or failed native server.
951
+ 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");
954
+ for (const entry of result.data) {
955
+ const server = object(entry);
956
+ if (server?.name === required.name) { if (found) this._refuse("mcp-inventory-duplicate"); found = server; }
957
+ }
958
+ if (result.nextCursor == null) {
959
+ 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");
962
+ 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] } });
964
+ return;
965
+ }
966
+ if (typeof result.nextCursor !== "string" || !result.nextCursor || seen.has(result.nextCursor)) this._refuse("mcp-inventory-cursor");
967
+ cursor = result.nextCursor; seen.add(cursor);
968
+ }
969
+ this._refuse("mcp-inventory-page-limit");
970
+ }
971
+
972
+ protected override _canReleaseAttempt(a: TurnState): boolean {
973
+ // An observer/write failure may occur after delivery. A terminal alone does
974
+ // not settle the unanswered turn/start call or authorize another admission.
975
+ return a.rpcSettled && (a.rpcOutcome === "resolved" || a.rpcOutcome === "failed");
976
+ }
746
977
 
747
978
  protected _buildSpawnArgs(): string[] {
748
- // app-server on localhost needs no auth token. Approvals/sandbox are set
749
- // per-thread in thread/start (approvalPolicy / sandbox below).
750
- return ["app-server", "--listen", "ws://127.0.0.1:0"];
979
+ return ["app-server", "--listen", "stdio://"];
751
980
  }
752
981
 
753
982
  protected async _handshake(): Promise<void> {
754
- await this._rpcRequest("initialize", {
755
- protocolVersion: "2025-06-18",
983
+ this._threadReady = false;
984
+ const initialized = object(await this._rpcRequest("initialize", {
756
985
  capabilities: {},
757
986
  clientInfo: { name: "inixiative-bench", version: "0.1.0" },
758
- });
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");
759
990
  this._rpcNotify("initialized");
760
991
  }
761
992
 
762
993
  protected async _sendTurn(
763
994
  message: string,
764
995
  ): Promise<{ input: number; output: number } | undefined> {
765
- // Start (or reuse) a thread. Disable codex's approvals + sandbox so OUR
766
- // container is the only jail.
767
- if (!this._externalSessionId) {
768
- const started = (await this._rpcRequest("thread/start", {
996
+ const a = this._inflight!.evidence;
997
+ if (!this._threadReady) {
998
+ const binding = this._externalSessionId;
999
+ const method = binding ? "thread/resume" : "thread/start";
1000
+ const response = object(await this._rpcRequest(method, {
1001
+ ...(binding ? { threadId: binding } : {}),
769
1002
  model: this._model,
770
1003
  cwd: this._cwd,
771
1004
  approvalPolicy: "never",
772
1005
  sandbox: "danger-full-access",
773
- ...(this._effort ? { modelReasoningEffort: this._effort } : {}),
774
- })) as Record<string, unknown> | undefined;
775
- const thread = started?.thread as Record<string, unknown> | undefined;
776
- const id = (thread?.id ?? started?.threadId) as string | undefined;
777
- if (id) this._externalSessionId = id;
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");
1010
+ // Resume may rejoin a live thread. Never steer known pre-existing work.
1011
+ // The installed schema requires status. Missing/unknown state cannot
1012
+ // 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");
1019
+ 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} : {}) };
1022
+ if (this._onThreadReady) {
1023
+ try { await this._onThreadReady(id); }
1024
+ catch (cause) { throw new NativeValidationError("Native binding persistence failed before turn/start", {cause}); }
1025
+ }
1026
+ this._threadReady = true;
1027
+ // Configuration belongs to this process/thread, never to the next turn.
1028
+ // 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) } } });
778
1036
  }
779
-
780
- const completed = new Promise<{ input: number; output: number } | undefined>(
781
- (resolve, reject) => {
782
- this._turnDone = { resolve, reject };
783
- },
784
- );
785
-
786
- // turn/start streams turn/started, item/*, turn/completed back as
787
- // notifications, consumed in _classify; turn/completed resolves the turn.
1037
+ a.identity = { ...a.identity, threadId: this._externalSessionId, ...(this._nativeSessionId ? { nativeSessionId: this._nativeSessionId } : {}) };
1038
+ await this._readiness();
1039
+ // 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; });
788
1044
  await this._rpcRequest("turn/start", {
789
1045
  threadId: this._externalSessionId,
790
- input: message,
1046
+ input: [{ type: "text", text: message }],
1047
+ ...(this._effort ? { effort: this._effort } : {}),
791
1048
  });
1049
+ await completed; // Native terminal and RPC acknowledgment are both required.
1050
+ return a.tokens;
1051
+ }
792
1052
 
793
- return completed;
1053
+ protected override _observeRpcResult(id: number, result: unknown): void {
1054
+ const a = this._inflight?.evidence;
1055
+ if (!a || this._appAdmission !== a.admissionId || a.identity.rpcRequestId !== id) return;
1056
+ 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" };
1060
+ }
1061
+
1062
+ private _turn(value: unknown): { id: string; status: string; error?: unknown } | undefined {
1063
+ 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;
1066
+ return turn as { id: string; status: string; error?: unknown };
794
1067
  }
795
1068
 
796
1069
  protected _classify(msg: Record<string, unknown>): SessionEvent[] {
797
- const method = msg.method as string | undefined;
1070
+ const method = typeof msg.method === "string" ? msg.method : undefined;
798
1071
  if (!method) return [];
799
- const params = (msg.params ?? {}) as Record<string, unknown>;
800
-
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
+ }];
1080
+ 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");
1087
+ const identity = { ...a.identity, threadId, turnId, ...(itemId ? { itemId } : {}) };
1088
+ const status = (): SessionEvent[] => [{ ...identity, kind: "native_status", timestamp: Date.now(), raw: { method } }];
801
1089
  if (method === "turn/completed") {
802
- const usage = (params.usage ?? params.tokenUsage) as
803
- | Record<string, number>
804
- | undefined;
805
- const tokens = usage
806
- ? {
807
- input: (usage.inputTokens ?? usage.input_tokens ?? 0) as number,
808
- output: (usage.outputTokens ?? usage.output_tokens ?? 0) as number,
809
- }
810
- : undefined;
811
- this._turnDone?.resolve(tokens);
1090
+ if (!turn || turn.status === "inProgress") return unowned("unrecognized-event");
1091
+ const error = object(turn.error);
1092
+ if (turn.error != null && typeof error?.message !== "string") return unowned("unrecognized-event");
1093
+ this._seenTerminals.add(turnId);
1094
+ this._turnDone?.();
812
1095
  this._turnDone = undefined;
813
- // Note: no `tokens` on this event — _sendTurn returns them to _dispatchTurn,
814
- // which does the accounting once. Putting tokens here too would double-count.
815
- return [{ kind: "result", timestamp: Date.now(), text: this._resultText, raw: msg }];
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 } : {}) } }];
1099
+ }
1100
+ if (method === "thread/tokenUsage/updated") {
1101
+ 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");
1104
+ // `last` is a snapshot, not an additive delta. Total accounting happens once
1105
+ // when the original native turn and RPC settle; absent usage stays absent.
1106
+ a.tokens = { input: last.inputTokens, output: last.outputTokens };
1107
+ return status();
1108
+ }
1109
+ if (method === "item/agentMessage/delta" || method === "item/commandExecution/outputDelta") {
1110
+ if (!itemId || typeof params?.delta !== "string") return unowned("unrecognized-event");
1111
+ 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 }];
1117
+ }
1118
+ if (prior?.type !== "commandExecution") return unowned("unmatched-tool");
1119
+ return [{ ...identity, kind: "native_status", timestamp: Date.now(), toolName: "shell", text: params.delta }];
816
1120
  }
817
-
818
1121
  if (method === "item/completed" || method === "item/started") {
819
- const item = (params.item ?? params) as Record<string, unknown>;
820
- return classifyCodexEvent(item, "type");
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");
1127
+ this._items.set(itemId, { type: item.type, completed: true });
1128
+ return [{ ...identity, kind: "text", timestamp: Date.now(), text: item.text }];
1129
+ }
1130
+ if (item.type === "commandExecution") {
1131
+ if (typeof item.command !== "string") return unowned("malformed-tool");
1132
+ const prior = this._items.get(itemId);
1133
+ if (method === "item/started") {
1134
+ if (prior) return unowned("duplicate-tool");
1135
+ 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 } }];
1137
+ }
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");
1141
+ 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) }];
1145
+ }
1146
+ if (item.type === "mcpToolCall") {
1147
+ if (typeof item.server !== "string" || !item.server || typeof item.tool !== "string" || !item.tool) return unowned("malformed-tool");
1148
+ 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) }];
1155
+ }
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);
1160
+ const output = result ? publicMcpResult({ Ok: { content: result.content } }) : undefined;
1161
+ if (item.error != null && typeof error?.message !== "string") return unowned("malformed-tool");
1162
+ 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 } : {}) }];
1167
+ }
1168
+ // Reasoning and unsupported items retain safe identity/type diagnostics only.
1169
+ return unowned("unrecognized-event");
821
1170
  }
822
-
823
- // turn/started and other lifecycle notifications carry no turn content.
824
- return [];
1171
+ return method === "turn/started" ? status() : unowned("unrecognized-event");
825
1172
  }
826
1173
  }
827
1174
 
@@ -838,6 +1185,50 @@ export class CodexAppServerSession extends BaseCodexSession {
838
1185
  // command_execution / commandExecution → tool_use (+ tool_result when done)
839
1186
  // token_count / usage → tokens (attached to a result event)
840
1187
 
1188
+ const object = (value: unknown): Record<string, unknown> | undefined =>
1189
+ value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
1190
+
1191
+ function publicMcpArguments(value: unknown): Pick<SessionEvent, "toolInput" | "toolInputOmitted"> {
1192
+ if (!object(value)) return { toolInputOmitted: "Native arguments absent or not an object" };
1193
+ let omitted = false;
1194
+ 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
+ }));
1203
+ };
1204
+ const toolInput = clean(value) as Record<string, unknown>;
1205
+ return { toolInput, ...(omitted ? { toolInputOmitted: "Non-public or unsupported argument fields omitted" } : {}) };
1206
+ }
1207
+
1208
+ /** Rust Result<CallToolResult,String> from protocol.rs. This is legacy event
1209
+ * normalization, not the app-server ThreadItem schema. Keep all public text
1210
+ * blocks (including the bridge's JSON receipt), not arbitrary envelope fields. */
1211
+ function publicMcpResult(value: unknown): Pick<SessionEvent, "toolOutput" | "toolError" | "toolOutputOmitted"> | undefined {
1212
+ const result = object(value);
1213
+ if (!result || Object.keys(result).length !== 1) return;
1214
+ if (typeof result.Err === "string") return { toolOutput: result.Err, toolError: true };
1215
+ 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 => {
1219
+ 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 };
1223
+ }
1224
+ omitted = true;
1225
+ // 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" };
1227
+ });
1228
+ return { toolOutput: JSON.stringify({ content, ...(ok.isError !== undefined ? { isError: ok.isError } : {}) }),
1229
+ ...(ok.isError !== undefined ? { toolError: ok.isError as boolean } : {}), ...(omitted ? { toolOutputOmitted: true } : {}) };
1230
+ }
1231
+
841
1232
  function classifyCodexEvent(
842
1233
  ev: Record<string, unknown>,
843
1234
  tag: string,
@@ -874,7 +1265,7 @@ function classifyCodexEvent(
874
1265
  ) {
875
1266
  const command = (ev.command ?? ev.cmd) as string | string[] | undefined;
876
1267
  const cmdStr = Array.isArray(command) ? command.join(" ") : command;
877
- events.push({
1268
+ if (type !== "exec_command_end") events.push({
878
1269
  kind: "tool_use",
879
1270
  timestamp: ts,
880
1271
  toolName: "shell",