@inixiative/agent-session 0.5.2 → 0.6.1

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,35 +1,19 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
2
  import { codexLimitSnapshot, type LimitSnapshot, mergeLimits } from './limits';
3
3
  import { retainEvidence } from './retained-evidence';
4
+ import { ToolGates } from './tool-gate';
4
5
  import { TRANSPORTS } from './transport';
5
6
  import { TurnState } from './turn-state';
6
7
  // ---------------------------------------------------------------------------
7
- // CodexSession — long-lived OpenAI Codex CLI process with full event capture
8
- // ---------------------------------------------------------------------------
9
- //
10
- // Two implementations of the SAME HarnessSession interface, mirroring
11
- // ClaudeCodeSession. Both keep one persistent `codex` process alive and stream
12
- // turns over a JSON-RPC channel on stdin/stdout, classifying every event into
13
- // the shared SessionEvent taxonomy and resolving a turn on completion.
14
- //
15
- // CodexMcpSession (default) — `codex mcp-server` (stdio MCP JSON-RPC)
16
- // CodexAppServerSession (experimental) — `codex app-server` (stdio JSON-RPC)
17
- //
18
- // Both DISABLE codex's own approvals + sandbox (the union of
19
- // `--dangerously-bypass-approvals-and-sandbox`) so OUR container is the only
20
- // jail — identical intent to ClaudeCodeSession's `bypassPermissions`.
21
- //
22
- // Architecture (mirrors claude-code-session.ts):
23
- // start() → spawn one process, start background stdout reader, MCP handshake
24
- // send() → JSON-RPC tools/call (codex / codex-reply), resolve on completion
25
- // fork() → new (unstarted) session resuming from the captured threadId
26
- // kill() → close stdin, kill process
8
+ // CodexAppServerSession — one persistent `codex app-server --listen stdio://`
9
+ // process per session, driven over JSON-RPC (thread/start, thread/resume,
10
+ // turn/start). Every notification is classified into the shared SessionEvent
11
+ // taxonomy; a turn resolves once its native terminal and RPC have both settled.
12
+ // Approvals and sandbox default to never / danger-full-access (the container
13
+ // is the jail); appServer.approvalPolicy routes approvals to onToolRequest().
14
+ // The native thread ID is the externalSessionId used for cold resume.
27
15
  //
28
- // codex's native "session ID" is the threadId returned by the `codex` tool's
29
- // structuredContent — captured into externalSessionId and used for multi-turn
30
- // (`codex-reply`) and fork.
31
- //
32
- // Verified against codex 0.140.
16
+ // Wire contract: codex-cli 0.155.1 v2 JSON schema.
33
17
  // ---------------------------------------------------------------------------
34
18
 
