@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.
- package/README.md +45 -13
- package/package.json +3 -3
- package/src/claude-agent-sdk-session.ts +136 -3
- package/src/claude-code-session.ts +275 -14
- package/src/claude-primed.ts +153 -35
- package/src/codex-session.ts +339 -607
- package/src/harness-session.ts +52 -2
- package/src/index.ts +6 -4
- package/src/pool.ts +1 -1
- package/src/primed.ts +1 -1
- package/src/tool-gate.ts +61 -0
- package/src/transport.ts +11 -38
- package/src/transports.ts +1 -4
package/src/codex-session.ts
CHANGED
|
@@ -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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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
|
|
101
|
-
*
|
|
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
|
|
106
|
-
*
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
183
|
+
private _proc: PipedSubprocess | null = null;
|
|
184
|
+
private _stderr = '';
|
|
160
185
|
|
|
161
186
|
// -- Session state --
|
|
162
|
-
|
|
163
|
-
|
|
187
|
+
private _externalSessionId?: string;
|
|
188
|
+
private _alive = false;
|
|
164
189
|
private _endEmitted = false;
|
|
165
|
-
|
|
166
|
-
|
|
190
|
+
private _eventLog: SessionEvent[] = [];
|
|
191
|
+
private _handlers: SessionEventHandler[] = [];
|
|
167
192
|
private _observerFailures = { synchronous: 0, asynchronous: 0 };
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
+
private _rpcId = 0;
|
|
180
203
|
/** Pending JSON-RPC responses, keyed by request id. */
|
|
181
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
777
|
+
private _rpcRequest(method: string, params?: unknown): Promise<unknown> {
|
|
749
778
|
const id = ++this._rpcId;
|
|
750
|
-
if (
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1186
|
+
private _buildSpawnArgs(): string[] {
|
|
1403
1187
|
return ['app-server', '--listen', 'stdio://'];
|
|
1404
1188
|
}
|
|
1405
1189
|
|
|
1406
|
-
|
|
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
|
-
|
|
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:
|
|
1439
|
-
|
|
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 !==
|
|
1262
|
+
response.approvalPolicy !== this._approvalPolicy ||
|
|
1469
1263
|
response.approvalsReviewer !== 'user' ||
|
|
1470
|
-
object(response.sandbox)?.type !==
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
1883
|
-
*
|
|
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' | '
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
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;
|