@inixiative/agent-session 0.6.0 → 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.
@@ -5,32 +5,15 @@ import { ToolGates } from './tool-gate';
5
5
  import { TRANSPORTS } from './transport';
6
6
  import { TurnState } from './turn-state';
7
7
  // ---------------------------------------------------------------------------
8
- // CodexSession — long-lived OpenAI Codex CLI process with full event capture
9
- // ---------------------------------------------------------------------------
10
- //
11
- // Two implementations of the SAME HarnessSession interface, mirroring
12
- // ClaudeCodeSession. Both keep one persistent `codex` process alive and stream
13
- // turns over a JSON-RPC channel on stdin/stdout, classifying every event into
14
- // the shared SessionEvent taxonomy and resolving a turn on completion.
15
- //
16
- // CodexMcpSession (default) — `codex mcp-server` (stdio MCP JSON-RPC)
17
- // CodexAppServerSession (experimental) — `codex app-server` (stdio JSON-RPC)
18
- //
19
- // Both DISABLE codex's own approvals + sandbox (the union of
20
- // `--dangerously-bypass-approvals-and-sandbox`) so OUR container is the only
21
- // jail — identical intent to ClaudeCodeSession's `bypassPermissions`.
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.
22
15
  //
23
- // Architecture (mirrors claude-code-session.ts):
24
- // start() → spawn one process, start background stdout reader, MCP handshake
25
- // send() → JSON-RPC tools/call (codex / codex-reply), resolve on completion
26
- // fork() → new (unstarted) session resuming from the captured threadId
27
- // kill() → close stdin, kill process
28
- //
29
- // codex's native "session ID" is the threadId returned by the `codex` tool's
30
- // structuredContent — captured into externalSessionId and used for multi-turn
31
- // (`codex-reply`) and fork.
32
- //
33
- // Verified against codex 0.140.
16
+ // Wire contract: codex-cli 0.155.1 v2 JSON schema.
34
17
  // ---------------------------------------------------------------------------
35
18
 
