@inixiative/agent-session 0.1.0 → 0.2.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 +22 -1
- package/package.json +32 -7
- package/src/claude-code-session.ts +252 -109
- package/src/claude-usage.ts +30 -0
- package/src/codex-session.ts +521 -130
- package/src/harness-session.ts +121 -10
- package/src/index.ts +2 -0
- package/src/retained-evidence.ts +20 -0
- package/src/turn-state.ts +42 -0
package/src/codex-session.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { TurnState } from "./turn-state";
|
|
2
|
+
import { retainEvidence } from "./retained-evidence";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
1
4
|
// ---------------------------------------------------------------------------
|
|
2
5
|
// CodexSession — long-lived OpenAI Codex CLI process with full event capture
|
|
3
6
|
// ---------------------------------------------------------------------------
|
|
@@ -8,7 +11,7 @@
|
|
|
8
11
|
// the shared SessionEvent taxonomy and resolving a turn on completion.
|
|
9
12
|
//
|
|
10
13
|
// CodexMcpSession (default) — `codex mcp-server` (stdio MCP JSON-RPC)
|
|
11
|
-
// CodexAppServerSession (experimental) — `codex app-server` (
|
|
14
|
+
// CodexAppServerSession (experimental) — `codex app-server` (stdio JSON-RPC)
|
|
12
15
|
//
|
|
13
16
|
// Both DISABLE codex's own approvals + sandbox (the union of
|
|
14
17
|
// `--dangerously-bypass-approvals-and-sandbox`) so OUR container is the only
|
|
@@ -67,6 +70,15 @@ export type CodexSpawn = (
|
|
|
67
70
|
// ---------------------------------------------------------------------------
|
|
68
71
|
|
|
69
72
|
export interface CodexSessionConfig {
|
|
73
|
+
/** Explicit app-server integration. Never applied by the default MCP engine.
|
|
74
|
+
* Config is native launch data, not event/recording data. */
|
|
75
|
+
appServer?: {
|
|
76
|
+
/** Persist the actual returned binding before model admission; failure blocks work. */
|
|
77
|
+
onThreadReady?: (binding: string) => void | Promise<void>;
|
|
78
|
+
requireConfiguration?: boolean;
|
|
79
|
+
config?: Readonly<Record<string, unknown>>;
|
|
80
|
+
requiredMcpServer?: { readonly name: string; readonly tools: readonly string[] };
|
|
81
|
+
};
|
|
70
82
|
/** Path to codex CLI binary. Defaults to "codex". */
|
|
71
83
|
bin?: string;
|
|
72
84
|
/** Model. Defaults to "gpt-5.5". */
|
|
@@ -88,8 +100,8 @@ export interface CodexSessionConfig {
|
|
|
88
100
|
baseContext?: string;
|
|
89
101
|
/**
|
|
90
102
|
* codex's native thread ID (the value the `codex` tool returns). When set, the
|
|
91
|
-
* first send()
|
|
92
|
-
*
|
|
103
|
+
* first send() uses `codex-reply` on MCP (loaded threads only), or
|
|
104
|
+
* `thread/resume` on app-server. Only app-server supports cold resume by ID.
|
|
93
105
|
*/
|
|
94
106
|
externalSessionId?: string;
|
|
95
107
|
/**
|
|
@@ -105,6 +117,8 @@ export interface CodexSessionConfig {
|
|
|
105
117
|
// ---------------------------------------------------------------------------
|
|
106
118
|
|
|
107
119
|
interface QueuedTurn {
|
|
120
|
+
onAdmission?: import("./harness-session").SessionSendOptions["onAdmission"];
|
|
121
|
+
evidence: TurnState;
|
|
108
122
|
message: string;
|
|
109
123
|
timeout: number;
|
|
110
124
|
resolve: (result: SessionResult) => void;
|
|
@@ -141,8 +155,10 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
141
155
|
// -- Session state --
|
|
142
156
|
protected _externalSessionId?: string;
|
|
143
157
|
protected _alive = false;
|
|
158
|
+
private _endEmitted = false;
|
|
144
159
|
protected _eventLog: SessionEvent[] = [];
|
|
145
160
|
protected _handlers: SessionEventHandler[] = [];
|
|
161
|
+
private _observerFailures = { synchronous: 0, asynchronous: 0 };
|
|
146
162
|
protected _beforeSendHooks: BeforeSendHook[] = [];
|
|
147
163
|
protected _turns = 0;
|
|
148
164
|
protected _totalTokens = { input: 0, output: 0 };
|
|
@@ -163,6 +179,8 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
163
179
|
|
|
164
180
|
// -- Turn queue (identical to ClaudeCodeSession) --
|
|
165
181
|
protected _queue: QueuedTurn[] = [];
|
|
182
|
+
protected _attempts: TurnState[] = [];
|
|
183
|
+
protected _seenTerminals = new Set<string>();
|
|
166
184
|
protected _inflight: QueuedTurn | null = null;
|
|
167
185
|
protected _turnEvents: SessionEvent[] = [];
|
|
168
186
|
protected _resultText = "";
|
|
@@ -193,9 +211,13 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
193
211
|
// Accessors (identical to ClaudeCodeSession)
|
|
194
212
|
// ---------------------------------------------------------------------------
|
|
195
213
|
|
|
214
|
+
get accounting() { return "observed-only" as const; }
|
|
215
|
+
get attempts() { return this._attempts.map(a => a.snapshot()); }
|
|
216
|
+
get diagnostics() { return Object.freeze({ observerFailures: Object.freeze({ ...this._observerFailures }) }); }
|
|
217
|
+
|
|
196
218
|
get alive(): boolean { return this._alive; }
|
|
197
219
|
get externalSessionId(): string | undefined { return this._externalSessionId; }
|
|
198
|
-
get events(): readonly SessionEvent[] { return this._eventLog; }
|
|
220
|
+
get events(): readonly SessionEvent[] { return Object.freeze([...this._eventLog]); }
|
|
199
221
|
get turns(): number { return this._turns; }
|
|
200
222
|
get totalTokens(): Readonly<{ input: number; output: number }> {
|
|
201
223
|
return { ...this._totalTokens };
|
|
@@ -242,7 +264,7 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
242
264
|
|
|
243
265
|
interrupt(): void {
|
|
244
266
|
if (!this._inflight) return;
|
|
245
|
-
this._rejectInflight(new Error("
|
|
267
|
+
this._rejectInflight(new Error("Local waiter interrupted; native cancellation unacknowledged"), "interrupt-request");
|
|
246
268
|
}
|
|
247
269
|
|
|
248
270
|
kill(): void {
|
|
@@ -254,7 +276,7 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
254
276
|
this._turnTimer = null;
|
|
255
277
|
}
|
|
256
278
|
|
|
257
|
-
this._rejectInflight(new Error("Session killed"));
|
|
279
|
+
this._rejectInflight(new Error("Session killed; native outcome may remain unknown"), "killed");
|
|
258
280
|
this._rejectQueue(new Error("Session killed"));
|
|
259
281
|
for (const p of this._pending.values()) p.reject(new Error("Session killed"));
|
|
260
282
|
this._pending.clear();
|
|
@@ -269,6 +291,7 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
269
291
|
artifact(): SessionArtifact {
|
|
270
292
|
return {
|
|
271
293
|
externalSessionId: this._externalSessionId,
|
|
294
|
+
attempts: this.attempts, accounting: "observed-only", diagnostics: this.diagnostics,
|
|
272
295
|
events: [...this._eventLog],
|
|
273
296
|
startedAt: this._startedAt,
|
|
274
297
|
endedAt: this._alive ? undefined : Date.now(),
|
|
@@ -285,6 +308,7 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
285
308
|
// ---------------------------------------------------------------------------
|
|
286
309
|
|
|
287
310
|
async start(): Promise<void> {
|
|
311
|
+
if (this._attempts.some(a => a.dispatch === "attempted" && a.nativeOutcome === "unknown")) throw new Error("Native outcome unresolved; automatic resume is blocked");
|
|
288
312
|
if (this._proc) throw new Error("Session already started");
|
|
289
313
|
|
|
290
314
|
const args = this._buildSpawnArgs();
|
|
@@ -306,14 +330,17 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
306
330
|
}
|
|
307
331
|
|
|
308
332
|
this._alive = true;
|
|
333
|
+
this._endEmitted = false;
|
|
309
334
|
this._emit({ kind: "session_start", timestamp: Date.now() });
|
|
310
335
|
|
|
311
|
-
this.
|
|
336
|
+
const proc = this._proc;
|
|
337
|
+
const stdoutDone = this._readStdout(proc);
|
|
312
338
|
this._readStderr();
|
|
313
339
|
|
|
314
|
-
|
|
315
|
-
if (!this._alive) return;
|
|
340
|
+
proc.exited.then(async (code) => {
|
|
341
|
+
if (this._proc !== proc || !this._alive) return;
|
|
316
342
|
this._alive = false;
|
|
343
|
+
await stdoutDone; // Drain buffered terminal evidence before interpreting process exit.
|
|
317
344
|
const errMsg = this._stderr.trim()
|
|
318
345
|
? `Process exited (code ${code}): ${this._stderr.trim().slice(0, 500)}`
|
|
319
346
|
: `Process exited with code ${code}`;
|
|
@@ -321,7 +348,7 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
321
348
|
this._rejectQueue(new Error("Session ended"));
|
|
322
349
|
for (const p of this._pending.values()) p.reject(new Error("Session ended"));
|
|
323
350
|
this._pending.clear();
|
|
324
|
-
this._emit({ kind: "session_end", timestamp: Date.now() });
|
|
351
|
+
this._emit({ kind: "session_end", timestamp: Date.now(), transportOutcome: "failed" });
|
|
325
352
|
});
|
|
326
353
|
|
|
327
354
|
await this._handshake();
|
|
@@ -333,8 +360,12 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
333
360
|
|
|
334
361
|
async send(
|
|
335
362
|
message: string,
|
|
336
|
-
opts?:
|
|
363
|
+
opts?: import("./harness-session").SessionSendOptions,
|
|
337
364
|
): Promise<SessionResult> {
|
|
365
|
+
if (this._attempts.some(a => a.dispatch === "attempted" && a.nativeOutcome === "unknown" && (!this._alive || a.localOutcome !== "pending"))) {
|
|
366
|
+
const blocked = new TurnState(); this._attempts.push(blocked);
|
|
367
|
+
throw blocked.fail(new Error("Native outcome unresolved; send not dispatched"), "blocked");
|
|
368
|
+
}
|
|
338
369
|
if (!this._proc && this._externalSessionId) {
|
|
339
370
|
await this.start();
|
|
340
371
|
}
|
|
@@ -355,7 +386,11 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
355
386
|
}
|
|
356
387
|
|
|
357
388
|
return new Promise<SessionResult>((resolve, reject) => {
|
|
358
|
-
const
|
|
389
|
+
const evidence = new TurnState(); this._attempts.push(evidence);
|
|
390
|
+
const turn: QueuedTurn = { evidence, message: transformed, timeout, resolve, reject, onAdmission: opts?.onAdmission };
|
|
391
|
+
if (!this._alive || (this._inflight && this._inflight.evidence.localOutcome !== "pending")) {
|
|
392
|
+
reject(evidence.fail(new Error("Previous native work unresolved; send not dispatched"), "blocked")); return;
|
|
393
|
+
}
|
|
359
394
|
if (!this._inflight) {
|
|
360
395
|
this._dispatchTurn(turn);
|
|
361
396
|
} else {
|
|
@@ -368,16 +403,35 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
368
403
|
// Private — turn dispatch + queue (mirrors ClaudeCodeSession)
|
|
369
404
|
// ---------------------------------------------------------------------------
|
|
370
405
|
|
|
406
|
+
readonly admissionProtocol = "prewrite-v1" as const;
|
|
407
|
+
inspectAttempt(admissionId: string) { return this._attempts.find(attempt => attempt.admissionId === admissionId)?.snapshot(); }
|
|
408
|
+
|
|
371
409
|
private _dispatchTurn(turn: QueuedTurn): void {
|
|
372
410
|
this._inflight = turn;
|
|
373
|
-
|
|
411
|
+
if (turn.onAdmission) {
|
|
412
|
+
Promise.resolve().then(() => turn.onAdmission!(turn.evidence.snapshot())).then(() => {
|
|
413
|
+
if (this._inflight === turn && this._alive && turn.evidence.localOutcome === "pending") this._writeTurn(turn);
|
|
414
|
+
else if (this._inflight === turn && turn.evidence.dispatch === "not-dispatched") { this._inflight = null; this._processNextTurn(); }
|
|
415
|
+
}).catch(error => {
|
|
416
|
+
turn.reject(turn.evidence.fail(error instanceof Error ? error : new Error(String(error)), "registration"));
|
|
417
|
+
if (this._inflight === turn) { this._inflight = null; this._processNextTurn(); }
|
|
418
|
+
});
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
this._writeTurn(turn);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private _writeTurn(turn: QueuedTurn): void {
|
|
425
|
+
turn.evidence.dispatch = "attempted";
|
|
426
|
+
this._turnEvents = turn.evidence.events;
|
|
374
427
|
this._resultText = "";
|
|
375
428
|
|
|
376
429
|
// Send via the subclass's wire protocol. The returned promise resolves when
|
|
377
430
|
// the tools/call (or turn) completes; that resolves the turn.
|
|
378
431
|
this._sendTurn(turn.message)
|
|
379
432
|
.then((tokens) => {
|
|
380
|
-
if (this._inflight !== turn) return;
|
|
433
|
+
if (this._inflight !== turn) return;
|
|
434
|
+
turn.evidence.rpcSettled = true;
|
|
381
435
|
if (tokens) {
|
|
382
436
|
this._totalTokens.input += tokens.input;
|
|
383
437
|
this._totalTokens.output += tokens.output;
|
|
@@ -386,14 +440,16 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
386
440
|
})
|
|
387
441
|
.catch((err: Error) => {
|
|
388
442
|
if (this._inflight !== turn) return;
|
|
389
|
-
|
|
390
|
-
|
|
443
|
+
turn.evidence.rpcSettled = true;
|
|
444
|
+
if (turn.evidence.rpcOutcome === "pending") turn.evidence.rpcOutcome = "unknown";
|
|
445
|
+
this._rejectInflight(err, err instanceof NativeValidationError ? "validation" : "rpc");
|
|
446
|
+
this._releaseKnownTurn();
|
|
391
447
|
});
|
|
392
448
|
|
|
393
449
|
if (turn.timeout > 0) {
|
|
394
450
|
this._turnTimer = setTimeout(() => {
|
|
395
451
|
this._turnTimer = null;
|
|
396
|
-
this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`));
|
|
452
|
+
this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`), "timeout");
|
|
397
453
|
}, turn.timeout);
|
|
398
454
|
}
|
|
399
455
|
}
|
|
@@ -406,38 +462,54 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
406
462
|
this._turnTimer = null;
|
|
407
463
|
}
|
|
408
464
|
|
|
465
|
+
const a = this._inflight.evidence;
|
|
466
|
+
a.content = this._resultText;
|
|
467
|
+
a.tokens = tokens ?? a.tokens;
|
|
468
|
+
if (a.localOutcome === "pending") {
|
|
469
|
+
a.localOutcome = "resolved";
|
|
470
|
+
this._inflight.resolve(a.result(this._externalSessionId));
|
|
471
|
+
}
|
|
472
|
+
if (a.nativeOutcome === "unknown") this._rejectQueue(new Error("Native terminal missing; queued send not dispatched"));
|
|
473
|
+
this._releaseKnownTurn();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private _releaseKnownTurn(): void {
|
|
477
|
+
const a = this._inflight?.evidence;
|
|
478
|
+
if (!a || !this._canReleaseAttempt(a) || a.nativeOutcome === "unknown") return;
|
|
409
479
|
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
480
|
this._inflight = null;
|
|
418
|
-
|
|
419
481
|
this._processNextTurn();
|
|
420
482
|
}
|
|
421
483
|
|
|
484
|
+
protected _canReleaseAttempt(attempt: TurnState): boolean { return attempt.rpcSettled; }
|
|
485
|
+
|
|
422
486
|
private _processNextTurn(): void {
|
|
423
|
-
if (this._queue.length > 0 && this._alive) {
|
|
487
|
+
if (!this._inflight && this._queue.length > 0 && this._alive) {
|
|
424
488
|
const next = this._queue.shift()!;
|
|
425
489
|
this._dispatchTurn(next);
|
|
426
490
|
}
|
|
427
491
|
}
|
|
428
492
|
|
|
429
|
-
protected _rejectInflight(err: Error): void {
|
|
493
|
+
protected _rejectInflight(err: Error, reason: import("./harness-session").SessionAttempt["localFailure"] = "transport"): void {
|
|
430
494
|
if (!this._inflight) return;
|
|
431
|
-
if (this._turnTimer) {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
this.
|
|
436
|
-
|
|
495
|
+
if (this._turnTimer) { clearTimeout(this._turnTimer); this._turnTimer = null; }
|
|
496
|
+
const a = this._inflight.evidence;
|
|
497
|
+
if ((reason === "transport" || reason === "killed") && a.transportOutcome === "open") a.transportOutcome = reason === "killed" ? "closed" : "failed";
|
|
498
|
+
if (a.localOutcome === "pending") this._inflight.reject(a.fail(err, reason));
|
|
499
|
+
this._rejectQueue(new Error("Previous native work unresolved; queued send not dispatched"));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private _transportFailure(err: Error): void {
|
|
503
|
+
this._alive = false;
|
|
504
|
+
this._rejectInflight(err);
|
|
505
|
+
this._rejectQueue(err);
|
|
506
|
+
this._emit({ kind: "session_end", timestamp: Date.now(), transportOutcome: "failed" });
|
|
507
|
+
for (const p of this._pending.values()) p.reject(err);
|
|
508
|
+
this._pending.clear();
|
|
437
509
|
}
|
|
438
510
|
|
|
439
511
|
protected _rejectQueue(err: Error): void {
|
|
440
|
-
for (const turn of this._queue) turn.reject(err);
|
|
512
|
+
for (const turn of this._queue) turn.reject(turn.evidence.fail(err, "blocked"));
|
|
441
513
|
this._queue = [];
|
|
442
514
|
}
|
|
443
515
|
|
|
@@ -445,8 +517,8 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
445
517
|
// Private — stdout/stderr readers (mirrors ClaudeCodeSession)
|
|
446
518
|
// ---------------------------------------------------------------------------
|
|
447
519
|
|
|
448
|
-
private async _readStdout(): Promise<void> {
|
|
449
|
-
const reader =
|
|
520
|
+
private async _readStdout(proc: PipedSubprocess): Promise<void> {
|
|
521
|
+
const reader = proc.stdout.getReader();
|
|
450
522
|
const decoder = new TextDecoder();
|
|
451
523
|
let buffer = "";
|
|
452
524
|
try {
|
|
@@ -458,12 +530,13 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
458
530
|
buffer = lines.pop()!;
|
|
459
531
|
for (const line of lines) {
|
|
460
532
|
if (!line.trim()) continue;
|
|
461
|
-
this._processLine(line);
|
|
533
|
+
if (this._proc === proc || !this._proc) this._processLine(line);
|
|
462
534
|
}
|
|
463
535
|
}
|
|
464
|
-
if (buffer.trim()) this._processLine(buffer);
|
|
536
|
+
if (buffer.trim() && (this._proc === proc || !this._proc)) this._processLine(buffer);
|
|
537
|
+
if (this._proc === proc && this._alive) this._transportFailure(new Error(this._stderr.trim() ? `Native stdout closed: ${this._stderr.trim().slice(0, 500)}` : "Native stdout closed"));
|
|
465
538
|
} catch (err) {
|
|
466
|
-
this.
|
|
539
|
+
if (this._proc === proc) this._transportFailure(err as Error);
|
|
467
540
|
}
|
|
468
541
|
}
|
|
469
542
|
|
|
@@ -490,36 +563,42 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
490
563
|
} catch {
|
|
491
564
|
return;
|
|
492
565
|
}
|
|
493
|
-
const raw = msg
|
|
566
|
+
const raw = object(msg);
|
|
567
|
+
if (!raw) return;
|
|
568
|
+
if (typeof raw.method === "string" && (typeof raw.id === "string" || typeof raw.id === "number") && this._serverRequest(raw)) return;
|
|
494
569
|
|
|
495
570
|
// JSON-RPC response to one of our requests (has matching `id` + result/error).
|
|
496
571
|
if (typeof raw.id === "number" && (("result" in raw) || ("error" in raw))) {
|
|
497
572
|
const pending = this._pending.get(raw.id);
|
|
498
573
|
if (pending) {
|
|
499
574
|
this._pending.delete(raw.id);
|
|
575
|
+
const a = this._inflight?.evidence;
|
|
576
|
+
if (a?.identity.rpcRequestId === raw.id) a.rpcOutcome = raw.error ? "failed" : "resolved";
|
|
500
577
|
if ("error" in raw && raw.error) {
|
|
501
578
|
const e = raw.error as Record<string, unknown>;
|
|
502
579
|
pending.reject(new Error((e.message as string) ?? JSON.stringify(e)));
|
|
503
580
|
} else {
|
|
504
|
-
|
|
581
|
+
// Apply protocol identity synchronously before later lines in this
|
|
582
|
+
// same stdout chunk. Awaiting the promise first can lose a terminal.
|
|
583
|
+
try { this._observeRpcResult(raw.id, raw.result); pending.resolve(raw.result); }
|
|
584
|
+
catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))); }
|
|
505
585
|
}
|
|
506
586
|
return;
|
|
507
587
|
}
|
|
508
588
|
}
|
|
509
589
|
|
|
510
|
-
// Otherwise it's a notification (streamed event) — classify it.
|
|
511
590
|
const classified = this._classify(raw);
|
|
512
591
|
for (const event of classified) {
|
|
513
|
-
this.
|
|
514
|
-
this.
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
}
|
|
518
|
-
if (
|
|
519
|
-
|
|
520
|
-
this._totalTokens.output += event.tokens.output;
|
|
521
|
-
}
|
|
592
|
+
const a = this._inflight?.evidence;
|
|
593
|
+
if (!a || event.unattributedReason) { this._emit(event); continue; }
|
|
594
|
+
const e = a.record(event);
|
|
595
|
+
this._resultText = a.content;
|
|
596
|
+
if (e.nativeOutcome && e.terminal) { a.nativeOutcome = e.nativeOutcome; a.terminal = e.terminal; }
|
|
597
|
+
if (e.tokens) { this._totalTokens.input += e.tokens.input; this._totalTokens.output += e.tokens.output; }
|
|
598
|
+
this._emit(e);
|
|
522
599
|
}
|
|
600
|
+
this._releaseKnownTurn();
|
|
601
|
+
|
|
523
602
|
}
|
|
524
603
|
|
|
525
604
|
// ---------------------------------------------------------------------------
|
|
@@ -527,16 +606,19 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
527
606
|
// ---------------------------------------------------------------------------
|
|
528
607
|
|
|
529
608
|
protected _emit(event: SessionEvent): void {
|
|
609
|
+
event = retainEvidence(event);
|
|
610
|
+
if (event.kind === "session_end") {
|
|
611
|
+
if (this._endEmitted) return;
|
|
612
|
+
this._endEmitted = true;
|
|
613
|
+
}
|
|
530
614
|
this._eventLog.push(event);
|
|
531
615
|
for (const handler of this._handlers) {
|
|
532
616
|
try {
|
|
533
|
-
handler(event);
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
);
|
|
539
|
-
}
|
|
617
|
+
const observation: unknown = handler(event);
|
|
618
|
+
if (observation && typeof (observation as PromiseLike<unknown>).then === "function") {
|
|
619
|
+
void Promise.resolve(observation).catch(() => { this._observerFailures.asynchronous++; });
|
|
620
|
+
}
|
|
621
|
+
} catch { this._observerFailures.synchronous++; }
|
|
540
622
|
}
|
|
541
623
|
}
|
|
542
624
|
|
|
@@ -546,6 +628,10 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
546
628
|
|
|
547
629
|
protected _rpcRequest(method: string, params?: unknown): Promise<unknown> {
|
|
548
630
|
const id = ++this._rpcId;
|
|
631
|
+
if ((method === "tools/call" || method === "turn/start") && this._inflight) {
|
|
632
|
+
this._inflight.evidence.identity = { ...this._inflight.evidence.identity, rpcRequestId: id };
|
|
633
|
+
this._inflight.evidence.rpcOutcome = "pending";
|
|
634
|
+
}
|
|
549
635
|
const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
|
550
636
|
return new Promise<unknown>((resolve, reject) => {
|
|
551
637
|
this._pending.set(id, { resolve, reject });
|
|
@@ -559,6 +645,8 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
559
645
|
});
|
|
560
646
|
}
|
|
561
647
|
|
|
648
|
+
protected _serverRequest(_request: Record<string, unknown>): boolean { return false; }
|
|
649
|
+
|
|
562
650
|
protected _rpcNotify(method: string, params?: unknown): void {
|
|
563
651
|
const payload = JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n";
|
|
564
652
|
this._proc!.stdin.write(payload);
|
|
@@ -580,8 +668,11 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
580
668
|
/** Map a streamed notification to SessionEvents. */
|
|
581
669
|
protected abstract _classify(msg: Record<string, unknown>): SessionEvent[];
|
|
582
670
|
|
|
671
|
+
protected _observeRpcResult(_id: number, _result: unknown): void {}
|
|
672
|
+
|
|
583
673
|
/** Shared fork constructor — subclass passes its own ctor. */
|
|
584
674
|
fork(opts?: { cwd?: string; baseContext?: string }): HarnessSession {
|
|
675
|
+
if (this._inflight) throw new Error("Cannot fork while native ownership is unresolved");
|
|
585
676
|
if (!this._externalSessionId) {
|
|
586
677
|
throw new Error(
|
|
587
678
|
"Cannot fork — no thread ID yet (send at least one message first)",
|
|
@@ -617,6 +708,8 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
617
708
|
// multi-turn + fork).
|
|
618
709
|
|
|
619
710
|
export class CodexMcpSession extends BaseCodexSession {
|
|
711
|
+
private _mcpAdmission?: string;
|
|
712
|
+
private _mcpCalls = new Map<string, { invocation: Record<string, unknown>; ended: boolean }>();
|
|
620
713
|
protected _buildSpawnArgs(): string[] {
|
|
621
714
|
// Disable codex's own approvals + sandbox (the union of
|
|
622
715
|
// --dangerously-bypass-approvals-and-sandbox) so OUR container is the jail.
|
|
@@ -672,14 +765,17 @@ export class CodexMcpSession extends BaseCodexSession {
|
|
|
672
765
|
| Record<string, unknown>
|
|
673
766
|
| undefined;
|
|
674
767
|
const threadId = structured?.threadId as string | undefined;
|
|
768
|
+
if (threadId && this._externalSessionId && threadId !== this._externalSessionId) throw new Error("MCP response changed the native resume binding");
|
|
675
769
|
if (threadId && !this._externalSessionId) {
|
|
676
770
|
this._externalSessionId = threadId;
|
|
677
771
|
}
|
|
772
|
+
if (threadId && this._inflight) this._inflight.evidence.identity = { ...this._inflight.evidence.identity, threadId };
|
|
678
773
|
// Final text: prefer structuredContent.content, else the tool result content.
|
|
679
774
|
const finalText =
|
|
680
775
|
(structured?.content as string | undefined) ??
|
|
681
776
|
this._extractToolText(result?.content);
|
|
682
|
-
if (
|
|
777
|
+
if (result?.isError === true) throw new Error(finalText ?? "MCP tools/call failed");
|
|
778
|
+
if (finalText) { this._resultText = finalText; if (this._inflight) this._inflight.evidence.content = finalText; }
|
|
683
779
|
|
|
684
780
|
// Usage, when the call result reports it. Streamed token_count events are
|
|
685
781
|
// already accounted by the base loop; the call result is the authoritative
|
|
@@ -688,7 +784,7 @@ export class CodexMcpSession extends BaseCodexSession {
|
|
|
688
784
|
// token event was seen this turn.
|
|
689
785
|
const sawStreamedTokens = this._turnEvents.some((e) => e.tokens);
|
|
690
786
|
const usage = structured?.usage as Record<string, number> | undefined;
|
|
691
|
-
if (usage && !sawStreamedTokens) {
|
|
787
|
+
if (usage && !sawStreamedTokens && typeof (usage.input_tokens ?? usage.inputTokens) === "number" && typeof (usage.output_tokens ?? usage.outputTokens) === "number") {
|
|
692
788
|
return {
|
|
693
789
|
input: (usage.input_tokens ?? usage.inputTokens ?? 0) as number,
|
|
694
790
|
output: (usage.output_tokens ?? usage.outputTokens ?? 0) as number,
|
|
@@ -714,114 +810,365 @@ export class CodexMcpSession extends BaseCodexSession {
|
|
|
714
810
|
const params = msg.params as Record<string, unknown> | undefined;
|
|
715
811
|
const ev = (params?.msg ?? params) as Record<string, unknown> | undefined;
|
|
716
812
|
if (!ev) return [];
|
|
717
|
-
|
|
813
|
+
const a = this._inflight?.evidence;
|
|
814
|
+
const str = (v: unknown) => typeof v === "string" ? v : undefined;
|
|
815
|
+
const nativeSessionId = str(ev.session_id), threadId = str(ev.thread_id);
|
|
816
|
+
const turnId = str(ev.turn_id), envelopeId = str(params?.id);
|
|
817
|
+
const item = ev.item as Record<string, unknown> | undefined;
|
|
818
|
+
const unowned = (reason: NonNullable<SessionEvent["unattributedReason"]>): SessionEvent[] => [{
|
|
819
|
+
kind: "native_status", timestamp: Date.now(), correlation: "unknown", unattributedReason: reason,
|
|
820
|
+
nativeSessionId, threadId, turnId, callId: str(ev.call_id), itemId: str(item?.id) ?? str(ev.item_id),
|
|
821
|
+
// Keep the envelope too: params.id may explain a rejected correlation.
|
|
822
|
+
// Unowned MCP calls retain identity/type diagnostics, never unrelated tool payloads.
|
|
823
|
+
raw: ev.type === "mcp_tool_call_begin" || ev.type === "mcp_tool_call_end" ? { type: ev.type } : msg,
|
|
824
|
+
}];
|
|
825
|
+
if (threadId && this._externalSessionId && threadId !== this._externalSessionId) return unowned("foreign-session");
|
|
826
|
+
if (nativeSessionId && a?.identity.nativeSessionId && nativeSessionId !== a.identity.nativeSessionId) return unowned("foreign-session");
|
|
827
|
+
if ((envelopeId && this._seenTerminals.has(envelopeId)) || (turnId && this._seenTerminals.has(turnId))) {
|
|
828
|
+
return unowned(ev.type === "task_complete" ? "duplicate-terminal" : "after-terminal");
|
|
829
|
+
}
|
|
830
|
+
if (!a) {
|
|
831
|
+
if (ev.type === "task_complete" && turnId) this._seenTerminals.add(turnId);
|
|
832
|
+
return unowned("no-admission");
|
|
833
|
+
}
|
|
834
|
+
if (ev.type === "session_configured") {
|
|
835
|
+
if (!this._externalSessionId && threadId) this._externalSessionId = threadId;
|
|
836
|
+
a.identity = { ...a.identity, nativeSessionId, threadId };
|
|
837
|
+
}
|
|
838
|
+
if (ev.type !== "session_configured" && a.identity.turnId && envelopeId && envelopeId !== a.identity.turnId) return unowned("foreign-turn");
|
|
839
|
+
if (threadId && a.identity.threadId && threadId !== a.identity.threadId) return unowned("foreign-session");
|
|
840
|
+
if (ev.type === "task_started" && turnId && !a.identity.turnId) a.identity = { ...a.identity, turnId, correlation: "native-turn" };
|
|
841
|
+
if (turnId && turnId !== a.identity.turnId) return unowned("foreign-turn");
|
|
842
|
+
if (ev.type === "task_complete") {
|
|
843
|
+
if (!turnId || turnId !== a.identity.turnId) return unowned("unrecognized-event");
|
|
844
|
+
this._seenTerminals.add(turnId);
|
|
845
|
+
return [{ ...a.identity, kind: "result", timestamp: Date.now(), nativeOutcome: "completed", terminal: { type: "task_complete", turnId }, raw: ev }];
|
|
846
|
+
}
|
|
847
|
+
const identity = { ...a.identity, threadId: threadId ?? a.identity.threadId, turnId: turnId ?? a.identity.turnId,
|
|
848
|
+
callId: str(ev.call_id), itemId: str(item?.id) ?? str(ev.item_id) };
|
|
849
|
+
if (ev.type === "mcp_tool_call_begin" || ev.type === "mcp_tool_call_end") {
|
|
850
|
+
const invocation = object(ev.invocation);
|
|
851
|
+
if (!identity.callId || !invocation || typeof invocation.server !== "string" || !invocation.server
|
|
852
|
+
|| typeof invocation.tool !== "string" || !invocation.tool) return unowned("malformed-tool");
|
|
853
|
+
if (this._mcpAdmission !== a.identity.admissionId) {
|
|
854
|
+
this._mcpAdmission = a.identity.admissionId; this._mcpCalls.clear();
|
|
855
|
+
}
|
|
856
|
+
// Compare the original argument value, including absence. Never join a result
|
|
857
|
+
// for another server/tool or silently substitute arguments from its envelope.
|
|
858
|
+
const join = { server: invocation.server, tool: invocation.tool, arguments: invocation.arguments };
|
|
859
|
+
const prior = this._mcpCalls.get(identity.callId);
|
|
860
|
+
const tool = { toolName: `mcp__${invocation.server}__${invocation.tool}`, toolServer: invocation.server, toolMethod: invocation.tool };
|
|
861
|
+
if (ev.type === "mcp_tool_call_begin") {
|
|
862
|
+
if (prior) return unowned("duplicate-tool");
|
|
863
|
+
this._mcpCalls.set(identity.callId, { invocation: structuredClone(join), ended: false });
|
|
864
|
+
const args = publicMcpArguments(invocation.arguments);
|
|
865
|
+
return [{ ...identity, ...tool, kind: "tool_use", timestamp: Date.now(), ...args }];
|
|
866
|
+
}
|
|
867
|
+
if (!prior || !isDeepStrictEqual(prior.invocation, join)) return unowned("unmatched-tool");
|
|
868
|
+
if (prior.ended) return unowned("duplicate-tool");
|
|
869
|
+
const result = publicMcpResult(ev.result);
|
|
870
|
+
if (!result) return unowned("malformed-tool");
|
|
871
|
+
prior.ended = true;
|
|
872
|
+
return [{ ...identity, ...tool, kind: "tool_result", timestamp: Date.now(), ...result }];
|
|
873
|
+
}
|
|
874
|
+
if (["session_configured", "task_started", "item_started", "item_completed"].includes(String(ev.type))) {
|
|
875
|
+
return [{ ...identity, kind: "native_status", timestamp: Date.now(), nativeOutcome: a.nativeOutcome, raw: ev }];
|
|
876
|
+
}
|
|
877
|
+
// Notifications without a turn ID are associated only with the single owned RPC.
|
|
878
|
+
const classified = classifyCodexEvent(ev, "type");
|
|
879
|
+
return classified.length ? classified.map(event => ({ ...identity, ...event })) : unowned("unrecognized-event");
|
|
718
880
|
}
|
|
719
881
|
}
|
|
720
882
|
|
|
721
883
|
// ---------------------------------------------------------------------------
|
|
722
|
-
// CodexAppServerSession (EXPERIMENTAL) — `codex app-server` over
|
|
884
|
+
// CodexAppServerSession (EXPERIMENTAL) — `codex app-server` over stdio
|
|
723
885
|
// ---------------------------------------------------------------------------
|
|
724
886
|
//
|
|
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.
|
|
887
|
+
// Wire contract: installed codex-cli 0.153.4 v2 JSON schema. Controlled tests
|
|
888
|
+
// establish source behavior, not a real create/resume/native-completion capture.
|
|
889
|
+
// No default switch. Full history hydration, native fork and cancellation remain
|
|
890
|
+
// separate integration work; a local interrupt never acknowledges native stop.
|
|
891
|
+
|
|
892
|
+
class NativeValidationError extends Error {}
|
|
739
893
|
|
|
740
894
|
export class CodexAppServerSession extends BaseCodexSession {
|
|
741
|
-
|
|
742
|
-
private
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
895
|
+
readonly appServerProtocol = "owned-thread-v1";
|
|
896
|
+
private readonly _options: NonNullable<CodexSessionConfig["appServer"]>;
|
|
897
|
+
private readonly _onThreadReady?: (binding: string) => void | Promise<void>;
|
|
898
|
+
private _startedOnce = false;
|
|
899
|
+
private _threadReady = false;
|
|
900
|
+
private _nativeSessionId?: string;
|
|
901
|
+
private _appAdmission?: string;
|
|
902
|
+
private _turnDone?: () => void;
|
|
903
|
+
private _items = new Map<string, { type: string; input?: unknown; completed: boolean; server?: string; tool?: string; text?: string }>();
|
|
904
|
+
|
|
905
|
+
constructor(config?: CodexSessionConfig) {
|
|
906
|
+
super(config);
|
|
907
|
+
const {onThreadReady,...options}=config?.appServer??{};
|
|
908
|
+
this._onThreadReady=onThreadReady;
|
|
909
|
+
this._options = retainEvidence(options);
|
|
910
|
+
const required = this._options.requiredMcpServer;
|
|
911
|
+
if (required && (!/^[a-zA-Z0-9_-]+$/.test(required.name) || !required.tools.length || required.tools.some(t => !/^[a-zA-Z0-9_-]+$/.test(t))))
|
|
912
|
+
throw new NativeValidationError("Invalid required MCP inventory");
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
override fork(): HarnessSession { throw Error("Native app-server fork is not implemented; a same-thread wrapper cannot create a native fork or inherit a process capability"); }
|
|
916
|
+
|
|
917
|
+
override async start(): Promise<void> {
|
|
918
|
+
if (this._inflight) throw Error("Native call ownership unresolved; automatic resume is blocked");
|
|
919
|
+
if (this._startedOnce && !this._alive) throw Error("App-server restart requires a new owned instance and preflight");
|
|
920
|
+
this._startedOnce = true;
|
|
921
|
+
try { await super.start(); }
|
|
922
|
+
catch(error) { this.kill(); throw error; }
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
protected override _serverRequest(request: Record<string, unknown>): boolean {
|
|
926
|
+
// JSON-RPC's explicit method-not-supported refusal also covers unknown future
|
|
927
|
+
// requests. Never approve native work or echo private request parameters.
|
|
928
|
+
const payload = JSON.stringify({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Client interaction is unsupported by this adapter" } }) + "\n";
|
|
929
|
+
const params=object(request.params),a=this._inflight?.evidence;
|
|
930
|
+
const identity=a?.identity.turnId && params?.threadId===a.identity.threadId && params?.turnId===a.identity.turnId
|
|
931
|
+
? {...a.identity,admissionId:a.admissionId} : {unattributedReason:"unrecognized-event" as const};
|
|
932
|
+
try { this._proc!.stdin.write(payload); this._proc!.stdin.flush(); }
|
|
933
|
+
catch { this._emit({ ...identity, kind: "native_status", timestamp: Date.now(), raw: { type: "server-request-refusal", status: "write-failed" } }); return true; }
|
|
934
|
+
this._emit({ ...identity, kind: "native_status", timestamp: Date.now(), raw: { type: "server-request-refusal", status: "refused" } });
|
|
935
|
+
return true;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
private _refuse(reason: string, status = "unknown"): never {
|
|
939
|
+
const a = this._inflight?.evidence;
|
|
940
|
+
this._emit({ ...(a ? { admissionId: a.admissionId, ...a.identity } : {}), kind: "native_status", timestamp: Date.now(),
|
|
941
|
+
raw: { type: "thread-refused", reason, status } });
|
|
942
|
+
throw new NativeValidationError(`Native thread validation refused: ${reason} (${status}); no turn was sent`);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
private async _readiness(): Promise<void> {
|
|
946
|
+
const required = this._options.requiredMcpServer;
|
|
947
|
+
if (!required) return;
|
|
948
|
+
let cursor: string | undefined;
|
|
949
|
+
const seen = new Set<string>(); let found: Record<string, unknown> | undefined;
|
|
950
|
+
// Pagination only, never polling/retrying a starting or failed native server.
|
|
951
|
+
for (let page = 0; page < 32; page++) {
|
|
952
|
+
const result = object(await this._rpcRequest("mcpServerStatus/list", { threadId: this._externalSessionId, detail: "toolsAndAuthOnly", limit: 100, ...(cursor ? { cursor } : {}) }));
|
|
953
|
+
if (!Array.isArray(result?.data)) this._refuse("mcp-inventory-malformed");
|
|
954
|
+
for (const entry of result.data) {
|
|
955
|
+
const server = object(entry);
|
|
956
|
+
if (server?.name === required.name) { if (found) this._refuse("mcp-inventory-duplicate"); found = server; }
|
|
957
|
+
}
|
|
958
|
+
if (result.nextCursor == null) {
|
|
959
|
+
const tools = object(found?.tools);
|
|
960
|
+
if (!found || found.runtimeStatus !== "connected" || !tools || required.tools.some(name => object(tools[name])?.name !== name))
|
|
961
|
+
this._refuse("mcp-inventory-unavailable");
|
|
962
|
+
const a = this._inflight!.evidence;
|
|
963
|
+
this._emit({ ...a.identity, admissionId: a.admissionId, kind: "native_status", timestamp: Date.now(), raw: { type: "mcp-ready", status: "connected", server: required.name, tools: [...required.tools] } });
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
if (typeof result.nextCursor !== "string" || !result.nextCursor || seen.has(result.nextCursor)) this._refuse("mcp-inventory-cursor");
|
|
967
|
+
cursor = result.nextCursor; seen.add(cursor);
|
|
968
|
+
}
|
|
969
|
+
this._refuse("mcp-inventory-page-limit");
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
protected override _canReleaseAttempt(a: TurnState): boolean {
|
|
973
|
+
// An observer/write failure may occur after delivery. A terminal alone does
|
|
974
|
+
// not settle the unanswered turn/start call or authorize another admission.
|
|
975
|
+
return a.rpcSettled && (a.rpcOutcome === "resolved" || a.rpcOutcome === "failed");
|
|
976
|
+
}
|
|
746
977
|
|
|
747
978
|
protected _buildSpawnArgs(): string[] {
|
|
748
|
-
|
|
749
|
-
// per-thread in thread/start (approvalPolicy / sandbox below).
|
|
750
|
-
return ["app-server", "--listen", "ws://127.0.0.1:0"];
|
|
979
|
+
return ["app-server", "--listen", "stdio://"];
|
|
751
980
|
}
|
|
752
981
|
|
|
753
982
|
protected async _handshake(): Promise<void> {
|
|
754
|
-
|
|
755
|
-
|
|
983
|
+
this._threadReady = false;
|
|
984
|
+
const initialized = object(await this._rpcRequest("initialize", {
|
|
756
985
|
capabilities: {},
|
|
757
986
|
clientInfo: { name: "inixiative-bench", version: "0.1.0" },
|
|
758
|
-
});
|
|
987
|
+
}));
|
|
988
|
+
if (this._options.requireConfiguration && ["userAgent","platformFamily","platformOs","codexHome"].some(key=>typeof initialized?.[key]!=="string" || !initialized[key]))
|
|
989
|
+
throw new NativeValidationError("Native initialize response lacks required configuration; no thread or turn was sent");
|
|
759
990
|
this._rpcNotify("initialized");
|
|
760
991
|
}
|
|
761
992
|
|
|
762
993
|
protected async _sendTurn(
|
|
763
994
|
message: string,
|
|
764
995
|
): Promise<{ input: number; output: number } | undefined> {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const
|
|
996
|
+
const a = this._inflight!.evidence;
|
|
997
|
+
if (!this._threadReady) {
|
|
998
|
+
const binding = this._externalSessionId;
|
|
999
|
+
const method = binding ? "thread/resume" : "thread/start";
|
|
1000
|
+
const response = object(await this._rpcRequest(method, {
|
|
1001
|
+
...(binding ? { threadId: binding } : {}),
|
|
769
1002
|
model: this._model,
|
|
770
1003
|
cwd: this._cwd,
|
|
771
1004
|
approvalPolicy: "never",
|
|
772
1005
|
sandbox: "danger-full-access",
|
|
773
|
-
...(this.
|
|
774
|
-
}))
|
|
775
|
-
const thread =
|
|
776
|
-
|
|
777
|
-
|
|
1006
|
+
...(this._options.config ? { config: this._options.config } : {}),
|
|
1007
|
+
}));
|
|
1008
|
+
const thread = object(response?.thread), id = thread?.id;
|
|
1009
|
+
if (typeof id !== "string" || !id || (binding && id !== binding)) this._refuse("binding-mismatch");
|
|
1010
|
+
// Resume may rejoin a live thread. Never steer known pre-existing work.
|
|
1011
|
+
// The installed schema requires status. Missing/unknown state cannot
|
|
1012
|
+
// authorize work, even if this response otherwise identifies our thread.
|
|
1013
|
+
if (object(thread?.status)?.type !== "idle"
|
|
1014
|
+
|| (Array.isArray(thread?.turns) && thread.turns.some(turn => object(turn)?.status === "inProgress")))
|
|
1015
|
+
this._refuse("not-idle", ["active", "notLoaded", "systemError"].includes(String(object(thread?.status)?.type)) ? String(object(thread?.status)?.type) : "unknown");
|
|
1016
|
+
if (this._options.requireConfiguration && (typeof response?.model !== "string" || !response.model || typeof response.modelProvider !== "string" || !response.modelProvider
|
|
1017
|
+
|| response.cwd !== this._cwd || response.approvalPolicy !== "never" || response.approvalsReviewer !== "user" || object(response.sandbox)?.type !== "dangerFullAccess"
|
|
1018
|
+
|| !Array.isArray(thread?.turns) || thread.turns.some(value=>!this._turn(value)))) this._refuse("configuration-missing-or-incompatible");
|
|
1019
|
+
this._externalSessionId = id;
|
|
1020
|
+
this._nativeSessionId = typeof thread?.sessionId === "string" && thread.sessionId ? thread.sessionId : undefined;
|
|
1021
|
+
a.identity = { ...a.identity, threadId: id, ...(this._nativeSessionId ? {nativeSessionId:this._nativeSessionId} : {}) };
|
|
1022
|
+
if (this._onThreadReady) {
|
|
1023
|
+
try { await this._onThreadReady(id); }
|
|
1024
|
+
catch (cause) { throw new NativeValidationError("Native binding persistence failed before turn/start", {cause}); }
|
|
1025
|
+
}
|
|
1026
|
+
this._threadReady = true;
|
|
1027
|
+
// Configuration belongs to this process/thread, never to the next turn.
|
|
1028
|
+
// Do not copy historical turns, instructions, paths or arbitrary config.
|
|
1029
|
+
this._emit({ kind: "native_status", timestamp: Date.now(), threadId: id,
|
|
1030
|
+
nativeSessionId: this._nativeSessionId, unattributedReason: "session-configuration", raw: { type: "thread-configured", method, threadId: id,
|
|
1031
|
+
...(object(thread?.status)?.type === "idle" ? { status: "idle" } : {}),
|
|
1032
|
+
...(typeof response?.model === "string" ? { model: response.model } : {}),
|
|
1033
|
+
...(response?.reasoningEffort === null || VALID_EFFORTS.includes(String(response?.reasoningEffort)) ? { reasoningEffort: response?.reasoningEffort } : {}),
|
|
1034
|
+
history: { source: method, turns: Array.isArray(thread?.turns) ? thread.turns.slice(0,200).flatMap(value => {const t=this._turn(value);return t?[{id:t.id,status:t.status}]:[];}) : [],
|
|
1035
|
+
available: Array.isArray(thread?.turns), hasMore: !!response?.turnsBackwardsCursor || (Array.isArray(thread?.turns) && thread.turns.length>200) } } });
|
|
778
1036
|
}
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
);
|
|
785
|
-
|
|
786
|
-
// turn/start streams turn/started, item/*, turn/completed back as
|
|
787
|
-
// notifications, consumed in _classify; turn/completed resolves the turn.
|
|
1037
|
+
a.identity = { ...a.identity, threadId: this._externalSessionId, ...(this._nativeSessionId ? { nativeSessionId: this._nativeSessionId } : {}) };
|
|
1038
|
+
await this._readiness();
|
|
1039
|
+
// Timeout/kill during create/resume must not cause a later model write.
|
|
1040
|
+
if (this._inflight?.evidence !== a || a.localOutcome !== "pending" || !this._alive) throw Error("Local admission closed before turn/start; no turn was sent");
|
|
1041
|
+
a.identity = { ...a.identity, threadId: this._externalSessionId, ...(this._nativeSessionId ? { nativeSessionId: this._nativeSessionId } : {}) };
|
|
1042
|
+
this._appAdmission = a.admissionId; this._items.clear();
|
|
1043
|
+
const completed = new Promise<void>(resolve => { this._turnDone = resolve; });
|
|
788
1044
|
await this._rpcRequest("turn/start", {
|
|
789
1045
|
threadId: this._externalSessionId,
|
|
790
|
-
input: message,
|
|
1046
|
+
input: [{ type: "text", text: message }],
|
|
1047
|
+
...(this._effort ? { effort: this._effort } : {}),
|
|
791
1048
|
});
|
|
1049
|
+
await completed; // Native terminal and RPC acknowledgment are both required.
|
|
1050
|
+
return a.tokens;
|
|
1051
|
+
}
|
|
792
1052
|
|
|
793
|
-
|
|
1053
|
+
protected override _observeRpcResult(id: number, result: unknown): void {
|
|
1054
|
+
const a = this._inflight?.evidence;
|
|
1055
|
+
if (!a || this._appAdmission !== a.admissionId || a.identity.rpcRequestId !== id) return;
|
|
1056
|
+
const turn = this._turn(object(result)?.turn);
|
|
1057
|
+
if (!turn || (a.identity.turnId && turn.id !== a.identity.turnId)
|
|
1058
|
+
|| (!a.identity.turnId && this._seenTerminals.has(turn.id))) throw new NativeValidationError("Native turn/start response missing or conflicts with owned turn");
|
|
1059
|
+
a.identity = { ...a.identity, turnId: turn.id, correlation: "native-turn" };
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
private _turn(value: unknown): { id: string; status: string; error?: unknown } | undefined {
|
|
1063
|
+
const turn = object(value);
|
|
1064
|
+
if (!turn || typeof turn.id !== "string" || !turn.id || !Array.isArray(turn.items)
|
|
1065
|
+
|| !["inProgress", "completed", "failed", "interrupted"].includes(String(turn.status))) return;
|
|
1066
|
+
return turn as { id: string; status: string; error?: unknown };
|
|
794
1067
|
}
|
|
795
1068
|
|
|
796
1069
|
protected _classify(msg: Record<string, unknown>): SessionEvent[] {
|
|
797
|
-
const method = msg.method
|
|
1070
|
+
const method = typeof msg.method === "string" ? msg.method : undefined;
|
|
798
1071
|
if (!method) return [];
|
|
799
|
-
const params = (msg.params
|
|
800
|
-
|
|
1072
|
+
const params = object(msg.params), turn = this._turn(params?.turn), item = object(params?.item);
|
|
1073
|
+
const str = (value: unknown) => typeof value === "string" && value ? value : undefined;
|
|
1074
|
+
const threadId = str(params?.threadId), turnId = turn?.id ?? str(params?.turnId), itemId = str(item?.id) ?? str(params?.itemId);
|
|
1075
|
+
const unowned = (reason: NonNullable<SessionEvent["unattributedReason"]>): SessionEvent[] => [{
|
|
1076
|
+
kind: "native_status", timestamp: Date.now(), threadId, turnId, itemId, correlation: "unknown", unattributedReason: reason,
|
|
1077
|
+
raw: { method: ["turn/started", "turn/completed", "item/started", "item/completed", "thread/tokenUsage/updated", "item/agentMessage/delta", "item/commandExecution/outputDelta"].includes(method) ? method : "unrecognized",
|
|
1078
|
+
...(item ? { itemType: ["agentMessage", "commandExecution", "reasoning", "mcpToolCall"].includes(String(item.type)) ? item.type : "unsupported" } : {}) },
|
|
1079
|
+
}];
|
|
1080
|
+
const a = this._inflight?.evidence;
|
|
1081
|
+
if (!threadId || threadId !== this._externalSessionId) return unowned("foreign-session");
|
|
1082
|
+
if (!a || this._appAdmission !== a.admissionId) return unowned("no-admission");
|
|
1083
|
+
if (turnId && this._seenTerminals.has(turnId)) return unowned(method === "turn/completed" ? "duplicate-terminal" : "after-terminal");
|
|
1084
|
+
if (method === "turn/started" && turn?.status === "inProgress" && !a.identity.turnId)
|
|
1085
|
+
a.identity = { ...a.identity, threadId, turnId: turn.id, correlation: "native-turn" };
|
|
1086
|
+
if (!turnId || turnId !== a.identity.turnId) return unowned("foreign-turn");
|
|
1087
|
+
const identity = { ...a.identity, threadId, turnId, ...(itemId ? { itemId } : {}) };
|
|
1088
|
+
const status = (): SessionEvent[] => [{ ...identity, kind: "native_status", timestamp: Date.now(), raw: { method } }];
|
|
801
1089
|
if (method === "turn/completed") {
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
input: (usage.inputTokens ?? usage.input_tokens ?? 0) as number,
|
|
808
|
-
output: (usage.outputTokens ?? usage.output_tokens ?? 0) as number,
|
|
809
|
-
}
|
|
810
|
-
: undefined;
|
|
811
|
-
this._turnDone?.resolve(tokens);
|
|
1090
|
+
if (!turn || turn.status === "inProgress") return unowned("unrecognized-event");
|
|
1091
|
+
const error = object(turn.error);
|
|
1092
|
+
if (turn.error != null && typeof error?.message !== "string") return unowned("unrecognized-event");
|
|
1093
|
+
this._seenTerminals.add(turnId);
|
|
1094
|
+
this._turnDone?.();
|
|
812
1095
|
this._turnDone = undefined;
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1096
|
+
return [{ ...identity, kind: "result", timestamp: Date.now(), text: a.content,
|
|
1097
|
+
nativeOutcome: turn.status === "completed" && !error ? "completed" : "failed",
|
|
1098
|
+
terminal: { type: method, turnId, subtype: turn.status, ...(error ? { reason: error.message as string } : {}) } }];
|
|
1099
|
+
}
|
|
1100
|
+
if (method === "thread/tokenUsage/updated") {
|
|
1101
|
+
const last = object(object(params?.tokenUsage)?.last);
|
|
1102
|
+
if (typeof last?.inputTokens !== "number" || typeof last?.outputTokens !== "number"
|
|
1103
|
+
|| !Number.isSafeInteger(last.inputTokens) || !Number.isSafeInteger(last.outputTokens) || last.inputTokens < 0 || last.outputTokens < 0) return unowned("unrecognized-event");
|
|
1104
|
+
// `last` is a snapshot, not an additive delta. Total accounting happens once
|
|
1105
|
+
// when the original native turn and RPC settle; absent usage stays absent.
|
|
1106
|
+
a.tokens = { input: last.inputTokens, output: last.outputTokens };
|
|
1107
|
+
return status();
|
|
1108
|
+
}
|
|
1109
|
+
if (method === "item/agentMessage/delta" || method === "item/commandExecution/outputDelta") {
|
|
1110
|
+
if (!itemId || typeof params?.delta !== "string") return unowned("unrecognized-event");
|
|
1111
|
+
const prior = this._items.get(itemId);
|
|
1112
|
+
if (prior?.completed) return unowned("after-terminal");
|
|
1113
|
+
if (method === "item/agentMessage/delta") {
|
|
1114
|
+
if (prior && prior.type !== "agentMessage") return unowned("unmatched-tool");
|
|
1115
|
+
this._items.set(itemId, { type: "agentMessage", completed: false, text: (prior?.text ?? "") + params.delta });
|
|
1116
|
+
return [{ ...identity, kind: "text_delta", timestamp: Date.now(), text: params.delta }];
|
|
1117
|
+
}
|
|
1118
|
+
if (prior?.type !== "commandExecution") return unowned("unmatched-tool");
|
|
1119
|
+
return [{ ...identity, kind: "native_status", timestamp: Date.now(), toolName: "shell", text: params.delta }];
|
|
816
1120
|
}
|
|
817
|
-
|
|
818
1121
|
if (method === "item/completed" || method === "item/started") {
|
|
819
|
-
|
|
820
|
-
|
|
1122
|
+
if (!itemId || !item || typeof item.type !== "string") return unowned("unrecognized-event");
|
|
1123
|
+
if (item.type === "agentMessage") {
|
|
1124
|
+
if (method !== "item/completed") return status();
|
|
1125
|
+
if (this._items.get(itemId)?.completed) return unowned("after-terminal");
|
|
1126
|
+
if (typeof item.text !== "string" || (item.phase != null && !["commentary", "final_answer"].includes(String(item.phase)))) return unowned("unrecognized-event");
|
|
1127
|
+
this._items.set(itemId, { type: item.type, completed: true });
|
|
1128
|
+
return [{ ...identity, kind: "text", timestamp: Date.now(), text: item.text }];
|
|
1129
|
+
}
|
|
1130
|
+
if (item.type === "commandExecution") {
|
|
1131
|
+
if (typeof item.command !== "string") return unowned("malformed-tool");
|
|
1132
|
+
const prior = this._items.get(itemId);
|
|
1133
|
+
if (method === "item/started") {
|
|
1134
|
+
if (prior) return unowned("duplicate-tool");
|
|
1135
|
+
this._items.set(itemId, { type: item.type, input: item.command, completed: false });
|
|
1136
|
+
return [{ ...identity, kind: "tool_use", timestamp: Date.now(), toolName: "shell", toolInput: { command: item.command } }];
|
|
1137
|
+
}
|
|
1138
|
+
if (!prior || prior.type !== item.type || prior.input !== item.command) return unowned("unmatched-tool");
|
|
1139
|
+
if (prior.completed) return unowned("duplicate-tool");
|
|
1140
|
+
if (!["completed", "failed", "declined"].includes(String(item.status))) return unowned("malformed-tool");
|
|
1141
|
+
prior.completed = true;
|
|
1142
|
+
return [{ ...identity, kind: "tool_result", timestamp: Date.now(), toolName: "shell",
|
|
1143
|
+
...(typeof item.aggregatedOutput === "string" ? { toolOutput: item.aggregatedOutput } : { toolOutputOmitted: true }),
|
|
1144
|
+
toolError: item.status !== "completed" || (typeof item.exitCode === "number" && item.exitCode !== 0) }];
|
|
1145
|
+
}
|
|
1146
|
+
if (item.type === "mcpToolCall") {
|
|
1147
|
+
if (typeof item.server !== "string" || !item.server || typeof item.tool !== "string" || !item.tool) return unowned("malformed-tool");
|
|
1148
|
+
const prior = this._items.get(itemId);
|
|
1149
|
+
const tool = { toolServer: item.server, toolMethod: item.tool, toolName: `mcp__${item.server}__${item.tool}` };
|
|
1150
|
+
if (method === "item/started") {
|
|
1151
|
+
if (prior) return unowned("duplicate-tool");
|
|
1152
|
+
if (item.status !== "inProgress") return unowned("malformed-tool");
|
|
1153
|
+
this._items.set(itemId, { type: item.type, server: item.server, tool: item.tool, input: retainEvidence(item.arguments), completed: false });
|
|
1154
|
+
return [{ ...identity, ...tool, kind: "tool_use", timestamp: Date.now(), ...publicMcpArguments(item.arguments) }];
|
|
1155
|
+
}
|
|
1156
|
+
if (!prior || prior.type !== item.type || prior.server !== item.server || prior.tool !== item.tool || !isDeepStrictEqual(prior.input, item.arguments)) return unowned("unmatched-tool");
|
|
1157
|
+
if (prior.completed) return unowned("duplicate-tool");
|
|
1158
|
+
if (!["completed", "failed"].includes(String(item.status))) return unowned("malformed-tool");
|
|
1159
|
+
const error = object(item.error), result = object(item.result);
|
|
1160
|
+
const output = result ? publicMcpResult({ Ok: { content: result.content } }) : undefined;
|
|
1161
|
+
if (item.error != null && typeof error?.message !== "string") return unowned("malformed-tool");
|
|
1162
|
+
prior.completed = true;
|
|
1163
|
+
return [{ ...identity, ...tool, kind: "tool_result", timestamp: Date.now(),
|
|
1164
|
+
...(output ?? (error ? { toolOutput: error.message as string } : { toolOutputOmitted: true })),
|
|
1165
|
+
toolError: item.status === "failed" || !!error,
|
|
1166
|
+
...(result && Object.keys(result).some(k => k !== "content" && result[k] != null) ? { toolOutputOmitted: true } : {}) }];
|
|
1167
|
+
}
|
|
1168
|
+
// Reasoning and unsupported items retain safe identity/type diagnostics only.
|
|
1169
|
+
return unowned("unrecognized-event");
|
|
821
1170
|
}
|
|
822
|
-
|
|
823
|
-
// turn/started and other lifecycle notifications carry no turn content.
|
|
824
|
-
return [];
|
|
1171
|
+
return method === "turn/started" ? status() : unowned("unrecognized-event");
|
|
825
1172
|
}
|
|
826
1173
|
}
|
|
827
1174
|
|
|
@@ -838,6 +1185,50 @@ export class CodexAppServerSession extends BaseCodexSession {
|
|
|
838
1185
|
// command_execution / commandExecution → tool_use (+ tool_result when done)
|
|
839
1186
|
// token_count / usage → tokens (attached to a result event)
|
|
840
1187
|
|
|
1188
|
+
const object = (value: unknown): Record<string, unknown> | undefined =>
|
|
1189
|
+
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
1190
|
+
|
|
1191
|
+
function publicMcpArguments(value: unknown): Pick<SessionEvent, "toolInput" | "toolInputOmitted"> {
|
|
1192
|
+
if (!object(value)) return { toolInputOmitted: "Native arguments absent or not an object" };
|
|
1193
|
+
let omitted = false;
|
|
1194
|
+
const clean = (v: unknown, depth = 0): unknown => {
|
|
1195
|
+
if (depth > 32) { omitted = true; return null; }
|
|
1196
|
+
if (v === null || ["string", "number", "boolean"].includes(typeof v)) return v;
|
|
1197
|
+
if (Array.isArray(v)) return v.map(x => clean(x, depth + 1));
|
|
1198
|
+
if (!object(v)) { omitted = true; return null; }
|
|
1199
|
+
return Object.fromEntries(Object.entries(v as Record<string, unknown>).flatMap(([key, item]) => {
|
|
1200
|
+
if (/^(?:__proto__|constructor|prototype|env|environment|authorization|credentials?|api[-_]?key|access[-_]?token|refresh[-_]?token|password|secret|reasoning|thinking)$/i.test(key)) { omitted = true; return []; }
|
|
1201
|
+
return [[key, clean(item, depth + 1)]];
|
|
1202
|
+
}));
|
|
1203
|
+
};
|
|
1204
|
+
const toolInput = clean(value) as Record<string, unknown>;
|
|
1205
|
+
return { toolInput, ...(omitted ? { toolInputOmitted: "Non-public or unsupported argument fields omitted" } : {}) };
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** Rust Result<CallToolResult,String> from protocol.rs. This is legacy event
|
|
1209
|
+
* normalization, not the app-server ThreadItem schema. Keep all public text
|
|
1210
|
+
* blocks (including the bridge's JSON receipt), not arbitrary envelope fields. */
|
|
1211
|
+
function publicMcpResult(value: unknown): Pick<SessionEvent, "toolOutput" | "toolError" | "toolOutputOmitted"> | undefined {
|
|
1212
|
+
const result = object(value);
|
|
1213
|
+
if (!result || Object.keys(result).length !== 1) return;
|
|
1214
|
+
if (typeof result.Err === "string") return { toolOutput: result.Err, toolError: true };
|
|
1215
|
+
const ok = object(result.Ok);
|
|
1216
|
+
if (!ok || !Array.isArray(ok.content) || (ok.isError !== undefined && typeof ok.isError !== "boolean")) return;
|
|
1217
|
+
let omitted = Object.keys(ok).some(k => k !== "content" && k !== "isError");
|
|
1218
|
+
const content = ok.content.map(value => {
|
|
1219
|
+
const block = object(value);
|
|
1220
|
+
if (block?.type === "text" && typeof block.text === "string") {
|
|
1221
|
+
if (Object.keys(block).some(k => k !== "type" && k !== "text")) omitted = true;
|
|
1222
|
+
return { type: "text", text: block.text };
|
|
1223
|
+
}
|
|
1224
|
+
omitted = true;
|
|
1225
|
+
// Unknown shape values are not safe labels. Known non-text types retain type only.
|
|
1226
|
+
return { type: ["image", "audio", "resource", "resource_link"].includes(String(block?.type)) ? block!.type : "unsupported" };
|
|
1227
|
+
});
|
|
1228
|
+
return { toolOutput: JSON.stringify({ content, ...(ok.isError !== undefined ? { isError: ok.isError } : {}) }),
|
|
1229
|
+
...(ok.isError !== undefined ? { toolError: ok.isError as boolean } : {}), ...(omitted ? { toolOutputOmitted: true } : {}) };
|
|
1230
|
+
}
|
|
1231
|
+
|
|
841
1232
|
function classifyCodexEvent(
|
|
842
1233
|
ev: Record<string, unknown>,
|
|
843
1234
|
tag: string,
|
|
@@ -874,7 +1265,7 @@ function classifyCodexEvent(
|
|
|
874
1265
|
) {
|
|
875
1266
|
const command = (ev.command ?? ev.cmd) as string | string[] | undefined;
|
|
876
1267
|
const cmdStr = Array.isArray(command) ? command.join(" ") : command;
|
|
877
|
-
events.push({
|
|
1268
|
+
if (type !== "exec_command_end") events.push({
|
|
878
1269
|
kind: "tool_use",
|
|
879
1270
|
timestamp: ts,
|
|
880
1271
|
toolName: "shell",
|