@inixiative/agent-session 0.2.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/README.md +77 -6
- package/package.json +10 -4
- package/src/claude-agent-sdk-session.ts +316 -0
- package/src/claude-code-session.ts +584 -207
- package/src/claude-primed.ts +655 -0
- package/src/claude-usage.ts +3 -4
- package/src/codex-primed.ts +1482 -0
- package/src/codex-session.ts +1080 -395
- package/src/harness-session.ts +82 -35
- package/src/index.ts +106 -12
- 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 +2 -2
- package/src/routing.ts +273 -0
- package/src/transport.ts +215 -0
- package/src/transports.ts +97 -0
- package/src/turn-state.ts +55 -25
package/src/codex-session.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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';
|
|
4
6
|
// ---------------------------------------------------------------------------
|
|
5
7
|
// CodexSession — long-lived OpenAI Codex CLI process with full event capture
|
|
6
8
|
// ---------------------------------------------------------------------------
|
|
@@ -33,19 +35,20 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
33
35
|
import type {
|
|
34
36
|
BeforeSendHook,
|
|
35
37
|
HarnessSession,
|
|
38
|
+
NativeInterruptOutcome,
|
|
39
|
+
SessionArtifact,
|
|
36
40
|
SessionEvent,
|
|
37
41
|
SessionEventHandler,
|
|
38
42
|
SessionResult,
|
|
39
|
-
|
|
40
|
-
} from "./harness-session";
|
|
43
|
+
} from './harness-session';
|
|
41
44
|
|
|
42
45
|
// Re-export types so importers can stay on one path (mirrors claude-code-session).
|
|
43
46
|
export type {
|
|
47
|
+
SessionArtifact,
|
|
44
48
|
SessionEvent,
|
|
45
49
|
SessionEventKind,
|
|
46
50
|
SessionResult,
|
|
47
|
-
|
|
48
|
-
} from "./harness-session";
|
|
51
|
+
} from './harness-session';
|
|
49
52
|
|
|
50
53
|
// ---------------------------------------------------------------------------
|
|
51
54
|
// Shared subprocess shape (mirrors ClaudeCodeSession.PipedSubprocess)
|
|
@@ -104,6 +107,8 @@ export interface CodexSessionConfig {
|
|
|
104
107
|
* `thread/resume` on app-server. Only app-server supports cold resume by ID.
|
|
105
108
|
*/
|
|
106
109
|
externalSessionId?: string;
|
|
110
|
+
/** Environment merged over process.env (e.g. CODEX_HOME for a profile). */
|
|
111
|
+
env?: Record<string, string | undefined>;
|
|
107
112
|
/**
|
|
108
113
|
* Override for the process spawner. Defaults to Bun.spawn. Tests inject a fake
|
|
109
114
|
* subprocess that emulates the codex JSON-RPC protocol; the docker-spawn helper
|
|
@@ -117,7 +122,7 @@ export interface CodexSessionConfig {
|
|
|
117
122
|
// ---------------------------------------------------------------------------
|
|
118
123
|
|
|
119
124
|
interface QueuedTurn {
|
|
120
|
-
onAdmission?: import(
|
|
125
|
+
onAdmission?: import('./harness-session').SessionSendOptions['onAdmission'];
|
|
121
126
|
evidence: TurnState;
|
|
122
127
|
message: string;
|
|
123
128
|
timeout: number;
|
|
@@ -126,7 +131,7 @@ interface QueuedTurn {
|
|
|
126
131
|
}
|
|
127
132
|
|
|
128
133
|
// codex reasoning-effort levels (model_reasoning_effort), low→high.
|
|
129
|
-
const VALID_EFFORTS = [
|
|
134
|
+
const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh'];
|
|
130
135
|
|
|
131
136
|
// ---------------------------------------------------------------------------
|
|
132
137
|
// Base class — shared queue, event log, classification helpers, lifecycle
|
|
@@ -147,10 +152,11 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
147
152
|
protected _defaultTimeout: number;
|
|
148
153
|
protected _baseContext?: string;
|
|
149
154
|
protected _spawn?: CodexSpawn;
|
|
155
|
+
protected _env?: Record<string, string | undefined>;
|
|
150
156
|
|
|
151
157
|
// -- Process --
|
|
152
158
|
protected _proc: PipedSubprocess | null = null;
|
|
153
|
-
protected _stderr =
|
|
159
|
+
protected _stderr = '';
|
|
154
160
|
|
|
155
161
|
// -- Session state --
|
|
156
162
|
protected _externalSessionId?: string;
|
|
@@ -183,27 +189,28 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
183
189
|
protected _seenTerminals = new Set<string>();
|
|
184
190
|
protected _inflight: QueuedTurn | null = null;
|
|
185
191
|
protected _turnEvents: SessionEvent[] = [];
|
|
186
|
-
protected _resultText =
|
|
192
|
+
protected _resultText = '';
|
|
187
193
|
protected _turnTimer: ReturnType<typeof setTimeout> | null = null;
|
|
188
194
|
|
|
189
195
|
constructor(config?: CodexSessionConfig) {
|
|
190
|
-
const bin = config?.bin ??
|
|
191
|
-
if (!/^[a-zA-Z0-9_
|
|
196
|
+
const bin = config?.bin ?? 'codex';
|
|
197
|
+
if (!/^[a-zA-Z0-9_./\\-]+$/.test(bin)) {
|
|
192
198
|
throw new Error(`Invalid codex CLI binary path: "${bin}"`);
|
|
193
199
|
}
|
|
194
200
|
if (config?.effort && !VALID_EFFORTS.includes(config.effort)) {
|
|
195
201
|
throw new Error(
|
|
196
|
-
`Invalid codex effort "${config.effort}". Valid: ${VALID_EFFORTS.join(
|
|
202
|
+
`Invalid codex effort "${config.effort}". Valid: ${VALID_EFFORTS.join(', ')}`,
|
|
197
203
|
);
|
|
198
204
|
}
|
|
199
205
|
this._bin = bin;
|
|
200
|
-
this._model = config?.model ??
|
|
206
|
+
this._model = config?.model ?? 'gpt-5.5';
|
|
201
207
|
this._effort = config?.effort;
|
|
202
208
|
this._cwd = config?.cwd ?? process.cwd();
|
|
203
209
|
this._defaultTimeout = config?.timeout ?? 600_000;
|
|
204
210
|
this._baseContext = config?.baseContext;
|
|
205
211
|
this._externalSessionId = config?.externalSessionId;
|
|
206
212
|
this._spawn = config?.spawn;
|
|
213
|
+
this._env = config?.env;
|
|
207
214
|
this._startedAt = Date.now();
|
|
208
215
|
}
|
|
209
216
|
|
|
@@ -211,14 +218,28 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
211
218
|
// Accessors (identical to ClaudeCodeSession)
|
|
212
219
|
// ---------------------------------------------------------------------------
|
|
213
220
|
|
|
214
|
-
get accounting() {
|
|
215
|
-
|
|
216
|
-
|
|
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
|
+
}
|
|
217
230
|
|
|
218
|
-
get alive(): boolean {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
get
|
|
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
|
+
}
|
|
222
243
|
get totalTokens(): Readonly<{ input: number; output: number }> {
|
|
223
244
|
return { ...this._totalTokens };
|
|
224
245
|
}
|
|
@@ -251,7 +272,7 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
251
272
|
*/
|
|
252
273
|
async push(payload: { kind: string; text: string }): Promise<void> {
|
|
253
274
|
this._emit({
|
|
254
|
-
kind:
|
|
275
|
+
kind: 'error',
|
|
255
276
|
timestamp: Date.now(),
|
|
256
277
|
text: `push_ignored: kind=${payload.kind} — codex turn has no OOB channel`,
|
|
257
278
|
raw: payload,
|
|
@@ -264,7 +285,10 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
264
285
|
|
|
265
286
|
interrupt(): void {
|
|
266
287
|
if (!this._inflight) return;
|
|
267
|
-
this._rejectInflight(
|
|
288
|
+
this._rejectInflight(
|
|
289
|
+
new Error('Local waiter interrupted; native cancellation unacknowledged'),
|
|
290
|
+
'interrupt-request',
|
|
291
|
+
);
|
|
268
292
|
}
|
|
269
293
|
|
|
270
294
|
kill(): void {
|
|
@@ -276,30 +300,40 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
276
300
|
this._turnTimer = null;
|
|
277
301
|
}
|
|
278
302
|
|
|
279
|
-
this._rejectInflight(new Error(
|
|
280
|
-
this._rejectQueue(new Error(
|
|
281
|
-
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'));
|
|
282
306
|
this._pending.clear();
|
|
283
307
|
|
|
284
|
-
try {
|
|
285
|
-
|
|
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
|
+
}
|
|
286
318
|
this._proc = null;
|
|
287
319
|
|
|
288
|
-
this._emit({ kind:
|
|
320
|
+
this._emit({ kind: 'session_end', timestamp: Date.now() });
|
|
289
321
|
}
|
|
290
322
|
|
|
291
323
|
artifact(): SessionArtifact {
|
|
292
324
|
return {
|
|
293
325
|
externalSessionId: this._externalSessionId,
|
|
294
|
-
attempts: this.attempts,
|
|
326
|
+
attempts: this.attempts,
|
|
327
|
+
accounting: 'observed-only',
|
|
328
|
+
diagnostics: this.diagnostics,
|
|
295
329
|
events: [...this._eventLog],
|
|
296
330
|
startedAt: this._startedAt,
|
|
297
331
|
endedAt: this._alive ? undefined : Date.now(),
|
|
298
332
|
turns: this._turns,
|
|
299
333
|
totalTokens: { ...this._totalTokens },
|
|
300
|
-
toolCalls: this._eventLog.filter((e) => e.kind ===
|
|
301
|
-
toolResults: this._eventLog.filter((e) => e.kind ===
|
|
302
|
-
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,
|
|
303
337
|
};
|
|
304
338
|
}
|
|
305
339
|
|
|
@@ -308,30 +342,36 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
308
342
|
// ---------------------------------------------------------------------------
|
|
309
343
|
|
|
310
344
|
async start(): Promise<void> {
|
|
311
|
-
if (this._attempts.some(a => a.dispatch ===
|
|
312
|
-
|
|
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');
|
|
313
348
|
|
|
314
349
|
const args = this._buildSpawnArgs();
|
|
315
350
|
const env: Record<string, string | undefined> = {
|
|
316
351
|
...process.env,
|
|
317
|
-
|
|
352
|
+
...this._env,
|
|
353
|
+
DISABLE_AUTOUPDATER: '1',
|
|
318
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];
|
|
319
359
|
|
|
320
360
|
if (this._spawn) {
|
|
321
361
|
this._proc = this._spawn([this._bin, ...args], { cwd: this._cwd, env });
|
|
322
362
|
} else {
|
|
323
363
|
this._proc = Bun.spawn([this._bin, ...args], {
|
|
324
364
|
cwd: this._cwd,
|
|
325
|
-
stdin:
|
|
326
|
-
stdout:
|
|
327
|
-
stderr:
|
|
365
|
+
stdin: 'pipe',
|
|
366
|
+
stdout: 'pipe',
|
|
367
|
+
stderr: 'pipe',
|
|
328
368
|
env,
|
|
329
369
|
}) as unknown as PipedSubprocess;
|
|
330
370
|
}
|
|
331
371
|
|
|
332
372
|
this._alive = true;
|
|
333
373
|
this._endEmitted = false;
|
|
334
|
-
this._emit({ kind:
|
|
374
|
+
this._emit({ kind: 'session_start', timestamp: Date.now() });
|
|
335
375
|
|
|
336
376
|
const proc = this._proc;
|
|
337
377
|
const stdoutDone = this._readStdout(proc);
|
|
@@ -345,10 +385,10 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
345
385
|
? `Process exited (code ${code}): ${this._stderr.trim().slice(0, 500)}`
|
|
346
386
|
: `Process exited with code ${code}`;
|
|
347
387
|
this._rejectInflight(new Error(errMsg));
|
|
348
|
-
this._rejectQueue(new Error(
|
|
349
|
-
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'));
|
|
350
390
|
this._pending.clear();
|
|
351
|
-
this._emit({ kind:
|
|
391
|
+
this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
|
|
352
392
|
});
|
|
353
393
|
|
|
354
394
|
await this._handshake();
|
|
@@ -360,17 +400,25 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
360
400
|
|
|
361
401
|
async send(
|
|
362
402
|
message: string,
|
|
363
|
-
opts?: import(
|
|
403
|
+
opts?: import('./harness-session').SessionSendOptions,
|
|
364
404
|
): Promise<SessionResult> {
|
|
365
|
-
if (
|
|
366
|
-
|
|
367
|
-
|
|
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');
|
|
368
416
|
}
|
|
369
417
|
if (!this._proc && this._externalSessionId) {
|
|
370
418
|
await this.start();
|
|
371
419
|
}
|
|
372
|
-
if (!this._proc) throw new Error(
|
|
373
|
-
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');
|
|
374
422
|
|
|
375
423
|
const timeout = opts?.timeout ?? this._defaultTimeout;
|
|
376
424
|
|
|
@@ -386,10 +434,24 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
386
434
|
}
|
|
387
435
|
|
|
388
436
|
return new Promise<SessionResult>((resolve, reject) => {
|
|
389
|
-
const evidence = new TurnState();
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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;
|
|
393
455
|
}
|
|
394
456
|
if (!this._inflight) {
|
|
395
457
|
this._dispatchTurn(turn);
|
|
@@ -403,28 +465,45 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
403
465
|
// Private — turn dispatch + queue (mirrors ClaudeCodeSession)
|
|
404
466
|
// ---------------------------------------------------------------------------
|
|
405
467
|
|
|
406
|
-
readonly admissionProtocol =
|
|
407
|
-
inspectAttempt(admissionId: string) {
|
|
468
|
+
readonly admissionProtocol = 'prewrite-v1' as const;
|
|
469
|
+
inspectAttempt(admissionId: string) {
|
|
470
|
+
return this._attempts.find((attempt) => attempt.admissionId === admissionId)?.snapshot();
|
|
471
|
+
}
|
|
408
472
|
|
|
409
473
|
private _dispatchTurn(turn: QueuedTurn): void {
|
|
410
474
|
this._inflight = turn;
|
|
411
475
|
if (turn.onAdmission) {
|
|
412
|
-
Promise.resolve()
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
+
});
|
|
419
498
|
return;
|
|
420
499
|
}
|
|
421
500
|
this._writeTurn(turn);
|
|
422
501
|
}
|
|
423
502
|
|
|
424
503
|
private _writeTurn(turn: QueuedTurn): void {
|
|
425
|
-
turn.evidence.dispatch =
|
|
504
|
+
turn.evidence.dispatch = 'attempted';
|
|
426
505
|
this._turnEvents = turn.evidence.events;
|
|
427
|
-
this._resultText =
|
|
506
|
+
this._resultText = '';
|
|
428
507
|
|
|
429
508
|
// Send via the subclass's wire protocol. The returned promise resolves when
|
|
430
509
|
// the tools/call (or turn) completes; that resolves the turn.
|
|
@@ -441,15 +520,15 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
441
520
|
.catch((err: Error) => {
|
|
442
521
|
if (this._inflight !== turn) return;
|
|
443
522
|
turn.evidence.rpcSettled = true;
|
|
444
|
-
if (turn.evidence.rpcOutcome ===
|
|
445
|
-
this._rejectInflight(err, err instanceof NativeValidationError ?
|
|
523
|
+
if (turn.evidence.rpcOutcome === 'pending') turn.evidence.rpcOutcome = 'unknown';
|
|
524
|
+
this._rejectInflight(err, err instanceof NativeValidationError ? 'validation' : 'rpc');
|
|
446
525
|
this._releaseKnownTurn();
|
|
447
526
|
});
|
|
448
527
|
|
|
449
528
|
if (turn.timeout > 0) {
|
|
450
529
|
this._turnTimer = setTimeout(() => {
|
|
451
530
|
this._turnTimer = null;
|
|
452
|
-
this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`),
|
|
531
|
+
this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`), 'timeout');
|
|
453
532
|
}, turn.timeout);
|
|
454
533
|
}
|
|
455
534
|
}
|
|
@@ -465,23 +544,26 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
465
544
|
const a = this._inflight.evidence;
|
|
466
545
|
a.content = this._resultText;
|
|
467
546
|
a.tokens = tokens ?? a.tokens;
|
|
468
|
-
if (a.localOutcome ===
|
|
469
|
-
a.localOutcome =
|
|
547
|
+
if (a.localOutcome === 'pending') {
|
|
548
|
+
a.localOutcome = 'resolved';
|
|
470
549
|
this._inflight.resolve(a.result(this._externalSessionId));
|
|
471
550
|
}
|
|
472
|
-
if (a.nativeOutcome ===
|
|
551
|
+
if (a.nativeOutcome === 'unknown')
|
|
552
|
+
this._rejectQueue(new Error('Native terminal missing; queued send not dispatched'));
|
|
473
553
|
this._releaseKnownTurn();
|
|
474
554
|
}
|
|
475
555
|
|
|
476
556
|
private _releaseKnownTurn(): void {
|
|
477
557
|
const a = this._inflight?.evidence;
|
|
478
|
-
if (!a || !this._canReleaseAttempt(a) || a.nativeOutcome ===
|
|
558
|
+
if (!a || !this._canReleaseAttempt(a) || a.nativeOutcome === 'unknown') return;
|
|
479
559
|
this._turns++;
|
|
480
560
|
this._inflight = null;
|
|
481
561
|
this._processNextTurn();
|
|
482
562
|
}
|
|
483
563
|
|
|
484
|
-
protected _canReleaseAttempt(attempt: TurnState): boolean {
|
|
564
|
+
protected _canReleaseAttempt(attempt: TurnState): boolean {
|
|
565
|
+
return attempt.rpcSettled;
|
|
566
|
+
}
|
|
485
567
|
|
|
486
568
|
private _processNextTurn(): void {
|
|
487
569
|
if (!this._inflight && this._queue.length > 0 && this._alive) {
|
|
@@ -490,26 +572,33 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
490
572
|
}
|
|
491
573
|
}
|
|
492
574
|
|
|
493
|
-
protected _rejectInflight(
|
|
575
|
+
protected _rejectInflight(
|
|
576
|
+
err: Error,
|
|
577
|
+
reason: import('./harness-session').SessionAttempt['localFailure'] = 'transport',
|
|
578
|
+
): void {
|
|
494
579
|
if (!this._inflight) return;
|
|
495
|
-
if (this._turnTimer) {
|
|
580
|
+
if (this._turnTimer) {
|
|
581
|
+
clearTimeout(this._turnTimer);
|
|
582
|
+
this._turnTimer = null;
|
|
583
|
+
}
|
|
496
584
|
const a = this._inflight.evidence;
|
|
497
|
-
if ((reason ===
|
|
498
|
-
|
|
499
|
-
|
|
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'));
|
|
500
589
|
}
|
|
501
590
|
|
|
502
591
|
private _transportFailure(err: Error): void {
|
|
503
592
|
this._alive = false;
|
|
504
593
|
this._rejectInflight(err);
|
|
505
594
|
this._rejectQueue(err);
|
|
506
|
-
this._emit({ kind:
|
|
595
|
+
this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
|
|
507
596
|
for (const p of this._pending.values()) p.reject(err);
|
|
508
597
|
this._pending.clear();
|
|
509
598
|
}
|
|
510
599
|
|
|
511
600
|
protected _rejectQueue(err: Error): void {
|
|
512
|
-
for (const turn of this._queue) turn.reject(turn.evidence.fail(err,
|
|
601
|
+
for (const turn of this._queue) turn.reject(turn.evidence.fail(err, 'blocked'));
|
|
513
602
|
this._queue = [];
|
|
514
603
|
}
|
|
515
604
|
|
|
@@ -520,13 +609,13 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
520
609
|
private async _readStdout(proc: PipedSubprocess): Promise<void> {
|
|
521
610
|
const reader = proc.stdout.getReader();
|
|
522
611
|
const decoder = new TextDecoder();
|
|
523
|
-
let buffer =
|
|
612
|
+
let buffer = '';
|
|
524
613
|
try {
|
|
525
614
|
while (true) {
|
|
526
615
|
const { done, value } = await reader.read();
|
|
527
616
|
if (done) break;
|
|
528
617
|
buffer += decoder.decode(value, { stream: true });
|
|
529
|
-
const lines = buffer.split(
|
|
618
|
+
const lines = buffer.split('\n');
|
|
530
619
|
buffer = lines.pop()!;
|
|
531
620
|
for (const line of lines) {
|
|
532
621
|
if (!line.trim()) continue;
|
|
@@ -534,7 +623,14 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
534
623
|
}
|
|
535
624
|
}
|
|
536
625
|
if (buffer.trim() && (this._proc === proc || !this._proc)) this._processLine(buffer);
|
|
537
|
-
if (this._proc === proc && this._alive)
|
|
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
|
+
);
|
|
538
634
|
} catch (err) {
|
|
539
635
|
if (this._proc === proc) this._transportFailure(err as Error);
|
|
540
636
|
}
|
|
@@ -549,7 +645,9 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
549
645
|
if (done) break;
|
|
550
646
|
this._stderr += decoder.decode(value, { stream: true });
|
|
551
647
|
}
|
|
552
|
-
} catch {
|
|
648
|
+
} catch {
|
|
649
|
+
/* ignore */
|
|
650
|
+
}
|
|
553
651
|
}
|
|
554
652
|
|
|
555
653
|
// ---------------------------------------------------------------------------
|
|
@@ -565,23 +663,32 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
565
663
|
}
|
|
566
664
|
const raw = object(msg);
|
|
567
665
|
if (!raw) return;
|
|
568
|
-
if (
|
|
666
|
+
if (
|
|
667
|
+
typeof raw.method === 'string' &&
|
|
668
|
+
(typeof raw.id === 'string' || typeof raw.id === 'number') &&
|
|
669
|
+
this._serverRequest(raw)
|
|
670
|
+
)
|
|
671
|
+
return;
|
|
569
672
|
|
|
570
673
|
// JSON-RPC response to one of our requests (has matching `id` + result/error).
|
|
571
|
-
if (typeof raw.id ===
|
|
674
|
+
if (typeof raw.id === 'number' && ('result' in raw || 'error' in raw)) {
|
|
572
675
|
const pending = this._pending.get(raw.id);
|
|
573
676
|
if (pending) {
|
|
574
677
|
this._pending.delete(raw.id);
|
|
575
678
|
const a = this._inflight?.evidence;
|
|
576
|
-
if (a?.identity.rpcRequestId === raw.id) a.rpcOutcome = raw.error ?
|
|
577
|
-
if (
|
|
679
|
+
if (a?.identity.rpcRequestId === raw.id) a.rpcOutcome = raw.error ? 'failed' : 'resolved';
|
|
680
|
+
if ('error' in raw && raw.error) {
|
|
578
681
|
const e = raw.error as Record<string, unknown>;
|
|
579
682
|
pending.reject(new Error((e.message as string) ?? JSON.stringify(e)));
|
|
580
683
|
} else {
|
|
581
684
|
// Apply protocol identity synchronously before later lines in this
|
|
582
685
|
// same stdout chunk. Awaiting the promise first can lose a terminal.
|
|
583
|
-
try {
|
|
584
|
-
|
|
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
|
+
}
|
|
585
692
|
}
|
|
586
693
|
return;
|
|
587
694
|
}
|
|
@@ -590,15 +697,23 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
590
697
|
const classified = this._classify(raw);
|
|
591
698
|
for (const event of classified) {
|
|
592
699
|
const a = this._inflight?.evidence;
|
|
593
|
-
if (!a || event.unattributedReason) {
|
|
700
|
+
if (!a || event.unattributedReason) {
|
|
701
|
+
this._emit(event);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
594
704
|
const e = a.record(event);
|
|
595
705
|
this._resultText = a.content;
|
|
596
|
-
if (e.nativeOutcome && e.terminal) {
|
|
597
|
-
|
|
706
|
+
if (e.nativeOutcome && e.terminal) {
|
|
707
|
+
a.nativeOutcome = e.nativeOutcome;
|
|
708
|
+
a.terminal = e.terminal;
|
|
709
|
+
}
|
|
710
|
+
if (e.tokens) {
|
|
711
|
+
this._totalTokens.input += e.tokens.input;
|
|
712
|
+
this._totalTokens.output += e.tokens.output;
|
|
713
|
+
}
|
|
598
714
|
this._emit(e);
|
|
599
715
|
}
|
|
600
716
|
this._releaseKnownTurn();
|
|
601
|
-
|
|
602
717
|
}
|
|
603
718
|
|
|
604
719
|
// ---------------------------------------------------------------------------
|
|
@@ -607,7 +722,7 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
607
722
|
|
|
608
723
|
protected _emit(event: SessionEvent): void {
|
|
609
724
|
event = retainEvidence(event);
|
|
610
|
-
if (event.kind ===
|
|
725
|
+
if (event.kind === 'session_end') {
|
|
611
726
|
if (this._endEmitted) return;
|
|
612
727
|
this._endEmitted = true;
|
|
613
728
|
}
|
|
@@ -615,10 +730,14 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
615
730
|
for (const handler of this._handlers) {
|
|
616
731
|
try {
|
|
617
732
|
const observation: unknown = handler(event);
|
|
618
|
-
if (observation && typeof (observation as PromiseLike<unknown>).then ===
|
|
619
|
-
void Promise.resolve(observation).catch(() => {
|
|
733
|
+
if (observation && typeof (observation as PromiseLike<unknown>).then === 'function') {
|
|
734
|
+
void Promise.resolve(observation).catch(() => {
|
|
735
|
+
this._observerFailures.asynchronous++;
|
|
736
|
+
});
|
|
620
737
|
}
|
|
621
|
-
} catch {
|
|
738
|
+
} catch {
|
|
739
|
+
this._observerFailures.synchronous++;
|
|
740
|
+
}
|
|
622
741
|
}
|
|
623
742
|
}
|
|
624
743
|
|
|
@@ -628,11 +747,11 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
628
747
|
|
|
629
748
|
protected _rpcRequest(method: string, params?: unknown): Promise<unknown> {
|
|
630
749
|
const id = ++this._rpcId;
|
|
631
|
-
if ((method ===
|
|
750
|
+
if ((method === 'tools/call' || method === 'turn/start') && this._inflight) {
|
|
632
751
|
this._inflight.evidence.identity = { ...this._inflight.evidence.identity, rpcRequestId: id };
|
|
633
|
-
this._inflight.evidence.rpcOutcome =
|
|
752
|
+
this._inflight.evidence.rpcOutcome = 'pending';
|
|
634
753
|
}
|
|
635
|
-
const payload = JSON.stringify({ jsonrpc:
|
|
754
|
+
const payload = `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`;
|
|
636
755
|
return new Promise<unknown>((resolve, reject) => {
|
|
637
756
|
this._pending.set(id, { resolve, reject });
|
|
638
757
|
try {
|
|
@@ -645,10 +764,12 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
645
764
|
});
|
|
646
765
|
}
|
|
647
766
|
|
|
648
|
-
protected _serverRequest(_request: Record<string, unknown>): boolean {
|
|
767
|
+
protected _serverRequest(_request: Record<string, unknown>): boolean {
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
649
770
|
|
|
650
771
|
protected _rpcNotify(method: string, params?: unknown): void {
|
|
651
|
-
const payload = JSON.stringify({ jsonrpc:
|
|
772
|
+
const payload = `${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`;
|
|
652
773
|
this._proc!.stdin.write(payload);
|
|
653
774
|
this._proc!.stdin.flush();
|
|
654
775
|
}
|
|
@@ -672,11 +793,9 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
672
793
|
|
|
673
794
|
/** Shared fork constructor — subclass passes its own ctor. */
|
|
674
795
|
fork(opts?: { cwd?: string; baseContext?: string }): HarnessSession {
|
|
675
|
-
if (this._inflight) throw new Error(
|
|
796
|
+
if (this._inflight) throw new Error('Cannot fork while native ownership is unresolved');
|
|
676
797
|
if (!this._externalSessionId) {
|
|
677
|
-
throw new Error(
|
|
678
|
-
"Cannot fork — no thread ID yet (send at least one message first)",
|
|
679
|
-
);
|
|
798
|
+
throw new Error('Cannot fork — no thread ID yet (send at least one message first)');
|
|
680
799
|
}
|
|
681
800
|
const Ctor = this.constructor as new (c?: CodexSessionConfig) => BaseCodexSession;
|
|
682
801
|
const forked = new Ctor({
|
|
@@ -688,6 +807,7 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
688
807
|
baseContext: opts?.baseContext ?? this._baseContext,
|
|
689
808
|
externalSessionId: this._externalSessionId,
|
|
690
809
|
spawn: this._spawn,
|
|
810
|
+
env: this._env,
|
|
691
811
|
});
|
|
692
812
|
forked._forking = true;
|
|
693
813
|
return forked;
|
|
@@ -708,6 +828,9 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
708
828
|
// multi-turn + fork).
|
|
709
829
|
|
|
710
830
|
export class CodexMcpSession extends BaseCodexSession {
|
|
831
|
+
get transport() {
|
|
832
|
+
return TRANSPORTS['codex-mcp'];
|
|
833
|
+
}
|
|
711
834
|
private _mcpAdmission?: string;
|
|
712
835
|
private _mcpCalls = new Map<string, { invocation: Record<string, unknown>; ended: boolean }>();
|
|
713
836
|
protected _buildSpawnArgs(): string[] {
|
|
@@ -715,32 +838,34 @@ export class CodexMcpSession extends BaseCodexSession {
|
|
|
715
838
|
// --dangerously-bypass-approvals-and-sandbox) so OUR container is the jail.
|
|
716
839
|
// model_reasoning_effort sets the effort level; passed as TOML-ish `-c` values.
|
|
717
840
|
const args = [
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
841
|
+
'mcp-server',
|
|
842
|
+
'-c',
|
|
843
|
+
`sandbox_mode="danger-full-access"`,
|
|
844
|
+
'-c',
|
|
845
|
+
`approval_policy="never"`,
|
|
721
846
|
];
|
|
722
847
|
if (this._effort) {
|
|
723
|
-
args.push(
|
|
848
|
+
args.push('-c', `model_reasoning_effort="${this._effort}"`);
|
|
724
849
|
}
|
|
725
850
|
return args;
|
|
726
851
|
}
|
|
727
852
|
|
|
728
853
|
protected async _handshake(): Promise<void> {
|
|
729
|
-
await this._rpcRequest(
|
|
730
|
-
protocolVersion:
|
|
854
|
+
await this._rpcRequest('initialize', {
|
|
855
|
+
protocolVersion: '2025-06-18',
|
|
731
856
|
capabilities: {},
|
|
732
|
-
clientInfo: { name:
|
|
857
|
+
clientInfo: { name: 'inixiative-bench', version: '0.1.0' },
|
|
733
858
|
});
|
|
734
|
-
this._rpcNotify(
|
|
859
|
+
this._rpcNotify('notifications/initialized');
|
|
735
860
|
// tools/list confirms the `codex` + `codex-reply` tools are present.
|
|
736
|
-
await this._rpcRequest(
|
|
861
|
+
await this._rpcRequest('tools/list', {});
|
|
737
862
|
}
|
|
738
863
|
|
|
739
864
|
protected async _sendTurn(
|
|
740
865
|
message: string,
|
|
741
866
|
): Promise<{ input: number; output: number } | undefined> {
|
|
742
867
|
const isReply = this._externalSessionId !== undefined;
|
|
743
|
-
const name = isReply ?
|
|
868
|
+
const name = isReply ? 'codex-reply' : 'codex';
|
|
744
869
|
const args: Record<string, unknown> = isReply
|
|
745
870
|
? { prompt: message, threadId: this._externalSessionId }
|
|
746
871
|
: {
|
|
@@ -748,34 +873,34 @@ export class CodexMcpSession extends BaseCodexSession {
|
|
|
748
873
|
model: this._model,
|
|
749
874
|
cwd: this._cwd,
|
|
750
875
|
// Belt-and-suspenders: also disable per-call (matches spawn `-c` flags).
|
|
751
|
-
sandbox:
|
|
752
|
-
|
|
753
|
-
...(this._effort
|
|
754
|
-
? { config: { model_reasoning_effort: this._effort } }
|
|
755
|
-
: {}),
|
|
876
|
+
sandbox: 'danger-full-access',
|
|
877
|
+
'approval-policy': 'never',
|
|
878
|
+
...(this._effort ? { config: { model_reasoning_effort: this._effort } } : {}),
|
|
756
879
|
};
|
|
757
880
|
|
|
758
|
-
const result = (await this._rpcRequest(
|
|
881
|
+
const result = (await this._rpcRequest('tools/call', {
|
|
759
882
|
name,
|
|
760
883
|
arguments: args,
|
|
761
884
|
})) as Record<string, unknown> | undefined;
|
|
762
885
|
|
|
763
886
|
// Capture the threadId for multi-turn (codex-reply) + fork.
|
|
764
|
-
const structured = result?.structuredContent as
|
|
765
|
-
| Record<string, unknown>
|
|
766
|
-
| undefined;
|
|
887
|
+
const structured = result?.structuredContent as Record<string, unknown> | undefined;
|
|
767
888
|
const threadId = structured?.threadId as string | undefined;
|
|
768
|
-
if (threadId && this._externalSessionId && threadId !== this._externalSessionId)
|
|
889
|
+
if (threadId && this._externalSessionId && threadId !== this._externalSessionId)
|
|
890
|
+
throw new Error('MCP response changed the native resume binding');
|
|
769
891
|
if (threadId && !this._externalSessionId) {
|
|
770
892
|
this._externalSessionId = threadId;
|
|
771
893
|
}
|
|
772
|
-
if (threadId && this._inflight)
|
|
894
|
+
if (threadId && this._inflight)
|
|
895
|
+
this._inflight.evidence.identity = { ...this._inflight.evidence.identity, threadId };
|
|
773
896
|
// Final text: prefer structuredContent.content, else the tool result content.
|
|
774
897
|
const finalText =
|
|
775
|
-
(structured?.content as string | undefined) ??
|
|
776
|
-
|
|
777
|
-
if (
|
|
778
|
-
|
|
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
|
+
}
|
|
779
904
|
|
|
780
905
|
// Usage, when the call result reports it. Streamed token_count events are
|
|
781
906
|
// already accounted by the base loop; the call result is the authoritative
|
|
@@ -784,7 +909,12 @@ export class CodexMcpSession extends BaseCodexSession {
|
|
|
784
909
|
// token event was seen this turn.
|
|
785
910
|
const sawStreamedTokens = this._turnEvents.some((e) => e.tokens);
|
|
786
911
|
const usage = structured?.usage as Record<string, number> | undefined;
|
|
787
|
-
if (
|
|
912
|
+
if (
|
|
913
|
+
usage &&
|
|
914
|
+
!sawStreamedTokens &&
|
|
915
|
+
typeof (usage.input_tokens ?? usage.inputTokens) === 'number' &&
|
|
916
|
+
typeof (usage.output_tokens ?? usage.outputTokens) === 'number'
|
|
917
|
+
) {
|
|
788
918
|
return {
|
|
789
919
|
input: (usage.input_tokens ?? usage.inputTokens ?? 0) as number,
|
|
790
920
|
output: (usage.output_tokens ?? usage.outputTokens ?? 0) as number,
|
|
@@ -798,85 +928,160 @@ export class CodexMcpSession extends BaseCodexSession {
|
|
|
798
928
|
const parts: string[] = [];
|
|
799
929
|
for (const block of content) {
|
|
800
930
|
const b = block as Record<string, unknown>;
|
|
801
|
-
if (b.type ===
|
|
931
|
+
if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
|
|
802
932
|
}
|
|
803
|
-
return parts.length ? parts.join(
|
|
933
|
+
return parts.length ? parts.join('\n') : undefined;
|
|
804
934
|
}
|
|
805
935
|
|
|
806
936
|
protected _classify(msg: Record<string, unknown>): SessionEvent[] {
|
|
807
937
|
// Streamed events arrive as JSON-RPC notifications: method "codex/event"
|
|
808
938
|
// with params carrying a `msg` of a tagged type.
|
|
809
|
-
if (msg.method !==
|
|
939
|
+
if (msg.method !== 'codex/event') return [];
|
|
810
940
|
const params = msg.params as Record<string, unknown> | undefined;
|
|
811
941
|
const ev = (params?.msg ?? params) as Record<string, unknown> | undefined;
|
|
812
942
|
if (!ev) return [];
|
|
813
943
|
const a = this._inflight?.evidence;
|
|
814
|
-
const str = (v: unknown) => typeof v ===
|
|
815
|
-
const nativeSessionId = str(ev.session_id),
|
|
816
|
-
|
|
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);
|
|
817
949
|
const item = ev.item as Record<string, unknown> | undefined;
|
|
818
|
-
const unowned = (reason: NonNullable<SessionEvent[
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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');
|
|
829
982
|
}
|
|
830
983
|
if (!a) {
|
|
831
|
-
if (ev.type ===
|
|
832
|
-
return unowned(
|
|
984
|
+
if (ev.type === 'task_complete' && turnId) this._seenTerminals.add(turnId);
|
|
985
|
+
return unowned('no-admission');
|
|
833
986
|
}
|
|
834
|
-
if (ev.type ===
|
|
987
|
+
if (ev.type === 'session_configured') {
|
|
835
988
|
if (!this._externalSessionId && threadId) this._externalSessionId = threadId;
|
|
836
989
|
a.identity = { ...a.identity, nativeSessionId, threadId };
|
|
837
990
|
}
|
|
838
|
-
if (
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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');
|
|
844
1005
|
this._seenTerminals.add(turnId);
|
|
845
|
-
return [
|
|
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
|
+
];
|
|
846
1016
|
}
|
|
847
|
-
const identity = {
|
|
848
|
-
|
|
849
|
-
|
|
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') {
|
|
850
1025
|
const invocation = object(ev.invocation);
|
|
851
|
-
if (
|
|
852
|
-
|
|
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');
|
|
853
1035
|
if (this._mcpAdmission !== a.identity.admissionId) {
|
|
854
|
-
this._mcpAdmission = a.identity.admissionId;
|
|
1036
|
+
this._mcpAdmission = a.identity.admissionId;
|
|
1037
|
+
this._mcpCalls.clear();
|
|
855
1038
|
}
|
|
856
1039
|
// Compare the original argument value, including absence. Never join a result
|
|
857
1040
|
// for another server/tool or silently substitute arguments from its envelope.
|
|
858
|
-
const join = {
|
|
1041
|
+
const join = {
|
|
1042
|
+
server: invocation.server,
|
|
1043
|
+
tool: invocation.tool,
|
|
1044
|
+
arguments: invocation.arguments,
|
|
1045
|
+
};
|
|
859
1046
|
const prior = this._mcpCalls.get(identity.callId);
|
|
860
|
-
const tool = {
|
|
861
|
-
|
|
862
|
-
|
|
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');
|
|
863
1054
|
this._mcpCalls.set(identity.callId, { invocation: structuredClone(join), ended: false });
|
|
864
1055
|
const args = publicMcpArguments(invocation.arguments);
|
|
865
|
-
return [{ ...identity, ...tool, kind:
|
|
1056
|
+
return [{ ...identity, ...tool, kind: 'tool_use', timestamp: Date.now(), ...args }];
|
|
866
1057
|
}
|
|
867
|
-
if (!prior || !isDeepStrictEqual(prior.invocation, join)) return unowned(
|
|
868
|
-
if (prior.ended) return unowned(
|
|
1058
|
+
if (!prior || !isDeepStrictEqual(prior.invocation, join)) return unowned('unmatched-tool');
|
|
1059
|
+
if (prior.ended) return unowned('duplicate-tool');
|
|
869
1060
|
const result = publicMcpResult(ev.result);
|
|
870
|
-
if (!result) return unowned(
|
|
1061
|
+
if (!result) return unowned('malformed-tool');
|
|
871
1062
|
prior.ended = true;
|
|
872
|
-
return [{ ...identity, ...tool, kind:
|
|
1063
|
+
return [{ ...identity, ...tool, kind: 'tool_result', timestamp: Date.now(), ...result }];
|
|
873
1064
|
}
|
|
874
|
-
if (
|
|
875
|
-
|
|
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
|
+
];
|
|
876
1079
|
}
|
|
877
1080
|
// Notifications without a turn ID are associated only with the single owned RPC.
|
|
878
|
-
const classified = classifyCodexEvent(ev,
|
|
879
|
-
return classified.length
|
|
1081
|
+
const classified = classifyCodexEvent(ev, 'type');
|
|
1082
|
+
return classified.length
|
|
1083
|
+
? classified.map((event) => ({ ...identity, ...event }))
|
|
1084
|
+
: unowned('unrecognized-event');
|
|
880
1085
|
}
|
|
881
1086
|
}
|
|
882
1087
|
|
|
@@ -892,102 +1097,330 @@ export class CodexMcpSession extends BaseCodexSession {
|
|
|
892
1097
|
class NativeValidationError extends Error {}
|
|
893
1098
|
|
|
894
1099
|
export class CodexAppServerSession extends BaseCodexSession {
|
|
895
|
-
|
|
896
|
-
|
|
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']>;
|
|
897
1234
|
private readonly _onThreadReady?: (binding: string) => void | Promise<void>;
|
|
898
1235
|
private _startedOnce = false;
|
|
899
1236
|
private _threadReady = false;
|
|
900
1237
|
private _nativeSessionId?: string;
|
|
901
1238
|
private _appAdmission?: string;
|
|
902
1239
|
private _turnDone?: () => void;
|
|
903
|
-
private _items = new Map<
|
|
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
|
+
>();
|
|
904
1251
|
|
|
905
1252
|
constructor(config?: CodexSessionConfig) {
|
|
906
1253
|
super(config);
|
|
907
|
-
const {onThreadReady
|
|
908
|
-
this._onThreadReady=onThreadReady;
|
|
1254
|
+
const { onThreadReady, ...options } = config?.appServer ?? {};
|
|
1255
|
+
this._onThreadReady = onThreadReady;
|
|
909
1256
|
this._options = retainEvidence(options);
|
|
910
1257
|
const required = this._options.requiredMcpServer;
|
|
911
|
-
if (
|
|
912
|
-
|
|
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');
|
|
913
1265
|
}
|
|
914
1266
|
|
|
915
|
-
override fork(): HarnessSession {
|
|
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
|
+
}
|
|
916
1272
|
|
|
917
1273
|
override async start(): Promise<void> {
|
|
918
|
-
if (this._inflight)
|
|
919
|
-
|
|
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');
|
|
920
1278
|
this._startedOnce = true;
|
|
921
|
-
try {
|
|
922
|
-
|
|
1279
|
+
try {
|
|
1280
|
+
await super.start();
|
|
1281
|
+
} catch (error) {
|
|
1282
|
+
this.kill();
|
|
1283
|
+
throw error;
|
|
1284
|
+
}
|
|
923
1285
|
}
|
|
924
1286
|
|
|
925
1287
|
protected override _serverRequest(request: Record<string, unknown>): boolean {
|
|
926
1288
|
// JSON-RPC's explicit method-not-supported refusal also covers unknown future
|
|
927
1289
|
// requests. Never approve native work or echo private request parameters.
|
|
928
|
-
const payload = JSON.stringify({
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
+
});
|
|
935
1321
|
return true;
|
|
936
1322
|
}
|
|
937
1323
|
|
|
938
|
-
private _refuse(reason: string, status =
|
|
1324
|
+
private _refuse(reason: string, status = 'unknown'): never {
|
|
939
1325
|
const a = this._inflight?.evidence;
|
|
940
|
-
this._emit({
|
|
941
|
-
|
|
942
|
-
|
|
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
|
+
);
|
|
943
1335
|
}
|
|
944
1336
|
|
|
945
1337
|
private async _readiness(): Promise<void> {
|
|
946
1338
|
const required = this._options.requiredMcpServer;
|
|
947
1339
|
if (!required) return;
|
|
948
1340
|
let cursor: string | undefined;
|
|
949
|
-
const seen = new Set<string>();
|
|
1341
|
+
const seen = new Set<string>();
|
|
1342
|
+
let found: Record<string, unknown> | undefined;
|
|
950
1343
|
// Pagination only, never polling/retrying a starting or failed native server.
|
|
951
1344
|
for (let page = 0; page < 32; page++) {
|
|
952
|
-
const result = object(
|
|
953
|
-
|
|
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');
|
|
954
1354
|
for (const entry of result.data) {
|
|
955
1355
|
const server = object(entry);
|
|
956
|
-
if (server?.name === required.name) {
|
|
1356
|
+
if (server?.name === required.name) {
|
|
1357
|
+
if (found) this._refuse('mcp-inventory-duplicate');
|
|
1358
|
+
found = server;
|
|
1359
|
+
}
|
|
957
1360
|
}
|
|
958
1361
|
if (result.nextCursor == null) {
|
|
959
1362
|
const tools = object(found?.tools);
|
|
960
|
-
if (
|
|
961
|
-
|
|
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');
|
|
962
1369
|
const a = this._inflight!.evidence;
|
|
963
|
-
this._emit({
|
|
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
|
+
});
|
|
964
1382
|
return;
|
|
965
1383
|
}
|
|
966
|
-
if (
|
|
967
|
-
|
|
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);
|
|
968
1392
|
}
|
|
969
|
-
this._refuse(
|
|
1393
|
+
this._refuse('mcp-inventory-page-limit');
|
|
970
1394
|
}
|
|
971
1395
|
|
|
972
1396
|
protected override _canReleaseAttempt(a: TurnState): boolean {
|
|
973
1397
|
// An observer/write failure may occur after delivery. A terminal alone does
|
|
974
1398
|
// not settle the unanswered turn/start call or authorize another admission.
|
|
975
|
-
return a.rpcSettled && (a.rpcOutcome ===
|
|
1399
|
+
return a.rpcSettled && (a.rpcOutcome === 'resolved' || a.rpcOutcome === 'failed');
|
|
976
1400
|
}
|
|
977
1401
|
|
|
978
1402
|
protected _buildSpawnArgs(): string[] {
|
|
979
|
-
return [
|
|
1403
|
+
return ['app-server', '--listen', 'stdio://'];
|
|
980
1404
|
}
|
|
981
1405
|
|
|
982
1406
|
protected async _handshake(): Promise<void> {
|
|
983
1407
|
this._threadReady = false;
|
|
984
|
-
const initialized = object(
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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');
|
|
991
1424
|
}
|
|
992
1425
|
|
|
993
1426
|
protected async _sendTurn(
|
|
@@ -996,54 +1429,123 @@ export class CodexAppServerSession extends BaseCodexSession {
|
|
|
996
1429
|
const a = this._inflight!.evidence;
|
|
997
1430
|
if (!this._threadReady) {
|
|
998
1431
|
const binding = this._externalSessionId;
|
|
999
|
-
const method = binding ?
|
|
1000
|
-
const response = object(
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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');
|
|
1010
1447
|
// Resume may rejoin a live thread. Never steer known pre-existing work.
|
|
1011
1448
|
// The installed schema requires status. Missing/unknown state cannot
|
|
1012
1449
|
// authorize work, even if this response otherwise identifies our thread.
|
|
1013
|
-
if (
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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');
|
|
1019
1475
|
this._externalSessionId = id;
|
|
1020
|
-
this._nativeSessionId =
|
|
1021
|
-
|
|
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
|
+
};
|
|
1022
1483
|
if (this._onThreadReady) {
|
|
1023
|
-
try {
|
|
1024
|
-
|
|
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
|
+
}
|
|
1025
1491
|
}
|
|
1026
1492
|
this._threadReady = true;
|
|
1027
1493
|
// Configuration belongs to this process/thread, never to the next turn.
|
|
1028
1494
|
// Do not copy historical turns, instructions, paths or arbitrary config.
|
|
1029
|
-
this._emit({
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
+
});
|
|
1036
1526
|
}
|
|
1037
|
-
a.identity = {
|
|
1527
|
+
a.identity = {
|
|
1528
|
+
...a.identity,
|
|
1529
|
+
threadId: this._externalSessionId,
|
|
1530
|
+
...(this._nativeSessionId ? { nativeSessionId: this._nativeSessionId } : {}),
|
|
1531
|
+
};
|
|
1038
1532
|
await this._readiness();
|
|
1039
1533
|
// Timeout/kill during create/resume must not cause a later model write.
|
|
1040
|
-
if (this._inflight?.evidence !== a || a.localOutcome !==
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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', {
|
|
1045
1547
|
threadId: this._externalSessionId,
|
|
1046
|
-
input: [{ type:
|
|
1548
|
+
input: [{ type: 'text', text: message }],
|
|
1047
1549
|
...(this._effort ? { effort: this._effort } : {}),
|
|
1048
1550
|
});
|
|
1049
1551
|
await completed; // Native terminal and RPC acknowledgment are both required.
|
|
@@ -1054,121 +1556,273 @@ export class CodexAppServerSession extends BaseCodexSession {
|
|
|
1054
1556
|
const a = this._inflight?.evidence;
|
|
1055
1557
|
if (!a || this._appAdmission !== a.admissionId || a.identity.rpcRequestId !== id) return;
|
|
1056
1558
|
const turn = this._turn(object(result)?.turn);
|
|
1057
|
-
if (
|
|
1058
|
-
|
|
1059
|
-
|
|
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' };
|
|
1060
1568
|
}
|
|
1061
1569
|
|
|
1062
1570
|
private _turn(value: unknown): { id: string; status: string; error?: unknown } | undefined {
|
|
1063
1571
|
const turn = object(value);
|
|
1064
|
-
if (
|
|
1065
|
-
|
|
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;
|
|
1066
1580
|
return turn as { id: string; status: string; error?: unknown };
|
|
1067
1581
|
}
|
|
1068
1582
|
|
|
1069
1583
|
protected _classify(msg: Record<string, unknown>): SessionEvent[] {
|
|
1070
|
-
const method = typeof msg.method ===
|
|
1584
|
+
const method = typeof msg.method === 'string' ? msg.method : undefined;
|
|
1071
1585
|
if (!method) return [];
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
+
];
|
|
1080
1630
|
const a = this._inflight?.evidence;
|
|
1081
|
-
if (!threadId || threadId !== this._externalSessionId) return unowned(
|
|
1082
|
-
if (!a || this._appAdmission !== a.admissionId) return unowned(
|
|
1083
|
-
if (turnId && this._seenTerminals.has(turnId))
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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');
|
|
1087
1638
|
const identity = { ...a.identity, threadId, turnId, ...(itemId ? { itemId } : {}) };
|
|
1088
|
-
const status = (): SessionEvent[] => [
|
|
1089
|
-
|
|
1090
|
-
|
|
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');
|
|
1091
1644
|
const error = object(turn.error);
|
|
1092
|
-
if (turn.error != null && typeof error?.message !==
|
|
1645
|
+
if (turn.error != null && typeof error?.message !== 'string')
|
|
1646
|
+
return unowned('unrecognized-event');
|
|
1093
1647
|
this._seenTerminals.add(turnId);
|
|
1094
1648
|
this._turnDone?.();
|
|
1095
1649
|
this._turnDone = undefined;
|
|
1096
|
-
return [
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
+
];
|
|
1099
1665
|
}
|
|
1100
|
-
if (method ===
|
|
1666
|
+
if (method === 'thread/tokenUsage/updated') {
|
|
1101
1667
|
const last = object(object(params?.tokenUsage)?.last);
|
|
1102
|
-
if (
|
|
1103
|
-
|
|
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');
|
|
1104
1677
|
// `last` is a snapshot, not an additive delta. Total accounting happens once
|
|
1105
1678
|
// when the original native turn and RPC settle; absent usage stays absent.
|
|
1106
1679
|
a.tokens = { input: last.inputTokens, output: last.outputTokens };
|
|
1107
1680
|
return status();
|
|
1108
1681
|
}
|
|
1109
|
-
if (method ===
|
|
1110
|
-
if (!itemId || typeof params?.delta !==
|
|
1682
|
+
if (method === 'item/agentMessage/delta' || method === 'item/commandExecution/outputDelta') {
|
|
1683
|
+
if (!itemId || typeof params?.delta !== 'string') return unowned('unrecognized-event');
|
|
1111
1684
|
const prior = this._items.get(itemId);
|
|
1112
|
-
if (prior?.completed) return unowned(
|
|
1113
|
-
if (method ===
|
|
1114
|
-
if (prior && prior.type !==
|
|
1115
|
-
this._items.set(itemId, {
|
|
1116
|
-
|
|
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 }];
|
|
1117
1694
|
}
|
|
1118
|
-
if (prior?.type !==
|
|
1119
|
-
return [
|
|
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
|
+
];
|
|
1120
1705
|
}
|
|
1121
|
-
if (method ===
|
|
1122
|
-
if (!itemId || !item || typeof item.type !==
|
|
1123
|
-
if (item.type ===
|
|
1124
|
-
if (method !==
|
|
1125
|
-
if (this._items.get(itemId)?.completed) return unowned(
|
|
1126
|
-
if (
|
|
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');
|
|
1127
1716
|
this._items.set(itemId, { type: item.type, completed: true });
|
|
1128
|
-
return [{ ...identity, kind:
|
|
1717
|
+
return [{ ...identity, kind: 'text', timestamp: Date.now(), text: item.text }];
|
|
1129
1718
|
}
|
|
1130
|
-
if (item.type ===
|
|
1131
|
-
if (typeof item.command !==
|
|
1719
|
+
if (item.type === 'commandExecution') {
|
|
1720
|
+
if (typeof item.command !== 'string') return unowned('malformed-tool');
|
|
1132
1721
|
const prior = this._items.get(itemId);
|
|
1133
|
-
if (method ===
|
|
1134
|
-
if (prior) return unowned(
|
|
1722
|
+
if (method === 'item/started') {
|
|
1723
|
+
if (prior) return unowned('duplicate-tool');
|
|
1135
1724
|
this._items.set(itemId, { type: item.type, input: item.command, completed: false });
|
|
1136
|
-
return [
|
|
1725
|
+
return [
|
|
1726
|
+
{
|
|
1727
|
+
...identity,
|
|
1728
|
+
kind: 'tool_use',
|
|
1729
|
+
timestamp: Date.now(),
|
|
1730
|
+
toolName: 'shell',
|
|
1731
|
+
toolInput: { command: item.command },
|
|
1732
|
+
},
|
|
1733
|
+
];
|
|
1137
1734
|
}
|
|
1138
|
-
if (!prior || prior.type !== item.type || prior.input !== item.command)
|
|
1139
|
-
|
|
1140
|
-
if (
|
|
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');
|
|
1141
1740
|
prior.completed = true;
|
|
1142
|
-
return [
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
+
];
|
|
1145
1755
|
}
|
|
1146
|
-
if (item.type ===
|
|
1147
|
-
if (
|
|
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');
|
|
1148
1764
|
const prior = this._items.get(itemId);
|
|
1149
|
-
const tool = {
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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
|
+
];
|
|
1155
1789
|
}
|
|
1156
|
-
if (
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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);
|
|
1160
1803
|
const output = result ? publicMcpResult({ Ok: { content: result.content } }) : undefined;
|
|
1161
|
-
if (item.error != null && typeof error?.message !==
|
|
1804
|
+
if (item.error != null && typeof error?.message !== 'string')
|
|
1805
|
+
return unowned('malformed-tool');
|
|
1162
1806
|
prior.completed = true;
|
|
1163
|
-
return [
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
+
];
|
|
1167
1821
|
}
|
|
1168
1822
|
// Reasoning and unsupported items retain safe identity/type diagnostics only.
|
|
1169
|
-
return unowned(
|
|
1823
|
+
return unowned('unrecognized-event');
|
|
1170
1824
|
}
|
|
1171
|
-
return method ===
|
|
1825
|
+
return method === 'turn/started' ? status() : unowned('unrecognized-event');
|
|
1172
1826
|
}
|
|
1173
1827
|
}
|
|
1174
1828
|
|
|
@@ -1186,101 +1840,134 @@ export class CodexAppServerSession extends BaseCodexSession {
|
|
|
1186
1840
|
// token_count / usage → tokens (attached to a result event)
|
|
1187
1841
|
|
|
1188
1842
|
const object = (value: unknown): Record<string, unknown> | undefined =>
|
|
1189
|
-
value !== null && typeof value ===
|
|
1843
|
+
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
1844
|
+
? (value as Record<string, unknown>)
|
|
1845
|
+
: undefined;
|
|
1190
1846
|
|
|
1191
|
-
function publicMcpArguments(value: unknown): Pick<SessionEvent,
|
|
1192
|
-
if (!object(value)) return { toolInputOmitted:
|
|
1847
|
+
function publicMcpArguments(value: unknown): Pick<SessionEvent, 'toolInput' | 'toolInputOmitted'> {
|
|
1848
|
+
if (!object(value)) return { toolInputOmitted: 'Native arguments absent or not an object' };
|
|
1193
1849
|
let omitted = false;
|
|
1194
1850
|
const clean = (v: unknown, depth = 0): unknown => {
|
|
1195
|
-
if (depth > 32) {
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
+
);
|
|
1203
1874
|
};
|
|
1204
1875
|
const toolInput = clean(value) as Record<string, unknown>;
|
|
1205
|
-
return {
|
|
1876
|
+
return {
|
|
1877
|
+
toolInput,
|
|
1878
|
+
...(omitted ? { toolInputOmitted: 'Non-public or unsupported argument fields omitted' } : {}),
|
|
1879
|
+
};
|
|
1206
1880
|
}
|
|
1207
1881
|
|
|
1208
1882
|
/** Rust Result<CallToolResult,String> from protocol.rs. This is legacy event
|
|
1209
1883
|
* normalization, not the app-server ThreadItem schema. Keep all public text
|
|
1210
1884
|
* blocks (including the bridge's JSON receipt), not arbitrary envelope fields. */
|
|
1211
|
-
function publicMcpResult(
|
|
1885
|
+
function publicMcpResult(
|
|
1886
|
+
value: unknown,
|
|
1887
|
+
): Pick<SessionEvent, 'toolOutput' | 'toolError' | 'toolOutputOmitted'> | undefined {
|
|
1212
1888
|
const result = object(value);
|
|
1213
1889
|
if (!result || Object.keys(result).length !== 1) return;
|
|
1214
|
-
if (typeof result.Err ===
|
|
1890
|
+
if (typeof result.Err === 'string') return { toolOutput: result.Err, toolError: true };
|
|
1215
1891
|
const ok = object(result.Ok);
|
|
1216
|
-
if (
|
|
1217
|
-
|
|
1218
|
-
|
|
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) => {
|
|
1219
1900
|
const block = object(value);
|
|
1220
|
-
if (block?.type ===
|
|
1221
|
-
if (Object.keys(block).some(k => k !==
|
|
1222
|
-
return { type:
|
|
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 };
|
|
1223
1904
|
}
|
|
1224
1905
|
omitted = true;
|
|
1225
1906
|
// Unknown shape values are not safe labels. Known non-text types retain type only.
|
|
1226
|
-
return {
|
|
1907
|
+
return {
|
|
1908
|
+
type: ['image', 'audio', 'resource', 'resource_link'].includes(String(block?.type))
|
|
1909
|
+
? block!.type
|
|
1910
|
+
: 'unsupported',
|
|
1911
|
+
};
|
|
1227
1912
|
});
|
|
1228
|
-
return {
|
|
1229
|
-
|
|
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
|
+
};
|
|
1230
1921
|
}
|
|
1231
1922
|
|
|
1232
|
-
function classifyCodexEvent(
|
|
1233
|
-
ev: Record<string, unknown>,
|
|
1234
|
-
tag: string,
|
|
1235
|
-
): SessionEvent[] {
|
|
1923
|
+
function classifyCodexEvent(ev: Record<string, unknown>, tag: string): SessionEvent[] {
|
|
1236
1924
|
const ts = Date.now();
|
|
1237
|
-
const type = String(ev[tag] ??
|
|
1925
|
+
const type = String(ev[tag] ?? '').toLowerCase();
|
|
1238
1926
|
const events: SessionEvent[] = [];
|
|
1239
1927
|
|
|
1240
1928
|
// Agent message → text.
|
|
1241
|
-
if (type ===
|
|
1929
|
+
if (type === 'agent_message' || type === 'agentmessage' || type === 'agent_message_delta') {
|
|
1242
1930
|
const text = (ev.message ?? ev.text ?? ev.delta) as string | undefined;
|
|
1243
|
-
if (text) events.push({ kind:
|
|
1931
|
+
if (text) events.push({ kind: 'text', timestamp: ts, text, raw: ev });
|
|
1244
1932
|
return events;
|
|
1245
1933
|
}
|
|
1246
1934
|
|
|
1247
1935
|
// Reasoning → thinking.
|
|
1248
1936
|
if (
|
|
1249
|
-
type ===
|
|
1250
|
-
type ===
|
|
1251
|
-
type ===
|
|
1252
|
-
type ===
|
|
1937
|
+
type === 'reasoning' ||
|
|
1938
|
+
type === 'agent_reasoning' ||
|
|
1939
|
+
type === 'agentreasoning' ||
|
|
1940
|
+
type === 'agent_reasoning_delta'
|
|
1253
1941
|
) {
|
|
1254
1942
|
const text = (ev.text ?? ev.reasoning ?? ev.delta ?? ev.summary) as string | undefined;
|
|
1255
|
-
if (text) events.push({ kind:
|
|
1943
|
+
if (text) events.push({ kind: 'thinking', timestamp: ts, text, raw: ev });
|
|
1256
1944
|
return events;
|
|
1257
1945
|
}
|
|
1258
1946
|
|
|
1259
1947
|
// Command execution → tool_use, plus tool_result if output is present.
|
|
1260
1948
|
if (
|
|
1261
|
-
type ===
|
|
1262
|
-
type ===
|
|
1263
|
-
type ===
|
|
1264
|
-
type ===
|
|
1949
|
+
type === 'command_execution' ||
|
|
1950
|
+
type === 'commandexecution' ||
|
|
1951
|
+
type === 'exec_command_begin' ||
|
|
1952
|
+
type === 'exec_command_end'
|
|
1265
1953
|
) {
|
|
1266
1954
|
const command = (ev.command ?? ev.cmd) as string | string[] | undefined;
|
|
1267
|
-
const cmdStr = Array.isArray(command) ? command.join(
|
|
1268
|
-
if (type !==
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
| 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;
|
|
1278
1965
|
const exitCode = (ev.exit_code ?? ev.exitCode) as number | undefined;
|
|
1279
1966
|
if (output !== undefined || exitCode !== undefined) {
|
|
1280
1967
|
events.push({
|
|
1281
|
-
kind:
|
|
1968
|
+
kind: 'tool_result',
|
|
1282
1969
|
timestamp: ts,
|
|
1283
|
-
toolOutput: output ??
|
|
1970
|
+
toolOutput: output ?? '',
|
|
1284
1971
|
toolError: exitCode !== undefined && exitCode !== 0,
|
|
1285
1972
|
raw: ev,
|
|
1286
1973
|
});
|
|
@@ -1290,19 +1977,17 @@ function classifyCodexEvent(
|
|
|
1290
1977
|
|
|
1291
1978
|
// Token usage → carried on a result-less event so accounting picks it up.
|
|
1292
1979
|
if (
|
|
1293
|
-
type ===
|
|
1294
|
-
type ===
|
|
1295
|
-
type ===
|
|
1296
|
-
type ===
|
|
1980
|
+
type === 'token_count' ||
|
|
1981
|
+
type === 'token_usage' ||
|
|
1982
|
+
type === 'usage' ||
|
|
1983
|
+
type === 'tokenusage'
|
|
1297
1984
|
) {
|
|
1298
1985
|
const u = (ev.info ?? ev.usage ?? ev) as Record<string, unknown>;
|
|
1299
|
-
const input =
|
|
1300
|
-
|
|
1301
|
-
const output =
|
|
1302
|
-
(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;
|
|
1303
1988
|
if (input || output) {
|
|
1304
1989
|
events.push({
|
|
1305
|
-
kind:
|
|
1990
|
+
kind: 'result',
|
|
1306
1991
|
timestamp: ts,
|
|
1307
1992
|
tokens: { input, output },
|
|
1308
1993
|
raw: ev,
|
|
@@ -1312,9 +1997,9 @@ function classifyCodexEvent(
|
|
|
1312
1997
|
}
|
|
1313
1998
|
|
|
1314
1999
|
// Errors.
|
|
1315
|
-
if (type ===
|
|
2000
|
+
if (type === 'error' || type === 'stream_error') {
|
|
1316
2001
|
events.push({
|
|
1317
|
-
kind:
|
|
2002
|
+
kind: 'error',
|
|
1318
2003
|
timestamp: ts,
|
|
1319
2004
|
text: (ev.message ?? ev.error ?? JSON.stringify(ev)) as string,
|
|
1320
2005
|
raw: ev,
|