35
19
  import type {
@@ -40,6 +24,7 @@ import type {
40
24
  SessionEvent,
41
25
  SessionEventHandler,
42
26
  SessionResult,
27
+ ToolGate,
43
28
  } from './harness-session';
44
29
 
45
30
  // Re-export types so importers can stay on one path (mirrors claude-code-session).
@@ -63,6 +48,40 @@ export interface PipedSubprocess {
63
48
  kill(): void;
64
49
  }
65
50
 
51
+ export type CodexApprovalPolicy = 'never' | 'on-request' | 'untrusted';
52
+ export type CodexSandbox = 'read-only' | 'workspace-write' | 'danger-full-access';
53
+ export type HostPermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions';
54
+
55
+ /**
56
+ * The app-server approval policy and sandbox for a host permission mode (Claude's mode ids).
57
+ * `acceptEdits` maps like `default`: under workspace-write, edits inside the workspace are never
58
+ * asked about; only escalations (writes outside it, commands needing more access) reach the gates.
59
+ */
60
+ export function codexPermissions(mode: HostPermissionMode): {
61
+ approvalPolicy: CodexApprovalPolicy;
62
+ sandbox: CodexSandbox;
63
+ } {
64
+ switch (mode) {
65
+ case 'default':
66
+ case 'acceptEdits':
67
+ return { approvalPolicy: 'on-request', sandbox: 'workspace-write' };
68
+ case 'plan':
69
+ return { approvalPolicy: 'never', sandbox: 'read-only' };
70
+ case 'bypassPermissions':
71
+ return { approvalPolicy: 'never', sandbox: 'danger-full-access' };
72
+ }
73
+ }
74
+
75
+ const SANDBOX_TYPES: Record<CodexSandbox, string> = {
76
+ 'read-only': 'readOnly',
77
+ 'workspace-write': 'workspaceWrite',
78
+ 'danger-full-access': 'dangerFullAccess',
79
+ };
80
+ const APPROVAL_METHODS = new Set([
81
+ 'item/commandExecution/requestApproval',
82
+ 'item/fileChange/requestApproval',
83
+ ]);
84
+
66
85
  export type CodexSpawn = (
67
86
  cmd: string[],
68
87
  opts: { cwd: string; env: Record<string, string | undefined> },
@@ -73,14 +92,24 @@ export type CodexSpawn = (
73
92
  // ---------------------------------------------------------------------------
74
93
 
75
94
  export interface CodexSessionConfig {
76
- /** Explicit app-server integration. Never applied by the default MCP engine.
77
- * Config is native launch data, not event/recording data. */
95
+ /** Thread launch options. Config is native launch data, not event/recording data. */
78
96
  appServer?: {
79
97
  /** Persist the actual returned binding before model admission; failure blocks work. */
80
98
  onThreadReady?: (binding: string) => void | Promise<void>;
81
99
  requireConfiguration?: boolean;
82
100
  config?: Readonly<Record<string, unknown>>;
83
101
  requiredMcpServer?: { readonly name: string; readonly tools: readonly string[] };
102
+ /**
103
+ * Native approval policy and sandbox for the thread. Default `never` / `danger-full-access`.
104
+ * With `on-request` or `untrusted`, approval requests go to the gates registered through
105
+ * onToolRequest() (stage `permission` only; Codex has no pre-use stage); with none registered
106
+ * they are declined. The thread is started with `approvalsReviewer: "user"` and refused if the
107
+ * runtime reports another reviewer (a config.toml `approvals_reviewer` would bypass the gates).
108
+ * Under `never` nothing reaches a gate and onToolRequest() throws.
109
+ * `codexPermissions(mode)` maps the host permission modes.
110
+ */
111
+ approvalPolicy?: CodexApprovalPolicy;
112
+ sandbox?: CodexSandbox;
84
113
  };
85
114
  /** Path to codex CLI binary. Defaults to "codex". */
86
115
  bin?: string;
@@ -97,14 +126,13 @@ export interface CodexSessionConfig {
97
126
  timeout?: number;
98
127
  /**
99
128
  * Base context to pre-load. Codex has no `--append-system-prompt`; we prepend
100
- * it to the first turn's prompt (and keep it for fork). Persists logically for
101
- * the session via the continued thread.
129
+ * it to the first turn's prompt. Persists logically for the session via the
130
+ * continued thread.
102
131
  */
103
132
  baseContext?: string;
104
133
  /**
105
- * codex's native thread ID (the value the `codex` tool returns). When set, the
106
- * first send() uses `codex-reply` on MCP (loaded threads only), or
107
- * `thread/resume` on app-server. Only app-server supports cold resume by ID.
134
+ * codex's native thread ID. When set, the first send() resumes it with
135
+ * `thread/resume` (cold resume by ID).
108
136
  */
109
137
  externalSessionId?: string;
110
138
  /** Environment merged over process.env (e.g. CODEX_HOME for a profile). */
@@ -133,64 +161,58 @@ interface QueuedTurn {
133
161
  // codex reasoning-effort levels (model_reasoning_effort), low→high.
134
162
  const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh'];
135
163
 
136
- // ---------------------------------------------------------------------------
137
- // Base class — shared queue, event log, classification helpers, lifecycle
138
- // ---------------------------------------------------------------------------
139
- //
140
- // CodexMcpSession and CodexAppServerSession differ only in the wire protocol
141
- // (how start() handshakes, how a turn is sent, and how a raw message maps to
142
- // SessionEvents). Everything else — the turn queue, token accounting, the
143
- // stdout read loop, event emission, fork, artifact — is shared here, exactly as
144
- // ClaudeCodeSession structures it.
164
+ class NativeValidationError extends Error {}
165
+
166
+ export class CodexAppServerSession implements HarnessSession {
167
+ get transport() {
168
+ return TRANSPORTS['codex-app-server'];
169
+ }
170
+ readonly appServerProtocol = 'owned-thread-v1';
145
171
 
146
- abstract class BaseCodexSession implements HarnessSession {
147
172
  // -- Config --
148
- protected _bin: string;
149
- protected _model: string;
150
- protected _effort?: string;
151
- protected _cwd: string;
152
- protected _defaultTimeout: number;
153
- protected _baseContext?: string;
154
- protected _spawn?: CodexSpawn;
155
- protected _env?: Record<string, string | undefined>;
173
+ private _bin: string;
174
+ private _model: string;
175
+ private _effort?: string;
176
+ private _cwd: string;
177
+ private _defaultTimeout: number;
178
+ private _baseContext?: string;
179
+ private _spawn?: CodexSpawn;
180
+ private _env?: Record<string, string | undefined>;
156
181
 
157
182
  // -- Process --
158
- protected _proc: PipedSubprocess | null = null;
159
- protected _stderr = '';
183
+ private _proc: PipedSubprocess | null = null;
184
+ private _stderr = '';
160
185
 
161
186
  // -- Session state --
162
- protected _externalSessionId?: string;
163
- protected _alive = false;
187
+ private _externalSessionId?: string;
188
+ private _alive = false;
164
189
  private _endEmitted = false;
165
- protected _eventLog: SessionEvent[] = [];
166
- protected _handlers: SessionEventHandler[] = [];
190
+ private _eventLog: SessionEvent[] = [];
191
+ private _handlers: SessionEventHandler[] = [];
167
192
  private _observerFailures = { synchronous: 0, asynchronous: 0 };
168
- protected _beforeSendHooks: BeforeSendHook[] = [];
169
- protected _turns = 0;
170
- protected _totalTokens = { input: 0, output: 0 };
171
- protected _startedAt: number;
193
+ private _beforeSendHooks: BeforeSendHook[] = [];
194
+ private _turns = 0;
195
+ private _totalTokens = { input: 0, output: 0 };
196
+ private _startedAt: number;
172
197
  /** Whether baseContext has been prepended onto a sent turn yet. */
173
- protected _injectedBaseContext = false;
174
- /** Whether this session was created via fork() (continues a thread). */
175
- protected _forking = false;
198
+ private _injectedBaseContext = false;
176
199
 
177
200
  // -- JSON-RPC --
178
201
  /** Monotonic JSON-RPC request id. */
179
- protected _rpcId = 0;
202
+ private _rpcId = 0;
180
203
  /** Pending JSON-RPC responses, keyed by request id. */
181
- protected _pending = new Map<
204
+ private _pending = new Map<
182
205
  number,
183
206
  { resolve: (result: unknown) => void; reject: (e: Error) => void }
184
207
  >();
185
208
 
186
209
  // -- Turn queue (identical to ClaudeCodeSession) --
187
- protected _queue: QueuedTurn[] = [];
188
- protected _attempts: TurnState[] = [];
189
- protected _seenTerminals = new Set<string>();
190
- protected _inflight: QueuedTurn | null = null;
191
- protected _turnEvents: SessionEvent[] = [];
192
- protected _resultText = '';
193
- protected _turnTimer: ReturnType<typeof setTimeout> | null = null;
210
+ private _queue: QueuedTurn[] = [];
211
+ private _attempts: TurnState[] = [];
212
+ private _seenTerminals = new Set<string>();
213
+ private _inflight: QueuedTurn | null = null;
214
+ private _resultText = '';
215
+ private _turnTimer: ReturnType<typeof setTimeout> | null = null;
194
216
 
195
217
  constructor(config?: CodexSessionConfig) {
196
218
  const bin = config?.bin ?? 'codex';
@@ -212,6 +234,17 @@ abstract class BaseCodexSession implements HarnessSession {
212
234
  this._spawn = config?.spawn;
213
235
  this._env = config?.env;
214
236
  this._startedAt = Date.now();
237
+ const { onThreadReady, ...options } = config?.appServer ?? {};
238
+ this._onThreadReady = onThreadReady;
239
+ this._options = retainEvidence(options);
240
+ const required = this._options.requiredMcpServer;
241
+ if (
242
+ required &&
243
+ (!/^[a-zA-Z0-9_-]+$/.test(required.name) ||
244
+ !required.tools.length ||
245
+ required.tools.some((t) => !/^[a-zA-Z0-9_-]+$/.test(t)))
246
+ )
247
+ throw new NativeValidationError('Invalid required MCP inventory');
215
248
  }
216
249
 
217
250
  // ---------------------------------------------------------------------------
@@ -264,21 +297,6 @@ abstract class BaseCodexSession implements HarnessSession {
264
297
  };
265
298
  }
266
299
 
267
- /**
268
- * Mid-turn push. Like Claude Code's stream-json stdin, codex's tools/call has
269
- * no out-of-band signal channel mid-turn; we emit a "push_ignored" error event
270
- * so callers observe the attempt without the model seeing the payload until the
271
- * next turn.
272
- */
273
- async push(payload: { kind: string; text: string }): Promise<void> {
274
- this._emit({
275
- kind: 'error',
276
- timestamp: Date.now(),
277
- text: `push_ignored: kind=${payload.kind} — codex turn has no OOB channel`,
278
- raw: payload,
279
- });
280
- }
281
-
282
300
  // ---------------------------------------------------------------------------
283
301
  // interrupt / kill / artifact (identical to ClaudeCodeSession)
284
302
  // ---------------------------------------------------------------------------
@@ -304,6 +322,7 @@ abstract class BaseCodexSession implements HarnessSession {
304
322
  this._rejectQueue(new Error('Session killed'));
305
323
  for (const p of this._pending.values()) p.reject(new Error('Session killed'));
306
324
  this._pending.clear();
325
+ this._withdrawRequests();
307
326
 
308
327
  try {
309
328
  this._proc.stdin.end();
@@ -338,10 +357,24 @@ abstract class BaseCodexSession implements HarnessSession {
338
357
  }
339
358
 
340
359
  // ---------------------------------------------------------------------------
341
- // start() — spawn the persistent process + handshake (subclass-specific)
360
+ // start() — spawn the persistent process + handshake
342
361
  // ---------------------------------------------------------------------------
343
362
 
344
363
  async start(): Promise<void> {
364
+ if (this._inflight)
365
+ throw Error('Native call ownership unresolved; automatic resume is blocked');
366
+ if (this._startedOnce && !this._alive)
367
+ throw Error('App-server restart requires a new owned instance and preflight');
368
+ this._startedOnce = true;
369
+ try {
370
+ await this._launch();
371
+ } catch (error) {
372
+ this.kill();
373
+ throw error;
374
+ }
375
+ }
376
+
377
+ private async _launch(): Promise<void> {
345
378
  if (this._attempts.some((a) => a.dispatch === 'attempted' && a.nativeOutcome === 'unknown'))
346
379
  throw new Error('Native outcome unresolved; automatic resume is blocked');
347
380
  if (this._proc) throw new Error('Session already started');
@@ -388,6 +421,7 @@ abstract class BaseCodexSession implements HarnessSession {
388
421
  this._rejectQueue(new Error('Session ended'));
389
422
  for (const p of this._pending.values()) p.reject(new Error('Session ended'));
390
423
  this._pending.clear();
424
+ this._withdrawRequests();
391
425
  this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
392
426
  });
393
427
 
@@ -502,11 +536,9 @@ abstract class BaseCodexSession implements HarnessSession {
502
536
 
503
537
  private _writeTurn(turn: QueuedTurn): void {
504
538
  turn.evidence.dispatch = 'attempted';
505
- this._turnEvents = turn.evidence.events;
506
539
  this._resultText = '';
507
540
 
508
- // Send via the subclass's wire protocol. The returned promise resolves when
509
- // the tools/call (or turn) completes; that resolves the turn.
541
+ // Resolves once the native terminal and the turn/start RPC have both settled.
510
542
  this._sendTurn(turn.message)
511
543
  .then((tokens) => {
512
544
  if (this._inflight !== turn) return;
@@ -561,10 +593,6 @@ abstract class BaseCodexSession implements HarnessSession {
561
593
  this._processNextTurn();
562
594
  }
563
595
 
564
- protected _canReleaseAttempt(attempt: TurnState): boolean {
565
- return attempt.rpcSettled;
566
- }
567
-
568
596
  private _processNextTurn(): void {
569
597
  if (!this._inflight && this._queue.length > 0 && this._alive) {
570
598
  const next = this._queue.shift()!;
@@ -572,7 +600,7 @@ abstract class BaseCodexSession implements HarnessSession {
572
600
  }
573
601
  }
574
602
 
575
- protected _rejectInflight(
603
+ private _rejectInflight(
576
604
  err: Error,
577
605
  reason: import('./harness-session').SessionAttempt['localFailure'] = 'transport',
578
606
  ): void {
@@ -595,9 +623,10 @@ abstract class BaseCodexSession implements HarnessSession {
595
623
  this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
596
624
  for (const p of this._pending.values()) p.reject(err);
597
625
  this._pending.clear();
626
+ this._withdrawRequests();
598
627
  }
599
628
 
600
- protected _rejectQueue(err: Error): void {
629
+ private _rejectQueue(err: Error): void {
601
630
  for (const turn of this._queue) turn.reject(turn.evidence.fail(err, 'blocked'));
602
631
  this._queue = [];
603
632
  }
@@ -651,7 +680,7 @@ abstract class BaseCodexSession implements HarnessSession {
651
680
  }
652
681
 
653
682
  // ---------------------------------------------------------------------------
654
- // Private — JSON-RPC line processor (shared envelope; payload is per-protocol)
683
+ // Private — JSON-RPC line processor
655
684
  // ---------------------------------------------------------------------------
656
685
 
657
686
  private _processLine(line: string): void {
@@ -720,7 +749,7 @@ abstract class BaseCodexSession implements HarnessSession {
720
749
  // Private — emit (identical to ClaudeCodeSession)
721
750
  // ---------------------------------------------------------------------------
722
751
 
723
- protected _emit(event: SessionEvent): void {
752
+ private _emit(event: SessionEvent): void {
724
753
  event = retainEvidence(event);
725
754
  if (event.kind === 'session_end') {
726
755
  if (this._endEmitted) return;
@@ -745,9 +774,9 @@ abstract class BaseCodexSession implements HarnessSession {
745
774
  // Private — JSON-RPC request helper (resolves on the matching response)
746
775
  // ---------------------------------------------------------------------------
747
776
 
748
- protected _rpcRequest(method: string, params?: unknown): Promise<unknown> {
777
+ private _rpcRequest(method: string, params?: unknown): Promise<unknown> {
749
778
  const id = ++this._rpcId;
750
- if ((method === 'tools/call' || method === 'turn/start') && this._inflight) {
779
+ if (method === 'turn/start' && this._inflight) {
751
780
  this._inflight.evidence.identity = { ...this._inflight.evidence.identity, rpcRequestId: id };
752
781
  this._inflight.evidence.rpcOutcome = 'pending';
753
782
  }
@@ -764,343 +793,16 @@ abstract class BaseCodexSession implements HarnessSession {
764
793
  });
765
794
  }
766
795
 
767
- protected _serverRequest(_request: Record<string, unknown>): boolean {
768
- return false;
769
- }
770
-
771
- protected _rpcNotify(method: string, params?: unknown): void {
796
+ private _rpcNotify(method: string, params?: unknown): void {
772
797
  const payload = `${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`;
773
798
  this._proc!.stdin.write(payload);
774
799
  this._proc!.stdin.flush();
775
800
  }
776
801
 
777
802
  // ---------------------------------------------------------------------------
778
- // Subclass hooks — the only protocol-specific surface
803
+ // Account limits, native interrupt, steer
779
804
  // ---------------------------------------------------------------------------
780
805
 
781
- /** CLI args for the persistent process. */
782
- protected abstract _buildSpawnArgs(): string[];
783
- /** Protocol handshake after spawn (initialize / initialized / etc.). */
784
- protected abstract _handshake(): Promise<void>;
785
- /** Send one turn; resolve with token usage when the turn completes. */
786
- protected abstract _sendTurn(
787
- message: string,
788
- ): Promise<{ input: number; output: number } | undefined>;
789
- /** Map a streamed notification to SessionEvents. */
790
- protected abstract _classify(msg: Record<string, unknown>): SessionEvent[];
791
-
792
- protected _observeRpcResult(_id: number, _result: unknown): void {}
793
-
794
- /** Shared fork constructor — subclass passes its own ctor. */
795
- fork(opts?: { cwd?: string; baseContext?: string }): HarnessSession {
796
- if (this._inflight) throw new Error('Cannot fork while native ownership is unresolved');
797
- if (!this._externalSessionId) {
798
- throw new Error('Cannot fork — no thread ID yet (send at least one message first)');
799
- }
800
- const Ctor = this.constructor as new (c?: CodexSessionConfig) => BaseCodexSession;
801
- const forked = new Ctor({
802
- bin: this._bin,
803
- model: this._model,
804
- effort: this._effort,
805
- cwd: opts?.cwd ?? this._cwd,
806
- timeout: this._defaultTimeout,
807
- baseContext: opts?.baseContext ?? this._baseContext,
808
- externalSessionId: this._externalSessionId,
809
- spawn: this._spawn,
810
- env: this._env,
811
- });
812
- forked._forking = true;
813
- return forked;
814
- }
815
- }
816
-
817
- // ---------------------------------------------------------------------------
818
- // CodexMcpSession (DEFAULT) — `codex mcp-server` over stdio MCP JSON-RPC
819
- // ---------------------------------------------------------------------------
820
- //
821
- // Handshake: initialize → notifications/initialized → tools/list.
822
- // A turn is an MCP `tools/call`:
823
- // - first turn → tool "codex" (params: prompt, model, cwd, ...)
824
- // - subsequent turns → tool "codex-reply" (params: prompt, threadId)
825
- // During the call, codex streams `codex/event` notifications (agent message,
826
- // reasoning, command execution, token usage) — mapped to SessionEvents. The
827
- // threadId comes back in the call result's structuredContent (captured for
828
- // multi-turn + fork).
829
-
830
- export class CodexMcpSession extends BaseCodexSession {
831
- get transport() {
832
- return TRANSPORTS['codex-mcp'];
833
- }
834
- private _mcpAdmission?: string;
835
- private _mcpCalls = new Map<string, { invocation: Record<string, unknown>; ended: boolean }>();
836
- protected _buildSpawnArgs(): string[] {
837
- // Disable codex's own approvals + sandbox (the union of
838
- // --dangerously-bypass-approvals-and-sandbox) so OUR container is the jail.
839
- // model_reasoning_effort sets the effort level; passed as TOML-ish `-c` values.
840
- const args = [
841
- 'mcp-server',
842
- '-c',
843
- `sandbox_mode="danger-full-access"`,
844
- '-c',
845
- `approval_policy="never"`,
846
- ];
847
- if (this._effort) {
848
- args.push('-c', `model_reasoning_effort="${this._effort}"`);
849
- }
850
- return args;
851
- }
852
-
853
- protected async _handshake(): Promise<void> {
854
- await this._rpcRequest('initialize', {
855
- protocolVersion: '2025-06-18',
856
- capabilities: {},
857
- clientInfo: { name: 'inixiative-bench', version: '0.1.0' },
858
- });
859
- this._rpcNotify('notifications/initialized');
860
- // tools/list confirms the `codex` + `codex-reply` tools are present.
861
- await this._rpcRequest('tools/list', {});
862
- }
863
-
864
- protected async _sendTurn(
865
- message: string,
866
- ): Promise<{ input: number; output: number } | undefined> {
867
- const isReply = this._externalSessionId !== undefined;
868
- const name = isReply ? 'codex-reply' : 'codex';
869
- const args: Record<string, unknown> = isReply
870
- ? { prompt: message, threadId: this._externalSessionId }
871
- : {
872
- prompt: message,
873
- model: this._model,
874
- cwd: this._cwd,
875
- // Belt-and-suspenders: also disable per-call (matches spawn `-c` flags).
876
- sandbox: 'danger-full-access',
877
- 'approval-policy': 'never',
878
- ...(this._effort ? { config: { model_reasoning_effort: this._effort } } : {}),
879
- };
880
-
881
- const result = (await this._rpcRequest('tools/call', {
882
- name,
883
- arguments: args,
884
- })) as Record<string, unknown> | undefined;
885
-
886
- // Capture the threadId for multi-turn (codex-reply) + fork.
887
- const structured = result?.structuredContent as Record<string, unknown> | undefined;
888
- const threadId = structured?.threadId as string | undefined;
889
- if (threadId && this._externalSessionId && threadId !== this._externalSessionId)
890
- throw new Error('MCP response changed the native resume binding');
891
- if (threadId && !this._externalSessionId) {
892
- this._externalSessionId = threadId;
893
- }
894
- if (threadId && this._inflight)
895
- this._inflight.evidence.identity = { ...this._inflight.evidence.identity, threadId };
896
- // Final text: prefer structuredContent.content, else the tool result content.
897
- const 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
- }
904
-
905
- // Usage, when the call result reports it. Streamed token_count events are
906
- // already accounted by the base loop; the call result is the authoritative
907
- // turn total, returned to _dispatchTurn for the SessionResult. To avoid
908
- // double-counting against streamed events, we only return it if no streamed
909
- // token event was seen this turn.
910
- const sawStreamedTokens = this._turnEvents.some((e) => e.tokens);
911
- const usage = structured?.usage as Record<string, number> | undefined;
912
- if (
913
- usage &&
914
- !sawStreamedTokens &&
915
- typeof (usage.input_tokens ?? usage.inputTokens) === 'number' &&
916
- typeof (usage.output_tokens ?? usage.outputTokens) === 'number'
917
- ) {
918
- return {
919
- input: (usage.input_tokens ?? usage.inputTokens ?? 0) as number,
920
- output: (usage.output_tokens ?? usage.outputTokens ?? 0) as number,
921
- };
922
- }
923
- return undefined;
924
- }
925
-
926
- private _extractToolText(content: unknown): string | undefined {
927
- if (!Array.isArray(content)) return undefined;
928
- const parts: string[] = [];
929
- for (const block of content) {
930
- const b = block as Record<string, unknown>;
931
- if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
932
- }
933
- return parts.length ? parts.join('\n') : undefined;
934
- }
935
-
936
- protected _classify(msg: Record<string, unknown>): SessionEvent[] {
937
- // Streamed events arrive as JSON-RPC notifications: method "codex/event"
938
- // with params carrying a `msg` of a tagged type.
939
- if (msg.method !== 'codex/event') return [];
940
- const params = msg.params as Record<string, unknown> | undefined;
941
- const ev = (params?.msg ?? params) as Record<string, unknown> | undefined;
942
- if (!ev) return [];
943
- const a = this._inflight?.evidence;
944
- const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
945
- const nativeSessionId = str(ev.session_id),
946
- threadId = str(ev.thread_id);
947
- const turnId = str(ev.turn_id),
948
- envelopeId = str(params?.id);
949
- const item = ev.item as Record<string, unknown> | undefined;
950
- const unowned = (reason: NonNullable<SessionEvent['unattributedReason']>): SessionEvent[] => [
951
- {
952
- kind: 'native_status',
953
- timestamp: Date.now(),
954
- correlation: 'unknown',
955
- unattributedReason: reason,
956
- nativeSessionId,
957
- threadId,
958
- turnId,
959
- callId: str(ev.call_id),
960
- itemId: str(item?.id) ?? str(ev.item_id),
961
- // Keep the envelope too: params.id may explain a rejected correlation.
962
- // Unowned MCP calls retain identity/type diagnostics, never unrelated tool payloads.
963
- raw:
964
- ev.type === 'mcp_tool_call_begin' || ev.type === 'mcp_tool_call_end'
965
- ? { type: ev.type }
966
- : msg,
967
- },
968
- ];
969
- if (threadId && this._externalSessionId && threadId !== this._externalSessionId)
970
- return unowned('foreign-session');
971
- if (
972
- nativeSessionId &&
973
- a?.identity.nativeSessionId &&
974
- nativeSessionId !== a.identity.nativeSessionId
975
- )
976
- return unowned('foreign-session');
977
- if (
978
- (envelopeId && this._seenTerminals.has(envelopeId)) ||
979
- (turnId && this._seenTerminals.has(turnId))
980
- ) {
981
- return unowned(ev.type === 'task_complete' ? 'duplicate-terminal' : 'after-terminal');
982
- }
983
- if (!a) {
984
- if (ev.type === 'task_complete' && turnId) this._seenTerminals.add(turnId);
985
- return unowned('no-admission');
986
- }
987
- if (ev.type === 'session_configured') {
988
- if (!this._externalSessionId && threadId) this._externalSessionId = threadId;
989
- a.identity = { ...a.identity, nativeSessionId, threadId };
990
- }
991
- if (
992
- ev.type !== 'session_configured' &&
993
- a.identity.turnId &&
994
- envelopeId &&
995
- envelopeId !== a.identity.turnId
996
- )
997
- return unowned('foreign-turn');
998
- if (threadId && a.identity.threadId && threadId !== a.identity.threadId)
999
- return unowned('foreign-session');
1000
- if (ev.type === 'task_started' && turnId && !a.identity.turnId)
1001
- a.identity = { ...a.identity, turnId, correlation: 'native-turn' };
1002
- if (turnId && turnId !== a.identity.turnId) return unowned('foreign-turn');
1003
- if (ev.type === 'task_complete') {
1004
- if (!turnId || turnId !== a.identity.turnId) return unowned('unrecognized-event');
1005
- this._seenTerminals.add(turnId);
1006
- return [
1007
- {
1008
- ...a.identity,
1009
- kind: 'result',
1010
- timestamp: Date.now(),
1011
- nativeOutcome: 'completed',
1012
- terminal: { type: 'task_complete', turnId },
1013
- raw: ev,
1014
- },
1015
- ];
1016
- }
1017
- const identity = {
1018
- ...a.identity,
1019
- threadId: threadId ?? a.identity.threadId,
1020
- turnId: turnId ?? a.identity.turnId,
1021
- callId: str(ev.call_id),
1022
- itemId: str(item?.id) ?? str(ev.item_id),
1023
- };
1024
- if (ev.type === 'mcp_tool_call_begin' || ev.type === 'mcp_tool_call_end') {
1025
- const invocation = object(ev.invocation);
1026
- if (
1027
- !identity.callId ||
1028
- !invocation ||
1029
- typeof invocation.server !== 'string' ||
1030
- !invocation.server ||
1031
- typeof invocation.tool !== 'string' ||
1032
- !invocation.tool
1033
- )
1034
- return unowned('malformed-tool');
1035
- if (this._mcpAdmission !== a.identity.admissionId) {
1036
- this._mcpAdmission = a.identity.admissionId;
1037
- this._mcpCalls.clear();
1038
- }
1039
- // Compare the original argument value, including absence. Never join a result
1040
- // for another server/tool or silently substitute arguments from its envelope.
1041
- const join = {
1042
- server: invocation.server,
1043
- tool: invocation.tool,
1044
- arguments: invocation.arguments,
1045
- };
1046
- const prior = this._mcpCalls.get(identity.callId);
1047
- const tool = {
1048
- toolName: `mcp__${invocation.server}__${invocation.tool}`,
1049
- toolServer: invocation.server,
1050
- toolMethod: invocation.tool,
1051
- };
1052
- if (ev.type === 'mcp_tool_call_begin') {
1053
- if (prior) return unowned('duplicate-tool');
1054
- this._mcpCalls.set(identity.callId, { invocation: structuredClone(join), ended: false });
1055
- const args = publicMcpArguments(invocation.arguments);
1056
- return [{ ...identity, ...tool, kind: 'tool_use', timestamp: Date.now(), ...args }];
1057
- }
1058
- if (!prior || !isDeepStrictEqual(prior.invocation, join)) return unowned('unmatched-tool');
1059
- if (prior.ended) return unowned('duplicate-tool');
1060
- const result = publicMcpResult(ev.result);
1061
- if (!result) return unowned('malformed-tool');
1062
- prior.ended = true;
1063
- return [{ ...identity, ...tool, kind: 'tool_result', timestamp: Date.now(), ...result }];
1064
- }
1065
- if (
1066
- ['session_configured', 'task_started', 'item_started', 'item_completed'].includes(
1067
- String(ev.type),
1068
- )
1069
- ) {
1070
- return [
1071
- {
1072
- ...identity,
1073
- kind: 'native_status',
1074
- timestamp: Date.now(),
1075
- nativeOutcome: a.nativeOutcome,
1076
- raw: ev,
1077
- },
1078
- ];
1079
- }
1080
- // Notifications without a turn ID are associated only with the single owned RPC.
1081
- const classified = classifyCodexEvent(ev, 'type');
1082
- return classified.length
1083
- ? classified.map((event) => ({ ...identity, ...event }))
1084
- : unowned('unrecognized-event');
1085
- }
1086
- }
1087
-
1088
- // ---------------------------------------------------------------------------
1089
- // CodexAppServerSession (EXPERIMENTAL) — `codex app-server` over stdio
1090
- // ---------------------------------------------------------------------------
1091
- //
1092
- // Wire contract: installed codex-cli 0.153.4 v2 JSON schema. Controlled tests
1093
- // establish source behavior, not a real create/resume/native-completion capture.
1094
- // No default switch. Full history hydration, native fork and cancellation remain
1095
- // separate integration work; a local interrupt never acknowledges native stop.
1096
-
1097
- class NativeValidationError extends Error {}
1098
-
1099
- export class CodexAppServerSession extends BaseCodexSession {
1100
- get transport() {
1101
- return TRANSPORTS['codex-app-server'];
1102
- }
1103
- readonly appServerProtocol = 'owned-thread-v1';
1104
806
  private _limits?: LimitSnapshot;
1105
807
 
1106
808
  /** Latest account limits observed on this session (stream or poll). */
@@ -1176,7 +878,7 @@ export class CodexAppServerSession extends BaseCodexSession {
1176
878
  }
1177
879
 
1178
880
  /** 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> {
881
+ async push(payload: { kind: string; text: string }): Promise<void> {
1180
882
  const a = this._inflight?.evidence;
1181
883
  const ignored = (reason: string) =>
1182
884
  this._emit({
@@ -1237,6 +939,11 @@ export class CodexAppServerSession extends BaseCodexSession {
1237
939
  private _nativeSessionId?: string;
1238
940
  private _appAdmission?: string;
1239
941
  private _turnDone?: () => void;
942
+ private _gates = new ToolGates();
943
+ /** Approval requests awaiting a gate decision, by JSON-RPC request id. */
944
+ private _approvals = new Map<string, AbortController>();
945
+ /** The owned turn's in-progress fileChange items' changes, by item id, for their approvals. */
946
+ private _fileChanges = new Map<string, readonly CodexFileChange[]>();
1240
947
  private _items = new Map<
1241
948
  string,
1242
949
  {
@@ -1249,42 +956,119 @@ export class CodexAppServerSession extends BaseCodexSession {
1249
956
  }
1250
957
  >();
1251
958
 
1252
- constructor(config?: CodexSessionConfig) {
1253
- super(config);
1254
- const { onThreadReady, ...options } = config?.appServer ?? {};
1255
- this._onThreadReady = onThreadReady;
1256
- this._options = retainEvidence(options);
1257
- const required = this._options.requiredMcpServer;
1258
- if (
1259
- required &&
1260
- (!/^[a-zA-Z0-9_-]+$/.test(required.name) ||
1261
- !required.tools.length ||
1262
- required.tools.some((t) => !/^[a-zA-Z0-9_-]+$/.test(t)))
1263
- )
1264
- throw new NativeValidationError('Invalid required MCP inventory');
1265
- }
1266
-
1267
- override fork(): HarnessSession {
959
+ fork(): HarnessSession {
1268
960
  throw Error(
1269
961
  'Native app-server fork is not implemented; a same-thread wrapper cannot create a native fork or inherit a process capability',
1270
962
  );
1271
963
  }
1272
964
 
1273
- override async start(): Promise<void> {
1274
- if (this._inflight)
1275
- throw Error('Native call ownership unresolved; automatic resume is blocked');
1276
- if (this._startedOnce && !this._alive)
1277
- throw Error('App-server restart requires a new owned instance and preflight');
1278
- this._startedOnce = true;
1279
- try {
1280
- await super.start();
1281
- } catch (error) {
1282
- this.kill();
1283
- throw error;
1284
- }
965
+ /** Needs an `approvalPolicy` other than `never`. Gates registered here are not copied (no fork). */
966
+ onToolRequest(gate: ToolGate): () => void {
967
+ if (this._approvalPolicy === 'never')
968
+ throw new Error(
969
+ 'Tool gates need an app-server session launched with approvalPolicy "on-request" or "untrusted"; under "never" no approval reaches them',
970
+ );
971
+ return this._gates.add(gate);
972
+ }
973
+
974
+ private _withdrawRequests(): void {
975
+ for (const approval of this._approvals.values()) approval.abort();
976
+ this._approvals.clear();
977
+ }
978
+
979
+ private get _approvalPolicy(): CodexApprovalPolicy {
980
+ return this._options.approvalPolicy ?? 'never';
981
+ }
982
+
983
+ /** Approval requests are asked of this client, so they must reach the gates. */
984
+ private get _gated(): boolean {
985
+ return this._approvalPolicy !== 'never';
986
+ }
987
+
988
+ private get _sandbox(): CodexSandbox {
989
+ return this._options.sandbox ?? 'danger-full-access';
990
+ }
991
+
992
+ /** A command or file-change approval for the owned turn, decided by the gates. */
993
+ private _approval(request: Record<string, unknown>): boolean {
994
+ const method = request.method as string;
995
+ const params = object(request.params);
996
+ const a = this._inflight?.evidence;
997
+ if (
998
+ !APPROVAL_METHODS.has(method) ||
999
+ !a?.identity.turnId ||
1000
+ params?.threadId !== a.identity.threadId ||
1001
+ params?.turnId !== a.identity.turnId ||
1002
+ (typeof request.id !== 'string' && typeof request.id !== 'number')
1003
+ )
1004
+ return false;
1005
+ const str = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
1006
+ const command = method === 'item/commandExecution/requestApproval';
1007
+ const network = object(params.networkApprovalContext);
1008
+ const host = str(network?.host),
1009
+ protocol = str(network?.protocol);
1010
+ const changes = str(params.itemId) ? this._fileChanges.get(params.itemId as string) : undefined;
1011
+ const toolInput: Record<string, unknown> = command
1012
+ ? {
1013
+ ...(str(params.command) ? { command: params.command } : {}),
1014
+ ...(str(params.cwd) ? { cwd: params.cwd } : {}),
1015
+ ...(str(params.kind) ? { kind: params.kind } : {}),
1016
+ ...(host ? { networkApprovalContext: { host, ...(protocol ? { protocol } : {}) } } : {}),
1017
+ }
1018
+ : {
1019
+ ...(str(params.grantRoot) ? { grantRoot: params.grantRoot } : {}),
1020
+ ...(changes ? { changes: structuredClone(changes) } : {}),
1021
+ };
1022
+ const proc = this._proc;
1023
+ const id = request.id;
1024
+ const key = JSON.stringify(id);
1025
+ const controller = new AbortController();
1026
+ this._approvals.get(key)?.abort();
1027
+ this._approvals.set(key, controller);
1028
+ const identity = { ...a.identity, admissionId: a.admissionId };
1029
+ this._emit({
1030
+ ...identity,
1031
+ kind: 'native_status',
1032
+ timestamp: Date.now(),
1033
+ raw: { type: 'approval-request', method },
1034
+ });
1035
+ void this._gates
1036
+ .decide({
1037
+ stage: 'permission',
1038
+ toolName: command ? 'shell' : 'apply_patch',
1039
+ toolInput: Object.freeze(toolInput),
1040
+ ...(str(params.itemId) ? { callId: params.itemId as string } : {}),
1041
+ admissionId: a.admissionId,
1042
+ ...(str(params.reason) ? { description: params.reason as string } : {}),
1043
+ signal: controller.signal,
1044
+ })
1045
+ .then((decision) => {
1046
+ if (controller.signal.aborted || this._proc !== proc || !this._alive || !proc) return;
1047
+ this._approvals.delete(key);
1048
+ const accepted = decision?.behavior === 'allow';
1049
+ try {
1050
+ proc.stdin.write(
1051
+ `${JSON.stringify({ jsonrpc: '2.0', id, result: { decision: accepted ? 'accept' : 'decline' } })}\n`,
1052
+ );
1053
+ proc.stdin.flush();
1054
+ } catch {
1055
+ return;
1056
+ }
1057
+ this._emit({
1058
+ ...identity,
1059
+ kind: 'native_status',
1060
+ timestamp: Date.now(),
1061
+ raw: { type: 'approval-response', method, decision: accepted ? 'accept' : 'decline' },
1062
+ });
1063
+ })
1064
+ .finally(() => {
1065
+ if (this._approvals.get(key) === controller) this._approvals.delete(key);
1066
+ });
1067
+ return true;
1285
1068
  }
1286
1069
 
1287
- protected override _serverRequest(request: Record<string, unknown>): boolean {
1070
+ private _serverRequest(request: Record<string, unknown>): boolean {
1071
+ if (this._approval(request)) return true;
1288
1072
  // JSON-RPC's explicit method-not-supported refusal also covers unknown future
1289
1073
  // requests. Never approve native work or echo private request parameters.
1290
1074
  const payload = `${JSON.stringify({
@@ -1393,17 +1177,17 @@ export class CodexAppServerSession extends BaseCodexSession {
1393
1177
  this._refuse('mcp-inventory-page-limit');
1394
1178
  }
1395
1179
 
1396
- protected override _canReleaseAttempt(a: TurnState): boolean {
1180
+ private _canReleaseAttempt(a: TurnState): boolean {
1397
1181
  // An observer/write failure may occur after delivery. A terminal alone does
1398
1182
  // not settle the unanswered turn/start call or authorize another admission.
1399
1183
  return a.rpcSettled && (a.rpcOutcome === 'resolved' || a.rpcOutcome === 'failed');
1400
1184
  }
1401
1185
 
1402
- protected _buildSpawnArgs(): string[] {
1186
+ private _buildSpawnArgs(): string[] {
1403
1187
  return ['app-server', '--listen', 'stdio://'];
1404
1188
  }
1405
1189
 
1406
- protected async _handshake(): Promise<void> {
1190
+ private async _handshake(): Promise<void> {
1407
1191
  this._threadReady = false;
1408
1192
  const initialized = object(
1409
1193
  await this._rpcRequest('initialize', {
@@ -1423,9 +1207,7 @@ export class CodexAppServerSession extends BaseCodexSession {
1423
1207
  this._rpcNotify('initialized');
1424
1208
  }
1425
1209
 
1426
- protected async _sendTurn(
1427
- message: string,
1428
- ): Promise<{ input: number; output: number } | undefined> {
1210
+ private async _sendTurn(message: string): Promise<{ input: number; output: number } | undefined> {
1429
1211
  const a = this._inflight!.evidence;
1430
1212
  if (!this._threadReady) {
1431
1213
  const binding = this._externalSessionId;
@@ -1435,8 +1217,10 @@ export class CodexAppServerSession extends BaseCodexSession {
1435
1217
  ...(binding ? { threadId: binding } : {}),
1436
1218
  model: this._model,
1437
1219
  cwd: this._cwd,
1438
- approvalPolicy: 'never',
1439
- sandbox: 'danger-full-access',
1220
+ approvalPolicy: this._approvalPolicy,
1221
+ // Approvals go to the gates, never to a config-selected reviewer (auto_review, guardian).
1222
+ ...(this._gated ? { approvalsReviewer: 'user' } : {}),
1223
+ sandbox: this._sandbox,
1440
1224
  ...(this._options.config ? { config: this._options.config } : {}),
1441
1225
  }),
1442
1226
  );
@@ -1458,6 +1242,16 @@ export class CodexAppServerSession extends BaseCodexSession {
1458
1242
  ? String(object(thread?.status)?.type)
1459
1243
  : 'unknown',
1460
1244
  );
1245
+ if (
1246
+ this._gated &&
1247
+ (response?.approvalsReviewer !== 'user' || response.approvalPolicy !== this._approvalPolicy)
1248
+ )
1249
+ this._refuse(
1250
+ 'approvals-not-routed-to-user',
1251
+ ['user', 'auto_review', 'guardian_subagent'].includes(String(response?.approvalsReviewer))
1252
+ ? String(response?.approvalsReviewer)
1253
+ : 'unknown',
1254
+ );
1461
1255
  if (
1462
1256
  this._options.requireConfiguration &&
1463
1257
  (typeof response?.model !== 'string' ||
@@ -1465,9 +1259,9 @@ export class CodexAppServerSession extends BaseCodexSession {
1465
1259
  typeof response.modelProvider !== 'string' ||
1466
1260
  !response.modelProvider ||
1467
1261
  response.cwd !== this._cwd ||
1468
- response.approvalPolicy !== 'never' ||
1262
+ response.approvalPolicy !== this._approvalPolicy ||
1469
1263
  response.approvalsReviewer !== 'user' ||
1470
- object(response.sandbox)?.type !== 'dangerFullAccess' ||
1264
+ object(response.sandbox)?.type !== SANDBOX_TYPES[this._sandbox] ||
1471
1265
  !Array.isArray(thread?.turns) ||
1472
1266
  thread.turns.some((value) => !this._turn(value)))
1473
1267
  )
@@ -1497,6 +1291,7 @@ export class CodexAppServerSession extends BaseCodexSession {
1497
1291
  timestamp: Date.now(),
1498
1292
  threadId: id,
1499
1293
  nativeSessionId: this._nativeSessionId,
1294
+ correlation: 'unknown',
1500
1295
  unattributedReason: 'session-configuration',
1501
1296
  raw: {
1502
1297
  type: 'thread-configured',
@@ -1552,7 +1347,7 @@ export class CodexAppServerSession extends BaseCodexSession {
1552
1347
  return a.tokens;
1553
1348
  }
1554
1349
 
1555
- protected override _observeRpcResult(id: number, result: unknown): void {
1350
+ private _observeRpcResult(id: number, result: unknown): void {
1556
1351
  const a = this._inflight?.evidence;
1557
1352
  if (!a || this._appAdmission !== a.admissionId || a.identity.rpcRequestId !== id) return;
1558
1353
  const turn = this._turn(object(result)?.turn);
@@ -1580,13 +1375,20 @@ export class CodexAppServerSession extends BaseCodexSession {
1580
1375
  return turn as { id: string; status: string; error?: unknown };
1581
1376
  }
1582
1377
 
1583
- protected _classify(msg: Record<string, unknown>): SessionEvent[] {
1378
+ private _classify(msg: Record<string, unknown>): SessionEvent[] {
1584
1379
  const method = typeof msg.method === 'string' ? msg.method : undefined;
1585
1380
  if (!method) return [];
1586
1381
  if (method === 'account/rateLimits/updated') {
1587
1382
  const snapshot = codexLimitSnapshot(msg.params, 'stream');
1588
1383
  return snapshot ? [this._observeLimits(snapshot)] : [];
1589
1384
  }
1385
+ if (method === 'serverRequest/resolved') {
1386
+ // The runtime settled or withdrew an approval itself: a later gate decision is discarded.
1387
+ const requestId = object(msg.params)?.requestId;
1388
+ const key = JSON.stringify(requestId);
1389
+ this._approvals.get(key)?.abort();
1390
+ this._approvals.delete(key);
1391
+ }
1590
1392
  const params = object(msg.params),
1591
1393
  turn = this._turn(params?.turn),
1592
1394
  item = object(params?.item);
@@ -1645,6 +1447,7 @@ export class CodexAppServerSession extends BaseCodexSession {
1645
1447
  if (turn.error != null && typeof error?.message !== 'string')
1646
1448
  return unowned('unrecognized-event');
1647
1449
  this._seenTerminals.add(turnId);
1450
+ this._fileChanges.clear();
1648
1451
  this._turnDone?.();
1649
1452
  this._turnDone = undefined;
1650
1453
  return [
@@ -1703,8 +1506,19 @@ export class CodexAppServerSession extends BaseCodexSession {
1703
1506
  },
1704
1507
  ];
1705
1508
  }
1509
+ if (method === 'item/fileChange/patchUpdated') {
1510
+ // Changes only feed the item's approval request; the event itself stays a diagnostic.
1511
+ if (itemId && this._fileChanges.has(itemId))
1512
+ this._fileChanges.set(itemId, fileChanges(params?.changes));
1513
+ return unowned('unrecognized-event');
1514
+ }
1706
1515
  if (method === 'item/completed' || method === 'item/started') {
1707
1516
  if (!itemId || !item || typeof item.type !== 'string') return unowned('unrecognized-event');
1517
+ if (item.type === 'fileChange') {
1518
+ if (method === 'item/started') this._fileChanges.set(itemId, fileChanges(item.changes));
1519
+ else this._fileChanges.delete(itemId);
1520
+ return unowned('unrecognized-event');
1521
+ }
1708
1522
  if (item.type === 'agentMessage') {
1709
1523
  if (method !== 'item/completed') return status();
1710
1524
  if (this._items.get(itemId)?.completed) return unowned('after-terminal');
@@ -1800,7 +1614,7 @@ export class CodexAppServerSession extends BaseCodexSession {
1800
1614
  return unowned('malformed-tool');
1801
1615
  const error = object(item.error),
1802
1616
  result = object(item.result);
1803
- const output = result ? publicMcpResult({ Ok: { content: result.content } }) : undefined;
1617
+ const output = result ? publicMcpResult(result.content) : undefined;
1804
1618
  if (item.error != null && typeof error?.message !== 'string')
1805
1619
  return unowned('malformed-tool');
1806
1620
  prior.completed = true;
@@ -1827,23 +1641,57 @@ export class CodexAppServerSession extends BaseCodexSession {
1827
1641
  }
1828
1642
 
1829
1643
  // ---------------------------------------------------------------------------
1830
- // Shared event mapping — codex item/event → SessionEvent
1644
+ // Helpers
1831
1645
  // ---------------------------------------------------------------------------
1832
- //
1833
- // Both protocols carry the same ThreadItem / event shapes (agent message,
1834
- // reasoning, command execution, token usage). `tag` is the discriminant field
1835
- // ("type" for both mcp `codex/event.msg.type` and app-server `item.type`).
1836
- //
1837
- // agent_message / agentMessage → text
1838
- // reasoning / agent_reasoning → thinking
1839
- // command_execution / commandExecution → tool_use (+ tool_result when done)
1840
- // token_count / usage → tokens (attached to a result event)
1841
1646
 
1842
1647
  const object = (value: unknown): Record<string, unknown> | undefined =>
1843
1648
  value !== null && typeof value === 'object' && !Array.isArray(value)
1844
1649
  ? (value as Record<string, unknown>)
1845
1650
  : undefined;
1846
1651
 
1652
+ /** A fileChange item's change as a file-change gate sees it (`toolInput.changes`). */
1653
+ export interface CodexFileChange {
1654
+ readonly path: string;
1655
+ readonly kind: 'add' | 'delete' | 'update';
1656
+ /** Rename target of an `update`. */
1657
+ readonly movePath?: string;
1658
+ /** Unified diff, present while it fits the bound; otherwise `diffOmitted`. */
1659
+ readonly diff?: string;
1660
+ readonly diffOmitted?: true;
1661
+ }
1662
+ const FILE_CHANGE_DIFF_LIMIT = 16 * 1024;
1663
+ const FILE_CHANGE_DIFF_TOTAL = 64 * 1024;
1664
+
1665
+ /** Native FileUpdateChange[] (codex 0.155.1) → CodexFileChange[]; diffs only within the bound. */
1666
+ function fileChanges(value: unknown): readonly CodexFileChange[] {
1667
+ if (!Array.isArray(value)) return [];
1668
+ let budget = FILE_CHANGE_DIFF_TOTAL;
1669
+ const out: CodexFileChange[] = [];
1670
+ for (const entry of value) {
1671
+ const change = object(entry);
1672
+ const kind = object(change?.kind);
1673
+ if (
1674
+ typeof change?.path !== 'string' ||
1675
+ !change.path ||
1676
+ (kind?.type !== 'add' && kind?.type !== 'delete' && kind?.type !== 'update')
1677
+ )
1678
+ continue;
1679
+ const diff = typeof change.diff === 'string' ? change.diff : undefined;
1680
+ const fits =
1681
+ diff !== undefined && diff.length <= FILE_CHANGE_DIFF_LIMIT && diff.length <= budget;
1682
+ if (fits) budget -= diff.length;
1683
+ out.push({
1684
+ path: change.path,
1685
+ kind: kind.type,
1686
+ ...(kind.type === 'update' && typeof kind.move_path === 'string' && kind.move_path
1687
+ ? { movePath: kind.move_path }
1688
+ : {}),
1689
+ ...(fits ? { diff } : diff ? { diffOmitted: true as const } : {}),
1690
+ });
1691
+ }
1692
+ return out;
1693
+ }
1694
+
1847
1695
  function publicMcpArguments(value: unknown): Pick<SessionEvent, 'toolInput' | 'toolInputOmitted'> {
1848
1696
  if (!object(value)) return { toolInputOmitted: 'Native arguments absent or not an object' };
1849
1697
  let omitted = false;
@@ -1879,25 +1727,15 @@ function publicMcpArguments(value: unknown): Pick<SessionEvent, 'toolInput' | 't
1879
1727
  };
1880
1728
  }
1881
1729
 
1882
- /** Rust Result<CallToolResult,String> from protocol.rs. This is legacy event
1883
- * normalization, not the app-server ThreadItem schema. Keep all public text
1884
- * blocks (including the bridge's JSON receipt), not arbitrary envelope fields. */
1730
+ /** An mcpToolCall result's content: public text blocks (including the bridge's JSON receipt)
1731
+ * are kept; any other block keeps only a known type label. */
1885
1732
  function publicMcpResult(
1886
1733
  value: unknown,
1887
- ): Pick<SessionEvent, 'toolOutput' | 'toolError' | 'toolOutputOmitted'> | undefined {
1888
- const result = object(value);
1889
- if (!result || Object.keys(result).length !== 1) return;
1890
- if (typeof result.Err === 'string') return { toolOutput: result.Err, toolError: true };
1891
- const ok = object(result.Ok);
1892
- if (
1893
- !ok ||
1894
- !Array.isArray(ok.content) ||
1895
- (ok.isError !== undefined && typeof ok.isError !== 'boolean')
1896
- )
1897
- return;
1898
- let omitted = Object.keys(ok).some((k) => k !== 'content' && k !== 'isError');
1899
- const content = ok.content.map((value) => {
1900
- const block = object(value);
1734
+ ): Pick<SessionEvent, 'toolOutput' | 'toolOutputOmitted'> | undefined {
1735
+ if (!Array.isArray(value)) return;
1736
+ let omitted = false;
1737
+ const content = value.map((entry) => {
1738
+ const block = object(entry);
1901
1739
  if (block?.type === 'text' && typeof block.text === 'string') {
1902
1740
  if (Object.keys(block).some((k) => k !== 'type' && k !== 'text')) omitted = true;
1903
1741
  return { type: 'text', text: block.text };
@@ -1911,113 +1749,7 @@ function publicMcpResult(
1911
1749
  };
1912
1750
  });
1913
1751
  return {
1914
- toolOutput: JSON.stringify({
1915
- content,
1916
- ...(ok.isError !== undefined ? { isError: ok.isError } : {}),
1917
- }),
1918
- ...(ok.isError !== undefined ? { toolError: ok.isError as boolean } : {}),
1752
+ toolOutput: JSON.stringify({ content }),
1919
1753
  ...(omitted ? { toolOutputOmitted: true } : {}),
1920
1754
  };
1921
1755
  }
1922
-
1923
- function classifyCodexEvent(ev: Record<string, unknown>, tag: string): SessionEvent[] {
1924
- const ts = Date.now();
1925
- const type = String(ev[tag] ?? '').toLowerCase();
1926
- const events: SessionEvent[] = [];
1927
-
1928
- // Agent message → text.
1929
- if (type === 'agent_message' || type === 'agentmessage' || type === 'agent_message_delta') {
1930
- const text = (ev.message ?? ev.text ?? ev.delta) as string | undefined;
1931
- if (text) events.push({ kind: 'text', timestamp: ts, text, raw: ev });
1932
- return events;
1933
- }
1934
-
1935
- // Reasoning → thinking.
1936
- if (
1937
- type === 'reasoning' ||
1938
- type === 'agent_reasoning' ||
1939
- type === 'agentreasoning' ||
1940
- type === 'agent_reasoning_delta'
1941
- ) {
1942
- const text = (ev.text ?? ev.reasoning ?? ev.delta ?? ev.summary) as string | undefined;
1943
- if (text) events.push({ kind: 'thinking', timestamp: ts, text, raw: ev });
1944
- return events;
1945
- }
1946
-
1947
- // Command execution → tool_use, plus tool_result if output is present.
1948
- if (
1949
- type === 'command_execution' ||
1950
- type === 'commandexecution' ||
1951
- type === 'exec_command_begin' ||
1952
- type === 'exec_command_end'
1953
- ) {
1954
- const command = (ev.command ?? ev.cmd) as string | string[] | 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;
1965
- const exitCode = (ev.exit_code ?? ev.exitCode) as number | undefined;
1966
- if (output !== undefined || exitCode !== undefined) {
1967
- events.push({
1968
- kind: 'tool_result',
1969
- timestamp: ts,
1970
- toolOutput: output ?? '',
1971
- toolError: exitCode !== undefined && exitCode !== 0,
1972
- raw: ev,
1973
- });
1974
- }
1975
- return events;
1976
- }
1977
-
1978
- // Token usage → carried on a result-less event so accounting picks it up.
1979
- if (
1980
- type === 'token_count' ||
1981
- type === 'token_usage' ||
1982
- type === 'usage' ||
1983
- type === 'tokenusage'
1984
- ) {
1985
- const u = (ev.info ?? ev.usage ?? ev) as Record<string, unknown>;
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;
1988
- if (input || output) {
1989
- events.push({
1990
- kind: 'result',
1991
- timestamp: ts,
1992
- tokens: { input, output },
1993
- raw: ev,
1994
- });
1995
- }
1996
- return events;
1997
- }
1998
-
1999
- // Errors.
2000
- if (type === 'error' || type === 'stream_error') {
2001
- events.push({
2002
- kind: 'error',
2003
- timestamp: ts,
2004
- text: (ev.message ?? ev.error ?? JSON.stringify(ev)) as string,
2005
- raw: ev,
2006
- });
2007
- return events;
2008
- }
2009
-
2010
- // Unclassified events are preserved via `raw` on nothing — but we keep them
2011
- // out of turn content. Oracle can still introspect via the live stream.
2012
- return events;
2013
- }
2014
-
2015
- // ---------------------------------------------------------------------------
2016
- // Default export selection
2017
- // ---------------------------------------------------------------------------
2018
-
2019
- /**
2020
- * The default CodexSession is the MCP variant (stdio mcp-server). Import
2021
- * CodexAppServerSession explicitly for the experimental app-server variant.
2022
- */
2023
- export const CodexSession = CodexMcpSession;