36
19
  import type {
@@ -109,8 +92,7 @@ export type CodexSpawn = (
109
92
  // ---------------------------------------------------------------------------
110
93
 
111
94
  export interface CodexSessionConfig {
112
- /** Explicit app-server integration. Never applied by the default MCP engine.
113
- * Config is native launch data, not event/recording data. */
95
+ /** Thread launch options. Config is native launch data, not event/recording data. */
114
96
  appServer?: {
115
97
  /** Persist the actual returned binding before model admission; failure blocks work. */
116
98
  onThreadReady?: (binding: string) => void | Promise<void>;
@@ -144,14 +126,13 @@ export interface CodexSessionConfig {
144
126
  timeout?: number;
145
127
  /**
146
128
  * Base context to pre-load. Codex has no `--append-system-prompt`; we prepend
147
- * it to the first turn's prompt (and keep it for fork). Persists logically for
148
- * the session via the continued thread.
129
+ * it to the first turn's prompt. Persists logically for the session via the
130
+ * continued thread.
149
131
  */
150
132
  baseContext?: string;
151
133
  /**
152
- * codex's native thread ID (the value the `codex` tool returns). When set, the
153
- * first send() uses `codex-reply` on MCP (loaded threads only), or
154
- * `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).
155
136
  */
156
137
  externalSessionId?: string;
157
138
  /** Environment merged over process.env (e.g. CODEX_HOME for a profile). */
@@ -180,64 +161,58 @@ interface QueuedTurn {
180
161
  // codex reasoning-effort levels (model_reasoning_effort), low→high.
181
162
  const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh'];
182
163
 
183
- // ---------------------------------------------------------------------------
184
- // Base class — shared queue, event log, classification helpers, lifecycle
185
- // ---------------------------------------------------------------------------
186
- //
187
- // CodexMcpSession and CodexAppServerSession differ only in the wire protocol
188
- // (how start() handshakes, how a turn is sent, and how a raw message maps to
189
- // SessionEvents). Everything else — the turn queue, token accounting, the
190
- // stdout read loop, event emission, fork, artifact — is shared here, exactly as
191
- // 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';
192
171
 
193
- abstract class BaseCodexSession implements HarnessSession {
194
172
  // -- Config --
195
- protected _bin: string;
196
- protected _model: string;
197
- protected _effort?: string;
198
- protected _cwd: string;
199
- protected _defaultTimeout: number;
200
- protected _baseContext?: string;
201
- protected _spawn?: CodexSpawn;
202
- 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>;
203
181
 
204
182
  // -- Process --
205
- protected _proc: PipedSubprocess | null = null;
206
- protected _stderr = '';
183
+ private _proc: PipedSubprocess | null = null;
184
+ private _stderr = '';
207
185
 
208
186
  // -- Session state --
209
- protected _externalSessionId?: string;
210
- protected _alive = false;
187
+ private _externalSessionId?: string;
188
+ private _alive = false;
211
189
  private _endEmitted = false;
212
- protected _eventLog: SessionEvent[] = [];
213
- protected _handlers: SessionEventHandler[] = [];
190
+ private _eventLog: SessionEvent[] = [];
191
+ private _handlers: SessionEventHandler[] = [];
214
192
  private _observerFailures = { synchronous: 0, asynchronous: 0 };
215
- protected _beforeSendHooks: BeforeSendHook[] = [];
216
- protected _turns = 0;
217
- protected _totalTokens = { input: 0, output: 0 };
218
- protected _startedAt: number;
193
+ private _beforeSendHooks: BeforeSendHook[] = [];
194
+ private _turns = 0;
195
+ private _totalTokens = { input: 0, output: 0 };
196
+ private _startedAt: number;
219
197
  /** Whether baseContext has been prepended onto a sent turn yet. */
220
- protected _injectedBaseContext = false;
221
- /** Whether this session was created via fork() (continues a thread). */
222
- protected _forking = false;
198
+ private _injectedBaseContext = false;
223
199
 
224
200
  // -- JSON-RPC --
225
201
  /** Monotonic JSON-RPC request id. */
226
- protected _rpcId = 0;
202
+ private _rpcId = 0;
227
203
  /** Pending JSON-RPC responses, keyed by request id. */
228
- protected _pending = new Map<
204
+ private _pending = new Map<
229
205
  number,
230
206
  { resolve: (result: unknown) => void; reject: (e: Error) => void }
231
207
  >();
232
208
 
233
209
  // -- Turn queue (identical to ClaudeCodeSession) --
234
- protected _queue: QueuedTurn[] = [];
235
- protected _attempts: TurnState[] = [];
236
- protected _seenTerminals = new Set<string>();
237
- protected _inflight: QueuedTurn | null = null;
238
- protected _turnEvents: SessionEvent[] = [];
239
- protected _resultText = '';
240
- 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;
241
216
 
242
217
  constructor(config?: CodexSessionConfig) {
243
218
  const bin = config?.bin ?? 'codex';
@@ -259,6 +234,17 @@ abstract class BaseCodexSession implements HarnessSession {
259
234
  this._spawn = config?.spawn;
260
235
  this._env = config?.env;
261
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');
262
248
  }
263
249
 
264
250
  // ---------------------------------------------------------------------------
@@ -311,21 +297,6 @@ abstract class BaseCodexSession implements HarnessSession {
311
297
  };
312
298
  }
313
299
 
314
- /**
315
- * Mid-turn push. Like Claude Code's stream-json stdin, codex's tools/call has
316
- * no out-of-band signal channel mid-turn; we emit a "push_ignored" error event
317
- * so callers observe the attempt without the model seeing the payload until the
318
- * next turn.
319
- */
320
- async push(payload: { kind: string; text: string }): Promise<void> {
321
- this._emit({
322
- kind: 'error',
323
- timestamp: Date.now(),
324
- text: `push_ignored: kind=${payload.kind} — codex turn has no OOB channel`,
325
- raw: payload,
326
- });
327
- }
328
-
329
300
  // ---------------------------------------------------------------------------
330
301
  // interrupt / kill / artifact (identical to ClaudeCodeSession)
331
302
  // ---------------------------------------------------------------------------
@@ -386,10 +357,24 @@ abstract class BaseCodexSession implements HarnessSession {
386
357
  }
387
358
 
388
359
  // ---------------------------------------------------------------------------
389
- // start() — spawn the persistent process + handshake (subclass-specific)
360
+ // start() — spawn the persistent process + handshake
390
361
  // ---------------------------------------------------------------------------
391
362
 
392
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> {
393
378
  if (this._attempts.some((a) => a.dispatch === 'attempted' && a.nativeOutcome === 'unknown'))
394
379
  throw new Error('Native outcome unresolved; automatic resume is blocked');
395
380
  if (this._proc) throw new Error('Session already started');
@@ -551,11 +536,9 @@ abstract class BaseCodexSession implements HarnessSession {
551
536
 
552
537
  private _writeTurn(turn: QueuedTurn): void {
553
538
  turn.evidence.dispatch = 'attempted';
554
- this._turnEvents = turn.evidence.events;
555
539
  this._resultText = '';
556
540
 
557
- // Send via the subclass's wire protocol. The returned promise resolves when
558
- // the tools/call (or turn) completes; that resolves the turn.
541
+ // Resolves once the native terminal and the turn/start RPC have both settled.
559
542
  this._sendTurn(turn.message)
560
543
  .then((tokens) => {
561
544
  if (this._inflight !== turn) return;
@@ -610,10 +593,6 @@ abstract class BaseCodexSession implements HarnessSession {
610
593
  this._processNextTurn();
611
594
  }
612
595
 
613
- protected _canReleaseAttempt(attempt: TurnState): boolean {
614
- return attempt.rpcSettled;
615
- }
616
-
617
596
  private _processNextTurn(): void {
618
597
  if (!this._inflight && this._queue.length > 0 && this._alive) {
619
598
  const next = this._queue.shift()!;
@@ -621,7 +600,7 @@ abstract class BaseCodexSession implements HarnessSession {
621
600
  }
622
601
  }
623
602
 
624
- protected _rejectInflight(
603
+ private _rejectInflight(
625
604
  err: Error,
626
605
  reason: import('./harness-session').SessionAttempt['localFailure'] = 'transport',
627
606
  ): void {
@@ -647,7 +626,7 @@ abstract class BaseCodexSession implements HarnessSession {
647
626
  this._withdrawRequests();
648
627
  }
649
628
 
650
- protected _rejectQueue(err: Error): void {
629
+ private _rejectQueue(err: Error): void {
651
630
  for (const turn of this._queue) turn.reject(turn.evidence.fail(err, 'blocked'));
652
631
  this._queue = [];
653
632
  }
@@ -701,7 +680,7 @@ abstract class BaseCodexSession implements HarnessSession {
701
680
  }
702
681
 
703
682
  // ---------------------------------------------------------------------------
704
- // Private — JSON-RPC line processor (shared envelope; payload is per-protocol)
683
+ // Private — JSON-RPC line processor
705
684
  // ---------------------------------------------------------------------------
706
685
 
707
686
  private _processLine(line: string): void {
@@ -770,7 +749,7 @@ abstract class BaseCodexSession implements HarnessSession {
770
749
  // Private — emit (identical to ClaudeCodeSession)
771
750
  // ---------------------------------------------------------------------------
772
751
 
773
- protected _emit(event: SessionEvent): void {
752
+ private _emit(event: SessionEvent): void {
774
753
  event = retainEvidence(event);
775
754
  if (event.kind === 'session_end') {
776
755
  if (this._endEmitted) return;
@@ -795,9 +774,9 @@ abstract class BaseCodexSession implements HarnessSession {
795
774
  // Private — JSON-RPC request helper (resolves on the matching response)
796
775
  // ---------------------------------------------------------------------------
797
776
 
798
- protected _rpcRequest(method: string, params?: unknown): Promise<unknown> {
777
+ private _rpcRequest(method: string, params?: unknown): Promise<unknown> {
799
778
  const id = ++this._rpcId;
800
- if ((method === 'tools/call' || method === 'turn/start') && this._inflight) {
779
+ if (method === 'turn/start' && this._inflight) {
801
780
  this._inflight.evidence.identity = { ...this._inflight.evidence.identity, rpcRequestId: id };
802
781
  this._inflight.evidence.rpcOutcome = 'pending';
803
782
  }
@@ -814,346 +793,16 @@ abstract class BaseCodexSession implements HarnessSession {
814
793
  });
815
794
  }
816
795
 
817
- protected _serverRequest(_request: Record<string, unknown>): boolean {
818
- return false;
819
- }
820
-
821
- protected _rpcNotify(method: string, params?: unknown): void {
796
+ private _rpcNotify(method: string, params?: unknown): void {
822
797
  const payload = `${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`;
823
798
  this._proc!.stdin.write(payload);
824
799
  this._proc!.stdin.flush();
825
800
  }
826
801
 
827
802
  // ---------------------------------------------------------------------------
828
- // Subclass hooks — the only protocol-specific surface
803
+ // Account limits, native interrupt, steer
829
804
  // ---------------------------------------------------------------------------
830
805
 
831
- /** CLI args for the persistent process. */
832
- protected abstract _buildSpawnArgs(): string[];
833
- /** Protocol handshake after spawn (initialize / initialized / etc.). */
834
- protected abstract _handshake(): Promise<void>;
835
- /** Send one turn; resolve with token usage when the turn completes. */
836
- protected abstract _sendTurn(
837
- message: string,
838
- ): Promise<{ input: number; output: number } | undefined>;
839
- /** Map a streamed notification to SessionEvents. */
840
- protected abstract _classify(msg: Record<string, unknown>): SessionEvent[];
841
-
842
- protected _observeRpcResult(_id: number, _result: unknown): void {}
843
-
844
- /** The process is gone (kill, exit, transport failure): withdraw server requests still pending. */
845
- protected _withdrawRequests(): void {}
846
-
847
- /** Shared fork constructor — subclass passes its own ctor. */
848
- fork(opts?: { cwd?: string; baseContext?: string }): HarnessSession {
849
- if (this._inflight) throw new Error('Cannot fork while native ownership is unresolved');
850
- if (!this._externalSessionId) {
851
- throw new Error('Cannot fork — no thread ID yet (send at least one message first)');
852
- }
853
- const Ctor = this.constructor as new (c?: CodexSessionConfig) => BaseCodexSession;
854
- const forked = new Ctor({
855
- bin: this._bin,
856
- model: this._model,
857
- effort: this._effort,
858
- cwd: opts?.cwd ?? this._cwd,
859
- timeout: this._defaultTimeout,
860
- baseContext: opts?.baseContext ?? this._baseContext,
861
- externalSessionId: this._externalSessionId,
862
- spawn: this._spawn,
863
- env: this._env,
864
- });
865
- forked._forking = true;
866
- return forked;
867
- }
868
- }
869
-
870
- // ---------------------------------------------------------------------------
871
- // CodexMcpSession (DEFAULT) — `codex mcp-server` over stdio MCP JSON-RPC
872
- // ---------------------------------------------------------------------------
873
- //
874
- // Handshake: initialize → notifications/initialized → tools/list.
875
- // A turn is an MCP `tools/call`:
876
- // - first turn → tool "codex" (params: prompt, model, cwd, ...)
877
- // - subsequent turns → tool "codex-reply" (params: prompt, threadId)
878
- // During the call, codex streams `codex/event` notifications (agent message,
879
- // reasoning, command execution, token usage) — mapped to SessionEvents. The
880
- // threadId comes back in the call result's structuredContent (captured for
881
- // multi-turn + fork).
882
-
883
- export class CodexMcpSession extends BaseCodexSession {
884
- get transport() {
885
- return TRANSPORTS['codex-mcp'];
886
- }
887
- private _mcpAdmission?: string;
888
- private _mcpCalls = new Map<string, { invocation: Record<string, unknown>; ended: boolean }>();
889
- protected _buildSpawnArgs(): string[] {
890
- // Disable codex's own approvals + sandbox (the union of
891
- // --dangerously-bypass-approvals-and-sandbox) so OUR container is the jail.
892
- // model_reasoning_effort sets the effort level; passed as TOML-ish `-c` values.
893
- const args = [
894
- 'mcp-server',
895
- '-c',
896
- `sandbox_mode="danger-full-access"`,
897
- '-c',
898
- `approval_policy="never"`,
899
- ];
900
- if (this._effort) {
901
- args.push('-c', `model_reasoning_effort="${this._effort}"`);
902
- }
903
- return args;
904
- }
905
-
906
- protected async _handshake(): Promise<void> {
907
- await this._rpcRequest('initialize', {
908
- protocolVersion: '2025-06-18',
909
- capabilities: {},
910
- clientInfo: { name: 'inixiative-bench', version: '0.1.0' },
911
- });
912
- this._rpcNotify('notifications/initialized');
913
- // tools/list confirms the `codex` + `codex-reply` tools are present.
914
- await this._rpcRequest('tools/list', {});
915
- }
916
-
917
- protected async _sendTurn(
918
- message: string,
919
- ): Promise<{ input: number; output: number } | undefined> {
920
- const isReply = this._externalSessionId !== undefined;
921
- const name = isReply ? 'codex-reply' : 'codex';
922
- const args: Record<string, unknown> = isReply
923
- ? { prompt: message, threadId: this._externalSessionId }
924
- : {
925
- prompt: message,
926
- model: this._model,
927
- cwd: this._cwd,
928
- // Belt-and-suspenders: also disable per-call (matches spawn `-c` flags).
929
- sandbox: 'danger-full-access',
930
- 'approval-policy': 'never',
931
- ...(this._effort ? { config: { model_reasoning_effort: this._effort } } : {}),
932
- };
933
-
934
- const result = (await this._rpcRequest('tools/call', {
935
- name,
936
- arguments: args,
937
- })) as Record<string, unknown> | undefined;
938
-
939
- // Capture the threadId for multi-turn (codex-reply) + fork.
940
- const structured = result?.structuredContent as Record<string, unknown> | undefined;
941
- const threadId = structured?.threadId as string | undefined;
942
- if (threadId && this._externalSessionId && threadId !== this._externalSessionId)
943
- throw new Error('MCP response changed the native resume binding');
944
- if (threadId && !this._externalSessionId) {
945
- this._externalSessionId = threadId;
946
- }
947
- if (threadId && this._inflight)
948
- this._inflight.evidence.identity = { ...this._inflight.evidence.identity, threadId };
949
- // Final text: prefer structuredContent.content, else the tool result content.
950
- const finalText =
951
- (structured?.content as string | undefined) ?? this._extractToolText(result?.content);
952
- if (result?.isError === true) throw new Error(finalText ?? 'MCP tools/call failed');
953
- if (finalText) {
954
- this._resultText = finalText;
955
- if (this._inflight) this._inflight.evidence.content = finalText;
956
- }
957
-
958
- // Usage, when the call result reports it. Streamed token_count events are
959
- // already accounted by the base loop; the call result is the authoritative
960
- // turn total, returned to _dispatchTurn for the SessionResult. To avoid
961
- // double-counting against streamed events, we only return it if no streamed
962
- // token event was seen this turn.
963
- const sawStreamedTokens = this._turnEvents.some((e) => e.tokens);
964
- const usage = structured?.usage as Record<string, number> | undefined;
965
- if (
966
- usage &&
967
- !sawStreamedTokens &&
968
- typeof (usage.input_tokens ?? usage.inputTokens) === 'number' &&
969
- typeof (usage.output_tokens ?? usage.outputTokens) === 'number'
970
- ) {
971
- return {
972
- input: (usage.input_tokens ?? usage.inputTokens ?? 0) as number,
973
- output: (usage.output_tokens ?? usage.outputTokens ?? 0) as number,
974
- };
975
- }
976
- return undefined;
977
- }
978
-
979
- private _extractToolText(content: unknown): string | undefined {
980
- if (!Array.isArray(content)) return undefined;
981
- const parts: string[] = [];
982
- for (const block of content) {
983
- const b = block as Record<string, unknown>;
984
- if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
985
- }
986
- return parts.length ? parts.join('\n') : undefined;
987
- }
988
-
989
- protected _classify(msg: Record<string, unknown>): SessionEvent[] {
990
- // Streamed events arrive as JSON-RPC notifications: method "codex/event"
991
- // with params carrying a `msg` of a tagged type.
992
- if (msg.method !== 'codex/event') return [];
993
- const params = msg.params as Record<string, unknown> | undefined;
994
- const ev = (params?.msg ?? params) as Record<string, unknown> | undefined;
995
- if (!ev) return [];
996
- const a = this._inflight?.evidence;
997
- const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
998
- const nativeSessionId = str(ev.session_id),
999
- threadId = str(ev.thread_id);
1000
- const turnId = str(ev.turn_id),
1001
- envelopeId = str(params?.id);
1002
- const item = ev.item as Record<string, unknown> | undefined;
1003
- const unowned = (reason: NonNullable<SessionEvent['unattributedReason']>): SessionEvent[] => [
1004
- {
1005
- kind: 'native_status',
1006
- timestamp: Date.now(),
1007
- correlation: 'unknown',
1008
- unattributedReason: reason,
1009
- nativeSessionId,
1010
- threadId,
1011
- turnId,
1012
- callId: str(ev.call_id),
1013
- itemId: str(item?.id) ?? str(ev.item_id),
1014
- // Keep the envelope too: params.id may explain a rejected correlation.
1015
- // Unowned MCP calls retain identity/type diagnostics, never unrelated tool payloads.
1016
- raw:
1017
- ev.type === 'mcp_tool_call_begin' || ev.type === 'mcp_tool_call_end'
1018
- ? { type: ev.type }
1019
- : msg,
1020
- },
1021
- ];
1022
- if (threadId && this._externalSessionId && threadId !== this._externalSessionId)
1023
- return unowned('foreign-session');
1024
- if (
1025
- nativeSessionId &&
1026
- a?.identity.nativeSessionId &&
1027
- nativeSessionId !== a.identity.nativeSessionId
1028
- )
1029
- return unowned('foreign-session');
1030
- if (
1031
- (envelopeId && this._seenTerminals.has(envelopeId)) ||
1032
- (turnId && this._seenTerminals.has(turnId))
1033
- ) {
1034
- return unowned(ev.type === 'task_complete' ? 'duplicate-terminal' : 'after-terminal');
1035
- }
1036
- if (!a) {
1037
- if (ev.type === 'task_complete' && turnId) this._seenTerminals.add(turnId);
1038
- return unowned('no-admission');
1039
- }
1040
- if (ev.type === 'session_configured') {
1041
- if (!this._externalSessionId && threadId) this._externalSessionId = threadId;
1042
- a.identity = { ...a.identity, nativeSessionId, threadId };
1043
- }
1044
- if (
1045
- ev.type !== 'session_configured' &&
1046
- a.identity.turnId &&
1047
- envelopeId &&
1048
- envelopeId !== a.identity.turnId
1049
- )
1050
- return unowned('foreign-turn');
1051
- if (threadId && a.identity.threadId && threadId !== a.identity.threadId)
1052
- return unowned('foreign-session');
1053
- if (ev.type === 'task_started' && turnId && !a.identity.turnId)
1054
- a.identity = { ...a.identity, turnId, correlation: 'native-turn' };
1055
- if (turnId && turnId !== a.identity.turnId) return unowned('foreign-turn');
1056
- if (ev.type === 'task_complete') {
1057
- if (!turnId || turnId !== a.identity.turnId) return unowned('unrecognized-event');
1058
- this._seenTerminals.add(turnId);
1059
- return [
1060
- {
1061
- ...a.identity,
1062
- kind: 'result',
1063
- timestamp: Date.now(),
1064
- nativeOutcome: 'completed',
1065
- terminal: { type: 'task_complete', turnId },
1066
- raw: ev,
1067
- },
1068
- ];
1069
- }
1070
- const identity = {
1071
- ...a.identity,
1072
- threadId: threadId ?? a.identity.threadId,
1073
- turnId: turnId ?? a.identity.turnId,
1074
- callId: str(ev.call_id),
1075
- itemId: str(item?.id) ?? str(ev.item_id),
1076
- };
1077
- if (ev.type === 'mcp_tool_call_begin' || ev.type === 'mcp_tool_call_end') {
1078
- const invocation = object(ev.invocation);
1079
- if (
1080
- !identity.callId ||
1081
- !invocation ||
1082
- typeof invocation.server !== 'string' ||
1083
- !invocation.server ||
1084
- typeof invocation.tool !== 'string' ||
1085
- !invocation.tool
1086
- )
1087
- return unowned('malformed-tool');
1088
- if (this._mcpAdmission !== a.identity.admissionId) {
1089
- this._mcpAdmission = a.identity.admissionId;
1090
- this._mcpCalls.clear();
1091
- }
1092
- // Compare the original argument value, including absence. Never join a result
1093
- // for another server/tool or silently substitute arguments from its envelope.
1094
- const join = {
1095
- server: invocation.server,
1096
- tool: invocation.tool,
1097
- arguments: invocation.arguments,
1098
- };
1099
- const prior = this._mcpCalls.get(identity.callId);
1100
- const tool = {
1101
- toolName: `mcp__${invocation.server}__${invocation.tool}`,
1102
- toolServer: invocation.server,
1103
- toolMethod: invocation.tool,
1104
- };
1105
- if (ev.type === 'mcp_tool_call_begin') {
1106
- if (prior) return unowned('duplicate-tool');
1107
- this._mcpCalls.set(identity.callId, { invocation: structuredClone(join), ended: false });
1108
- const args = publicMcpArguments(invocation.arguments);
1109
- return [{ ...identity, ...tool, kind: 'tool_use', timestamp: Date.now(), ...args }];
1110
- }
1111
- if (!prior || !isDeepStrictEqual(prior.invocation, join)) return unowned('unmatched-tool');
1112
- if (prior.ended) return unowned('duplicate-tool');
1113
- const result = publicMcpResult(ev.result);
1114
- if (!result) return unowned('malformed-tool');
1115
- prior.ended = true;
1116
- return [{ ...identity, ...tool, kind: 'tool_result', timestamp: Date.now(), ...result }];
1117
- }
1118
- if (
1119
- ['session_configured', 'task_started', 'item_started', 'item_completed'].includes(
1120
- String(ev.type),
1121
- )
1122
- ) {
1123
- return [
1124
- {
1125
- ...identity,
1126
- kind: 'native_status',
1127
- timestamp: Date.now(),
1128
- nativeOutcome: a.nativeOutcome,
1129
- raw: ev,
1130
- },
1131
- ];
1132
- }
1133
- // Notifications without a turn ID are associated only with the single owned RPC.
1134
- const classified = classifyCodexEvent(ev, 'type');
1135
- return classified.length
1136
- ? classified.map((event) => ({ ...identity, ...event }))
1137
- : unowned('unrecognized-event');
1138
- }
1139
- }
1140
-
1141
- // ---------------------------------------------------------------------------
1142
- // CodexAppServerSession (EXPERIMENTAL) — `codex app-server` over stdio
1143
- // ---------------------------------------------------------------------------
1144
- //
1145
- // Wire contract: installed codex-cli 0.153.4 v2 JSON schema. Controlled tests
1146
- // establish source behavior, not a real create/resume/native-completion capture.
1147
- // No default switch. Full history hydration, native fork and cancellation remain
1148
- // separate integration work; a local interrupt never acknowledges native stop.
1149
-
1150
- class NativeValidationError extends Error {}
1151
-
1152
- export class CodexAppServerSession extends BaseCodexSession {
1153
- get transport() {
1154
- return TRANSPORTS['codex-app-server'];
1155
- }
1156
- readonly appServerProtocol = 'owned-thread-v1';
1157
806
  private _limits?: LimitSnapshot;
1158
807
 
1159
808
  /** Latest account limits observed on this session (stream or poll). */
@@ -1229,7 +878,7 @@ export class CodexAppServerSession extends BaseCodexSession {
1229
878
  }
1230
879
 
1231
880
  /** Mid-turn steer (turn/steer) into the owned in-flight turn; otherwise records push_ignored. */
1232
- override async push(payload: { kind: string; text: string }): Promise<void> {
881
+ async push(payload: { kind: string; text: string }): Promise<void> {
1233
882
  const a = this._inflight?.evidence;
1234
883
  const ignored = (reason: string) =>
1235
884
  this._emit({
@@ -1307,41 +956,12 @@ export class CodexAppServerSession extends BaseCodexSession {
1307
956
  }
1308
957
  >();
1309
958
 
1310
- constructor(config?: CodexSessionConfig) {
1311
- super(config);
1312
- const { onThreadReady, ...options } = config?.appServer ?? {};
1313
- this._onThreadReady = onThreadReady;
1314
- this._options = retainEvidence(options);
1315
- const required = this._options.requiredMcpServer;
1316
- if (
1317
- required &&
1318
- (!/^[a-zA-Z0-9_-]+$/.test(required.name) ||
1319
- !required.tools.length ||
1320
- required.tools.some((t) => !/^[a-zA-Z0-9_-]+$/.test(t)))
1321
- )
1322
- throw new NativeValidationError('Invalid required MCP inventory');
1323
- }
1324
-
1325
- override fork(): HarnessSession {
959
+ fork(): HarnessSession {
1326
960
  throw Error(
1327
961
  'Native app-server fork is not implemented; a same-thread wrapper cannot create a native fork or inherit a process capability',
1328
962
  );
1329
963
  }
1330
964
 
1331
- override async start(): Promise<void> {
1332
- if (this._inflight)
1333
- throw Error('Native call ownership unresolved; automatic resume is blocked');
1334
- if (this._startedOnce && !this._alive)
1335
- throw Error('App-server restart requires a new owned instance and preflight');
1336
- this._startedOnce = true;
1337
- try {
1338
- await super.start();
1339
- } catch (error) {
1340
- this.kill();
1341
- throw error;
1342
- }
1343
- }
1344
-
1345
965
  /** Needs an `approvalPolicy` other than `never`. Gates registered here are not copied (no fork). */
1346
966
  onToolRequest(gate: ToolGate): () => void {
1347
967
  if (this._approvalPolicy === 'never')
@@ -1351,7 +971,7 @@ export class CodexAppServerSession extends BaseCodexSession {
1351
971
  return this._gates.add(gate);
1352
972
  }
1353
973
 
1354
- protected override _withdrawRequests(): void {
974
+ private _withdrawRequests(): void {
1355
975
  for (const approval of this._approvals.values()) approval.abort();
1356
976
  this._approvals.clear();
1357
977
  }
@@ -1447,7 +1067,7 @@ export class CodexAppServerSession extends BaseCodexSession {
1447
1067
  return true;
1448
1068
  }
1449
1069
 
1450
- protected override _serverRequest(request: Record<string, unknown>): boolean {
1070
+ private _serverRequest(request: Record<string, unknown>): boolean {
1451
1071
  if (this._approval(request)) return true;
1452
1072
  // JSON-RPC's explicit method-not-supported refusal also covers unknown future
1453
1073
  // requests. Never approve native work or echo private request parameters.
@@ -1557,17 +1177,17 @@ export class CodexAppServerSession extends BaseCodexSession {
1557
1177
  this._refuse('mcp-inventory-page-limit');
1558
1178
  }
1559
1179
 
1560
- protected override _canReleaseAttempt(a: TurnState): boolean {
1180
+ private _canReleaseAttempt(a: TurnState): boolean {
1561
1181
  // An observer/write failure may occur after delivery. A terminal alone does
1562
1182
  // not settle the unanswered turn/start call or authorize another admission.
1563
1183
  return a.rpcSettled && (a.rpcOutcome === 'resolved' || a.rpcOutcome === 'failed');
1564
1184
  }
1565
1185
 
1566
- protected _buildSpawnArgs(): string[] {
1186
+ private _buildSpawnArgs(): string[] {
1567
1187
  return ['app-server', '--listen', 'stdio://'];
1568
1188
  }
1569
1189
 
1570
- protected async _handshake(): Promise<void> {
1190
+ private async _handshake(): Promise<void> {
1571
1191
  this._threadReady = false;
1572
1192
  const initialized = object(
1573
1193
  await this._rpcRequest('initialize', {
@@ -1587,9 +1207,7 @@ export class CodexAppServerSession extends BaseCodexSession {
1587
1207
  this._rpcNotify('initialized');
1588
1208
  }
1589
1209
 
1590
- protected async _sendTurn(
1591
- message: string,
1592
- ): Promise<{ input: number; output: number } | undefined> {
1210
+ private async _sendTurn(message: string): Promise<{ input: number; output: number } | undefined> {
1593
1211
  const a = this._inflight!.evidence;
1594
1212
  if (!this._threadReady) {
1595
1213
  const binding = this._externalSessionId;
@@ -1673,6 +1291,7 @@ export class CodexAppServerSession extends BaseCodexSession {
1673
1291
  timestamp: Date.now(),
1674
1292
  threadId: id,
1675
1293
  nativeSessionId: this._nativeSessionId,
1294
+ correlation: 'unknown',
1676
1295
  unattributedReason: 'session-configuration',
1677
1296
  raw: {
1678
1297
  type: 'thread-configured',
@@ -1728,7 +1347,7 @@ export class CodexAppServerSession extends BaseCodexSession {
1728
1347
  return a.tokens;
1729
1348
  }
1730
1349
 
1731
- protected override _observeRpcResult(id: number, result: unknown): void {
1350
+ private _observeRpcResult(id: number, result: unknown): void {
1732
1351
  const a = this._inflight?.evidence;
1733
1352
  if (!a || this._appAdmission !== a.admissionId || a.identity.rpcRequestId !== id) return;
1734
1353
  const turn = this._turn(object(result)?.turn);
@@ -1756,7 +1375,7 @@ export class CodexAppServerSession extends BaseCodexSession {
1756
1375
  return turn as { id: string; status: string; error?: unknown };
1757
1376
  }
1758
1377
 
1759
- protected _classify(msg: Record<string, unknown>): SessionEvent[] {
1378
+ private _classify(msg: Record<string, unknown>): SessionEvent[] {
1760
1379
  const method = typeof msg.method === 'string' ? msg.method : undefined;
1761
1380
  if (!method) return [];
1762
1381
  if (method === 'account/rateLimits/updated') {
@@ -1995,7 +1614,7 @@ export class CodexAppServerSession extends BaseCodexSession {
1995
1614
  return unowned('malformed-tool');
1996
1615
  const error = object(item.error),
1997
1616
  result = object(item.result);
1998
- const output = result ? publicMcpResult({ Ok: { content: result.content } }) : undefined;
1617
+ const output = result ? publicMcpResult(result.content) : undefined;
1999
1618
  if (item.error != null && typeof error?.message !== 'string')
2000
1619
  return unowned('malformed-tool');
2001
1620
  prior.completed = true;
@@ -2022,17 +1641,8 @@ export class CodexAppServerSession extends BaseCodexSession {
2022
1641
  }
2023
1642
 
2024
1643
  // ---------------------------------------------------------------------------
2025
- // Shared event mapping — codex item/event → SessionEvent
1644
+ // Helpers
2026
1645
  // ---------------------------------------------------------------------------
2027
- //
2028
- // Both protocols carry the same ThreadItem / event shapes (agent message,
2029
- // reasoning, command execution, token usage). `tag` is the discriminant field
2030
- // ("type" for both mcp `codex/event.msg.type` and app-server `item.type`).
2031
- //
2032
- // agent_message / agentMessage → text
2033
- // reasoning / agent_reasoning → thinking
2034
- // command_execution / commandExecution → tool_use (+ tool_result when done)
2035
- // token_count / usage → tokens (attached to a result event)
2036
1646
 
2037
1647
  const object = (value: unknown): Record<string, unknown> | undefined =>
2038
1648
  value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -2117,25 +1727,15 @@ function publicMcpArguments(value: unknown): Pick<SessionEvent, 'toolInput' | 't
2117
1727
  };
2118
1728
  }
2119
1729
 
2120
- /** Rust Result<CallToolResult,String> from protocol.rs. This is legacy event
2121
- * normalization, not the app-server ThreadItem schema. Keep all public text
2122
- * 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. */
2123
1732
  function publicMcpResult(
2124
1733
  value: unknown,
2125
- ): Pick<SessionEvent, 'toolOutput' | 'toolError' | 'toolOutputOmitted'> | undefined {
2126
- const result = object(value);
2127
- if (!result || Object.keys(result).length !== 1) return;
2128
- if (typeof result.Err === 'string') return { toolOutput: result.Err, toolError: true };
2129
- const ok = object(result.Ok);
2130
- if (
2131
- !ok ||
2132
- !Array.isArray(ok.content) ||
2133
- (ok.isError !== undefined && typeof ok.isError !== 'boolean')
2134
- )
2135
- return;
2136
- let omitted = Object.keys(ok).some((k) => k !== 'content' && k !== 'isError');
2137
- const content = ok.content.map((value) => {
2138
- 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);
2139
1739
  if (block?.type === 'text' && typeof block.text === 'string') {
2140
1740
  if (Object.keys(block).some((k) => k !== 'type' && k !== 'text')) omitted = true;
2141
1741
  return { type: 'text', text: block.text };
@@ -2149,113 +1749,7 @@ function publicMcpResult(
2149
1749
  };
2150
1750
  });
2151
1751
  return {
2152
- toolOutput: JSON.stringify({
2153
- content,
2154
- ...(ok.isError !== undefined ? { isError: ok.isError } : {}),
2155
- }),
2156
- ...(ok.isError !== undefined ? { toolError: ok.isError as boolean } : {}),
1752
+ toolOutput: JSON.stringify({ content }),
2157
1753
  ...(omitted ? { toolOutputOmitted: true } : {}),
2158
1754
  };
2159
1755
  }
2160
-
2161
- function classifyCodexEvent(ev: Record<string, unknown>, tag: string): SessionEvent[] {
2162
- const ts = Date.now();
2163
- const type = String(ev[tag] ?? '').toLowerCase();
2164
- const events: SessionEvent[] = [];
2165
-
2166
- // Agent message → text.
2167
- if (type === 'agent_message' || type === 'agentmessage' || type === 'agent_message_delta') {
2168
- const text = (ev.message ?? ev.text ?? ev.delta) as string | undefined;
2169
- if (text) events.push({ kind: 'text', timestamp: ts, text, raw: ev });
2170
- return events;
2171
- }
2172
-
2173
- // Reasoning → thinking.
2174
- if (
2175
- type === 'reasoning' ||
2176
- type === 'agent_reasoning' ||
2177
- type === 'agentreasoning' ||
2178
- type === 'agent_reasoning_delta'
2179
- ) {
2180
- const text = (ev.text ?? ev.reasoning ?? ev.delta ?? ev.summary) as string | undefined;
2181
- if (text) events.push({ kind: 'thinking', timestamp: ts, text, raw: ev });
2182
- return events;
2183
- }
2184
-
2185
- // Command execution → tool_use, plus tool_result if output is present.
2186
- if (
2187
- type === 'command_execution' ||
2188
- type === 'commandexecution' ||
2189
- type === 'exec_command_begin' ||
2190
- type === 'exec_command_end'
2191
- ) {
2192
- const command = (ev.command ?? ev.cmd) as string | string[] | undefined;
2193
- const cmdStr = Array.isArray(command) ? command.join(' ') : command;
2194
- if (type !== 'exec_command_end')
2195
- events.push({
2196
- kind: 'tool_use',
2197
- timestamp: ts,
2198
- toolName: 'shell',
2199
- toolInput: cmdStr ? { command: cmdStr } : (ev as Record<string, unknown>),
2200
- raw: ev,
2201
- });
2202
- const output = (ev.output ?? ev.stdout ?? ev.aggregated_output) as string | undefined;
2203
- const exitCode = (ev.exit_code ?? ev.exitCode) as number | undefined;
2204
- if (output !== undefined || exitCode !== undefined) {
2205
- events.push({
2206
- kind: 'tool_result',
2207
- timestamp: ts,
2208
- toolOutput: output ?? '',
2209
- toolError: exitCode !== undefined && exitCode !== 0,
2210
- raw: ev,
2211
- });
2212
- }
2213
- return events;
2214
- }
2215
-
2216
- // Token usage → carried on a result-less event so accounting picks it up.
2217
- if (
2218
- type === 'token_count' ||
2219
- type === 'token_usage' ||
2220
- type === 'usage' ||
2221
- type === 'tokenusage'
2222
- ) {
2223
- const u = (ev.info ?? ev.usage ?? ev) as Record<string, unknown>;
2224
- const input = (u.input_tokens ?? u.inputTokens ?? u.total_input_tokens ?? 0) as number;
2225
- const output = (u.output_tokens ?? u.outputTokens ?? u.total_output_tokens ?? 0) as number;
2226
- if (input || output) {
2227
- events.push({
2228
- kind: 'result',
2229
- timestamp: ts,
2230
- tokens: { input, output },
2231
- raw: ev,
2232
- });
2233
- }
2234
- return events;
2235
- }
2236
-
2237
- // Errors.
2238
- if (type === 'error' || type === 'stream_error') {
2239
- events.push({
2240
- kind: 'error',
2241
- timestamp: ts,
2242
- text: (ev.message ?? ev.error ?? JSON.stringify(ev)) as string,
2243
- raw: ev,
2244
- });
2245
- return events;
2246
- }
2247
-
2248
- // Unclassified events are preserved via `raw` on nothing — but we keep them
2249
- // out of turn content. Oracle can still introspect via the live stream.
2250
- return events;
2251
- }
2252
-
2253
- // ---------------------------------------------------------------------------
2254
- // Default export selection
2255
- // ---------------------------------------------------------------------------
2256
-
2257
- /**
2258
- * The default CodexSession is the MCP variant (stdio mcp-server). Import
2259
- * CodexAppServerSession explicitly for the experimental app-server variant.
2260
- */
2261
- export const CodexSession = CodexMcpSession;