@inixiative/agent-session 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -6
- package/package.json +39 -8
- package/src/claude-agent-sdk-session.ts +316 -0
- package/src/claude-code-session.ts +715 -195
- package/src/claude-primed.ts +655 -0
- package/src/claude-usage.ts +29 -0
- package/src/codex-primed.ts +1482 -0
- package/src/codex-session.ts +1317 -241
- package/src/harness-session.ts +182 -24
- package/src/index.ts +106 -10
- package/src/json-rpc.ts +240 -0
- package/src/limits-probe.ts +78 -0
- package/src/limits.ts +216 -0
- package/src/pool.ts +652 -0
- package/src/primed.ts +284 -0
- package/src/retained-evidence.ts +20 -0
- package/src/routing.ts +273 -0
- package/src/transport.ts +215 -0
- package/src/transports.ts +97 -0
- package/src/turn-state.ts +72 -0
package/src/codex-session.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { codexLimitSnapshot, type LimitSnapshot, mergeLimits } from './limits';
|
|
3
|
+
import { retainEvidence } from './retained-evidence';
|
|
4
|
+
import { TRANSPORTS } from './transport';
|
|
5
|
+
import { TurnState } from './turn-state';
|
|
1
6
|
// ---------------------------------------------------------------------------
|
|
2
7
|
// CodexSession — long-lived OpenAI Codex CLI process with full event capture
|
|
3
8
|
// ---------------------------------------------------------------------------
|
|
@@ -8,7 +13,7 @@
|
|
|
8
13
|
// the shared SessionEvent taxonomy and resolving a turn on completion.
|
|
9
14
|
//
|
|
10
15
|
// CodexMcpSession (default) — `codex mcp-server` (stdio MCP JSON-RPC)
|
|
11
|
-
// CodexAppServerSession (experimental) — `codex app-server` (
|
|
16
|
+
// CodexAppServerSession (experimental) — `codex app-server` (stdio JSON-RPC)
|
|
12
17
|
//
|
|
13
18
|
// Both DISABLE codex's own approvals + sandbox (the union of
|
|
14
19
|
// `--dangerously-bypass-approvals-and-sandbox`) so OUR container is the only
|
|
@@ -30,19 +35,20 @@
|
|
|
30
35
|
import type {
|
|
31
36
|
BeforeSendHook,
|
|
32
37
|
HarnessSession,
|
|
38
|
+
NativeInterruptOutcome,
|
|
39
|
+
SessionArtifact,
|
|
33
40
|
SessionEvent,
|
|
34
41
|
SessionEventHandler,
|
|
35
42
|
SessionResult,
|
|
36
|
-
|
|
37
|
-
} from "./harness-session";
|
|
43
|
+
} from './harness-session';
|
|
38
44
|
|
|
39
45
|
// Re-export types so importers can stay on one path (mirrors claude-code-session).
|
|
40
46
|
export type {
|
|
47
|
+
SessionArtifact,
|
|
41
48
|
SessionEvent,
|
|
42
49
|
SessionEventKind,
|
|
43
50
|
SessionResult,
|
|
44
|
-
|
|
45
|
-
} from "./harness-session";
|
|
51
|
+
} from './harness-session';
|
|
46
52
|
|
|
47
53
|
// ---------------------------------------------------------------------------
|
|
48
54
|
// Shared subprocess shape (mirrors ClaudeCodeSession.PipedSubprocess)
|
|
@@ -67,6 +73,15 @@ export type CodexSpawn = (
|
|
|
67
73
|
// ---------------------------------------------------------------------------
|
|
68
74
|
|
|
69
75
|
export interface CodexSessionConfig {
|
|
76
|
+
/** Explicit app-server integration. Never applied by the default MCP engine.
|
|
77
|
+
* Config is native launch data, not event/recording data. */
|
|
78
|
+
appServer?: {
|
|
79
|
+
/** Persist the actual returned binding before model admission; failure blocks work. */
|
|
80
|
+
onThreadReady?: (binding: string) => void | Promise<void>;
|
|
81
|
+
requireConfiguration?: boolean;
|
|
82
|
+
config?: Readonly<Record<string, unknown>>;
|
|
83
|
+
requiredMcpServer?: { readonly name: string; readonly tools: readonly string[] };
|
|
84
|
+
};
|
|
70
85
|
/** Path to codex CLI binary. Defaults to "codex". */
|
|
71
86
|
bin?: string;
|
|
72
87
|
/** Model. Defaults to "gpt-5.5". */
|
|
@@ -88,10 +103,12 @@ export interface CodexSessionConfig {
|
|
|
88
103
|
baseContext?: string;
|
|
89
104
|
/**
|
|
90
105
|
* codex's native thread ID (the value the `codex` tool returns). When set, the
|
|
91
|
-
* first send()
|
|
92
|
-
*
|
|
106
|
+
* first send() uses `codex-reply` on MCP (loaded threads only), or
|
|
107
|
+
* `thread/resume` on app-server. Only app-server supports cold resume by ID.
|
|
93
108
|
*/
|
|
94
109
|
externalSessionId?: string;
|
|
110
|
+
/** Environment merged over process.env (e.g. CODEX_HOME for a profile). */
|
|
111
|
+
env?: Record<string, string | undefined>;
|
|
95
112
|
/**
|
|
96
113
|
* Override for the process spawner. Defaults to Bun.spawn. Tests inject a fake
|
|
97
114
|
* subprocess that emulates the codex JSON-RPC protocol; the docker-spawn helper
|
|
@@ -105,6 +122,8 @@ export interface CodexSessionConfig {
|
|
|
105
122
|
// ---------------------------------------------------------------------------
|
|
106
123
|
|
|
107
124
|
interface QueuedTurn {
|
|
125
|
+
onAdmission?: import('./harness-session').SessionSendOptions['onAdmission'];
|
|
126
|
+
evidence: TurnState;
|
|
108
127
|
message: string;
|
|
109
128
|
timeout: number;
|
|
110
129
|
resolve: (result: SessionResult) => void;
|
|
@@ -112,7 +131,7 @@ interface QueuedTurn {
|
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
// codex reasoning-effort levels (model_reasoning_effort), low→high.
|
|
115
|
-
const VALID_EFFORTS = [
|
|
134
|
+
const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh'];
|
|
116
135
|
|
|
117
136
|
// ---------------------------------------------------------------------------
|
|
118
137
|
// Base class — shared queue, event log, classification helpers, lifecycle
|
|
@@ -133,16 +152,19 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
133
152
|
protected _defaultTimeout: number;
|
|
134
153
|
protected _baseContext?: string;
|
|
135
154
|
protected _spawn?: CodexSpawn;
|
|
155
|
+
protected _env?: Record<string, string | undefined>;
|
|
136
156
|
|
|
137
157
|
// -- Process --
|
|
138
158
|
protected _proc: PipedSubprocess | null = null;
|
|
139
|
-
protected _stderr =
|
|
159
|
+
protected _stderr = '';
|
|
140
160
|
|
|
141
161
|
// -- Session state --
|
|
142
162
|
protected _externalSessionId?: string;
|
|
143
163
|
protected _alive = false;
|
|
164
|
+
private _endEmitted = false;
|
|
144
165
|
protected _eventLog: SessionEvent[] = [];
|
|
145
166
|
protected _handlers: SessionEventHandler[] = [];
|
|
167
|
+
private _observerFailures = { synchronous: 0, asynchronous: 0 };
|
|
146
168
|
protected _beforeSendHooks: BeforeSendHook[] = [];
|
|
147
169
|
protected _turns = 0;
|
|
148
170
|
protected _totalTokens = { input: 0, output: 0 };
|
|
@@ -163,29 +185,32 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
163
185
|
|
|
164
186
|
// -- Turn queue (identical to ClaudeCodeSession) --
|
|
165
187
|
protected _queue: QueuedTurn[] = [];
|
|
188
|
+
protected _attempts: TurnState[] = [];
|
|
189
|
+
protected _seenTerminals = new Set<string>();
|
|
166
190
|
protected _inflight: QueuedTurn | null = null;
|
|
167
191
|
protected _turnEvents: SessionEvent[] = [];
|
|
168
|
-
protected _resultText =
|
|
192
|
+
protected _resultText = '';
|
|
169
193
|
protected _turnTimer: ReturnType<typeof setTimeout> | null = null;
|
|
170
194
|
|
|
171
195
|
constructor(config?: CodexSessionConfig) {
|
|
172
|
-
const bin = config?.bin ??
|
|
173
|
-
if (!/^[a-zA-Z0-9_
|
|
196
|
+
const bin = config?.bin ?? 'codex';
|
|
197
|
+
if (!/^[a-zA-Z0-9_./\\-]+$/.test(bin)) {
|
|
174
198
|
throw new Error(`Invalid codex CLI binary path: "${bin}"`);
|
|
175
199
|
}
|
|
176
200
|
if (config?.effort && !VALID_EFFORTS.includes(config.effort)) {
|
|
177
201
|
throw new Error(
|
|
178
|
-
`Invalid codex effort "${config.effort}". Valid: ${VALID_EFFORTS.join(
|
|
202
|
+
`Invalid codex effort "${config.effort}". Valid: ${VALID_EFFORTS.join(', ')}`,
|
|
179
203
|
);
|
|
180
204
|
}
|
|
181
205
|
this._bin = bin;
|
|
182
|
-
this._model = config?.model ??
|
|
206
|
+
this._model = config?.model ?? 'gpt-5.5';
|
|
183
207
|
this._effort = config?.effort;
|
|
184
208
|
this._cwd = config?.cwd ?? process.cwd();
|
|
185
209
|
this._defaultTimeout = config?.timeout ?? 600_000;
|
|
186
210
|
this._baseContext = config?.baseContext;
|
|
187
211
|
this._externalSessionId = config?.externalSessionId;
|
|
188
212
|
this._spawn = config?.spawn;
|
|
213
|
+
this._env = config?.env;
|
|
189
214
|
this._startedAt = Date.now();
|
|
190
215
|
}
|
|
191
216
|
|
|
@@ -193,10 +218,28 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
193
218
|
// Accessors (identical to ClaudeCodeSession)
|
|
194
219
|
// ---------------------------------------------------------------------------
|
|
195
220
|
|
|
196
|
-
get
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
get
|
|
221
|
+
get accounting() {
|
|
222
|
+
return 'observed-only' as const;
|
|
223
|
+
}
|
|
224
|
+
get attempts() {
|
|
225
|
+
return this._attempts.map((a) => a.snapshot());
|
|
226
|
+
}
|
|
227
|
+
get diagnostics() {
|
|
228
|
+
return Object.freeze({ observerFailures: Object.freeze({ ...this._observerFailures }) });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
get alive(): boolean {
|
|
232
|
+
return this._alive;
|
|
233
|
+
}
|
|
234
|
+
get externalSessionId(): string | undefined {
|
|
235
|
+
return this._externalSessionId;
|
|
236
|
+
}
|
|
237
|
+
get events(): readonly SessionEvent[] {
|
|
238
|
+
return Object.freeze([...this._eventLog]);
|
|
239
|
+
}
|
|
240
|
+
get turns(): number {
|
|
241
|
+
return this._turns;
|
|
242
|
+
}
|
|
200
243
|
get totalTokens(): Readonly<{ input: number; output: number }> {
|
|
201
244
|
return { ...this._totalTokens };
|
|
202
245
|
}
|
|
@@ -229,7 +272,7 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
229
272
|
*/
|
|
230
273
|
async push(payload: { kind: string; text: string }): Promise<void> {
|
|
231
274
|
this._emit({
|
|
232
|
-
kind:
|
|
275
|
+
kind: 'error',
|
|
233
276
|
timestamp: Date.now(),
|
|
234
277
|
text: `push_ignored: kind=${payload.kind} — codex turn has no OOB channel`,
|
|
235
278
|
raw: payload,
|
|
@@ -242,7 +285,10 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
242
285
|
|
|
243
286
|
interrupt(): void {
|
|
244
287
|
if (!this._inflight) return;
|
|
245
|
-
this._rejectInflight(
|
|
288
|
+
this._rejectInflight(
|
|
289
|
+
new Error('Local waiter interrupted; native cancellation unacknowledged'),
|
|
290
|
+
'interrupt-request',
|
|
291
|
+
);
|
|
246
292
|
}
|
|
247
293
|
|
|
248
294
|
kill(): void {
|
|
@@ -254,29 +300,40 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
254
300
|
this._turnTimer = null;
|
|
255
301
|
}
|
|
256
302
|
|
|
257
|
-
this._rejectInflight(new Error(
|
|
258
|
-
this._rejectQueue(new Error(
|
|
259
|
-
for (const p of this._pending.values()) p.reject(new Error(
|
|
303
|
+
this._rejectInflight(new Error('Session killed; native outcome may remain unknown'), 'killed');
|
|
304
|
+
this._rejectQueue(new Error('Session killed'));
|
|
305
|
+
for (const p of this._pending.values()) p.reject(new Error('Session killed'));
|
|
260
306
|
this._pending.clear();
|
|
261
307
|
|
|
262
|
-
try {
|
|
263
|
-
|
|
308
|
+
try {
|
|
309
|
+
this._proc.stdin.end();
|
|
310
|
+
} catch {
|
|
311
|
+
/* already closed */
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
this._proc.kill();
|
|
315
|
+
} catch {
|
|
316
|
+
/* already dead */
|
|
317
|
+
}
|
|
264
318
|
this._proc = null;
|
|
265
319
|
|
|
266
|
-
this._emit({ kind:
|
|
320
|
+
this._emit({ kind: 'session_end', timestamp: Date.now() });
|
|
267
321
|
}
|
|
268
322
|
|
|
269
323
|
artifact(): SessionArtifact {
|
|
270
324
|
return {
|
|
271
325
|
externalSessionId: this._externalSessionId,
|
|
326
|
+
attempts: this.attempts,
|
|
327
|
+
accounting: 'observed-only',
|
|
328
|
+
diagnostics: this.diagnostics,
|
|
272
329
|
events: [...this._eventLog],
|
|
273
330
|
startedAt: this._startedAt,
|
|
274
331
|
endedAt: this._alive ? undefined : Date.now(),
|
|
275
332
|
turns: this._turns,
|
|
276
333
|
totalTokens: { ...this._totalTokens },
|
|
277
|
-
toolCalls: this._eventLog.filter((e) => e.kind ===
|
|
278
|
-
toolResults: this._eventLog.filter((e) => e.kind ===
|
|
279
|
-
errors: this._eventLog.filter((e) => e.kind ===
|
|
334
|
+
toolCalls: this._eventLog.filter((e) => e.kind === 'tool_use').length,
|
|
335
|
+
toolResults: this._eventLog.filter((e) => e.kind === 'tool_result').length,
|
|
336
|
+
errors: this._eventLog.filter((e) => e.kind === 'error').length,
|
|
280
337
|
};
|
|
281
338
|
}
|
|
282
339
|
|
|
@@ -285,43 +342,53 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
285
342
|
// ---------------------------------------------------------------------------
|
|
286
343
|
|
|
287
344
|
async start(): Promise<void> {
|
|
288
|
-
if (this.
|
|
345
|
+
if (this._attempts.some((a) => a.dispatch === 'attempted' && a.nativeOutcome === 'unknown'))
|
|
346
|
+
throw new Error('Native outcome unresolved; automatic resume is blocked');
|
|
347
|
+
if (this._proc) throw new Error('Session already started');
|
|
289
348
|
|
|
290
349
|
const args = this._buildSpawnArgs();
|
|
291
350
|
const env: Record<string, string | undefined> = {
|
|
292
351
|
...process.env,
|
|
293
|
-
|
|
352
|
+
...this._env,
|
|
353
|
+
DISABLE_AUTOUPDATER: '1',
|
|
294
354
|
};
|
|
355
|
+
// Subscription transport: an inherited API key never selects metered billing.
|
|
356
|
+
delete env.OPENAI_API_KEY;
|
|
357
|
+
delete env.CODEX_API_KEY;
|
|
358
|
+
for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key];
|
|
295
359
|
|
|
296
360
|
if (this._spawn) {
|
|
297
361
|
this._proc = this._spawn([this._bin, ...args], { cwd: this._cwd, env });
|
|
298
362
|
} else {
|
|
299
363
|
this._proc = Bun.spawn([this._bin, ...args], {
|
|
300
364
|
cwd: this._cwd,
|
|
301
|
-
stdin:
|
|
302
|
-
stdout:
|
|
303
|
-
stderr:
|
|
365
|
+
stdin: 'pipe',
|
|
366
|
+
stdout: 'pipe',
|
|
367
|
+
stderr: 'pipe',
|
|
304
368
|
env,
|
|
305
369
|
}) as unknown as PipedSubprocess;
|
|
306
370
|
}
|
|
307
371
|
|
|
308
372
|
this._alive = true;
|
|
309
|
-
this.
|
|
373
|
+
this._endEmitted = false;
|
|
374
|
+
this._emit({ kind: 'session_start', timestamp: Date.now() });
|
|
310
375
|
|
|
311
|
-
this.
|
|
376
|
+
const proc = this._proc;
|
|
377
|
+
const stdoutDone = this._readStdout(proc);
|
|
312
378
|
this._readStderr();
|
|
313
379
|
|
|
314
|
-
|
|
315
|
-
if (!this._alive) return;
|
|
380
|
+
proc.exited.then(async (code) => {
|
|
381
|
+
if (this._proc !== proc || !this._alive) return;
|
|
316
382
|
this._alive = false;
|
|
383
|
+
await stdoutDone; // Drain buffered terminal evidence before interpreting process exit.
|
|
317
384
|
const errMsg = this._stderr.trim()
|
|
318
385
|
? `Process exited (code ${code}): ${this._stderr.trim().slice(0, 500)}`
|
|
319
386
|
: `Process exited with code ${code}`;
|
|
320
387
|
this._rejectInflight(new Error(errMsg));
|
|
321
|
-
this._rejectQueue(new Error(
|
|
322
|
-
for (const p of this._pending.values()) p.reject(new Error(
|
|
388
|
+
this._rejectQueue(new Error('Session ended'));
|
|
389
|
+
for (const p of this._pending.values()) p.reject(new Error('Session ended'));
|
|
323
390
|
this._pending.clear();
|
|
324
|
-
this._emit({ kind:
|
|
391
|
+
this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
|
|
325
392
|
});
|
|
326
393
|
|
|
327
394
|
await this._handshake();
|
|
@@ -333,13 +400,25 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
333
400
|
|
|
334
401
|
async send(
|
|
335
402
|
message: string,
|
|
336
|
-
opts?:
|
|
403
|
+
opts?: import('./harness-session').SessionSendOptions,
|
|
337
404
|
): Promise<SessionResult> {
|
|
405
|
+
if (
|
|
406
|
+
this._attempts.some(
|
|
407
|
+
(a) =>
|
|
408
|
+
a.dispatch === 'attempted' &&
|
|
409
|
+
a.nativeOutcome === 'unknown' &&
|
|
410
|
+
(!this._alive || a.localOutcome !== 'pending'),
|
|
411
|
+
)
|
|
412
|
+
) {
|
|
413
|
+
const blocked = new TurnState();
|
|
414
|
+
this._attempts.push(blocked);
|
|
415
|
+
throw blocked.fail(new Error('Native outcome unresolved; send not dispatched'), 'blocked');
|
|
416
|
+
}
|
|
338
417
|
if (!this._proc && this._externalSessionId) {
|
|
339
418
|
await this.start();
|
|
340
419
|
}
|
|
341
|
-
if (!this._proc) throw new Error(
|
|
342
|
-
if (!this._alive) throw new Error(
|
|
420
|
+
if (!this._proc) throw new Error('Session not started — call start() first');
|
|
421
|
+
if (!this._alive) throw new Error('Session ended');
|
|
343
422
|
|
|
344
423
|
const timeout = opts?.timeout ?? this._defaultTimeout;
|
|
345
424
|
|
|
@@ -355,7 +434,25 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
355
434
|
}
|
|
356
435
|
|
|
357
436
|
return new Promise<SessionResult>((resolve, reject) => {
|
|
358
|
-
const
|
|
437
|
+
const evidence = new TurnState();
|
|
438
|
+
this._attempts.push(evidence);
|
|
439
|
+
const turn: QueuedTurn = {
|
|
440
|
+
evidence,
|
|
441
|
+
message: transformed,
|
|
442
|
+
timeout,
|
|
443
|
+
resolve,
|
|
444
|
+
reject,
|
|
445
|
+
onAdmission: opts?.onAdmission,
|
|
446
|
+
};
|
|
447
|
+
if (!this._alive || (this._inflight && this._inflight.evidence.localOutcome !== 'pending')) {
|
|
448
|
+
reject(
|
|
449
|
+
evidence.fail(
|
|
450
|
+
new Error('Previous native work unresolved; send not dispatched'),
|
|
451
|
+
'blocked',
|
|
452
|
+
),
|
|
453
|
+
);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
359
456
|
if (!this._inflight) {
|
|
360
457
|
this._dispatchTurn(turn);
|
|
361
458
|
} else {
|
|
@@ -368,16 +465,52 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
368
465
|
// Private — turn dispatch + queue (mirrors ClaudeCodeSession)
|
|
369
466
|
// ---------------------------------------------------------------------------
|
|
370
467
|
|
|
468
|
+
readonly admissionProtocol = 'prewrite-v1' as const;
|
|
469
|
+
inspectAttempt(admissionId: string) {
|
|
470
|
+
return this._attempts.find((attempt) => attempt.admissionId === admissionId)?.snapshot();
|
|
471
|
+
}
|
|
472
|
+
|
|
371
473
|
private _dispatchTurn(turn: QueuedTurn): void {
|
|
372
474
|
this._inflight = turn;
|
|
373
|
-
|
|
374
|
-
|
|
475
|
+
if (turn.onAdmission) {
|
|
476
|
+
Promise.resolve()
|
|
477
|
+
.then(() => turn.onAdmission!(turn.evidence.snapshot()))
|
|
478
|
+
.then(() => {
|
|
479
|
+
if (this._inflight === turn && this._alive && turn.evidence.localOutcome === 'pending')
|
|
480
|
+
this._writeTurn(turn);
|
|
481
|
+
else if (this._inflight === turn && turn.evidence.dispatch === 'not-dispatched') {
|
|
482
|
+
this._inflight = null;
|
|
483
|
+
this._processNextTurn();
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
.catch((error) => {
|
|
487
|
+
turn.reject(
|
|
488
|
+
turn.evidence.fail(
|
|
489
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
490
|
+
'registration',
|
|
491
|
+
),
|
|
492
|
+
);
|
|
493
|
+
if (this._inflight === turn) {
|
|
494
|
+
this._inflight = null;
|
|
495
|
+
this._processNextTurn();
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
this._writeTurn(turn);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private _writeTurn(turn: QueuedTurn): void {
|
|
504
|
+
turn.evidence.dispatch = 'attempted';
|
|
505
|
+
this._turnEvents = turn.evidence.events;
|
|
506
|
+
this._resultText = '';
|
|
375
507
|
|
|
376
508
|
// Send via the subclass's wire protocol. The returned promise resolves when
|
|
377
509
|
// the tools/call (or turn) completes; that resolves the turn.
|
|
378
510
|
this._sendTurn(turn.message)
|
|
379
511
|
.then((tokens) => {
|
|
380
|
-
if (this._inflight !== turn) return;
|
|
512
|
+
if (this._inflight !== turn) return;
|
|
513
|
+
turn.evidence.rpcSettled = true;
|
|
381
514
|
if (tokens) {
|
|
382
515
|
this._totalTokens.input += tokens.input;
|
|
383
516
|
this._totalTokens.output += tokens.output;
|
|
@@ -386,14 +519,16 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
386
519
|
})
|
|
387
520
|
.catch((err: Error) => {
|
|
388
521
|
if (this._inflight !== turn) return;
|
|
389
|
-
|
|
390
|
-
|
|
522
|
+
turn.evidence.rpcSettled = true;
|
|
523
|
+
if (turn.evidence.rpcOutcome === 'pending') turn.evidence.rpcOutcome = 'unknown';
|
|
524
|
+
this._rejectInflight(err, err instanceof NativeValidationError ? 'validation' : 'rpc');
|
|
525
|
+
this._releaseKnownTurn();
|
|
391
526
|
});
|
|
392
527
|
|
|
393
528
|
if (turn.timeout > 0) {
|
|
394
529
|
this._turnTimer = setTimeout(() => {
|
|
395
530
|
this._turnTimer = null;
|
|
396
|
-
this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`));
|
|
531
|
+
this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`), 'timeout');
|
|
397
532
|
}, turn.timeout);
|
|
398
533
|
}
|
|
399
534
|
}
|
|
@@ -406,38 +541,64 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
406
541
|
this._turnTimer = null;
|
|
407
542
|
}
|
|
408
543
|
|
|
544
|
+
const a = this._inflight.evidence;
|
|
545
|
+
a.content = this._resultText;
|
|
546
|
+
a.tokens = tokens ?? a.tokens;
|
|
547
|
+
if (a.localOutcome === 'pending') {
|
|
548
|
+
a.localOutcome = 'resolved';
|
|
549
|
+
this._inflight.resolve(a.result(this._externalSessionId));
|
|
550
|
+
}
|
|
551
|
+
if (a.nativeOutcome === 'unknown')
|
|
552
|
+
this._rejectQueue(new Error('Native terminal missing; queued send not dispatched'));
|
|
553
|
+
this._releaseKnownTurn();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private _releaseKnownTurn(): void {
|
|
557
|
+
const a = this._inflight?.evidence;
|
|
558
|
+
if (!a || !this._canReleaseAttempt(a) || a.nativeOutcome === 'unknown') return;
|
|
409
559
|
this._turns++;
|
|
410
|
-
const result: SessionResult = {
|
|
411
|
-
content: this._resultText,
|
|
412
|
-
events: [...this._turnEvents],
|
|
413
|
-
tokens: tokens ?? this._turnEvents.find((e) => e.tokens)?.tokens,
|
|
414
|
-
externalSessionId: this._externalSessionId,
|
|
415
|
-
};
|
|
416
|
-
this._inflight.resolve(result);
|
|
417
560
|
this._inflight = null;
|
|
418
|
-
|
|
419
561
|
this._processNextTurn();
|
|
420
562
|
}
|
|
421
563
|
|
|
564
|
+
protected _canReleaseAttempt(attempt: TurnState): boolean {
|
|
565
|
+
return attempt.rpcSettled;
|
|
566
|
+
}
|
|
567
|
+
|
|
422
568
|
private _processNextTurn(): void {
|
|
423
|
-
if (this._queue.length > 0 && this._alive) {
|
|
569
|
+
if (!this._inflight && this._queue.length > 0 && this._alive) {
|
|
424
570
|
const next = this._queue.shift()!;
|
|
425
571
|
this._dispatchTurn(next);
|
|
426
572
|
}
|
|
427
573
|
}
|
|
428
574
|
|
|
429
|
-
protected _rejectInflight(
|
|
575
|
+
protected _rejectInflight(
|
|
576
|
+
err: Error,
|
|
577
|
+
reason: import('./harness-session').SessionAttempt['localFailure'] = 'transport',
|
|
578
|
+
): void {
|
|
430
579
|
if (!this._inflight) return;
|
|
431
580
|
if (this._turnTimer) {
|
|
432
581
|
clearTimeout(this._turnTimer);
|
|
433
582
|
this._turnTimer = null;
|
|
434
583
|
}
|
|
435
|
-
this._inflight.
|
|
436
|
-
|
|
584
|
+
const a = this._inflight.evidence;
|
|
585
|
+
if ((reason === 'transport' || reason === 'killed') && a.transportOutcome === 'open')
|
|
586
|
+
a.transportOutcome = reason === 'killed' ? 'closed' : 'failed';
|
|
587
|
+
if (a.localOutcome === 'pending') this._inflight.reject(a.fail(err, reason));
|
|
588
|
+
this._rejectQueue(new Error('Previous native work unresolved; queued send not dispatched'));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private _transportFailure(err: Error): void {
|
|
592
|
+
this._alive = false;
|
|
593
|
+
this._rejectInflight(err);
|
|
594
|
+
this._rejectQueue(err);
|
|
595
|
+
this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
|
|
596
|
+
for (const p of this._pending.values()) p.reject(err);
|
|
597
|
+
this._pending.clear();
|
|
437
598
|
}
|
|
438
599
|
|
|
439
600
|
protected _rejectQueue(err: Error): void {
|
|
440
|
-
for (const turn of this._queue) turn.reject(err);
|
|
601
|
+
for (const turn of this._queue) turn.reject(turn.evidence.fail(err, 'blocked'));
|
|
441
602
|
this._queue = [];
|
|
442
603
|
}
|
|
443
604
|
|
|
@@ -445,25 +606,33 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
445
606
|
// Private — stdout/stderr readers (mirrors ClaudeCodeSession)
|
|
446
607
|
// ---------------------------------------------------------------------------
|
|
447
608
|
|
|
448
|
-
private async _readStdout(): Promise<void> {
|
|
449
|
-
const reader =
|
|
609
|
+
private async _readStdout(proc: PipedSubprocess): Promise<void> {
|
|
610
|
+
const reader = proc.stdout.getReader();
|
|
450
611
|
const decoder = new TextDecoder();
|
|
451
|
-
let buffer =
|
|
612
|
+
let buffer = '';
|
|
452
613
|
try {
|
|
453
614
|
while (true) {
|
|
454
615
|
const { done, value } = await reader.read();
|
|
455
616
|
if (done) break;
|
|
456
617
|
buffer += decoder.decode(value, { stream: true });
|
|
457
|
-
const lines = buffer.split(
|
|
618
|
+
const lines = buffer.split('\n');
|
|
458
619
|
buffer = lines.pop()!;
|
|
459
620
|
for (const line of lines) {
|
|
460
621
|
if (!line.trim()) continue;
|
|
461
|
-
this._processLine(line);
|
|
622
|
+
if (this._proc === proc || !this._proc) this._processLine(line);
|
|
462
623
|
}
|
|
463
624
|
}
|
|
464
|
-
if (buffer.trim()) this._processLine(buffer);
|
|
625
|
+
if (buffer.trim() && (this._proc === proc || !this._proc)) this._processLine(buffer);
|
|
626
|
+
if (this._proc === proc && this._alive)
|
|
627
|
+
this._transportFailure(
|
|
628
|
+
new Error(
|
|
629
|
+
this._stderr.trim()
|
|
630
|
+
? `Native stdout closed: ${this._stderr.trim().slice(0, 500)}`
|
|
631
|
+
: 'Native stdout closed',
|
|
632
|
+
),
|
|
633
|
+
);
|
|
465
634
|
} catch (err) {
|
|
466
|
-
this.
|
|
635
|
+
if (this._proc === proc) this._transportFailure(err as Error);
|
|
467
636
|
}
|
|
468
637
|
}
|
|
469
638
|
|
|
@@ -476,7 +645,9 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
476
645
|
if (done) break;
|
|
477
646
|
this._stderr += decoder.decode(value, { stream: true });
|
|
478
647
|
}
|
|
479
|
-
} catch {
|
|
648
|
+
} catch {
|
|
649
|
+
/* ignore */
|
|
650
|
+
}
|
|
480
651
|
}
|
|
481
652
|
|
|
482
653
|
// ---------------------------------------------------------------------------
|
|
@@ -490,36 +661,59 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
490
661
|
} catch {
|
|
491
662
|
return;
|
|
492
663
|
}
|
|
493
|
-
const raw = msg
|
|
664
|
+
const raw = object(msg);
|
|
665
|
+
if (!raw) return;
|
|
666
|
+
if (
|
|
667
|
+
typeof raw.method === 'string' &&
|
|
668
|
+
(typeof raw.id === 'string' || typeof raw.id === 'number') &&
|
|
669
|
+
this._serverRequest(raw)
|
|
670
|
+
)
|
|
671
|
+
return;
|
|
494
672
|
|
|
495
673
|
// JSON-RPC response to one of our requests (has matching `id` + result/error).
|
|
496
|
-
if (typeof raw.id ===
|
|
674
|
+
if (typeof raw.id === 'number' && ('result' in raw || 'error' in raw)) {
|
|
497
675
|
const pending = this._pending.get(raw.id);
|
|
498
676
|
if (pending) {
|
|
499
677
|
this._pending.delete(raw.id);
|
|
500
|
-
|
|
678
|
+
const a = this._inflight?.evidence;
|
|
679
|
+
if (a?.identity.rpcRequestId === raw.id) a.rpcOutcome = raw.error ? 'failed' : 'resolved';
|
|
680
|
+
if ('error' in raw && raw.error) {
|
|
501
681
|
const e = raw.error as Record<string, unknown>;
|
|
502
682
|
pending.reject(new Error((e.message as string) ?? JSON.stringify(e)));
|
|
503
683
|
} else {
|
|
504
|
-
|
|
684
|
+
// Apply protocol identity synchronously before later lines in this
|
|
685
|
+
// same stdout chunk. Awaiting the promise first can lose a terminal.
|
|
686
|
+
try {
|
|
687
|
+
this._observeRpcResult(raw.id, raw.result);
|
|
688
|
+
pending.resolve(raw.result);
|
|
689
|
+
} catch (error) {
|
|
690
|
+
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
691
|
+
}
|
|
505
692
|
}
|
|
506
693
|
return;
|
|
507
694
|
}
|
|
508
695
|
}
|
|
509
696
|
|
|
510
|
-
// Otherwise it's a notification (streamed event) — classify it.
|
|
511
697
|
const classified = this._classify(raw);
|
|
512
698
|
for (const event of classified) {
|
|
513
|
-
this.
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
699
|
+
const a = this._inflight?.evidence;
|
|
700
|
+
if (!a || event.unattributedReason) {
|
|
701
|
+
this._emit(event);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
const e = a.record(event);
|
|
705
|
+
this._resultText = a.content;
|
|
706
|
+
if (e.nativeOutcome && e.terminal) {
|
|
707
|
+
a.nativeOutcome = e.nativeOutcome;
|
|
708
|
+
a.terminal = e.terminal;
|
|
517
709
|
}
|
|
518
|
-
if (
|
|
519
|
-
this._totalTokens.input +=
|
|
520
|
-
this._totalTokens.output +=
|
|
710
|
+
if (e.tokens) {
|
|
711
|
+
this._totalTokens.input += e.tokens.input;
|
|
712
|
+
this._totalTokens.output += e.tokens.output;
|
|
521
713
|
}
|
|
714
|
+
this._emit(e);
|
|
522
715
|
}
|
|
716
|
+
this._releaseKnownTurn();
|
|
523
717
|
}
|
|
524
718
|
|
|
525
719
|
// ---------------------------------------------------------------------------
|
|
@@ -527,15 +721,22 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
527
721
|
// ---------------------------------------------------------------------------
|
|
528
722
|
|
|
529
723
|
protected _emit(event: SessionEvent): void {
|
|
724
|
+
event = retainEvidence(event);
|
|
725
|
+
if (event.kind === 'session_end') {
|
|
726
|
+
if (this._endEmitted) return;
|
|
727
|
+
this._endEmitted = true;
|
|
728
|
+
}
|
|
530
729
|
this._eventLog.push(event);
|
|
531
730
|
for (const handler of this._handlers) {
|
|
532
731
|
try {
|
|
533
|
-
handler(event);
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
732
|
+
const observation: unknown = handler(event);
|
|
733
|
+
if (observation && typeof (observation as PromiseLike<unknown>).then === 'function') {
|
|
734
|
+
void Promise.resolve(observation).catch(() => {
|
|
735
|
+
this._observerFailures.asynchronous++;
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
} catch {
|
|
739
|
+
this._observerFailures.synchronous++;
|
|
539
740
|
}
|
|
540
741
|
}
|
|
541
742
|
}
|
|
@@ -546,7 +747,11 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
546
747
|
|
|
547
748
|
protected _rpcRequest(method: string, params?: unknown): Promise<unknown> {
|
|
548
749
|
const id = ++this._rpcId;
|
|
549
|
-
|
|
750
|
+
if ((method === 'tools/call' || method === 'turn/start') && this._inflight) {
|
|
751
|
+
this._inflight.evidence.identity = { ...this._inflight.evidence.identity, rpcRequestId: id };
|
|
752
|
+
this._inflight.evidence.rpcOutcome = 'pending';
|
|
753
|
+
}
|
|
754
|
+
const payload = `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`;
|
|
550
755
|
return new Promise<unknown>((resolve, reject) => {
|
|
551
756
|
this._pending.set(id, { resolve, reject });
|
|
552
757
|
try {
|
|
@@ -559,8 +764,12 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
559
764
|
});
|
|
560
765
|
}
|
|
561
766
|
|
|
767
|
+
protected _serverRequest(_request: Record<string, unknown>): boolean {
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
770
|
+
|
|
562
771
|
protected _rpcNotify(method: string, params?: unknown): void {
|
|
563
|
-
const payload = JSON.stringify({ jsonrpc:
|
|
772
|
+
const payload = `${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`;
|
|
564
773
|
this._proc!.stdin.write(payload);
|
|
565
774
|
this._proc!.stdin.flush();
|
|
566
775
|
}
|
|
@@ -580,12 +789,13 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
580
789
|
/** Map a streamed notification to SessionEvents. */
|
|
581
790
|
protected abstract _classify(msg: Record<string, unknown>): SessionEvent[];
|
|
582
791
|
|
|
792
|
+
protected _observeRpcResult(_id: number, _result: unknown): void {}
|
|
793
|
+
|
|
583
794
|
/** Shared fork constructor — subclass passes its own ctor. */
|
|
584
795
|
fork(opts?: { cwd?: string; baseContext?: string }): HarnessSession {
|
|
796
|
+
if (this._inflight) throw new Error('Cannot fork while native ownership is unresolved');
|
|
585
797
|
if (!this._externalSessionId) {
|
|
586
|
-
throw new Error(
|
|
587
|
-
"Cannot fork — no thread ID yet (send at least one message first)",
|
|
588
|
-
);
|
|
798
|
+
throw new Error('Cannot fork — no thread ID yet (send at least one message first)');
|
|
589
799
|
}
|
|
590
800
|
const Ctor = this.constructor as new (c?: CodexSessionConfig) => BaseCodexSession;
|
|
591
801
|
const forked = new Ctor({
|
|
@@ -597,6 +807,7 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
597
807
|
baseContext: opts?.baseContext ?? this._baseContext,
|
|
598
808
|
externalSessionId: this._externalSessionId,
|
|
599
809
|
spawn: this._spawn,
|
|
810
|
+
env: this._env,
|
|
600
811
|
});
|
|
601
812
|
forked._forking = true;
|
|
602
813
|
return forked;
|
|
@@ -617,37 +828,44 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
617
828
|
// multi-turn + fork).
|
|
618
829
|
|
|
619
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 }>();
|
|
620
836
|
protected _buildSpawnArgs(): string[] {
|
|
621
837
|
// Disable codex's own approvals + sandbox (the union of
|
|
622
838
|
// --dangerously-bypass-approvals-and-sandbox) so OUR container is the jail.
|
|
623
839
|
// model_reasoning_effort sets the effort level; passed as TOML-ish `-c` values.
|
|
624
840
|
const args = [
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
841
|
+
'mcp-server',
|
|
842
|
+
'-c',
|
|
843
|
+
`sandbox_mode="danger-full-access"`,
|
|
844
|
+
'-c',
|
|
845
|
+
`approval_policy="never"`,
|
|
628
846
|
];
|
|
629
847
|
if (this._effort) {
|
|
630
|
-
args.push(
|
|
848
|
+
args.push('-c', `model_reasoning_effort="${this._effort}"`);
|
|
631
849
|
}
|
|
632
850
|
return args;
|
|
633
851
|
}
|
|
634
852
|
|
|
635
853
|
protected async _handshake(): Promise<void> {
|
|
636
|
-
await this._rpcRequest(
|
|
637
|
-
protocolVersion:
|
|
854
|
+
await this._rpcRequest('initialize', {
|
|
855
|
+
protocolVersion: '2025-06-18',
|
|
638
856
|
capabilities: {},
|
|
639
|
-
clientInfo: { name:
|
|
857
|
+
clientInfo: { name: 'inixiative-bench', version: '0.1.0' },
|
|
640
858
|
});
|
|
641
|
-
this._rpcNotify(
|
|
859
|
+
this._rpcNotify('notifications/initialized');
|
|
642
860
|
// tools/list confirms the `codex` + `codex-reply` tools are present.
|
|
643
|
-
await this._rpcRequest(
|
|
861
|
+
await this._rpcRequest('tools/list', {});
|
|
644
862
|
}
|
|
645
863
|
|
|
646
864
|
protected async _sendTurn(
|
|
647
865
|
message: string,
|
|
648
866
|
): Promise<{ input: number; output: number } | undefined> {
|
|
649
867
|
const isReply = this._externalSessionId !== undefined;
|
|
650
|
-
const name = isReply ?
|
|
868
|
+
const name = isReply ? 'codex-reply' : 'codex';
|
|
651
869
|
const args: Record<string, unknown> = isReply
|
|
652
870
|
? { prompt: message, threadId: this._externalSessionId }
|
|
653
871
|
: {
|
|
@@ -655,31 +873,34 @@ export class CodexMcpSession extends BaseCodexSession {
|
|
|
655
873
|
model: this._model,
|
|
656
874
|
cwd: this._cwd,
|
|
657
875
|
// Belt-and-suspenders: also disable per-call (matches spawn `-c` flags).
|
|
658
|
-
sandbox:
|
|
659
|
-
|
|
660
|
-
...(this._effort
|
|
661
|
-
? { config: { model_reasoning_effort: this._effort } }
|
|
662
|
-
: {}),
|
|
876
|
+
sandbox: 'danger-full-access',
|
|
877
|
+
'approval-policy': 'never',
|
|
878
|
+
...(this._effort ? { config: { model_reasoning_effort: this._effort } } : {}),
|
|
663
879
|
};
|
|
664
880
|
|
|
665
|
-
const result = (await this._rpcRequest(
|
|
881
|
+
const result = (await this._rpcRequest('tools/call', {
|
|
666
882
|
name,
|
|
667
883
|
arguments: args,
|
|
668
884
|
})) as Record<string, unknown> | undefined;
|
|
669
885
|
|
|
670
886
|
// Capture the threadId for multi-turn (codex-reply) + fork.
|
|
671
|
-
const structured = result?.structuredContent as
|
|
672
|
-
| Record<string, unknown>
|
|
673
|
-
| undefined;
|
|
887
|
+
const structured = result?.structuredContent as Record<string, unknown> | undefined;
|
|
674
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');
|
|
675
891
|
if (threadId && !this._externalSessionId) {
|
|
676
892
|
this._externalSessionId = threadId;
|
|
677
893
|
}
|
|
894
|
+
if (threadId && this._inflight)
|
|
895
|
+
this._inflight.evidence.identity = { ...this._inflight.evidence.identity, threadId };
|
|
678
896
|
// Final text: prefer structuredContent.content, else the tool result content.
|
|
679
897
|
const finalText =
|
|
680
|
-
(structured?.content as string | undefined) ??
|
|
681
|
-
|
|
682
|
-
if (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
|
+
}
|
|
683
904
|
|
|
684
905
|
// Usage, when the call result reports it. Streamed token_count events are
|
|
685
906
|
// already accounted by the base loop; the call result is the authoritative
|
|
@@ -688,7 +909,12 @@ export class CodexMcpSession extends BaseCodexSession {
|
|
|
688
909
|
// token event was seen this turn.
|
|
689
910
|
const sawStreamedTokens = this._turnEvents.some((e) => e.tokens);
|
|
690
911
|
const usage = structured?.usage as Record<string, number> | undefined;
|
|
691
|
-
if (
|
|
912
|
+
if (
|
|
913
|
+
usage &&
|
|
914
|
+
!sawStreamedTokens &&
|
|
915
|
+
typeof (usage.input_tokens ?? usage.inputTokens) === 'number' &&
|
|
916
|
+
typeof (usage.output_tokens ?? usage.outputTokens) === 'number'
|
|
917
|
+
) {
|
|
692
918
|
return {
|
|
693
919
|
input: (usage.input_tokens ?? usage.inputTokens ?? 0) as number,
|
|
694
920
|
output: (usage.output_tokens ?? usage.outputTokens ?? 0) as number,
|
|
@@ -702,126 +928,901 @@ export class CodexMcpSession extends BaseCodexSession {
|
|
|
702
928
|
const parts: string[] = [];
|
|
703
929
|
for (const block of content) {
|
|
704
930
|
const b = block as Record<string, unknown>;
|
|
705
|
-
if (b.type ===
|
|
931
|
+
if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
|
|
706
932
|
}
|
|
707
|
-
return parts.length ? parts.join(
|
|
933
|
+
return parts.length ? parts.join('\n') : undefined;
|
|
708
934
|
}
|
|
709
935
|
|
|
710
936
|
protected _classify(msg: Record<string, unknown>): SessionEvent[] {
|
|
711
937
|
// Streamed events arrive as JSON-RPC notifications: method "codex/event"
|
|
712
938
|
// with params carrying a `msg` of a tagged type.
|
|
713
|
-
if (msg.method !==
|
|
939
|
+
if (msg.method !== 'codex/event') return [];
|
|
714
940
|
const params = msg.params as Record<string, unknown> | undefined;
|
|
715
941
|
const ev = (params?.msg ?? params) as Record<string, unknown> | undefined;
|
|
716
942
|
if (!ev) return [];
|
|
717
|
-
|
|
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');
|
|
718
1085
|
}
|
|
719
1086
|
}
|
|
720
1087
|
|
|
721
1088
|
// ---------------------------------------------------------------------------
|
|
722
|
-
// CodexAppServerSession (EXPERIMENTAL) — `codex app-server` over
|
|
1089
|
+
// CodexAppServerSession (EXPERIMENTAL) — `codex app-server` over stdio
|
|
723
1090
|
// ---------------------------------------------------------------------------
|
|
724
1091
|
//
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
// Consume turn/started, item/started, item/completed, turn/completed
|
|
732
|
-
// notifications (ThreadItem types agentMessage / reasoning / commandExecution;
|
|
733
|
-
// ThreadTokenUsage). Mapped to SessionEvents.
|
|
734
|
-
//
|
|
735
|
-
// We connect over the spawned process's stdin/stdout JSON-RPC for parity with
|
|
736
|
-
// the rest of the harness (the WebSocket listen address is for external
|
|
737
|
-
// clients; the stdio channel carries the same JSON-RPC frames). The base
|
|
738
|
-
// class's read/write loop is reused unchanged.
|
|
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 {}
|
|
739
1098
|
|
|
740
1099
|
export class CodexAppServerSession extends BaseCodexSession {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1100
|
+
get transport() {
|
|
1101
|
+
return TRANSPORTS['codex-app-server'];
|
|
1102
|
+
}
|
|
1103
|
+
readonly appServerProtocol = 'owned-thread-v1';
|
|
1104
|
+
private _limits?: LimitSnapshot;
|
|
1105
|
+
|
|
1106
|
+
/** Latest account limits observed on this session (stream or poll). */
|
|
1107
|
+
get limits(): LimitSnapshot | undefined {
|
|
1108
|
+
return this._limits;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
private _observeLimits(snapshot: LimitSnapshot): SessionEvent {
|
|
1112
|
+
this._limits = mergeLimits(this._limits, snapshot);
|
|
1113
|
+
return {
|
|
1114
|
+
kind: 'rate_limit',
|
|
1115
|
+
timestamp: Date.now(),
|
|
1116
|
+
correlation: 'unknown',
|
|
1117
|
+
unattributedReason: 'account-status',
|
|
1118
|
+
limits: this._limits,
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** account/rateLimits/read — no model turn. Requires a started session. */
|
|
1123
|
+
async readLimits(opts?: { timeoutMs?: number }): Promise<LimitSnapshot | undefined> {
|
|
1124
|
+
if (!this._proc || !this._alive) throw Error('Session not running');
|
|
1125
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1126
|
+
const result = await Promise.race([
|
|
1127
|
+
this._rpcRequest('account/rateLimits/read', { excludeResetCreditDetails: true }),
|
|
1128
|
+
new Promise<never>((_, reject) => {
|
|
1129
|
+
timer = setTimeout(
|
|
1130
|
+
() => reject(Error('account/rateLimits/read timed out')),
|
|
1131
|
+
opts?.timeoutMs ?? 10_000,
|
|
1132
|
+
);
|
|
1133
|
+
}),
|
|
1134
|
+
]).finally(() => clearTimeout(timer));
|
|
1135
|
+
const snapshot = codexLimitSnapshot(result, 'poll');
|
|
1136
|
+
if (snapshot) this._emit(this._observeLimits(snapshot));
|
|
1137
|
+
return snapshot;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* turn/interrupt for the owned in-flight turn. "acknowledged" once the turn's
|
|
1142
|
+
* own turn/completed arrives; the send() then settles through that terminal.
|
|
1143
|
+
*/
|
|
1144
|
+
async interruptNative(opts?: { timeoutMs?: number }): Promise<NativeInterruptOutcome> {
|
|
1145
|
+
const a = this._inflight?.evidence;
|
|
1146
|
+
if (
|
|
1147
|
+
a?.dispatch !== 'attempted' ||
|
|
1148
|
+
a.nativeOutcome !== 'unknown' ||
|
|
1149
|
+
!a.identity.turnId ||
|
|
1150
|
+
!this._externalSessionId
|
|
1151
|
+
)
|
|
1152
|
+
return 'no-turn';
|
|
1153
|
+
const deadline = Date.now() + (opts?.timeoutMs ?? 5_000);
|
|
1154
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1155
|
+
try {
|
|
1156
|
+
await Promise.race([
|
|
1157
|
+
this._rpcRequest('turn/interrupt', {
|
|
1158
|
+
threadId: this._externalSessionId,
|
|
1159
|
+
turnId: a.identity.turnId,
|
|
1160
|
+
}),
|
|
1161
|
+
new Promise<never>((_, reject) => {
|
|
1162
|
+
timer = setTimeout(
|
|
1163
|
+
() => reject(Error('turn/interrupt timed out')),
|
|
1164
|
+
Math.max(1, deadline - Date.now()),
|
|
1165
|
+
);
|
|
1166
|
+
}),
|
|
1167
|
+
]);
|
|
1168
|
+
} catch {
|
|
1169
|
+
return 'unacknowledged';
|
|
1170
|
+
} finally {
|
|
1171
|
+
clearTimeout(timer);
|
|
1172
|
+
}
|
|
1173
|
+
while (a.nativeOutcome === 'unknown' && this._alive && Date.now() < deadline)
|
|
1174
|
+
await Bun.sleep(10);
|
|
1175
|
+
return a.nativeOutcome === 'unknown' ? 'unacknowledged' : 'acknowledged';
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/** Mid-turn steer (turn/steer) into the owned in-flight turn; otherwise records push_ignored. */
|
|
1179
|
+
override async push(payload: { kind: string; text: string }): Promise<void> {
|
|
1180
|
+
const a = this._inflight?.evidence;
|
|
1181
|
+
const ignored = (reason: string) =>
|
|
1182
|
+
this._emit({
|
|
1183
|
+
kind: 'error',
|
|
1184
|
+
timestamp: Date.now(),
|
|
1185
|
+
text: `push_ignored: kind=${payload.kind} — ${reason}`,
|
|
1186
|
+
raw: { kind: payload.kind },
|
|
1187
|
+
});
|
|
1188
|
+
if (
|
|
1189
|
+
a?.dispatch !== 'attempted' ||
|
|
1190
|
+
a.nativeOutcome !== 'unknown' ||
|
|
1191
|
+
!a.identity.turnId ||
|
|
1192
|
+
!this._externalSessionId
|
|
1193
|
+
)
|
|
1194
|
+
return ignored('no owned in-flight turn');
|
|
1195
|
+
let timer: ReturnType<typeof setTimeout> | undefined,
|
|
1196
|
+
timedOut = false;
|
|
1197
|
+
try {
|
|
1198
|
+
await Promise.race([
|
|
1199
|
+
this._rpcRequest('turn/steer', {
|
|
1200
|
+
threadId: this._externalSessionId,
|
|
1201
|
+
expectedTurnId: a.identity.turnId,
|
|
1202
|
+
input: [{ type: 'text', text: payload.text }],
|
|
1203
|
+
}),
|
|
1204
|
+
new Promise<never>((_, reject) => {
|
|
1205
|
+
timer = setTimeout(() => {
|
|
1206
|
+
timedOut = true;
|
|
1207
|
+
reject(Error('turn/steer unanswered'));
|
|
1208
|
+
}, 10_000);
|
|
1209
|
+
}),
|
|
1210
|
+
]);
|
|
1211
|
+
this._emit({
|
|
1212
|
+
...a.identity,
|
|
1213
|
+
admissionId: a.admissionId,
|
|
1214
|
+
kind: 'native_status',
|
|
1215
|
+
timestamp: Date.now(),
|
|
1216
|
+
raw: { type: 'push-steered', kind: payload.kind },
|
|
1217
|
+
});
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
// A refusal is the runtime's answer; no answer leaves delivery unknown, never "ignored".
|
|
1220
|
+
if (timedOut || !this._alive)
|
|
1221
|
+
this._emit({
|
|
1222
|
+
...a.identity,
|
|
1223
|
+
admissionId: a.admissionId,
|
|
1224
|
+
kind: 'native_status',
|
|
1225
|
+
timestamp: Date.now(),
|
|
1226
|
+
raw: { type: 'push-unknown', kind: payload.kind },
|
|
1227
|
+
});
|
|
1228
|
+
else ignored(`turn/steer refused: ${(error as Error).message.slice(0, 200)}`);
|
|
1229
|
+
} finally {
|
|
1230
|
+
clearTimeout(timer);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
private readonly _options: NonNullable<CodexSessionConfig['appServer']>;
|
|
1234
|
+
private readonly _onThreadReady?: (binding: string) => void | Promise<void>;
|
|
1235
|
+
private _startedOnce = false;
|
|
1236
|
+
private _threadReady = false;
|
|
1237
|
+
private _nativeSessionId?: string;
|
|
1238
|
+
private _appAdmission?: string;
|
|
1239
|
+
private _turnDone?: () => void;
|
|
1240
|
+
private _items = new Map<
|
|
1241
|
+
string,
|
|
1242
|
+
{
|
|
1243
|
+
type: string;
|
|
1244
|
+
input?: unknown;
|
|
1245
|
+
completed: boolean;
|
|
1246
|
+
server?: string;
|
|
1247
|
+
tool?: string;
|
|
1248
|
+
text?: string;
|
|
1249
|
+
}
|
|
1250
|
+
>();
|
|
1251
|
+
|
|
1252
|
+
constructor(config?: CodexSessionConfig) {
|
|
1253
|
+
super(config);
|
|
1254
|
+
const { onThreadReady, ...options } = config?.appServer ?? {};
|
|
1255
|
+
this._onThreadReady = onThreadReady;
|
|
1256
|
+
this._options = retainEvidence(options);
|
|
1257
|
+
const required = this._options.requiredMcpServer;
|
|
1258
|
+
if (
|
|
1259
|
+
required &&
|
|
1260
|
+
(!/^[a-zA-Z0-9_-]+$/.test(required.name) ||
|
|
1261
|
+
!required.tools.length ||
|
|
1262
|
+
required.tools.some((t) => !/^[a-zA-Z0-9_-]+$/.test(t)))
|
|
1263
|
+
)
|
|
1264
|
+
throw new NativeValidationError('Invalid required MCP inventory');
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
override fork(): HarnessSession {
|
|
1268
|
+
throw Error(
|
|
1269
|
+
'Native app-server fork is not implemented; a same-thread wrapper cannot create a native fork or inherit a process capability',
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
override async start(): Promise<void> {
|
|
1274
|
+
if (this._inflight)
|
|
1275
|
+
throw Error('Native call ownership unresolved; automatic resume is blocked');
|
|
1276
|
+
if (this._startedOnce && !this._alive)
|
|
1277
|
+
throw Error('App-server restart requires a new owned instance and preflight');
|
|
1278
|
+
this._startedOnce = true;
|
|
1279
|
+
try {
|
|
1280
|
+
await super.start();
|
|
1281
|
+
} catch (error) {
|
|
1282
|
+
this.kill();
|
|
1283
|
+
throw error;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
protected override _serverRequest(request: Record<string, unknown>): boolean {
|
|
1288
|
+
// JSON-RPC's explicit method-not-supported refusal also covers unknown future
|
|
1289
|
+
// requests. Never approve native work or echo private request parameters.
|
|
1290
|
+
const payload = `${JSON.stringify({
|
|
1291
|
+
jsonrpc: '2.0',
|
|
1292
|
+
id: request.id,
|
|
1293
|
+
error: { code: -32601, message: 'Client interaction is unsupported by this adapter' },
|
|
1294
|
+
})}\n`;
|
|
1295
|
+
const params = object(request.params),
|
|
1296
|
+
a = this._inflight?.evidence;
|
|
1297
|
+
const identity =
|
|
1298
|
+
a?.identity.turnId &&
|
|
1299
|
+
params?.threadId === a.identity.threadId &&
|
|
1300
|
+
params?.turnId === a.identity.turnId
|
|
1301
|
+
? { ...a.identity, admissionId: a.admissionId }
|
|
1302
|
+
: { unattributedReason: 'unrecognized-event' as const };
|
|
1303
|
+
try {
|
|
1304
|
+
this._proc!.stdin.write(payload);
|
|
1305
|
+
this._proc!.stdin.flush();
|
|
1306
|
+
} catch {
|
|
1307
|
+
this._emit({
|
|
1308
|
+
...identity,
|
|
1309
|
+
kind: 'native_status',
|
|
1310
|
+
timestamp: Date.now(),
|
|
1311
|
+
raw: { type: 'server-request-refusal', status: 'write-failed' },
|
|
1312
|
+
});
|
|
1313
|
+
return true;
|
|
1314
|
+
}
|
|
1315
|
+
this._emit({
|
|
1316
|
+
...identity,
|
|
1317
|
+
kind: 'native_status',
|
|
1318
|
+
timestamp: Date.now(),
|
|
1319
|
+
raw: { type: 'server-request-refusal', status: 'refused' },
|
|
1320
|
+
});
|
|
1321
|
+
return true;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
private _refuse(reason: string, status = 'unknown'): never {
|
|
1325
|
+
const a = this._inflight?.evidence;
|
|
1326
|
+
this._emit({
|
|
1327
|
+
...(a ? { admissionId: a.admissionId, ...a.identity } : {}),
|
|
1328
|
+
kind: 'native_status',
|
|
1329
|
+
timestamp: Date.now(),
|
|
1330
|
+
raw: { type: 'thread-refused', reason, status },
|
|
1331
|
+
});
|
|
1332
|
+
throw new NativeValidationError(
|
|
1333
|
+
`Native thread validation refused: ${reason} (${status}); no turn was sent`,
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
private async _readiness(): Promise<void> {
|
|
1338
|
+
const required = this._options.requiredMcpServer;
|
|
1339
|
+
if (!required) return;
|
|
1340
|
+
let cursor: string | undefined;
|
|
1341
|
+
const seen = new Set<string>();
|
|
1342
|
+
let found: Record<string, unknown> | undefined;
|
|
1343
|
+
// Pagination only, never polling/retrying a starting or failed native server.
|
|
1344
|
+
for (let page = 0; page < 32; page++) {
|
|
1345
|
+
const result = object(
|
|
1346
|
+
await this._rpcRequest('mcpServerStatus/list', {
|
|
1347
|
+
threadId: this._externalSessionId,
|
|
1348
|
+
detail: 'toolsAndAuthOnly',
|
|
1349
|
+
limit: 100,
|
|
1350
|
+
...(cursor ? { cursor } : {}),
|
|
1351
|
+
}),
|
|
1352
|
+
);
|
|
1353
|
+
if (!Array.isArray(result?.data)) this._refuse('mcp-inventory-malformed');
|
|
1354
|
+
for (const entry of result.data) {
|
|
1355
|
+
const server = object(entry);
|
|
1356
|
+
if (server?.name === required.name) {
|
|
1357
|
+
if (found) this._refuse('mcp-inventory-duplicate');
|
|
1358
|
+
found = server;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
if (result.nextCursor == null) {
|
|
1362
|
+
const tools = object(found?.tools);
|
|
1363
|
+
if (
|
|
1364
|
+
found?.runtimeStatus !== 'connected' ||
|
|
1365
|
+
!tools ||
|
|
1366
|
+
required.tools.some((name) => object(tools[name])?.name !== name)
|
|
1367
|
+
)
|
|
1368
|
+
this._refuse('mcp-inventory-unavailable');
|
|
1369
|
+
const a = this._inflight!.evidence;
|
|
1370
|
+
this._emit({
|
|
1371
|
+
...a.identity,
|
|
1372
|
+
admissionId: a.admissionId,
|
|
1373
|
+
kind: 'native_status',
|
|
1374
|
+
timestamp: Date.now(),
|
|
1375
|
+
raw: {
|
|
1376
|
+
type: 'mcp-ready',
|
|
1377
|
+
status: 'connected',
|
|
1378
|
+
server: required.name,
|
|
1379
|
+
tools: [...required.tools],
|
|
1380
|
+
},
|
|
1381
|
+
});
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (
|
|
1385
|
+
typeof result.nextCursor !== 'string' ||
|
|
1386
|
+
!result.nextCursor ||
|
|
1387
|
+
seen.has(result.nextCursor)
|
|
1388
|
+
)
|
|
1389
|
+
this._refuse('mcp-inventory-cursor');
|
|
1390
|
+
cursor = result.nextCursor;
|
|
1391
|
+
seen.add(cursor);
|
|
1392
|
+
}
|
|
1393
|
+
this._refuse('mcp-inventory-page-limit');
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
protected override _canReleaseAttempt(a: TurnState): boolean {
|
|
1397
|
+
// An observer/write failure may occur after delivery. A terminal alone does
|
|
1398
|
+
// not settle the unanswered turn/start call or authorize another admission.
|
|
1399
|
+
return a.rpcSettled && (a.rpcOutcome === 'resolved' || a.rpcOutcome === 'failed');
|
|
1400
|
+
}
|
|
746
1401
|
|
|
747
1402
|
protected _buildSpawnArgs(): string[] {
|
|
748
|
-
|
|
749
|
-
// per-thread in thread/start (approvalPolicy / sandbox below).
|
|
750
|
-
return ["app-server", "--listen", "ws://127.0.0.1:0"];
|
|
1403
|
+
return ['app-server', '--listen', 'stdio://'];
|
|
751
1404
|
}
|
|
752
1405
|
|
|
753
1406
|
protected async _handshake(): Promise<void> {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1407
|
+
this._threadReady = false;
|
|
1408
|
+
const initialized = object(
|
|
1409
|
+
await this._rpcRequest('initialize', {
|
|
1410
|
+
capabilities: {},
|
|
1411
|
+
clientInfo: { name: 'inixiative-bench', version: '0.1.0' },
|
|
1412
|
+
}),
|
|
1413
|
+
);
|
|
1414
|
+
if (
|
|
1415
|
+
this._options.requireConfiguration &&
|
|
1416
|
+
['userAgent', 'platformFamily', 'platformOs', 'codexHome'].some(
|
|
1417
|
+
(key) => typeof initialized?.[key] !== 'string' || !initialized[key],
|
|
1418
|
+
)
|
|
1419
|
+
)
|
|
1420
|
+
throw new NativeValidationError(
|
|
1421
|
+
'Native initialize response lacks required configuration; no thread or turn was sent',
|
|
1422
|
+
);
|
|
1423
|
+
this._rpcNotify('initialized');
|
|
760
1424
|
}
|
|
761
1425
|
|
|
762
1426
|
protected async _sendTurn(
|
|
763
1427
|
message: string,
|
|
764
1428
|
): Promise<{ input: number; output: number } | undefined> {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
(
|
|
782
|
-
this.
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1429
|
+
const a = this._inflight!.evidence;
|
|
1430
|
+
if (!this._threadReady) {
|
|
1431
|
+
const binding = this._externalSessionId;
|
|
1432
|
+
const method = binding ? 'thread/resume' : 'thread/start';
|
|
1433
|
+
const response = object(
|
|
1434
|
+
await this._rpcRequest(method, {
|
|
1435
|
+
...(binding ? { threadId: binding } : {}),
|
|
1436
|
+
model: this._model,
|
|
1437
|
+
cwd: this._cwd,
|
|
1438
|
+
approvalPolicy: 'never',
|
|
1439
|
+
sandbox: 'danger-full-access',
|
|
1440
|
+
...(this._options.config ? { config: this._options.config } : {}),
|
|
1441
|
+
}),
|
|
1442
|
+
);
|
|
1443
|
+
const thread = object(response?.thread),
|
|
1444
|
+
id = thread?.id;
|
|
1445
|
+
if (typeof id !== 'string' || !id || (binding && id !== binding))
|
|
1446
|
+
this._refuse('binding-mismatch');
|
|
1447
|
+
// Resume may rejoin a live thread. Never steer known pre-existing work.
|
|
1448
|
+
// The installed schema requires status. Missing/unknown state cannot
|
|
1449
|
+
// authorize work, even if this response otherwise identifies our thread.
|
|
1450
|
+
if (
|
|
1451
|
+
object(thread?.status)?.type !== 'idle' ||
|
|
1452
|
+
(Array.isArray(thread?.turns) &&
|
|
1453
|
+
thread.turns.some((turn) => object(turn)?.status === 'inProgress'))
|
|
1454
|
+
)
|
|
1455
|
+
this._refuse(
|
|
1456
|
+
'not-idle',
|
|
1457
|
+
['active', 'notLoaded', 'systemError'].includes(String(object(thread?.status)?.type))
|
|
1458
|
+
? String(object(thread?.status)?.type)
|
|
1459
|
+
: 'unknown',
|
|
1460
|
+
);
|
|
1461
|
+
if (
|
|
1462
|
+
this._options.requireConfiguration &&
|
|
1463
|
+
(typeof response?.model !== 'string' ||
|
|
1464
|
+
!response.model ||
|
|
1465
|
+
typeof response.modelProvider !== 'string' ||
|
|
1466
|
+
!response.modelProvider ||
|
|
1467
|
+
response.cwd !== this._cwd ||
|
|
1468
|
+
response.approvalPolicy !== 'never' ||
|
|
1469
|
+
response.approvalsReviewer !== 'user' ||
|
|
1470
|
+
object(response.sandbox)?.type !== 'dangerFullAccess' ||
|
|
1471
|
+
!Array.isArray(thread?.turns) ||
|
|
1472
|
+
thread.turns.some((value) => !this._turn(value)))
|
|
1473
|
+
)
|
|
1474
|
+
this._refuse('configuration-missing-or-incompatible');
|
|
1475
|
+
this._externalSessionId = id;
|
|
1476
|
+
this._nativeSessionId =
|
|
1477
|
+
typeof thread?.sessionId === 'string' && thread.sessionId ? thread.sessionId : undefined;
|
|
1478
|
+
a.identity = {
|
|
1479
|
+
...a.identity,
|
|
1480
|
+
threadId: id,
|
|
1481
|
+
...(this._nativeSessionId ? { nativeSessionId: this._nativeSessionId } : {}),
|
|
1482
|
+
};
|
|
1483
|
+
if (this._onThreadReady) {
|
|
1484
|
+
try {
|
|
1485
|
+
await this._onThreadReady(id);
|
|
1486
|
+
} catch (cause) {
|
|
1487
|
+
throw new NativeValidationError('Native binding persistence failed before turn/start', {
|
|
1488
|
+
cause,
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
this._threadReady = true;
|
|
1493
|
+
// Configuration belongs to this process/thread, never to the next turn.
|
|
1494
|
+
// Do not copy historical turns, instructions, paths or arbitrary config.
|
|
1495
|
+
this._emit({
|
|
1496
|
+
kind: 'native_status',
|
|
1497
|
+
timestamp: Date.now(),
|
|
1498
|
+
threadId: id,
|
|
1499
|
+
nativeSessionId: this._nativeSessionId,
|
|
1500
|
+
unattributedReason: 'session-configuration',
|
|
1501
|
+
raw: {
|
|
1502
|
+
type: 'thread-configured',
|
|
1503
|
+
method,
|
|
1504
|
+
threadId: id,
|
|
1505
|
+
...(object(thread?.status)?.type === 'idle' ? { status: 'idle' } : {}),
|
|
1506
|
+
...(typeof response?.model === 'string' ? { model: response.model } : {}),
|
|
1507
|
+
...(response?.reasoningEffort === null ||
|
|
1508
|
+
VALID_EFFORTS.includes(String(response?.reasoningEffort))
|
|
1509
|
+
? { reasoningEffort: response?.reasoningEffort }
|
|
1510
|
+
: {}),
|
|
1511
|
+
history: {
|
|
1512
|
+
source: method,
|
|
1513
|
+
turns: Array.isArray(thread?.turns)
|
|
1514
|
+
? thread.turns.slice(0, 200).flatMap((value) => {
|
|
1515
|
+
const t = this._turn(value);
|
|
1516
|
+
return t ? [{ id: t.id, status: t.status }] : [];
|
|
1517
|
+
})
|
|
1518
|
+
: [],
|
|
1519
|
+
available: Array.isArray(thread?.turns),
|
|
1520
|
+
hasMore:
|
|
1521
|
+
!!response?.turnsBackwardsCursor ||
|
|
1522
|
+
(Array.isArray(thread?.turns) && thread.turns.length > 200),
|
|
1523
|
+
},
|
|
1524
|
+
},
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
a.identity = {
|
|
1528
|
+
...a.identity,
|
|
1529
|
+
threadId: this._externalSessionId,
|
|
1530
|
+
...(this._nativeSessionId ? { nativeSessionId: this._nativeSessionId } : {}),
|
|
1531
|
+
};
|
|
1532
|
+
await this._readiness();
|
|
1533
|
+
// Timeout/kill during create/resume must not cause a later model write.
|
|
1534
|
+
if (this._inflight?.evidence !== a || a.localOutcome !== 'pending' || !this._alive)
|
|
1535
|
+
throw Error('Local admission closed before turn/start; no turn was sent');
|
|
1536
|
+
a.identity = {
|
|
1537
|
+
...a.identity,
|
|
1538
|
+
threadId: this._externalSessionId,
|
|
1539
|
+
...(this._nativeSessionId ? { nativeSessionId: this._nativeSessionId } : {}),
|
|
1540
|
+
};
|
|
1541
|
+
this._appAdmission = a.admissionId;
|
|
1542
|
+
this._items.clear();
|
|
1543
|
+
const completed = new Promise<void>((resolve) => {
|
|
1544
|
+
this._turnDone = resolve;
|
|
1545
|
+
});
|
|
1546
|
+
await this._rpcRequest('turn/start', {
|
|
789
1547
|
threadId: this._externalSessionId,
|
|
790
|
-
input: message,
|
|
1548
|
+
input: [{ type: 'text', text: message }],
|
|
1549
|
+
...(this._effort ? { effort: this._effort } : {}),
|
|
791
1550
|
});
|
|
1551
|
+
await completed; // Native terminal and RPC acknowledgment are both required.
|
|
1552
|
+
return a.tokens;
|
|
1553
|
+
}
|
|
792
1554
|
|
|
793
|
-
|
|
1555
|
+
protected override _observeRpcResult(id: number, result: unknown): void {
|
|
1556
|
+
const a = this._inflight?.evidence;
|
|
1557
|
+
if (!a || this._appAdmission !== a.admissionId || a.identity.rpcRequestId !== id) return;
|
|
1558
|
+
const turn = this._turn(object(result)?.turn);
|
|
1559
|
+
if (
|
|
1560
|
+
!turn ||
|
|
1561
|
+
(a.identity.turnId && turn.id !== a.identity.turnId) ||
|
|
1562
|
+
(!a.identity.turnId && this._seenTerminals.has(turn.id))
|
|
1563
|
+
)
|
|
1564
|
+
throw new NativeValidationError(
|
|
1565
|
+
'Native turn/start response missing or conflicts with owned turn',
|
|
1566
|
+
);
|
|
1567
|
+
a.identity = { ...a.identity, turnId: turn.id, correlation: 'native-turn' };
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
private _turn(value: unknown): { id: string; status: string; error?: unknown } | undefined {
|
|
1571
|
+
const turn = object(value);
|
|
1572
|
+
if (
|
|
1573
|
+
!turn ||
|
|
1574
|
+
typeof turn.id !== 'string' ||
|
|
1575
|
+
!turn.id ||
|
|
1576
|
+
!Array.isArray(turn.items) ||
|
|
1577
|
+
!['inProgress', 'completed', 'failed', 'interrupted'].includes(String(turn.status))
|
|
1578
|
+
)
|
|
1579
|
+
return;
|
|
1580
|
+
return turn as { id: string; status: string; error?: unknown };
|
|
794
1581
|
}
|
|
795
1582
|
|
|
796
1583
|
protected _classify(msg: Record<string, unknown>): SessionEvent[] {
|
|
797
|
-
const method = msg.method
|
|
1584
|
+
const method = typeof msg.method === 'string' ? msg.method : undefined;
|
|
798
1585
|
if (!method) return [];
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1586
|
+
if (method === 'account/rateLimits/updated') {
|
|
1587
|
+
const snapshot = codexLimitSnapshot(msg.params, 'stream');
|
|
1588
|
+
return snapshot ? [this._observeLimits(snapshot)] : [];
|
|
1589
|
+
}
|
|
1590
|
+
const params = object(msg.params),
|
|
1591
|
+
turn = this._turn(params?.turn),
|
|
1592
|
+
item = object(params?.item);
|
|
1593
|
+
const str = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
|
|
1594
|
+
const threadId = str(params?.threadId),
|
|
1595
|
+
turnId = turn?.id ?? str(params?.turnId),
|
|
1596
|
+
itemId = str(item?.id) ?? str(params?.itemId);
|
|
1597
|
+
const unowned = (reason: NonNullable<SessionEvent['unattributedReason']>): SessionEvent[] => [
|
|
1598
|
+
{
|
|
1599
|
+
kind: 'native_status',
|
|
1600
|
+
timestamp: Date.now(),
|
|
1601
|
+
threadId,
|
|
1602
|
+
turnId,
|
|
1603
|
+
itemId,
|
|
1604
|
+
correlation: 'unknown',
|
|
1605
|
+
unattributedReason: reason,
|
|
1606
|
+
raw: {
|
|
1607
|
+
method: [
|
|
1608
|
+
'turn/started',
|
|
1609
|
+
'turn/completed',
|
|
1610
|
+
'item/started',
|
|
1611
|
+
'item/completed',
|
|
1612
|
+
'thread/tokenUsage/updated',
|
|
1613
|
+
'item/agentMessage/delta',
|
|
1614
|
+
'item/commandExecution/outputDelta',
|
|
1615
|
+
].includes(method)
|
|
1616
|
+
? method
|
|
1617
|
+
: 'unrecognized',
|
|
1618
|
+
...(item
|
|
1619
|
+
? {
|
|
1620
|
+
itemType: ['agentMessage', 'commandExecution', 'reasoning', 'mcpToolCall'].includes(
|
|
1621
|
+
String(item.type),
|
|
1622
|
+
)
|
|
1623
|
+
? item.type
|
|
1624
|
+
: 'unsupported',
|
|
1625
|
+
}
|
|
1626
|
+
: {}),
|
|
1627
|
+
},
|
|
1628
|
+
},
|
|
1629
|
+
];
|
|
1630
|
+
const a = this._inflight?.evidence;
|
|
1631
|
+
if (!threadId || threadId !== this._externalSessionId) return unowned('foreign-session');
|
|
1632
|
+
if (!a || this._appAdmission !== a.admissionId) return unowned('no-admission');
|
|
1633
|
+
if (turnId && this._seenTerminals.has(turnId))
|
|
1634
|
+
return unowned(method === 'turn/completed' ? 'duplicate-terminal' : 'after-terminal');
|
|
1635
|
+
if (method === 'turn/started' && turn?.status === 'inProgress' && !a.identity.turnId)
|
|
1636
|
+
a.identity = { ...a.identity, threadId, turnId: turn.id, correlation: 'native-turn' };
|
|
1637
|
+
if (!turnId || turnId !== a.identity.turnId) return unowned('foreign-turn');
|
|
1638
|
+
const identity = { ...a.identity, threadId, turnId, ...(itemId ? { itemId } : {}) };
|
|
1639
|
+
const status = (): SessionEvent[] => [
|
|
1640
|
+
{ ...identity, kind: 'native_status', timestamp: Date.now(), raw: { method } },
|
|
1641
|
+
];
|
|
1642
|
+
if (method === 'turn/completed') {
|
|
1643
|
+
if (!turn || turn.status === 'inProgress') return unowned('unrecognized-event');
|
|
1644
|
+
const error = object(turn.error);
|
|
1645
|
+
if (turn.error != null && typeof error?.message !== 'string')
|
|
1646
|
+
return unowned('unrecognized-event');
|
|
1647
|
+
this._seenTerminals.add(turnId);
|
|
1648
|
+
this._turnDone?.();
|
|
812
1649
|
this._turnDone = undefined;
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1650
|
+
return [
|
|
1651
|
+
{
|
|
1652
|
+
...identity,
|
|
1653
|
+
kind: 'result',
|
|
1654
|
+
timestamp: Date.now(),
|
|
1655
|
+
text: a.content,
|
|
1656
|
+
nativeOutcome: turn.status === 'completed' && !error ? 'completed' : 'failed',
|
|
1657
|
+
terminal: {
|
|
1658
|
+
type: method,
|
|
1659
|
+
turnId,
|
|
1660
|
+
subtype: turn.status,
|
|
1661
|
+
...(error ? { reason: error.message as string } : {}),
|
|
1662
|
+
},
|
|
1663
|
+
},
|
|
1664
|
+
];
|
|
816
1665
|
}
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1666
|
+
if (method === 'thread/tokenUsage/updated') {
|
|
1667
|
+
const last = object(object(params?.tokenUsage)?.last);
|
|
1668
|
+
if (
|
|
1669
|
+
typeof last?.inputTokens !== 'number' ||
|
|
1670
|
+
typeof last?.outputTokens !== 'number' ||
|
|
1671
|
+
!Number.isSafeInteger(last.inputTokens) ||
|
|
1672
|
+
!Number.isSafeInteger(last.outputTokens) ||
|
|
1673
|
+
last.inputTokens < 0 ||
|
|
1674
|
+
last.outputTokens < 0
|
|
1675
|
+
)
|
|
1676
|
+
return unowned('unrecognized-event');
|
|
1677
|
+
// `last` is a snapshot, not an additive delta. Total accounting happens once
|
|
1678
|
+
// when the original native turn and RPC settle; absent usage stays absent.
|
|
1679
|
+
a.tokens = { input: last.inputTokens, output: last.outputTokens };
|
|
1680
|
+
return status();
|
|
821
1681
|
}
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1682
|
+
if (method === 'item/agentMessage/delta' || method === 'item/commandExecution/outputDelta') {
|
|
1683
|
+
if (!itemId || typeof params?.delta !== 'string') return unowned('unrecognized-event');
|
|
1684
|
+
const prior = this._items.get(itemId);
|
|
1685
|
+
if (prior?.completed) return unowned('after-terminal');
|
|
1686
|
+
if (method === 'item/agentMessage/delta') {
|
|
1687
|
+
if (prior && prior.type !== 'agentMessage') return unowned('unmatched-tool');
|
|
1688
|
+
this._items.set(itemId, {
|
|
1689
|
+
type: 'agentMessage',
|
|
1690
|
+
completed: false,
|
|
1691
|
+
text: (prior?.text ?? '') + params.delta,
|
|
1692
|
+
});
|
|
1693
|
+
return [{ ...identity, kind: 'text_delta', timestamp: Date.now(), text: params.delta }];
|
|
1694
|
+
}
|
|
1695
|
+
if (prior?.type !== 'commandExecution') return unowned('unmatched-tool');
|
|
1696
|
+
return [
|
|
1697
|
+
{
|
|
1698
|
+
...identity,
|
|
1699
|
+
kind: 'native_status',
|
|
1700
|
+
timestamp: Date.now(),
|
|
1701
|
+
toolName: 'shell',
|
|
1702
|
+
text: params.delta,
|
|
1703
|
+
},
|
|
1704
|
+
];
|
|
1705
|
+
}
|
|
1706
|
+
if (method === 'item/completed' || method === 'item/started') {
|
|
1707
|
+
if (!itemId || !item || typeof item.type !== 'string') return unowned('unrecognized-event');
|
|
1708
|
+
if (item.type === 'agentMessage') {
|
|
1709
|
+
if (method !== 'item/completed') return status();
|
|
1710
|
+
if (this._items.get(itemId)?.completed) return unowned('after-terminal');
|
|
1711
|
+
if (
|
|
1712
|
+
typeof item.text !== 'string' ||
|
|
1713
|
+
(item.phase != null && !['commentary', 'final_answer'].includes(String(item.phase)))
|
|
1714
|
+
)
|
|
1715
|
+
return unowned('unrecognized-event');
|
|
1716
|
+
this._items.set(itemId, { type: item.type, completed: true });
|
|
1717
|
+
return [{ ...identity, kind: 'text', timestamp: Date.now(), text: item.text }];
|
|
1718
|
+
}
|
|
1719
|
+
if (item.type === 'commandExecution') {
|
|
1720
|
+
if (typeof item.command !== 'string') return unowned('malformed-tool');
|
|
1721
|
+
const prior = this._items.get(itemId);
|
|
1722
|
+
if (method === 'item/started') {
|
|
1723
|
+
if (prior) return unowned('duplicate-tool');
|
|
1724
|
+
this._items.set(itemId, { type: item.type, input: item.command, completed: false });
|
|
1725
|
+
return [
|
|
1726
|
+
{
|
|
1727
|
+
...identity,
|
|
1728
|
+
kind: 'tool_use',
|
|
1729
|
+
timestamp: Date.now(),
|
|
1730
|
+
toolName: 'shell',
|
|
1731
|
+
toolInput: { command: item.command },
|
|
1732
|
+
},
|
|
1733
|
+
];
|
|
1734
|
+
}
|
|
1735
|
+
if (!prior || prior.type !== item.type || prior.input !== item.command)
|
|
1736
|
+
return unowned('unmatched-tool');
|
|
1737
|
+
if (prior.completed) return unowned('duplicate-tool');
|
|
1738
|
+
if (!['completed', 'failed', 'declined'].includes(String(item.status)))
|
|
1739
|
+
return unowned('malformed-tool');
|
|
1740
|
+
prior.completed = true;
|
|
1741
|
+
return [
|
|
1742
|
+
{
|
|
1743
|
+
...identity,
|
|
1744
|
+
kind: 'tool_result',
|
|
1745
|
+
timestamp: Date.now(),
|
|
1746
|
+
toolName: 'shell',
|
|
1747
|
+
...(typeof item.aggregatedOutput === 'string'
|
|
1748
|
+
? { toolOutput: item.aggregatedOutput }
|
|
1749
|
+
: { toolOutputOmitted: true }),
|
|
1750
|
+
toolError:
|
|
1751
|
+
item.status !== 'completed' ||
|
|
1752
|
+
(typeof item.exitCode === 'number' && item.exitCode !== 0),
|
|
1753
|
+
},
|
|
1754
|
+
];
|
|
1755
|
+
}
|
|
1756
|
+
if (item.type === 'mcpToolCall') {
|
|
1757
|
+
if (
|
|
1758
|
+
typeof item.server !== 'string' ||
|
|
1759
|
+
!item.server ||
|
|
1760
|
+
typeof item.tool !== 'string' ||
|
|
1761
|
+
!item.tool
|
|
1762
|
+
)
|
|
1763
|
+
return unowned('malformed-tool');
|
|
1764
|
+
const prior = this._items.get(itemId);
|
|
1765
|
+
const tool = {
|
|
1766
|
+
toolServer: item.server,
|
|
1767
|
+
toolMethod: item.tool,
|
|
1768
|
+
toolName: `mcp__${item.server}__${item.tool}`,
|
|
1769
|
+
};
|
|
1770
|
+
if (method === 'item/started') {
|
|
1771
|
+
if (prior) return unowned('duplicate-tool');
|
|
1772
|
+
if (item.status !== 'inProgress') return unowned('malformed-tool');
|
|
1773
|
+
this._items.set(itemId, {
|
|
1774
|
+
type: item.type,
|
|
1775
|
+
server: item.server,
|
|
1776
|
+
tool: item.tool,
|
|
1777
|
+
input: retainEvidence(item.arguments),
|
|
1778
|
+
completed: false,
|
|
1779
|
+
});
|
|
1780
|
+
return [
|
|
1781
|
+
{
|
|
1782
|
+
...identity,
|
|
1783
|
+
...tool,
|
|
1784
|
+
kind: 'tool_use',
|
|
1785
|
+
timestamp: Date.now(),
|
|
1786
|
+
...publicMcpArguments(item.arguments),
|
|
1787
|
+
},
|
|
1788
|
+
];
|
|
1789
|
+
}
|
|
1790
|
+
if (
|
|
1791
|
+
!prior ||
|
|
1792
|
+
prior.type !== item.type ||
|
|
1793
|
+
prior.server !== item.server ||
|
|
1794
|
+
prior.tool !== item.tool ||
|
|
1795
|
+
!isDeepStrictEqual(prior.input, item.arguments)
|
|
1796
|
+
)
|
|
1797
|
+
return unowned('unmatched-tool');
|
|
1798
|
+
if (prior.completed) return unowned('duplicate-tool');
|
|
1799
|
+
if (!['completed', 'failed'].includes(String(item.status)))
|
|
1800
|
+
return unowned('malformed-tool');
|
|
1801
|
+
const error = object(item.error),
|
|
1802
|
+
result = object(item.result);
|
|
1803
|
+
const output = result ? publicMcpResult({ Ok: { content: result.content } }) : undefined;
|
|
1804
|
+
if (item.error != null && typeof error?.message !== 'string')
|
|
1805
|
+
return unowned('malformed-tool');
|
|
1806
|
+
prior.completed = true;
|
|
1807
|
+
return [
|
|
1808
|
+
{
|
|
1809
|
+
...identity,
|
|
1810
|
+
...tool,
|
|
1811
|
+
kind: 'tool_result',
|
|
1812
|
+
timestamp: Date.now(),
|
|
1813
|
+
...(output ??
|
|
1814
|
+
(error ? { toolOutput: error.message as string } : { toolOutputOmitted: true })),
|
|
1815
|
+
toolError: item.status === 'failed' || !!error,
|
|
1816
|
+
...(result && Object.keys(result).some((k) => k !== 'content' && result[k] != null)
|
|
1817
|
+
? { toolOutputOmitted: true }
|
|
1818
|
+
: {}),
|
|
1819
|
+
},
|
|
1820
|
+
];
|
|
1821
|
+
}
|
|
1822
|
+
// Reasoning and unsupported items retain safe identity/type diagnostics only.
|
|
1823
|
+
return unowned('unrecognized-event');
|
|
1824
|
+
}
|
|
1825
|
+
return method === 'turn/started' ? status() : unowned('unrecognized-event');
|
|
825
1826
|
}
|
|
826
1827
|
}
|
|
827
1828
|
|
|
@@ -838,58 +1839,135 @@ export class CodexAppServerSession extends BaseCodexSession {
|
|
|
838
1839
|
// command_execution / commandExecution → tool_use (+ tool_result when done)
|
|
839
1840
|
// token_count / usage → tokens (attached to a result event)
|
|
840
1841
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1842
|
+
const object = (value: unknown): Record<string, unknown> | undefined =>
|
|
1843
|
+
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
1844
|
+
? (value as Record<string, unknown>)
|
|
1845
|
+
: undefined;
|
|
1846
|
+
|
|
1847
|
+
function publicMcpArguments(value: unknown): Pick<SessionEvent, 'toolInput' | 'toolInputOmitted'> {
|
|
1848
|
+
if (!object(value)) return { toolInputOmitted: 'Native arguments absent or not an object' };
|
|
1849
|
+
let omitted = false;
|
|
1850
|
+
const clean = (v: unknown, depth = 0): unknown => {
|
|
1851
|
+
if (depth > 32) {
|
|
1852
|
+
omitted = true;
|
|
1853
|
+
return null;
|
|
1854
|
+
}
|
|
1855
|
+
if (v === null || ['string', 'number', 'boolean'].includes(typeof v)) return v;
|
|
1856
|
+
if (Array.isArray(v)) return v.map((x) => clean(x, depth + 1));
|
|
1857
|
+
if (!object(v)) {
|
|
1858
|
+
omitted = true;
|
|
1859
|
+
return null;
|
|
1860
|
+
}
|
|
1861
|
+
return Object.fromEntries(
|
|
1862
|
+
Object.entries(v as Record<string, unknown>).flatMap(([key, item]) => {
|
|
1863
|
+
if (
|
|
1864
|
+
/^(?:__proto__|constructor|prototype|env|environment|authorization|credentials?|api[-_]?key|access[-_]?token|refresh[-_]?token|password|secret|reasoning|thinking)$/i.test(
|
|
1865
|
+
key,
|
|
1866
|
+
)
|
|
1867
|
+
) {
|
|
1868
|
+
omitted = true;
|
|
1869
|
+
return [];
|
|
1870
|
+
}
|
|
1871
|
+
return [[key, clean(item, depth + 1)]];
|
|
1872
|
+
}),
|
|
1873
|
+
);
|
|
1874
|
+
};
|
|
1875
|
+
const toolInput = clean(value) as Record<string, unknown>;
|
|
1876
|
+
return {
|
|
1877
|
+
toolInput,
|
|
1878
|
+
...(omitted ? { toolInputOmitted: 'Non-public or unsupported argument fields omitted' } : {}),
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
/** Rust Result<CallToolResult,String> from protocol.rs. This is legacy event
|
|
1883
|
+
* normalization, not the app-server ThreadItem schema. Keep all public text
|
|
1884
|
+
* blocks (including the bridge's JSON receipt), not arbitrary envelope fields. */
|
|
1885
|
+
function publicMcpResult(
|
|
1886
|
+
value: unknown,
|
|
1887
|
+
): Pick<SessionEvent, 'toolOutput' | 'toolError' | 'toolOutputOmitted'> | undefined {
|
|
1888
|
+
const result = object(value);
|
|
1889
|
+
if (!result || Object.keys(result).length !== 1) return;
|
|
1890
|
+
if (typeof result.Err === 'string') return { toolOutput: result.Err, toolError: true };
|
|
1891
|
+
const ok = object(result.Ok);
|
|
1892
|
+
if (
|
|
1893
|
+
!ok ||
|
|
1894
|
+
!Array.isArray(ok.content) ||
|
|
1895
|
+
(ok.isError !== undefined && typeof ok.isError !== 'boolean')
|
|
1896
|
+
)
|
|
1897
|
+
return;
|
|
1898
|
+
let omitted = Object.keys(ok).some((k) => k !== 'content' && k !== 'isError');
|
|
1899
|
+
const content = ok.content.map((value) => {
|
|
1900
|
+
const block = object(value);
|
|
1901
|
+
if (block?.type === 'text' && typeof block.text === 'string') {
|
|
1902
|
+
if (Object.keys(block).some((k) => k !== 'type' && k !== 'text')) omitted = true;
|
|
1903
|
+
return { type: 'text', text: block.text };
|
|
1904
|
+
}
|
|
1905
|
+
omitted = true;
|
|
1906
|
+
// Unknown shape values are not safe labels. Known non-text types retain type only.
|
|
1907
|
+
return {
|
|
1908
|
+
type: ['image', 'audio', 'resource', 'resource_link'].includes(String(block?.type))
|
|
1909
|
+
? block!.type
|
|
1910
|
+
: 'unsupported',
|
|
1911
|
+
};
|
|
1912
|
+
});
|
|
1913
|
+
return {
|
|
1914
|
+
toolOutput: JSON.stringify({
|
|
1915
|
+
content,
|
|
1916
|
+
...(ok.isError !== undefined ? { isError: ok.isError } : {}),
|
|
1917
|
+
}),
|
|
1918
|
+
...(ok.isError !== undefined ? { toolError: ok.isError as boolean } : {}),
|
|
1919
|
+
...(omitted ? { toolOutputOmitted: true } : {}),
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
function classifyCodexEvent(ev: Record<string, unknown>, tag: string): SessionEvent[] {
|
|
845
1924
|
const ts = Date.now();
|
|
846
|
-
const type = String(ev[tag] ??
|
|
1925
|
+
const type = String(ev[tag] ?? '').toLowerCase();
|
|
847
1926
|
const events: SessionEvent[] = [];
|
|
848
1927
|
|
|
849
1928
|
// Agent message → text.
|
|
850
|
-
if (type ===
|
|
1929
|
+
if (type === 'agent_message' || type === 'agentmessage' || type === 'agent_message_delta') {
|
|
851
1930
|
const text = (ev.message ?? ev.text ?? ev.delta) as string | undefined;
|
|
852
|
-
if (text) events.push({ kind:
|
|
1931
|
+
if (text) events.push({ kind: 'text', timestamp: ts, text, raw: ev });
|
|
853
1932
|
return events;
|
|
854
1933
|
}
|
|
855
1934
|
|
|
856
1935
|
// Reasoning → thinking.
|
|
857
1936
|
if (
|
|
858
|
-
type ===
|
|
859
|
-
type ===
|
|
860
|
-
type ===
|
|
861
|
-
type ===
|
|
1937
|
+
type === 'reasoning' ||
|
|
1938
|
+
type === 'agent_reasoning' ||
|
|
1939
|
+
type === 'agentreasoning' ||
|
|
1940
|
+
type === 'agent_reasoning_delta'
|
|
862
1941
|
) {
|
|
863
1942
|
const text = (ev.text ?? ev.reasoning ?? ev.delta ?? ev.summary) as string | undefined;
|
|
864
|
-
if (text) events.push({ kind:
|
|
1943
|
+
if (text) events.push({ kind: 'thinking', timestamp: ts, text, raw: ev });
|
|
865
1944
|
return events;
|
|
866
1945
|
}
|
|
867
1946
|
|
|
868
1947
|
// Command execution → tool_use, plus tool_result if output is present.
|
|
869
1948
|
if (
|
|
870
|
-
type ===
|
|
871
|
-
type ===
|
|
872
|
-
type ===
|
|
873
|
-
type ===
|
|
1949
|
+
type === 'command_execution' ||
|
|
1950
|
+
type === 'commandexecution' ||
|
|
1951
|
+
type === 'exec_command_begin' ||
|
|
1952
|
+
type === 'exec_command_end'
|
|
874
1953
|
) {
|
|
875
1954
|
const command = (ev.command ?? ev.cmd) as string | string[] | undefined;
|
|
876
|
-
const cmdStr = Array.isArray(command) ? command.join(
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
| 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;
|
|
887
1965
|
const exitCode = (ev.exit_code ?? ev.exitCode) as number | undefined;
|
|
888
1966
|
if (output !== undefined || exitCode !== undefined) {
|
|
889
1967
|
events.push({
|
|
890
|
-
kind:
|
|
1968
|
+
kind: 'tool_result',
|
|
891
1969
|
timestamp: ts,
|
|
892
|
-
toolOutput: output ??
|
|
1970
|
+
toolOutput: output ?? '',
|
|
893
1971
|
toolError: exitCode !== undefined && exitCode !== 0,
|
|
894
1972
|
raw: ev,
|
|
895
1973
|
});
|
|
@@ -899,19 +1977,17 @@ function classifyCodexEvent(
|
|
|
899
1977
|
|
|
900
1978
|
// Token usage → carried on a result-less event so accounting picks it up.
|
|
901
1979
|
if (
|
|
902
|
-
type ===
|
|
903
|
-
type ===
|
|
904
|
-
type ===
|
|
905
|
-
type ===
|
|
1980
|
+
type === 'token_count' ||
|
|
1981
|
+
type === 'token_usage' ||
|
|
1982
|
+
type === 'usage' ||
|
|
1983
|
+
type === 'tokenusage'
|
|
906
1984
|
) {
|
|
907
1985
|
const u = (ev.info ?? ev.usage ?? ev) as Record<string, unknown>;
|
|
908
|
-
const input =
|
|
909
|
-
|
|
910
|
-
const output =
|
|
911
|
-
(u.output_tokens ?? u.outputTokens ?? u.total_output_tokens ?? 0) as number;
|
|
1986
|
+
const input = (u.input_tokens ?? u.inputTokens ?? u.total_input_tokens ?? 0) as number;
|
|
1987
|
+
const output = (u.output_tokens ?? u.outputTokens ?? u.total_output_tokens ?? 0) as number;
|
|
912
1988
|
if (input || output) {
|
|
913
1989
|
events.push({
|
|
914
|
-
kind:
|
|
1990
|
+
kind: 'result',
|
|
915
1991
|
timestamp: ts,
|
|
916
1992
|
tokens: { input, output },
|
|
917
1993
|
raw: ev,
|
|
@@ -921,9 +1997,9 @@ function classifyCodexEvent(
|
|
|
921
1997
|
}
|
|
922
1998
|
|
|
923
1999
|
// Errors.
|
|
924
|
-
if (type ===
|
|
2000
|
+
if (type === 'error' || type === 'stream_error') {
|
|
925
2001
|
events.push({
|
|
926
|
-
kind:
|
|
2002
|
+
kind: 'error',
|
|
927
2003
|
timestamp: ts,
|
|
928
2004
|
text: (ev.message ?? ev.error ?? JSON.stringify(ev)) as string,
|
|
929
2005
|
raw: ev,
|