@inixiative/agent-session 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1507 @@
1
+ // ---------------------------------------------------------------------------
2
+ // CodexPrimedSessions — primed decision sessions on one warm `codex app-server`
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // One process per account hosts every key's session as its own thread:
6
+ //
7
+ // prime thread/start (persisted; role instructions as developer instructions)
8
+ // + one primer turn carrying the stable context as user-role data.
9
+ // The primed thread is the key's live session and its cached prefix.
10
+ // decide thread/fork (ephemeral, through the primer turn, same instructions)
11
+ // + turn/start with the cycle input, then thread/unsubscribe.
12
+ // The primed thread never receives a decision, so each cycle starts
13
+ // from exactly the primed state. Decisions on one key run concurrently.
14
+ // miss no primed session for this context yet: the decision runs inline on
15
+ // a fresh thread (context + input) and priming happens in the
16
+ // background, never on the decision's critical path. A key whose
17
+ // primed context is replaced before it ever served is volatile and
18
+ // primes only once its context repeats.
19
+ // evict thread/delete of the primed thread.
20
+ //
21
+ // Verified against codex-cli 0.155.1: ephemeral threads refuse rollback and
22
+ // fork, and persisted threads refuse thread/rollback ("paginated threads"), so
23
+ // reset is a fork of a persisted primed thread. Forks must restate the
24
+ // instructions or they run on Codex's default base prompt and miss the cache.
25
+ //
26
+ // Text-only: tool features and plugins are disabled at launch; MCP servers,
27
+ // notify hooks and tool/environment instructions are disabled per thread;
28
+ // threads run read-only with approvals "never". Any non-text item,
29
+ // MCP server startup or server request on a decision thread is a violation:
30
+ // the turn is interrupted and the process recycled.
31
+ // ---------------------------------------------------------------------------
32
+
33
+ import type { PipedSubprocess } from './codex-session';
34
+ import type { SessionTokens } from './harness-session';
35
+ import { JsonRpcConnection, JsonRpcError } from './json-rpc';
36
+ import { codexLimitSnapshot, type LimitSnapshot, mergeLimits } from './limits';
37
+ import {
38
+ DecisionError,
39
+ type DecisionRequest,
40
+ type DecisionResult,
41
+ type PrimedEvent,
42
+ type PrimedSessions,
43
+ type PrimedSnapshot,
44
+ type PrimeSpec,
45
+ primeHash,
46
+ Slots,
47
+ } from './primed';
48
+
49
+ /** Codex features a decision never needs; same surface Foundry disables for `codex exec` decisions. */
50
+ export const CODEX_DECISION_DISABLED_FEATURES: readonly string[] = [
51
+ 'shell_tool',
52
+ 'unified_exec',
53
+ 'apps',
54
+ 'plugins',
55
+ 'remote_plugin',
56
+ 'browser_use',
57
+ 'browser_use_external',
58
+ 'computer_use',
59
+ 'image_generation',
60
+ 'memories',
61
+ 'multi_agent',
62
+ 'goals',
63
+ 'hooks',
64
+ 'view_image',
65
+ 'sleep_tool',
66
+ 'skill_search',
67
+ 'tool_suggest',
68
+ 'shell_snapshot',
69
+ 'skill_mcp_dependency_install',
70
+ 'workspace_dependencies',
71
+ 'in_app_browser',
72
+ 'in_app_local_automation',
73
+ 'personality',
74
+ 'mentions_v2',
75
+ ];
76
+
77
+ /**
78
+ * Per-thread configuration for decisions: no notify hook, no project docs or skills,
79
+ * and none of the instruction surfaces that describe tools or the environment.
80
+ * Applied on thread/start and thread/fork (verified equivalent to launch-level `-c`
81
+ * overrides), so the launch argv carries only approval, web-search and feature flags
82
+ * that credential launchers accept.
83
+ *
84
+ * The user's own CODEX_HOME (its AGENTS.md, memories, MCP servers) is not a thread
85
+ * setting: run decisions on a private CODEX_HOME holding only the login. A thread
86
+ * that reports an instruction source is refused, and an MCP server that starts is a
87
+ * violation.
88
+ */
89
+ export const CODEX_DECISION_THREAD_CONFIG: Readonly<Record<string, unknown>> = Object.freeze({
90
+ notify: [],
91
+ project_doc_max_bytes: 0,
92
+ 'skills.enabled': false,
93
+ include_permissions_instructions: false,
94
+ include_environment_context: false,
95
+ include_apps_instructions: false,
96
+ include_apps_usage_instructions: false,
97
+ include_collaboration_mode_instructions: false,
98
+ include_plugin_usage_instructions: false,
99
+ include_skills_usage_instructions: false,
100
+ });
101
+
102
+ /** `codex app-server` argv (without the binary) for tool-free decision sessions. */
103
+ export function codexDecisionLaunchArgs(): string[] {
104
+ return [
105
+ 'app-server',
106
+ '--listen',
107
+ 'stdio://',
108
+ '-c',
109
+ 'approval_policy="never"',
110
+ '-c',
111
+ 'web_search="disabled"',
112
+ ...CODEX_DECISION_DISABLED_FEATURES.flatMap((feature) => ['--disable', feature]),
113
+ ];
114
+ }
115
+
116
+ const TEXT_ITEMS = new Set(['userMessage', 'agentMessage', 'reasoning']);
117
+ const PRIMER_SUFFIX =
118
+ '\n\nThe above is standing context for the decisions that follow. Reply with OK only.';
119
+ const DEFAULT_BASE =
120
+ 'You are a text-only decision function. Use only the supplied instructions and context. ' +
121
+ 'You have no tools. Return exactly the requested answer, with no preamble.';
122
+ const VALID_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
123
+
124
+ export interface CodexPrimedConfig {
125
+ /** Path to the codex binary. Default "codex". */
126
+ bin?: string;
127
+ model: string;
128
+ effort?: string;
129
+ /** Private, otherwise empty working directory. It also identifies this host's threads for orphan cleanup. */
130
+ cwd: string;
131
+ /** Replaces Codex's own base instructions for every session. */
132
+ baseInstructions?: string;
133
+ /** Merged over process.env. OPENAI_API_KEY / CODEX_API_KEY are always removed. */
134
+ env?: Record<string, string | undefined>;
135
+ /**
136
+ * Process launcher (tests, containers, credential-lock wrappers). May be async;
137
+ * a rejection fails the decision before dispatch.
138
+ */
139
+ spawn?: (
140
+ cmd: string[],
141
+ opts: { cwd: string; env: Record<string, string | undefined> },
142
+ ) => PipedSubprocess | Promise<PipedSubprocess>;
143
+ /** Concurrent decision turns across all keys. Default 8. */
144
+ maxConcurrent?: number;
145
+ /** Primed sessions kept live; the least recently used idle key is evicted beyond this. Default 256. */
146
+ maxSessions?: number;
147
+ /** Evict keys idle longer than this. Default 30 min. */
148
+ idleMs?: number;
149
+ /** Default decision deadline including wait and priming. Default 30 s. */
150
+ timeoutMs?: number;
151
+ /** Require a ChatGPT subscription login (account/read). Default true. */
152
+ requireSubscription?: boolean;
153
+ /** Delete primed threads left in `cwd` by a crashed host at process start. Default true. */
154
+ sweepOrphans?: boolean;
155
+ /** Background primer turns at once, outside the decision slots. Default 2. */
156
+ maxBackgroundPrimes?: number;
157
+ /**
158
+ * Hedge a slow decision: when it has not finished after this many ms, the same
159
+ * input starts on a second branch, the first to finish wins and the other is
160
+ * interrupted. Cuts backend tail latency at the cost of a duplicate request for
161
+ * the slow fraction. Off when omitted.
162
+ */
163
+ hedgeAfterMs?: number;
164
+ /**
165
+ * Codex service tier for decision threads (e.g. "fast"). Measured on gpt-6-luna: p50 2.23 s vs 2.52 s
166
+ * with no tier, and a tighter tail. May consume subscription usage faster; off when omitted.
167
+ */
168
+ serviceTier?: string;
169
+ /** Extra per-thread configuration merged over CODEX_DECISION_THREAD_CONFIG (e.g. provider transport settings). */
170
+ threadConfig?: Readonly<Record<string, unknown>>;
171
+ clientName?: string;
172
+ onEvent?: (event: PrimedEvent) => void;
173
+ }
174
+
175
+ interface Primed {
176
+ readonly key: string;
177
+ readonly hash: string;
178
+ readonly generation: number;
179
+ readonly baseThreadId: string;
180
+ readonly primedTurnId?: string;
181
+ model?: string;
182
+ lastUsed: number;
183
+ /** Decisions currently forked from this session. */
184
+ inUse: number;
185
+ servedWarm: boolean;
186
+ /** Replaced or evicted while in use: deleted when the last decision finishes. */
187
+ stale?: 'idle' | 'capacity' | 'reprime' | 'requested' | 'close';
188
+ }
189
+
190
+ interface KeyState {
191
+ lastHash?: string;
192
+ touched: number;
193
+ volatile: boolean;
194
+ priming?: { hash: string; done: Promise<void> };
195
+ }
196
+
197
+ interface Branch {
198
+ readonly key: string;
199
+ /** The process this branch's thread lives on; only its notifications and loss apply. */
200
+ readonly conn: JsonRpcConnection;
201
+ turnId?: string;
202
+ text?: string;
203
+ finalText?: string;
204
+ partial: string;
205
+ usage?: Record<string, unknown>;
206
+ violation?: string;
207
+ error?: string;
208
+ /** The runtime fell back from its websocket transport to HTTPS during this turn. */
209
+ transportFallback?: boolean;
210
+ terminal?: { status: string; error?: Record<string, unknown> };
211
+ lost?: boolean;
212
+ /** Resolves on the native terminal. */
213
+ done: Promise<void>;
214
+ finish(): void;
215
+ /** Resolves on the terminal, a violation or process loss. */
216
+ attention: Promise<void>;
217
+ wake(): void;
218
+ }
219
+
220
+ /** Instruction files Codex loaded into a thread (the user's AGENTS.md, project docs); a decision allows none. */
221
+ const instructionSources = (response: Record<string, unknown> | undefined): string | undefined => {
222
+ const sources = response?.instructionSources;
223
+ return Array.isArray(sources) && sources.length ? sources.map(String).join(', ') : undefined;
224
+ };
225
+ const instructionLeak = (sources: string) =>
226
+ new DecisionError(
227
+ `Codex loaded instructions into a decision thread (${sources}); run decisions on a private CODEX_HOME`,
228
+ 'violation',
229
+ 'not-dispatched',
230
+ true,
231
+ );
232
+
233
+ const object = (value: unknown): Record<string, unknown> | undefined =>
234
+ value !== null && typeof value === 'object' && !Array.isArray(value)
235
+ ? (value as Record<string, unknown>)
236
+ : undefined;
237
+ const count = (value: unknown) =>
238
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
239
+
240
+ /** Codex `tokenUsage.last` → SessionTokens. Codex input includes cached input; ours excludes it. */
241
+ export function codexTokens(last: Record<string, unknown> | undefined): SessionTokens | undefined {
242
+ const input = count(last?.inputTokens),
243
+ output = count(last?.outputTokens);
244
+ if (input === undefined || output === undefined) return undefined;
245
+ const cached = count(last?.cachedInputTokens),
246
+ reasoning = count(last?.reasoningOutputTokens);
247
+ return {
248
+ input: input - Math.min(cached ?? 0, input),
249
+ output,
250
+ ...(cached !== undefined ? { cacheRead: cached } : {}),
251
+ ...(reasoning !== undefined ? { thinking: reasoning } : {}),
252
+ providerUsage: { ...last },
253
+ };
254
+ }
255
+
256
+ function failureReason(
257
+ error: Record<string, unknown> | undefined,
258
+ ): 'rate-limited' | 'auth' | 'native-failed' {
259
+ const info = error?.codexErrorInfo;
260
+ if (info === 'usageLimitExceeded' || info === 'rateLimitExceeded') return 'rate-limited';
261
+ if (info === 'unauthorized') return 'auth';
262
+ return /rate.?limit|usage.?limit|too many requests|\b429\b/i.test(String(error?.message ?? ''))
263
+ ? 'rate-limited'
264
+ : 'native-failed';
265
+ }
266
+
267
+ export class CodexPrimedSessions implements PrimedSessions {
268
+ readonly runtime = 'codex' as const;
269
+ private readonly _config: Required<
270
+ Pick<
271
+ CodexPrimedConfig,
272
+ | 'bin'
273
+ | 'maxConcurrent'
274
+ | 'maxSessions'
275
+ | 'idleMs'
276
+ | 'timeoutMs'
277
+ | 'requireSubscription'
278
+ | 'sweepOrphans'
279
+ | 'clientName'
280
+ | 'baseInstructions'
281
+ >
282
+ > &
283
+ CodexPrimedConfig;
284
+ private _conn?: JsonRpcConnection;
285
+ private _starting?: Promise<JsonRpcConnection>;
286
+ private _generation = 0;
287
+ private _sessions = new Map<string, Primed>();
288
+ private _keys = new Map<string, KeyState>();
289
+ private _branches = new Map<string, Branch>();
290
+ private _slots: Slots;
291
+ private _primeSlots: Slots;
292
+ private _limits?: LimitSnapshot;
293
+ private _lastPoll = 0;
294
+ private _closed = false;
295
+ private _failures: number[] = [];
296
+ private _idleTimer?: ReturnType<typeof setInterval>;
297
+
298
+ constructor(config: CodexPrimedConfig) {
299
+ const bin = config.bin ?? 'codex';
300
+ if (!/^[a-zA-Z0-9_./\\-]+$/.test(bin)) throw Error(`Invalid codex CLI binary path: "${bin}"`);
301
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,199}$/.test(config.model))
302
+ throw Error('Invalid decision model');
303
+ if (config.effort !== undefined && !VALID_EFFORTS.includes(config.effort))
304
+ throw Error(`Invalid codex effort "${config.effort}"`);
305
+ if (!config.cwd?.startsWith('/'))
306
+ throw Error('Primed Codex sessions require an absolute private working directory');
307
+ this._config = {
308
+ ...config,
309
+ bin,
310
+ maxConcurrent: config.maxConcurrent ?? 8,
311
+ maxSessions: config.maxSessions ?? 256,
312
+ idleMs: config.idleMs ?? 30 * 60_000,
313
+ timeoutMs: config.timeoutMs ?? 30_000,
314
+ requireSubscription: config.requireSubscription ?? true,
315
+ sweepOrphans: config.sweepOrphans ?? true,
316
+ clientName: config.clientName ?? 'agent-session-primed',
317
+ baseInstructions: config.baseInstructions ?? DEFAULT_BASE,
318
+ };
319
+ for (const [name, value] of [
320
+ ['maxSessions', this._config.maxSessions],
321
+ ['idleMs', this._config.idleMs],
322
+ ['timeoutMs', this._config.timeoutMs],
323
+ ] as const)
324
+ if (!Number.isSafeInteger(value) || value < 1)
325
+ throw Error(`${name} must be a positive integer`);
326
+ this._slots = new Slots(this._config.maxConcurrent);
327
+ this._primeSlots = new Slots(config.maxBackgroundPrimes ?? 2);
328
+ }
329
+
330
+ /** Start the process ahead of the first decision (optional). */
331
+ async start(): Promise<void> {
332
+ await this._process();
333
+ }
334
+
335
+ limits(): LimitSnapshot | undefined {
336
+ return this._limits;
337
+ }
338
+
339
+ snapshot(): PrimedSnapshot {
340
+ return {
341
+ runtime: 'codex',
342
+ closed: this._closed,
343
+ generation: this._generation,
344
+ processAlive: !!this._conn && !this._conn.closed,
345
+ sessions: this._sessions.size,
346
+ active: this._slots.active,
347
+ waiting: this._slots.waiting,
348
+ ...(this._limits ? { limits: this._limits } : {}),
349
+ };
350
+ }
351
+
352
+ /** Poll account limits without a model turn. */
353
+ async readLimits(opts?: { timeoutMs?: number }): Promise<LimitSnapshot | undefined> {
354
+ const conn = await this._process();
355
+ const snapshot = codexLimitSnapshot(
356
+ await conn.request(
357
+ 'account/rateLimits/read',
358
+ { excludeResetCreditDetails: true },
359
+ opts?.timeoutMs ?? 15_000,
360
+ ),
361
+ 'poll',
362
+ );
363
+ this._lastPoll = Date.now();
364
+ if (snapshot) this._observeLimits(snapshot);
365
+ return this._limits;
366
+ }
367
+
368
+ decide(spec: PrimeSpec, request: DecisionRequest): Promise<DecisionResult> {
369
+ if (this._closed)
370
+ return Promise.reject(
371
+ new DecisionError('Primed sessions closed', 'closed', 'not-dispatched', true),
372
+ );
373
+ if (!spec.key || typeof spec.instructions !== 'string' || typeof request.input !== 'string')
374
+ return Promise.reject(
375
+ new DecisionError('Invalid decision request', 'admission', 'not-dispatched', true),
376
+ );
377
+ const started = Date.now(),
378
+ deadline = started + (request.timeoutMs ?? this._config.timeoutMs);
379
+ return (async () => {
380
+ const release = await this._slots.acquire(deadline, request.signal);
381
+ try {
382
+ return await this._decide(spec, request, started, deadline);
383
+ } finally {
384
+ release();
385
+ }
386
+ })();
387
+ }
388
+
389
+ /**
390
+ * Prime a key ahead of its first decision (e.g. when a thread starts). Resolves
391
+ * when the primed session is installed; a failure leaves the key unprimed.
392
+ */
393
+ async prime(spec: PrimeSpec): Promise<void> {
394
+ if (this._closed || !spec.context) return;
395
+ const conn = await this._process();
396
+ const hash = primeHash(spec),
397
+ state = this._keyState(spec.key);
398
+ state.lastHash = hash;
399
+ const current = this._sessions.get(spec.key);
400
+ if (current && current.hash === hash && current.generation === this._generation) return;
401
+ await this._primeInBackground(conn, spec, hash, state, true);
402
+ }
403
+
404
+ async evict(key: string): Promise<void> {
405
+ this._keys.delete(key);
406
+ const s = this._sessions.get(key);
407
+ if (s) await this._drop(s, 'requested');
408
+ }
409
+
410
+ async close(): Promise<void> {
411
+ if (this._closed) return;
412
+ this._closed = true;
413
+ clearInterval(this._idleTimer);
414
+ const closed = new DecisionError('Primed sessions closed', 'closed', 'not-dispatched', true);
415
+ this._slots.drain(closed);
416
+ this._primeSlots.drain(closed);
417
+ const conn = this._conn;
418
+ await Promise.race([
419
+ Promise.all([...this._sessions.values()].map((s) => this._drop(s, 'close'))),
420
+ Bun.sleep(2_000),
421
+ ]);
422
+ this._conn = undefined;
423
+ if (conn) {
424
+ conn.kill();
425
+ await Promise.race([conn.exited.catch(() => 0), Bun.sleep(2_000)]);
426
+ }
427
+ }
428
+
429
+ // -------------------------------------------------------------------------
430
+
431
+ private _emit(event: PrimedEvent): void {
432
+ try {
433
+ this._config.onEvent?.(event);
434
+ } catch {
435
+ /* observers cannot change decisions */
436
+ }
437
+ }
438
+
439
+ private _observeLimits(snapshot: LimitSnapshot): void {
440
+ this._limits = mergeLimits(this._limits, snapshot);
441
+ this._emit({ type: 'limits', limits: this._limits });
442
+ }
443
+
444
+ /** Refuse before dispatch while the account reports ordinary usage blocked. */
445
+ private async _checkLimits(): Promise<void> {
446
+ if (!this._limits?.blocked) return;
447
+ const now = Date.now();
448
+ const resets = this._limits.windows
449
+ .filter((w) => w.usedPercent >= 100 && w.resetsAt)
450
+ .map((w) => w.resetsAt!);
451
+ if (resets.length && now >= Math.max(...resets)) {
452
+ this._limits = { ...this._limits, blocked: false };
453
+ return;
454
+ }
455
+ if (now - this._lastPoll >= 60_000) {
456
+ try {
457
+ await this.readLimits({ timeoutMs: 5_000 });
458
+ } catch {
459
+ /* keep the blocked view */
460
+ }
461
+ }
462
+ if (this._limits?.blocked)
463
+ throw new DecisionError(
464
+ 'Codex subscription usage limit reached; no fallback',
465
+ 'rate-limited',
466
+ 'not-dispatched',
467
+ true,
468
+ );
469
+ }
470
+
471
+ private _env(): Record<string, string | undefined> {
472
+ const env: Record<string, string | undefined> = {
473
+ ...process.env,
474
+ ...this._config.env,
475
+ DISABLE_AUTOUPDATER: '1',
476
+ };
477
+ delete env.OPENAI_API_KEY;
478
+ delete env.CODEX_API_KEY;
479
+ for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key];
480
+ return env;
481
+ }
482
+
483
+ private _process(): Promise<JsonRpcConnection> {
484
+ if (this._closed)
485
+ return Promise.reject(
486
+ new DecisionError('Primed sessions closed', 'closed', 'not-dispatched', true),
487
+ );
488
+ if (this._conn && !this._conn.closed) return Promise.resolve(this._conn);
489
+ if (this._starting) return this._starting;
490
+ const now = Date.now();
491
+ this._failures = this._failures.filter((t) => now - t < 60_000);
492
+ if (this._failures.length >= 3)
493
+ return Promise.reject(
494
+ new DecisionError(
495
+ 'Codex app-server failed repeatedly; paused for up to 60 s',
496
+ 'transport',
497
+ 'not-dispatched',
498
+ true,
499
+ ),
500
+ );
501
+ const generation = ++this._generation,
502
+ t0 = performance.now();
503
+ this._starting = (async () => {
504
+ await null; // settle only after `_starting` is assigned, so failures never stay cached
505
+ const argv = [this._config.bin, ...codexDecisionLaunchArgs()];
506
+ const options = { cwd: this._config.cwd, env: this._env() };
507
+ let proc: PipedSubprocess;
508
+ try {
509
+ proc = this._config.spawn
510
+ ? await this._config.spawn(argv, options)
511
+ : (Bun.spawn(argv, {
512
+ ...options,
513
+ stdin: 'pipe',
514
+ stdout: 'pipe',
515
+ stderr: 'pipe',
516
+ }) as unknown as PipedSubprocess);
517
+ } catch (error) {
518
+ this._failures.push(Date.now());
519
+ this._starting = undefined;
520
+ throw new DecisionError(
521
+ `Codex app-server launch refused: ${(error as Error).message}`,
522
+ 'transport',
523
+ 'not-dispatched',
524
+ true,
525
+ undefined,
526
+ { cause: error },
527
+ );
528
+ }
529
+ const conn = new JsonRpcConnection(proc, {
530
+ onNotification: (method, params) => this._notification(conn, method, params),
531
+ onRequest: (_id, _method, params) => {
532
+ this._serverRequest(conn, params);
533
+ return false;
534
+ },
535
+ onClose: () => this._lost(conn, generation),
536
+ });
537
+ try {
538
+ await conn.request(
539
+ 'initialize',
540
+ { clientInfo: { name: this._config.clientName, version: '0.2.0' }, capabilities: {} },
541
+ 15_000,
542
+ );
543
+ conn.notify('initialized');
544
+ if (this._config.requireSubscription) {
545
+ const account = object(object(await conn.request('account/read', {}, 15_000))?.account);
546
+ if (account?.type !== 'chatgpt')
547
+ throw new DecisionError(
548
+ `Codex decisions require a ChatGPT subscription login (found ${typeof account?.type === 'string' ? account.type : 'no login'}); no fallback`,
549
+ 'auth',
550
+ 'not-dispatched',
551
+ true,
552
+ );
553
+ }
554
+ try {
555
+ const limits = codexLimitSnapshot(
556
+ await conn.request(
557
+ 'account/rateLimits/read',
558
+ { excludeResetCreditDetails: true },
559
+ 15_000,
560
+ ),
561
+ 'poll',
562
+ );
563
+ this._lastPoll = Date.now();
564
+ if (limits) this._observeLimits(limits);
565
+ } catch {
566
+ /* limits are advisory at start */
567
+ }
568
+ if (this._config.sweepOrphans) await this._sweep(conn);
569
+ if (this._closed)
570
+ throw new DecisionError('Primed sessions closed', 'closed', 'not-dispatched', true);
571
+ this._conn = conn;
572
+ this._idleTimer ??= setInterval(
573
+ () => void this._evictIdle(),
574
+ Math.min(this._config.idleMs, 60_000),
575
+ );
576
+ (this._idleTimer as { unref?: () => void }).unref?.();
577
+ this._emit({ type: 'process-started', generation, ms: Math.round(performance.now() - t0) });
578
+ return conn;
579
+ } catch (error) {
580
+ conn.kill();
581
+ this._failures.push(Date.now());
582
+ if (error instanceof DecisionError) throw error;
583
+ throw new DecisionError(
584
+ `Codex app-server start failed: ${(error as Error).message}`,
585
+ 'transport',
586
+ 'not-dispatched',
587
+ true,
588
+ undefined,
589
+ { cause: error },
590
+ );
591
+ } finally {
592
+ this._starting = undefined;
593
+ }
594
+ })();
595
+ return this._starting;
596
+ }
597
+
598
+ /** Delete persisted primed threads a crashed host left in this private directory. */
599
+ private async _sweep(conn: JsonRpcConnection): Promise<void> {
600
+ let cursor: string | undefined;
601
+ for (let page = 0; page < 20; page++) {
602
+ // The private cwd identifies this host's threads (the local app-server refuses originator filtering).
603
+ const listed = object(
604
+ await conn
605
+ .request(
606
+ 'thread/list',
607
+ {
608
+ cwd: this._config.cwd,
609
+ sourceKinds: ['appServer'],
610
+ limit: 100,
611
+ ...(cursor ? { cursor } : {}),
612
+ },
613
+ 15_000,
614
+ )
615
+ .catch(() => undefined),
616
+ );
617
+ const data = Array.isArray(listed?.data) ? listed.data : [];
618
+ for (const entry of data) {
619
+ const id = object(entry)?.id;
620
+ if (typeof id === 'string' && id && object(entry)?.cwd === this._config.cwd)
621
+ await conn.request('thread/delete', { threadId: id }, 10_000).catch(() => undefined);
622
+ }
623
+ const next = listed?.nextCursor;
624
+ if (typeof next !== 'string' || !next || next === cursor) return;
625
+ cursor = next;
626
+ }
627
+ }
628
+
629
+ /** A process ended (or its stdout closed): its threads and primed sessions are gone, whatever generation is current. */
630
+ private _lost(conn: JsonRpcConnection, generation: number): void {
631
+ if (this._conn === conn) this._conn = undefined;
632
+ for (const session of [...this._sessions.values()]) {
633
+ if (session.generation !== generation) continue;
634
+ this._sessions.delete(session.key);
635
+ this._emit({ type: 'evicted', key: session.key, reason: 'process-lost' });
636
+ }
637
+ this._wakeBranches(conn, 'Native process lost');
638
+ }
639
+
640
+ private _wakeBranches(conn: JsonRpcConnection, error: string): void {
641
+ for (const branch of this._branches.values()) {
642
+ if (branch.conn !== conn || branch.terminal) continue;
643
+ branch.error ??= error;
644
+ branch.lost = true;
645
+ branch.wake();
646
+ }
647
+ }
648
+
649
+ /** Kill the process; true once its exit is observed (bounded wait). Every branch on it fails now, not at its deadline. */
650
+ private _recycle(conn: JsonRpcConnection, reason: string): Promise<boolean> {
651
+ if (this._conn === conn) {
652
+ this._conn = undefined;
653
+ this._emit({ type: 'process-recycled', generation: this._generation, reason });
654
+ }
655
+ this._wakeBranches(conn, `Native process recycled (${reason})`);
656
+ conn.kill();
657
+ return Promise.race([
658
+ conn.exited.then(
659
+ () => true,
660
+ () => false,
661
+ ),
662
+ Bun.sleep(2_000).then(() => false),
663
+ ]);
664
+ }
665
+
666
+ private _violation(
667
+ conn: JsonRpcConnection,
668
+ threadId: string,
669
+ branch: Branch,
670
+ detail: string,
671
+ ): void {
672
+ if (branch.violation) return;
673
+ branch.violation = detail;
674
+ this._emit({ type: 'violation', key: branch.key, detail });
675
+ if (branch.turnId)
676
+ void conn
677
+ .request('turn/interrupt', { threadId, turnId: branch.turnId }, 2_000)
678
+ .catch(() => undefined);
679
+ branch.wake();
680
+ }
681
+
682
+ private _serverRequest(
683
+ conn: JsonRpcConnection,
684
+ params: Record<string, unknown> | undefined,
685
+ ): void {
686
+ const threadId = typeof params?.threadId === 'string' ? params.threadId : undefined;
687
+ const branch = threadId ? this._branches.get(threadId) : undefined;
688
+ if (branch && threadId && branch.conn === conn)
689
+ this._violation(conn, threadId, branch, 'server request');
690
+ }
691
+
692
+ private _notification(
693
+ conn: JsonRpcConnection,
694
+ method: string,
695
+ params: Record<string, unknown> | undefined,
696
+ ): void {
697
+ if (method === 'account/rateLimits/updated') {
698
+ const limits = codexLimitSnapshot(params, 'stream');
699
+ if (limits) this._observeLimits(limits);
700
+ return;
701
+ }
702
+ const threadId = typeof params?.threadId === 'string' ? params.threadId : undefined;
703
+ const branch = threadId ? this._branches.get(threadId) : undefined;
704
+ if (!branch || !threadId || branch.conn !== conn) return;
705
+ const turn = object(params?.turn),
706
+ item = object(params?.item);
707
+ switch (method) {
708
+ case 'turn/started':
709
+ if (typeof turn?.id === 'string') branch.turnId ??= turn.id;
710
+ break;
711
+ case 'item/started':
712
+ case 'item/completed': {
713
+ const type = typeof item?.type === 'string' ? item.type : 'unknown';
714
+ if (!TEXT_ITEMS.has(type)) {
715
+ this._violation(conn, threadId, branch, `item ${type}`);
716
+ break;
717
+ }
718
+ if (
719
+ method === 'item/completed' &&
720
+ type === 'agentMessage' &&
721
+ typeof item?.text === 'string'
722
+ ) {
723
+ branch.text = item.text;
724
+ if (item.phase === 'final_answer') branch.finalText = item.text;
725
+ }
726
+ break;
727
+ }
728
+ case 'item/agentMessage/delta':
729
+ if (typeof params?.delta === 'string') branch.partial += params.delta;
730
+ break;
731
+ case 'thread/tokenUsage/updated':
732
+ branch.usage = object(object(params?.tokenUsage)?.last);
733
+ break;
734
+ case 'mcpServer/startupStatus/updated':
735
+ this._violation(conn, threadId, branch, 'mcp server started');
736
+ break;
737
+ case 'error':
738
+ case 'warning': {
739
+ const message = String(object(params?.error)?.message ?? params?.message ?? '');
740
+ // Transport notices are not failures: the runtime retries and falls back on its own.
741
+ if (/falling back from websockets/i.test(message)) {
742
+ if (!branch.transportFallback) {
743
+ branch.transportFallback = true;
744
+ this._emit({ type: 'transport-fallback', key: branch.key });
745
+ }
746
+ } else if (method === 'error') branch.error ??= message || 'native error';
747
+ break;
748
+ }
749
+ case 'turn/completed':
750
+ if (turn && (!branch.turnId || turn.id === branch.turnId)) {
751
+ branch.turnId ??= typeof turn.id === 'string' ? turn.id : undefined;
752
+ branch.terminal = {
753
+ status: String(turn.status),
754
+ ...(object(turn.error) ? { error: object(turn.error) } : {}),
755
+ };
756
+ branch.finish();
757
+ branch.wake();
758
+ }
759
+ break;
760
+ }
761
+ }
762
+
763
+ private _branch(conn: JsonRpcConnection, threadId: string, key: string): Branch {
764
+ let finish!: () => void, wake!: () => void;
765
+ const done = new Promise<void>((resolve) => {
766
+ finish = resolve;
767
+ });
768
+ const attention = new Promise<void>((resolve) => {
769
+ wake = resolve;
770
+ });
771
+ const branch: Branch = { key, conn, partial: '', done, finish, attention, wake };
772
+ this._branches.set(threadId, branch);
773
+ return branch;
774
+ }
775
+
776
+ private async _startTurn(
777
+ conn: JsonRpcConnection,
778
+ threadId: string,
779
+ branch: Branch,
780
+ input: string,
781
+ deadline: number,
782
+ extra: Record<string, unknown>,
783
+ ): Promise<void> {
784
+ const response = object(
785
+ await conn.request(
786
+ 'turn/start',
787
+ {
788
+ threadId,
789
+ input: [{ type: 'text', text: input }],
790
+ ...(this._config.effort ? { effort: this._config.effort } : {}),
791
+ ...extra,
792
+ },
793
+ Math.max(1, deadline - Date.now()),
794
+ ),
795
+ );
796
+ const turnId = object(response?.turn)?.id;
797
+ if (typeof turnId === 'string') branch.turnId ??= turnId;
798
+ }
799
+
800
+ /**
801
+ * Stop a branch this decision no longer needs and wait (bounded) for its terminal.
802
+ * True once its turn is settled; a violation or an unacknowledged interrupt recycles
803
+ * the process, and then settlement is its observed exit.
804
+ */
805
+ private async _stopBranch(
806
+ conn: JsonRpcConnection,
807
+ leg: { threadId: string; branch: Branch },
808
+ ): Promise<boolean> {
809
+ const { threadId, branch } = leg;
810
+ if (!branch.terminal && !branch.lost && branch.turnId) {
811
+ await conn
812
+ .request('turn/interrupt', { threadId, turnId: branch.turnId }, 2_000)
813
+ .catch(() => undefined);
814
+ await Promise.race([branch.done, Bun.sleep(2_000)]);
815
+ }
816
+ let settled = !!branch.terminal || !!branch.lost;
817
+ if (branch.violation || !settled)
818
+ settled =
819
+ (await this._recycle(
820
+ conn,
821
+ branch.violation ? `violation: ${branch.violation}` : 'unacknowledged interrupt',
822
+ )) || settled;
823
+ this._branches.delete(threadId);
824
+ if (this._conn === conn)
825
+ void conn.request('thread/unsubscribe', { threadId }, 10_000).catch(() => undefined);
826
+ return settled;
827
+ }
828
+
829
+ /**
830
+ * Run one turn on a thread and wait for its terminal. With a hedge, a second branch
831
+ * starts in a free slot once `hedge.afterMs` passes without an answer. Only a completed
832
+ * turn, a violation or process loss decides the race; a branch that fails natively
833
+ * leaves the other running. The decision is settled only when every branch it started
834
+ * is: the other branch is interrupted before this returns. On deadline or abort, every
835
+ * branch is interrupted; without acknowledgment the process is recycled. A violation
836
+ * always recycles: the launch policy did not hold.
837
+ */
838
+ private async _turn(
839
+ conn: JsonRpcConnection,
840
+ threadId: string,
841
+ branch: Branch,
842
+ input: string,
843
+ deadline: number,
844
+ extra: Record<string, unknown>,
845
+ signal?: AbortSignal,
846
+ hedge?: { afterMs: number; open(): Promise<{ threadId: string; branch: Branch }>; key: string },
847
+ options: { recycleUnsettled?: boolean } = {},
848
+ ): Promise<{
849
+ timedOut: boolean;
850
+ aborted: boolean;
851
+ settled: boolean;
852
+ threadId: string;
853
+ branch: Branch;
854
+ hedged: boolean;
855
+ }> {
856
+ type Leg = { threadId: string; branch: Branch };
857
+ type Signal =
858
+ | { kind: 'leg'; leg: Leg }
859
+ | { kind: 'timeout' }
860
+ | { kind: 'aborted' }
861
+ | { kind: 'hedge' }
862
+ | { kind: 'started'; leg: Leg; release: () => void }
863
+ | { kind: 'hedge-failed'; unknown: boolean; release: () => void };
864
+ const remaining = () => Math.max(1, deadline - Date.now());
865
+ await this._startTurn(conn, threadId, branch, input, deadline, extra);
866
+ const primary: Leg = { threadId, branch };
867
+ const legs: Leg[] = [primary];
868
+ let hedged = false,
869
+ hedgeRelease: (() => void) | undefined,
870
+ hedgeUnknown = false;
871
+ let timer: ReturnType<typeof setTimeout> | undefined,
872
+ hedgeTimer: ReturnType<typeof setTimeout> | undefined,
873
+ onAbort: (() => void) | undefined;
874
+ const waits = new Map<string, Promise<Signal>>();
875
+ const watch = (leg: Leg) =>
876
+ waits.set(
877
+ `leg:${leg.threadId}`,
878
+ leg.branch.attention.then(() => ({ kind: 'leg', leg }) as Signal),
879
+ );
880
+ watch(primary);
881
+ waits.set(
882
+ 'timeout',
883
+ new Promise<Signal>((resolve) => {
884
+ timer = setTimeout(() => resolve({ kind: 'timeout' }), remaining());
885
+ }),
886
+ );
887
+ waits.set(
888
+ 'aborted',
889
+ new Promise<Signal>((resolve) => {
890
+ onAbort = () => resolve({ kind: 'aborted' });
891
+ if (signal?.aborted) onAbort();
892
+ else signal?.addEventListener('abort', onAbort, { once: true });
893
+ }),
894
+ );
895
+ if (hedge && hedge.afterMs < remaining())
896
+ waits.set(
897
+ 'hedge',
898
+ new Promise<Signal>((resolve) => {
899
+ hedgeTimer = setTimeout(() => resolve({ kind: 'hedge' }), hedge.afterMs);
900
+ }),
901
+ );
902
+ let winner: Leg | undefined, stop: 'timeout' | 'aborted' | undefined;
903
+ try {
904
+ while (!winner && !stop) {
905
+ const next = await Promise.race(waits.values());
906
+ switch (next.kind) {
907
+ case 'timeout':
908
+ case 'aborted':
909
+ stop = next.kind;
910
+ break;
911
+ case 'hedge': {
912
+ waits.delete('hedge');
913
+ if (primary.branch.terminal || primary.branch.violation || primary.branch.lost) break;
914
+ // Never beyond maxConcurrent: hedge only when a slot is free, and hold it until the branch settles.
915
+ const release = this._slots.tryAcquire();
916
+ if (!release) break;
917
+ let opened: Leg | undefined;
918
+ waits.set(
919
+ 'start',
920
+ (async (): Promise<Signal> => {
921
+ try {
922
+ opened = await hedge!.open();
923
+ await this._startTurn(
924
+ conn,
925
+ opened.threadId,
926
+ opened.branch,
927
+ input,
928
+ deadline,
929
+ extra,
930
+ );
931
+ return { kind: 'started', leg: opened, release };
932
+ } catch (error) {
933
+ // A refused start wrote nothing that runs; any other failure may have started a turn we cannot see.
934
+ if (opened) this._branches.delete(opened.threadId);
935
+ return {
936
+ kind: 'hedge-failed',
937
+ unknown: !!opened && !(error instanceof JsonRpcError),
938
+ release,
939
+ };
940
+ }
941
+ })(),
942
+ );
943
+ break;
944
+ }
945
+ case 'started':
946
+ waits.delete('start');
947
+ hedgeRelease = next.release;
948
+ hedged = true;
949
+ legs.push(next.leg);
950
+ watch(next.leg);
951
+ this._emit({ type: 'hedged', key: hedge!.key, afterMs: hedge!.afterMs });
952
+ break;
953
+ case 'hedge-failed':
954
+ waits.delete('start');
955
+ next.release();
956
+ hedgeUnknown ||= next.unknown;
957
+ break;
958
+ case 'leg': {
959
+ waits.delete(`leg:${next.leg.threadId}`);
960
+ const b = next.leg.branch;
961
+ const others = legs.filter(
962
+ (l) => l !== next.leg && !l.branch.terminal && !l.branch.lost,
963
+ );
964
+ // A native failure on one branch leaves the other to answer; completion, violation or loss decides.
965
+ if (
966
+ b.terminal &&
967
+ b.terminal.status !== 'completed' &&
968
+ !b.violation &&
969
+ !b.lost &&
970
+ others.length
971
+ )
972
+ break;
973
+ winner = next.leg;
974
+ break;
975
+ }
976
+ }
977
+ }
978
+ } finally {
979
+ clearTimeout(timer);
980
+ clearTimeout(hedgeTimer);
981
+ if (onAbort) signal?.removeEventListener('abort', onAbort);
982
+ }
983
+ const timedOut = stop === 'timeout',
984
+ aborted = stop === 'aborted';
985
+ const chosen = winner ?? primary;
986
+ // Every other branch this decision started is stopped before the decision reports.
987
+ let othersSettled = true;
988
+ for (const leg of legs)
989
+ if (leg !== chosen) othersSettled = (await this._stopBranch(conn, leg)) && othersSettled;
990
+ const loserViolation = legs.find((l) => l !== chosen && l.branch.violation)?.branch.violation;
991
+ if (loserViolation) chosen.branch.violation ??= loserViolation;
992
+ hedgeRelease?.();
993
+ let settled = !!chosen.branch.terminal;
994
+ if (!chosen.branch.terminal && !chosen.branch.lost && chosen.branch.turnId) {
995
+ await conn
996
+ .request(
997
+ 'turn/interrupt',
998
+ { threadId: chosen.threadId, turnId: chosen.branch.turnId },
999
+ 2_000,
1000
+ )
1001
+ .catch(() => undefined);
1002
+ await Promise.race([chosen.branch.done, Bun.sleep(2_000)]);
1003
+ settled = !!chosen.branch.terminal;
1004
+ }
1005
+ if (
1006
+ chosen.branch.violation ||
1007
+ hedgeUnknown ||
1008
+ (!settled && options.recycleUnsettled !== false)
1009
+ ) {
1010
+ const reason = chosen.branch.violation
1011
+ ? `violation: ${chosen.branch.violation}`
1012
+ : hedgeUnknown
1013
+ ? 'hedge turn/start unsettled'
1014
+ : 'unacknowledged interrupt';
1015
+ settled = (await this._recycle(conn, reason)) || settled;
1016
+ }
1017
+ return {
1018
+ timedOut,
1019
+ aborted,
1020
+ settled: settled && othersSettled,
1021
+ threadId: chosen.threadId,
1022
+ branch: chosen.branch,
1023
+ hedged,
1024
+ };
1025
+ }
1026
+
1027
+ private _threadParams(instructions: string): Record<string, unknown> {
1028
+ return {
1029
+ model: this._config.model,
1030
+ cwd: this._config.cwd,
1031
+ approvalPolicy: 'never',
1032
+ sandbox: 'read-only',
1033
+ baseInstructions: this._config.baseInstructions,
1034
+ developerInstructions: instructions,
1035
+ config: { ...CODEX_DECISION_THREAD_CONFIG, ...this._config.threadConfig },
1036
+ ...(this._config.serviceTier ? { serviceTier: this._config.serviceTier } : {}),
1037
+ };
1038
+ }
1039
+
1040
+ /** Run the primer on a new persisted thread. The caller decides whether to install it. */
1041
+ private async _prime(
1042
+ conn: JsonRpcConnection,
1043
+ spec: PrimeSpec,
1044
+ hash: string,
1045
+ deadline: number,
1046
+ signal?: AbortSignal,
1047
+ ): Promise<Primed> {
1048
+ const generation = this._generation,
1049
+ t0 = performance.now();
1050
+ const started = object(
1051
+ await conn.request(
1052
+ 'thread/start',
1053
+ { ...this._threadParams(spec.instructions), ephemeral: false },
1054
+ Math.max(1, deadline - Date.now()),
1055
+ ),
1056
+ );
1057
+ const baseId = object(started?.thread)?.id;
1058
+ if (typeof baseId !== 'string' || !baseId)
1059
+ throw new DecisionError(
1060
+ 'Codex thread/start returned no thread',
1061
+ 'prime-failed',
1062
+ 'not-dispatched',
1063
+ true,
1064
+ );
1065
+ const leaked = instructionSources(started);
1066
+ if (leaked) {
1067
+ void conn.request('thread/delete', { threadId: baseId }, 10_000).catch(() => undefined);
1068
+ throw instructionLeak(leaked);
1069
+ }
1070
+ const branch = this._branch(conn, baseId, spec.key);
1071
+ let ok = false;
1072
+ try {
1073
+ let outcome: { timedOut: boolean; aborted: boolean; settled: boolean };
1074
+ // A primer is background work: an unacknowledged interrupt must not recycle the process every decision shares.
1075
+ try {
1076
+ outcome = await this._turn(
1077
+ conn,
1078
+ baseId,
1079
+ branch,
1080
+ `${spec.context}${PRIMER_SUFFIX}`,
1081
+ deadline,
1082
+ {},
1083
+ signal,
1084
+ undefined,
1085
+ { recycleUnsettled: false },
1086
+ );
1087
+ } catch (error) {
1088
+ // A primer turn/start without the runtime's own answer may still run: recycle before reporting.
1089
+ const settled =
1090
+ error instanceof JsonRpcError ||
1091
+ (await this._recycle(conn, 'primer turn/start unsettled'));
1092
+ throw new DecisionError(
1093
+ `Priming ${spec.key} failed: ${(error as Error).message}`,
1094
+ 'prime-failed',
1095
+ 'not-dispatched',
1096
+ settled,
1097
+ undefined,
1098
+ { cause: error },
1099
+ );
1100
+ }
1101
+ ok =
1102
+ !outcome.timedOut &&
1103
+ !outcome.aborted &&
1104
+ !branch.violation &&
1105
+ branch.terminal?.status === 'completed' &&
1106
+ !!branch.turnId;
1107
+ if (!ok) {
1108
+ const reason = branch.violation
1109
+ ? 'violation'
1110
+ : outcome.aborted
1111
+ ? 'aborted'
1112
+ : outcome.timedOut
1113
+ ? 'timeout'
1114
+ : branch.terminal?.status === 'failed'
1115
+ ? failureReason(branch.terminal.error)
1116
+ : branch.lost
1117
+ ? 'transport'
1118
+ : 'prime-failed';
1119
+ throw new DecisionError(
1120
+ `Priming ${spec.key} failed (${branch.violation ?? branch.terminal?.status ?? branch.error ?? 'no terminal'})`,
1121
+ reason === 'native-failed' ? 'prime-failed' : reason,
1122
+ 'not-dispatched',
1123
+ outcome.settled,
1124
+ );
1125
+ }
1126
+ } finally {
1127
+ this._branches.delete(baseId);
1128
+ if (!ok)
1129
+ void conn.request('thread/delete', { threadId: baseId }, 10_000).catch(() => undefined);
1130
+ }
1131
+ const primed: Primed = {
1132
+ key: spec.key,
1133
+ hash,
1134
+ generation,
1135
+ baseThreadId: baseId,
1136
+ primedTurnId: branch.turnId,
1137
+ model: typeof started?.model === 'string' ? started.model : undefined,
1138
+ lastUsed: Date.now(),
1139
+ inUse: 0,
1140
+ servedWarm: false,
1141
+ };
1142
+ this._emit({ type: 'primed', key: spec.key, hash, ms: Math.round(performance.now() - t0) });
1143
+ return primed;
1144
+ }
1145
+
1146
+ private _keyState(key: string): KeyState {
1147
+ let state = this._keys.get(key);
1148
+ if (!state) {
1149
+ state = { volatile: false, touched: Date.now() };
1150
+ this._keys.set(key, state);
1151
+ }
1152
+ state.touched = Date.now();
1153
+ return state;
1154
+ }
1155
+
1156
+ /**
1157
+ * Prime `hash` for a key off the decision path and install it if it is still the
1158
+ * key's current context. A volatile key primes only when its context repeats.
1159
+ */
1160
+ private _primeInBackground(
1161
+ conn: JsonRpcConnection,
1162
+ spec: PrimeSpec,
1163
+ hash: string,
1164
+ state: KeyState,
1165
+ force = false,
1166
+ ): Promise<void> {
1167
+ if (state.priming?.hash === hash) return state.priming.done;
1168
+ if (!force && state.volatile && state.lastHash !== hash) return Promise.resolve();
1169
+ let done!: Promise<void>;
1170
+ done = (async () => {
1171
+ await null; // `done` is assigned before this body runs
1172
+ let release: (() => void) | undefined;
1173
+ try {
1174
+ release = await this._primeSlots.acquire(Date.now() + this._config.timeoutMs);
1175
+ if (this._closed || this._conn !== conn || state.lastHash !== hash) return;
1176
+ await this._makeRoom(spec.key);
1177
+ const primed = await this._prime(conn, spec, hash, Date.now() + this._config.timeoutMs);
1178
+ const current = this._sessions.get(spec.key);
1179
+ // Stale by the time it is ready: the key was evicted, a newer context arrived, the process changed,
1180
+ // or the same context is already primed.
1181
+ if (
1182
+ this._closed ||
1183
+ this._keys.get(spec.key) !== state ||
1184
+ state.lastHash !== hash ||
1185
+ primed.generation !== this._generation ||
1186
+ (current && current.hash === hash && current.generation === this._generation)
1187
+ ) {
1188
+ void conn
1189
+ .request('thread/delete', { threadId: primed.baseThreadId }, 10_000)
1190
+ .catch(() => undefined);
1191
+ return;
1192
+ }
1193
+ if (current) this._retire(current, 'reprime', state);
1194
+ this._sessions.set(spec.key, primed);
1195
+ } catch {
1196
+ /* priming is best-effort: decisions keep running inline */
1197
+ } finally {
1198
+ release?.();
1199
+ if (state.priming?.done === done) state.priming = undefined;
1200
+ }
1201
+ })();
1202
+ state.priming = { hash, done };
1203
+ return done;
1204
+ }
1205
+
1206
+ /** Remove a session from service; delete it now, or when its last decision finishes. */
1207
+ private _retire(session: Primed, reason: NonNullable<Primed['stale']>, state?: KeyState): void {
1208
+ if (this._sessions.get(session.key) === session) this._sessions.delete(session.key);
1209
+ // Replaced before it ever served a decision: this key's context changes faster than priming pays off.
1210
+ if (reason === 'reprime' && state && !session.servedWarm) state.volatile = true;
1211
+ session.stale = reason;
1212
+ if (session.inUse === 0) void this._drop(session, reason);
1213
+ }
1214
+
1215
+ private async _drop(session: Primed, reason: NonNullable<Primed['stale']>): Promise<void> {
1216
+ if (this._sessions.get(session.key) === session) this._sessions.delete(session.key);
1217
+ this._emit({ type: 'evicted', key: session.key, reason });
1218
+ const conn = this._conn;
1219
+ if (conn && session.generation === this._generation)
1220
+ await conn
1221
+ .request('thread/delete', { threadId: session.baseThreadId }, 10_000)
1222
+ .catch(() => undefined);
1223
+ }
1224
+
1225
+ private async _evictIdle(): Promise<void> {
1226
+ const cutoff = Date.now() - this._config.idleMs;
1227
+ for (const session of [...this._sessions.values()])
1228
+ if (session.inUse === 0 && session.lastUsed < cutoff) this._retire(session, 'idle');
1229
+ for (const [key, state] of this._keys)
1230
+ if (!state.priming && !this._sessions.has(key) && state.touched < cutoff)
1231
+ this._keys.delete(key);
1232
+ }
1233
+
1234
+ private async _makeRoom(except: string): Promise<void> {
1235
+ while (this._sessions.size >= this._config.maxSessions) {
1236
+ const idle = [...this._sessions.values()]
1237
+ .filter((s) => s.inUse === 0 && s.key !== except)
1238
+ .sort((a, b) => a.lastUsed - b.lastUsed)[0];
1239
+ if (!idle) return;
1240
+ this._retire(idle, 'capacity');
1241
+ }
1242
+ }
1243
+
1244
+ private async _decide(
1245
+ spec: PrimeSpec,
1246
+ request: DecisionRequest,
1247
+ started: number,
1248
+ deadline: number,
1249
+ ): Promise<DecisionResult> {
1250
+ const waitMs = Date.now() - started;
1251
+ const signal = request.signal;
1252
+ const expired = () =>
1253
+ signal?.aborted
1254
+ ? new DecisionError('Decision aborted before dispatch', 'aborted', 'not-dispatched', true)
1255
+ : new DecisionError(
1256
+ 'Decision deadline passed before dispatch',
1257
+ 'timeout',
1258
+ 'not-dispatched',
1259
+ true,
1260
+ );
1261
+ if (Date.now() >= deadline || signal?.aborted) throw expired();
1262
+ const conn = await this._process();
1263
+ await this._checkLimits();
1264
+ const hash = primeHash(spec),
1265
+ state = this._keyState(spec.key);
1266
+ let session = this._sessions.get(spec.key);
1267
+ if (session && (session.hash !== hash || session.generation !== this._generation)) {
1268
+ // The context changed: the old primed session no longer matches. Retire it; prime the new one off-path.
1269
+ if (session.generation === this._generation) this._retire(session, 'reprime', state);
1270
+ else this._sessions.delete(spec.key);
1271
+ session = undefined;
1272
+ }
1273
+ const prime: 'warm' | 'cold' = session ? 'warm' : 'cold',
1274
+ primeMs = 0;
1275
+ if (!session && spec.context) void this._primeInBackground(conn, spec, hash, state);
1276
+ state.lastHash = hash;
1277
+ if (session) {
1278
+ session.inUse++;
1279
+ session.lastUsed = Date.now();
1280
+ }
1281
+ let threadId: string | undefined,
1282
+ admissionId: string | undefined,
1283
+ dispatched = false,
1284
+ hedgedDecision = false;
1285
+ // A miss runs inline: the stable context travels with this decision's input on a fresh thread.
1286
+ const input = session || !spec.context ? request.input : `${spec.context}\n\n${request.input}`;
1287
+ const params = this._threadParams(spec.instructions);
1288
+ let model = session?.model;
1289
+ /** A fresh branch for this decision: a fork of the primed state, or an inline thread. */
1290
+ const openBranch = async (): Promise<{ threadId: string; branch: Branch }> => {
1291
+ const response = object(
1292
+ await (session
1293
+ ? conn.request(
1294
+ 'thread/fork',
1295
+ {
1296
+ threadId: session.baseThreadId,
1297
+ lastTurnId: session.primedTurnId,
1298
+ ephemeral: true,
1299
+ excludeTurns: true,
1300
+ ...params,
1301
+ },
1302
+ Math.max(1, deadline - Date.now()),
1303
+ )
1304
+ : conn.request(
1305
+ 'thread/start',
1306
+ { ...params, ephemeral: true },
1307
+ Math.max(1, deadline - Date.now()),
1308
+ )),
1309
+ );
1310
+ const id = object(response?.thread)?.id;
1311
+ if (typeof id !== 'string' || !id) throw Error('Codex returned no decision thread');
1312
+ const leaked = instructionSources(response);
1313
+ if (leaked) throw instructionLeak(leaked);
1314
+ model ??= typeof response?.model === 'string' ? response.model : undefined;
1315
+ return { threadId: id, branch: this._branch(conn, id, spec.key) };
1316
+ };
1317
+ try {
1318
+ if (Date.now() >= deadline || signal?.aborted) throw expired();
1319
+ const t1 = performance.now();
1320
+ const opened = await openBranch().catch((error) => {
1321
+ if (error instanceof DecisionError) throw error;
1322
+ // The runtime refusing the fork means the primed thread is gone: retire it and prime again off-path.
1323
+ // A timeout proves nothing about the primed thread.
1324
+ if (session && error instanceof JsonRpcError) this._retire(session, 'reprime');
1325
+ throw new DecisionError(
1326
+ `Branching ${spec.key} failed: ${(error as Error).message}`,
1327
+ 'transport',
1328
+ 'not-dispatched',
1329
+ true,
1330
+ undefined,
1331
+ { cause: error },
1332
+ );
1333
+ });
1334
+ threadId = opened.threadId;
1335
+ if (session) session.model ??= model;
1336
+ const branchMs = Math.round(performance.now() - t1);
1337
+ let branch = opened.branch;
1338
+ admissionId = crypto.randomUUID();
1339
+ try {
1340
+ await request.onAdmission?.({ admissionId, key: spec.key, runtime: 'codex', threadId });
1341
+ } catch (cause) {
1342
+ throw new DecisionError(
1343
+ 'Decision admission refused before dispatch',
1344
+ 'admission',
1345
+ 'not-dispatched',
1346
+ true,
1347
+ admissionId,
1348
+ { cause },
1349
+ );
1350
+ }
1351
+ if (Date.now() >= deadline || signal?.aborted) throw expired();
1352
+ if (this._conn !== conn || this._closed)
1353
+ throw new DecisionError(
1354
+ 'Codex process lost before dispatch',
1355
+ 'transport',
1356
+ 'not-dispatched',
1357
+ true,
1358
+ admissionId,
1359
+ );
1360
+ const t2 = performance.now();
1361
+ dispatched = true;
1362
+ let outcome: Awaited<ReturnType<CodexPrimedSessions['_turn']>>;
1363
+ const hedgeAfterMs = this._config.hedgeAfterMs;
1364
+ try {
1365
+ outcome = await this._turn(
1366
+ conn,
1367
+ threadId,
1368
+ branch,
1369
+ input,
1370
+ deadline,
1371
+ request.outputSchema ? { outputSchema: request.outputSchema } : {},
1372
+ signal,
1373
+ hedgeAfterMs !== undefined
1374
+ ? { afterMs: hedgeAfterMs, open: openBranch, key: spec.key }
1375
+ : undefined,
1376
+ );
1377
+ } catch (error) {
1378
+ // A JSON-RPC error is the runtime's own refusal. Anything else (timeout, loss) leaves the turn unknown until the process is gone.
1379
+ const settled =
1380
+ error instanceof JsonRpcError || (await this._recycle(conn, 'turn/start unsettled'));
1381
+ throw new DecisionError(
1382
+ `Codex turn/start failed: ${(error as Error).message}`,
1383
+ 'transport',
1384
+ 'attempted',
1385
+ settled,
1386
+ admissionId,
1387
+ { cause: error },
1388
+ );
1389
+ }
1390
+ const turnMs = Math.round(performance.now() - t2);
1391
+ hedgedDecision = outcome.hedged;
1392
+ if (outcome.threadId !== threadId) threadId = outcome.threadId; // the losing branch is settled by _settleLoser
1393
+ branch = outcome.branch;
1394
+ if (branch.violation)
1395
+ throw new DecisionError(
1396
+ `Decision violated the text-only policy (${branch.violation})`,
1397
+ 'violation',
1398
+ 'attempted',
1399
+ outcome.settled,
1400
+ admissionId,
1401
+ );
1402
+ if (outcome.aborted && !branch.terminal)
1403
+ throw new DecisionError(
1404
+ 'Decision aborted; turn interrupted',
1405
+ 'aborted',
1406
+ 'attempted',
1407
+ outcome.settled,
1408
+ admissionId,
1409
+ );
1410
+ if (outcome.timedOut && !branch.terminal)
1411
+ throw new DecisionError(
1412
+ 'Decision deadline passed; turn interrupted',
1413
+ 'timeout',
1414
+ 'attempted',
1415
+ outcome.settled,
1416
+ admissionId,
1417
+ );
1418
+ if ((outcome.aborted || outcome.timedOut) && branch.terminal?.status === 'interrupted')
1419
+ throw new DecisionError(
1420
+ `Decision ${outcome.aborted ? 'aborted' : 'deadline passed'}; turn interrupted`,
1421
+ outcome.aborted ? 'aborted' : 'timeout',
1422
+ 'attempted',
1423
+ true,
1424
+ admissionId,
1425
+ );
1426
+ const terminal = branch.terminal;
1427
+ const content = branch.finalText ?? branch.text;
1428
+ if (terminal?.status !== 'completed' || content === undefined) {
1429
+ const reason =
1430
+ terminal?.status === 'failed'
1431
+ ? failureReason(terminal.error)
1432
+ : branch.error && !terminal
1433
+ ? 'transport'
1434
+ : 'native-failed';
1435
+ if (reason === 'rate-limited')
1436
+ this._observeLimits({
1437
+ runtime: 'codex',
1438
+ source: 'stream',
1439
+ observedAt: Date.now(),
1440
+ windows: [],
1441
+ blocked: true,
1442
+ reachedType: 'turn-failed',
1443
+ });
1444
+ throw new DecisionError(
1445
+ `Codex decision ${terminal?.status ?? `ended without a terminal (${branch.error ?? 'unknown'})`}${terminal?.error?.message ? `: ${String(terminal.error.message).slice(0, 300)}` : ''}`,
1446
+ reason,
1447
+ 'attempted',
1448
+ !!terminal || outcome.settled,
1449
+ admissionId,
1450
+ );
1451
+ }
1452
+ const tokens = codexTokens(branch.usage);
1453
+ this._emit({
1454
+ type: 'decision',
1455
+ key: spec.key,
1456
+ prime,
1457
+ ms: Date.now() - started,
1458
+ ...(tokens?.cacheRead !== undefined ? { cacheRead: tokens.cacheRead } : {}),
1459
+ ...(count(branch.usage?.inputTokens) !== undefined
1460
+ ? { input: count(branch.usage?.inputTokens) }
1461
+ : {}),
1462
+ });
1463
+ if (session) {
1464
+ session.servedWarm = true;
1465
+ state.volatile = false;
1466
+ }
1467
+ return {
1468
+ key: spec.key,
1469
+ admissionId,
1470
+ content,
1471
+ ...(tokens ? { tokens } : {}),
1472
+ ...(model ? { model } : {}),
1473
+ threadId,
1474
+ ...(branch.turnId ? { turnId: branch.turnId } : {}),
1475
+ prime,
1476
+ ...(outcome.hedged ? { hedged: true } : {}),
1477
+ ...(branch.transportFallback ? { transportFallback: true } : {}),
1478
+ timing: { waitMs, primeMs, branchMs, turnMs },
1479
+ };
1480
+ } catch (error) {
1481
+ const failure =
1482
+ error instanceof DecisionError
1483
+ ? error
1484
+ : new DecisionError(
1485
+ (error as Error).message,
1486
+ 'transport',
1487
+ dispatched ? 'attempted' : 'not-dispatched',
1488
+ !dispatched,
1489
+ admissionId,
1490
+ { cause: error },
1491
+ );
1492
+ if (hedgedDecision) failure.hedged = true;
1493
+ throw failure;
1494
+ } finally {
1495
+ if (session) {
1496
+ session.inUse--;
1497
+ session.lastUsed = Date.now();
1498
+ if (session.stale && session.inUse === 0) void this._drop(session, session.stale);
1499
+ }
1500
+ if (threadId) {
1501
+ this._branches.delete(threadId);
1502
+ if (this._conn === conn)
1503
+ void conn.request('thread/unsubscribe', { threadId }, 10_000).catch(() => undefined);
1504
+ }
1505
+ }
1506
+ }
1507
+ }