@zvada/agent-server 0.3.2 → 0.3.4

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.
@@ -39,11 +39,19 @@ export interface AppServerSession {
39
39
  sandbox: string;
40
40
  systemPromptAppend?: string;
41
41
  envFingerprint: string;
42
- /** Engine permission broker for the CURRENT turn (set per execute). */
43
- permissionHandler?: PermissionRequestHandler;
42
+ /** Native CLI auth and externally managed tokens cannot share a warm process. */
43
+ authMode?: CodexAppServerAuth["type"];
44
+ /** Credentials and permission broker belong only to the current execute. */
45
+ turn?: AppServerTurn;
44
46
  idleTimer?: ReturnType<typeof setTimeout>;
45
47
  }
46
48
 
49
+ interface AppServerTurn {
50
+ auth?: CodexAppServerAuth;
51
+ permissionHandler?: PermissionRequestHandler;
52
+ signal: AbortSignal;
53
+ }
54
+
47
55
  export function appServerSessionCompatible(
48
56
  existing: AppServerSession | undefined,
49
57
  options: AgentExecuteOptions,
@@ -145,17 +153,42 @@ const IDLE_TIMEOUT_MS = 5 * 60_000;
145
153
  /** How long a retryable error may go unanswered before the turn is failed. */
146
154
  const RETRY_STALL_TIMEOUT_MS = 120_000;
147
155
 
156
+ /** Access credentials for Codex's experimental, externally managed ChatGPT auth mode. */
157
+ export interface CodexChatGptAuthTokens {
158
+ accessToken: string;
159
+ chatgptAccountId: string;
160
+ chatgptPlanType?: string | null;
161
+ }
162
+
163
+ /** The host owns login, durable token storage, refresh serialization, and revocation. */
164
+ export interface CodexChatGptAuth {
165
+ type: "chatgptAuthTokens";
166
+ tokens: CodexChatGptAuthTokens;
167
+ refresh(previousAccountId: string): Promise<CodexChatGptAuthTokens>;
168
+ }
169
+
170
+ export type CodexAppServerAuth = { type: "apiKey"; apiKey: string } | CodexChatGptAuth;
171
+
148
172
  export interface CodexAppServerAgentOptions {
149
173
  createClient?: (options: CodexAppServerClientOptions) => CodexAppServerClient;
150
174
  idleTimeoutMs?: number;
151
175
  /** Fail a turn if the app-server goes silent this long after a retryable error. */
152
176
  retryStallTimeoutMs?: number;
177
+ /** Deadline for interrupt acknowledgement and the cancelled turn to drain. */
178
+ cancelGraceMs?: number;
153
179
  /**
154
180
  * Resolve the `codex` binary to spawn (typically the provisioner's,
155
181
  * memoized). Falls back to `$CODEX_CLI_PATH`, then PATH. Operator-level
156
182
  * only — never sourced from the wire.
157
183
  */
158
184
  resolveCliPath?: () => Promise<string | undefined>;
185
+ /**
186
+ * Resolve the selected API key or ChatGPT account before EVERY turn, including warm
187
+ * sessions. Operator-only: neither callbacks nor tokens belong in RunConfig.
188
+ * Return undefined to select native CLI auth; throw when the selected external
189
+ * account is unavailable. Switching back to native auth recreates the process.
190
+ */
191
+ resolveAuth?: (sessionId: string) => Promise<CodexAppServerAuth | undefined>;
159
192
  }
160
193
 
161
194
  /** Retryable v2 `error` notifications do not end the turn; Codex keeps going. */
@@ -176,12 +209,22 @@ export class CodexAppServerAgent extends BaseAgent {
176
209
  readonly harness = "codex-app-server" as const;
177
210
  readonly capabilities = CAPABILITIES;
178
211
  private readonly sessions = new SessionStore<AppServerSession>();
212
+ /** A cancel acknowledgement belongs to one invocation, even after it drains. */
213
+ private readonly turnInterruptState = new WeakMap<
214
+ AbortController,
215
+ { acknowledgement?: Promise<boolean> }
216
+ >();
179
217
 
180
218
  constructor(private readonly agentOptions: CodexAppServerAgentOptions = {}) {
181
219
  super();
182
220
  }
183
221
 
184
- private async resolveSession(options: AgentExecuteOptions): Promise<AppServerSession> {
222
+ private async resolveSession(
223
+ options: AgentExecuteOptions,
224
+ turn: AppServerTurn,
225
+ ): Promise<AppServerSession> {
226
+ const { signal } = turn;
227
+ signal.throwIfAborted();
185
228
  const approvalPolicy = approvalPolicyFor(options);
186
229
  const sandbox = mapSandbox(options.permissionMode);
187
230
  const existing = this.sessions.get(options.sessionId);
@@ -190,9 +233,21 @@ export class CodexAppServerAgent extends BaseAgent {
190
233
  // survives. `resumeThreadId` carries that over to the spawn below.
191
234
  let resumeThreadId = options.resumeSessionId;
192
235
  if (existing && !existing.client.closed) {
193
- if (appServerSessionCompatible(existing, options)) {
236
+ if (appServerSessionCompatible(existing, options) && existing.authMode === turn.auth?.type) {
194
237
  this.sessions.clearIdle(existing);
195
- return existing;
238
+ existing.turn = turn;
239
+ const onAbort = () => existing.client.close();
240
+ signal.addEventListener("abort", onAbort, { once: true });
241
+ try {
242
+ await this.authenticate(existing);
243
+ return existing;
244
+ } catch (error) {
245
+ existing.turn = undefined;
246
+ existing.client.close();
247
+ throw error;
248
+ } finally {
249
+ signal.removeEventListener("abort", onAbort);
250
+ }
196
251
  }
197
252
  // Preserve context within the same cwd/env boundary (mirrors the
198
253
  // claude-code restart policy); an explicit resumeSessionId always wins.
@@ -211,63 +266,134 @@ export class CodexAppServerAgent extends BaseAgent {
211
266
  (await this.agentOptions.resolveCliPath?.()) ?? process.env.CODEX_CLI_PATH ?? "codex",
212
267
  cwd: options.cwd,
213
268
  env: options.env ? ({ ...process.env, ...options.env } as NodeJS.ProcessEnv) : process.env,
269
+ ...(turn.auth?.type === "chatgptAuthTokens" && { experimentalApi: true }),
214
270
  };
271
+ signal.throwIfAborted();
215
272
  const client =
216
273
  this.agentOptions.createClient?.(clientOptions) ?? new CodexAppServerClient(clientOptions);
217
274
  // Self-heal: if the subprocess ever exits, drop it so the next turn respawns.
218
275
  client.onClose(() => this.sessions.dropIfCurrent(options.sessionId, client));
219
- await client.initialize();
220
-
221
- const startParams: Record<string, unknown> = {
222
- cwd: options.cwd,
223
- sandbox,
224
- approvalPolicy,
225
- experimentalRawEvents: false,
226
- persistExtendedHistory: false,
227
- };
228
- if (options.model) startParams.model = options.model;
229
- if (options.systemPromptAppend) startParams.developerInstructions = options.systemPromptAppend;
230
-
231
- // A dead/unknown resume target ("no rollout found ...") must not kill the
232
- // turn: ANY resume failure falls back to a fresh thread, and the fresh id
233
- // ≠ requested id makes execute() report resumed:false — the structured
234
- // fallback signal (claude/acp parity). Systemic failures (auth, dead
235
- // subprocess) fail thread/start identically, so nothing is masked.
236
- const startThread = async (): Promise<string> => {
237
- const r = (await client.request("thread/start", startParams)) as {
238
- thread?: { id?: string };
239
- };
240
- if (!r?.thread?.id) throw new Error("codex app-server thread/start returned no thread id");
241
- return r.thread.id;
242
- };
243
- let threadId: string | undefined;
244
- if (resumeThreadId) {
245
- threadId = await client
246
- .request("thread/resume", { threadId: resumeThreadId, ...startParams })
247
- .then((r) => (r as { thread?: { id?: string } })?.thread?.id ?? resumeThreadId)
248
- .catch(() => undefined);
249
- }
250
- threadId ??= await startThread();
251
-
276
+ const untrack = this.sessions.trackPending(options.sessionId, client);
277
+ const onAbort = () => client.close();
278
+ signal.addEventListener("abort", onAbort, { once: true });
252
279
  const session: AppServerSession = {
253
280
  client,
254
- threadId,
281
+ threadId: "",
255
282
  cwd: options.cwd,
256
283
  approvalPolicy,
257
284
  sandbox,
258
285
  systemPromptAppend: options.systemPromptAppend,
259
286
  envFingerprint: configFingerprint(options.env),
287
+ authMode: turn.auth?.type,
288
+ turn,
260
289
  };
290
+ this.bindRequests(session);
291
+ try {
292
+ signal.throwIfAborted();
293
+ await client.initialize();
294
+ await this.authenticate(session);
295
+
296
+ const startParams: Record<string, unknown> = {
297
+ cwd: options.cwd,
298
+ sandbox,
299
+ approvalPolicy,
300
+ experimentalRawEvents: false,
301
+ persistExtendedHistory: false,
302
+ };
303
+ if (options.model) startParams.model = options.model;
304
+ if (options.systemPromptAppend)
305
+ startParams.developerInstructions = options.systemPromptAppend;
306
+
307
+ // A dead/unknown resume target falls back to a fresh thread. Auth was
308
+ // established above, outside this fallback, so a login failure cannot
309
+ // silently start a different conversation or use ambient credentials.
310
+ let threadId: string | undefined;
311
+ if (resumeThreadId) {
312
+ threadId = await client
313
+ .request("thread/resume", { threadId: resumeThreadId, ...startParams })
314
+ .then((r) => (r as { thread?: { id?: string } })?.thread?.id ?? resumeThreadId)
315
+ .catch(() => undefined);
316
+ }
317
+ signal.throwIfAborted();
318
+ if (!threadId) {
319
+ const result = (await client.request("thread/start", startParams)) as {
320
+ thread?: { id?: string };
321
+ };
322
+ threadId = result?.thread?.id;
323
+ if (!threadId) throw new Error("codex app-server thread/start returned no thread id");
324
+ }
325
+ signal.throwIfAborted();
326
+ session.threadId = threadId;
327
+ this.sessions.set(options.sessionId, session);
328
+ return session;
329
+ } catch (error) {
330
+ session.turn = undefined;
331
+ client.setRequestHandler(undefined);
332
+ client.close();
333
+ throw error;
334
+ } finally {
335
+ signal.removeEventListener("abort", onAbort);
336
+ untrack();
337
+ }
338
+ }
339
+
340
+ private async authenticate(session: AppServerSession): Promise<void> {
341
+ session.turn?.signal.throwIfAborted();
342
+ const auth = session.turn?.auth;
343
+ if (auth) {
344
+ await session.client.request(
345
+ "account/login/start",
346
+ auth.type === "apiKey"
347
+ ? { type: "apiKey", apiKey: auth.apiKey }
348
+ : {
349
+ type: "chatgptAuthTokens",
350
+ accessToken: auth.tokens.accessToken,
351
+ chatgptAccountId: auth.tokens.chatgptAccountId,
352
+ chatgptPlanType: auth.tokens.chatgptPlanType ?? null,
353
+ },
354
+ );
355
+ }
356
+ session.turn?.signal.throwIfAborted();
357
+ }
358
+
359
+ private bindRequests(session: AppServerSession): void {
261
360
  // Approval requests (server→client) resolve against the engine broker of
262
361
  // whichever turn is current; thread mismatches and broker-less turns decline.
263
- client.setRequestHandler(async (method, params) => {
362
+ session.client.setRequestHandler(async (method, params) => {
363
+ const turn = session.turn;
364
+ if (method === "account/chatgptAuthTokens/refresh") {
365
+ if (
366
+ turn?.auth?.type !== "chatgptAuthTokens" ||
367
+ turn.signal.aborted ||
368
+ session.client.closed
369
+ ) {
370
+ throw new Error("Codex ChatGPT refresh has no active authenticated turn");
371
+ }
372
+ const accountId = turn.auth.tokens.chatgptAccountId;
373
+ if (params.previousAccountId != null && params.previousAccountId !== accountId) {
374
+ throw new Error("Codex ChatGPT refresh account does not match the selected account");
375
+ }
376
+ const tokens = await turn.auth.refresh(accountId);
377
+ if (session.turn !== turn || turn.signal.aborted || session.client.closed) {
378
+ throw new Error("Codex ChatGPT refresh outlived its turn");
379
+ }
380
+ if (tokens.chatgptAccountId !== accountId) {
381
+ throw new Error("Codex ChatGPT refresh returned a different account");
382
+ }
383
+ return {
384
+ accessToken: tokens.accessToken,
385
+ chatgptAccountId: tokens.chatgptAccountId,
386
+ chatgptPlanType: tokens.chatgptPlanType ?? null,
387
+ };
388
+ }
264
389
  const requestThread = params.threadId ?? params.conversationId;
265
390
  const v2 = method.startsWith("item/");
266
391
  const decline = v2 ? { decision: "decline" } : { decision: "denied" };
267
392
  if (requestThread && requestThread !== session.threadId) return decline;
268
- const handler = session.permissionHandler;
269
- if (!handler) return decline;
393
+ const handler = turn?.permissionHandler;
394
+ if (!handler || turn?.signal.aborted) return decline;
270
395
  const decision = await handler(approvalToolCall(method, params));
396
+ if (session.turn !== turn || turn?.signal.aborted) return decline;
271
397
  // Codex's approval reply is a bare verdict — no slot for
272
398
  // `decision.updatedInput`, so an edited input cannot be honoured here
273
399
  // (only claude-code's `canUseTool` can execute the edit). An edited
@@ -283,8 +409,6 @@ export class CodexAppServerAgent extends BaseAgent {
283
409
  return decline;
284
410
  }
285
411
  });
286
- this.sessions.set(options.sessionId, session);
287
- return session;
288
412
  }
289
413
 
290
414
  async *execute(
@@ -292,19 +416,31 @@ export class CodexAppServerAgent extends BaseAgent {
292
416
  options: AgentExecuteOptions,
293
417
  ): AsyncIterableIterator<RawAgentEvent> {
294
418
  const controller = this.trackTurn(options.sessionId, options.signal);
419
+ const turn: AppServerTurn = {
420
+ permissionHandler: options.onPermissionRequest,
421
+ signal: controller.signal,
422
+ };
423
+ const interruptState: { acknowledgement?: Promise<boolean> } = {};
424
+ this.turnInterruptState.set(controller, interruptState);
295
425
  const queue = new AsyncQueue<CodexNotification>();
296
426
  // A retryable error promises more notifications. If none arrive, the
297
427
  // subprocess is wedged-but-alive and the turn would hang forever — bound it.
298
428
  let stallTimer: ReturnType<typeof setTimeout> | undefined;
429
+ let cancelTimer: ReturnType<typeof setTimeout> | undefined;
299
430
  const clearStall = () => {
300
431
  if (stallTimer) clearTimeout(stallTimer);
301
432
  stallTimer = undefined;
302
433
  };
303
434
  const unsubscribe: Array<() => void> = [];
304
435
  let session: AppServerSession | undefined;
436
+ let turnEnded = false;
305
437
  try {
306
- session = await this.resolveSession(options);
307
- session.permissionHandler = options.onPermissionRequest;
438
+ controller.signal.throwIfAborted();
439
+ if (this.agentOptions.resolveAuth) {
440
+ turn.auth = await this.agentOptions.resolveAuth(options.sessionId);
441
+ }
442
+ controller.signal.throwIfAborted();
443
+ session = await this.resolveSession(options, turn);
308
444
  options.onNativeSession?.(session.threadId, {
309
445
  resumed: session.threadId === options.resumeSessionId,
310
446
  });
@@ -312,11 +448,31 @@ export class CodexAppServerAgent extends BaseAgent {
312
448
  const client = session.client;
313
449
 
314
450
  let turnId: string | undefined;
315
- let aborted = false;
316
- const interrupt = () => {
317
- if (turnId) {
318
- void client.request("turn/interrupt", { threadId, turnId }).catch(() => {});
451
+ const forceCancel = () => {
452
+ if (turnEnded) return;
453
+ queue.fail(new Error("turn aborted"));
454
+ client.close();
455
+ };
456
+ const onAbort = () => {
457
+ clearStall();
458
+ if (interruptState.acknowledgement) return;
459
+ if (!turnId) {
460
+ interruptState.acknowledgement = Promise.resolve(false);
461
+ forceCancel();
462
+ return;
319
463
  }
464
+ const graceMs = this.agentOptions.cancelGraceMs ?? 2_000;
465
+ cancelTimer = setTimeout(forceCancel, graceMs);
466
+ cancelTimer.unref?.();
467
+ interruptState.acknowledgement = client
468
+ .request("turn/interrupt", { threadId, turnId }, graceMs)
469
+ .then(
470
+ () => true,
471
+ () => {
472
+ forceCancel();
473
+ return false;
474
+ },
475
+ );
320
476
  };
321
477
 
322
478
  unsubscribe.push(
@@ -326,17 +482,21 @@ export class CodexAppServerAgent extends BaseAgent {
326
482
  const turn = n.params.turn as { id?: string } | undefined;
327
483
  if (turn?.id) {
328
484
  turnId = turn.id;
329
- if (aborted) interrupt(); // abort arrived before the turn id was known
330
485
  }
331
486
  }
487
+ if (appServerNotificationEndsTurn(n)) turnEnded = true;
488
+ if (turnEnded && controller.signal.aborted) {
489
+ queue.fail(new Error("turn aborted"));
490
+ return;
491
+ }
332
492
  queue.push(n);
333
493
  if (appServerNotificationEndsTurn(n)) queue.end();
334
- if (n.method === "error" && n.params.willRetry === true) {
494
+ if (!controller.signal.aborted && n.method === "error" && n.params.willRetry === true) {
335
495
  clearStall();
336
- stallTimer = setTimeout(
337
- () => queue.fail(new Error("codex app-server went silent after a retryable error")),
338
- this.agentOptions.retryStallTimeoutMs ?? RETRY_STALL_TIMEOUT_MS,
339
- );
496
+ stallTimer = setTimeout(() => {
497
+ queue.fail(new Error("codex app-server went silent after a retryable error"));
498
+ client.close();
499
+ }, this.agentOptions.retryStallTimeoutMs ?? RETRY_STALL_TIMEOUT_MS);
340
500
  stallTimer.unref?.();
341
501
  } else {
342
502
  clearStall();
@@ -345,19 +505,17 @@ export class CodexAppServerAgent extends BaseAgent {
345
505
  );
346
506
  // If the subprocess dies mid-turn, fail the turn instead of hanging.
347
507
  unsubscribe.push(
348
- client.onClose((err) => queue.fail(err ?? new Error("codex app-server exited mid-turn"))),
508
+ client.onClose((err) =>
509
+ queue.fail(
510
+ controller.signal.aborted
511
+ ? new Error("turn aborted")
512
+ : (err ?? new Error("codex app-server exited mid-turn")),
513
+ ),
514
+ ),
349
515
  );
350
516
 
351
- controller.signal.addEventListener(
352
- "abort",
353
- () => {
354
- aborted = true;
355
- interrupt();
356
- // Surface as a cancellation: the runtime classifies this as aborted.
357
- queue.fail(new Error("turn aborted"));
358
- },
359
- { once: true },
360
- );
517
+ controller.signal.addEventListener("abort", onAbort, { once: true });
518
+ unsubscribe.push(() => controller.signal.removeEventListener("abort", onAbort));
361
519
 
362
520
  const turnParams: Record<string, unknown> = {
363
521
  threadId,
@@ -366,7 +524,12 @@ export class CodexAppServerAgent extends BaseAgent {
366
524
  if (options.model) turnParams.model = options.model;
367
525
  if (options.thinkingLevel) turnParams.effort = codexReasoningEffort(options.thinkingLevel);
368
526
 
527
+ if (controller.signal.aborted) throw new Error("turn aborted");
369
528
  client.request("turn/start", turnParams).catch((err: unknown) => {
529
+ if (controller.signal.aborted) {
530
+ forceCancel();
531
+ return;
532
+ }
370
533
  queue.push({ method: "error", params: { message: String(err) } });
371
534
  queue.end();
372
535
  });
@@ -374,22 +537,41 @@ export class CodexAppServerAgent extends BaseAgent {
374
537
  for await (const notification of queue) {
375
538
  yield notification as RawAgentEvent;
376
539
  }
540
+ } catch (error) {
541
+ throw controller.signal.aborted ? new Error("turn aborted") : error;
377
542
  } finally {
543
+ turnEnded = true;
378
544
  clearStall();
545
+ if (cancelTimer) clearTimeout(cancelTimer);
379
546
  for (const off of unsubscribe) off();
380
547
  this.endTurn(options.sessionId, controller);
381
- if (session && !session.client.closed) {
382
- this.sessions.armIdle(
383
- options.sessionId,
384
- session,
385
- this.agentOptions.idleTimeoutMs ?? IDLE_TIMEOUT_MS,
386
- );
548
+ if (session?.turn === turn) {
549
+ session.turn = undefined;
550
+ if (!session.client.closed) {
551
+ this.sessions.armIdle(
552
+ options.sessionId,
553
+ session,
554
+ this.agentOptions.idleTimeoutMs ?? IDLE_TIMEOUT_MS,
555
+ );
556
+ }
387
557
  }
388
558
  }
389
559
  }
390
560
 
391
561
  override async cancel(sessionId: string): Promise<CancelResult> {
392
- return await super.cancel(sessionId);
562
+ // Snapshot the in-flight controllers before super.cancel() aborts them:
563
+ // the abort unblocks execute()'s queue and finally's endTurn drains the
564
+ // set on a later microtask, so reading afterwards would race that drain.
565
+ const controllers = [...(this.inflight.get(sessionId) ?? [])];
566
+ const base = await super.cancel(sessionId);
567
+ if (!base.hadTurn) return base;
568
+ const acknowledgements = await Promise.all(
569
+ controllers.map(
570
+ (controller) => this.turnInterruptState.get(controller)?.acknowledgement ?? false,
571
+ ),
572
+ );
573
+ const confirmed = acknowledgements.every(Boolean);
574
+ return { confirmed, hadTurn: true };
393
575
  }
394
576
 
395
577
  override async release(sessionId: string): Promise<void> {
@@ -44,6 +44,7 @@ interface CodexEvent {
44
44
  usage?: {
45
45
  input_tokens?: number;
46
46
  cached_input_tokens?: number;
47
+ cache_write_input_tokens?: number;
47
48
  output_tokens?: number;
48
49
  reasoning_output_tokens?: number;
49
50
  };
@@ -83,18 +84,17 @@ export class CodexSdkTransformer implements EventTransformer<unknown> {
83
84
  this.finishReason = "completed";
84
85
  this.stopReason = "end_turn";
85
86
  if (!ev.usage) return [];
87
+ const totalInput = ev.usage.input_tokens ?? 0;
88
+ const cached = ev.usage.cached_input_tokens ?? 0;
89
+ const written = ev.usage.cache_write_input_tokens ?? 0;
86
90
  this.usage = {
87
- input: ev.usage.input_tokens ?? 0,
91
+ // Input and cache buckets are disjoint in the normalized protocol.
92
+ input: Math.max(totalInput - cached - written, 0),
88
93
  output: ev.usage.output_tokens ?? 0,
89
94
  reasoning: ev.usage.reasoning_output_tokens ?? 0,
90
- cache: { read: ev.usage.cached_input_tokens ?? 0, write: 0 },
95
+ cache: { read: cached, write: written },
91
96
  };
92
- // The SDK reports usage only at turn end; input covers the full prompt
93
- // context, so it doubles as the context gauge.
94
- const used =
95
- (ev.usage.input_tokens ?? 0) +
96
- (ev.usage.cached_input_tokens ?? 0) +
97
- (ev.usage.output_tokens ?? 0);
97
+ const used = totalInput + (ev.usage.output_tokens ?? 0);
98
98
  return [{ kind: "usage", used }];
99
99
  }
100
100
  case "turn.failed":
@@ -1,6 +1,6 @@
1
1
  import type { Codex, Thread, ThreadEvent, ThreadOptions } from "@openai/codex-sdk";
2
2
  import type { AgentCapabilities, AgentInput, PermissionMode } from "../../../protocol/index.ts";
3
- import { codexReasoningEffort } from "../../../protocol/index.ts";
3
+ import { codexReasoningEffort, isCancellation } from "../../../protocol/index.ts";
4
4
  import type { AgentExecuteOptions, RawAgentEvent } from "../base.ts";
5
5
  import { BaseAgent } from "../base.ts";
6
6
  import { configFingerprint } from "../config-fingerprint.ts";
@@ -19,6 +19,7 @@ const CAPABILITIES: AgentCapabilities = {
19
19
 
20
20
  export interface LiveThread {
21
21
  thread: Thread;
22
+ codexPathOverride?: string;
22
23
  model?: string;
23
24
  thinkingLevel?: string;
24
25
  cwd: string;
@@ -49,8 +50,10 @@ function directoriesFingerprint(
49
50
  */
50
51
  function instanceFingerprint(
51
52
  options: Pick<AgentExecuteOptions, "apiKey" | "env" | "systemPromptAppend">,
53
+ codexPathOverride: string | undefined,
52
54
  ): string {
53
55
  return configFingerprint({
56
+ codexPathOverride,
54
57
  apiKey: options.apiKey,
55
58
  env: options.env,
56
59
  // One equality convention with codexThreadCompatible: absent == empty.
@@ -115,9 +118,9 @@ function threadOptions(options: AgentExecuteOptions): ThreadOptions {
115
118
 
116
119
  export interface CodexSdkAgentOptions {
117
120
  /**
118
- * Resolve the `codex` binary to spawn (typically the provisioner's,
119
- * memoized). `undefined` defers to the SDK's own lookup. Operator-level
120
- * only — never sourced from the wire.
121
+ * Resolve the `codex` binary before each execution, including warm turns.
122
+ * `undefined` defers to the SDK's own lookup. Operator-level only — never
123
+ * sourced from the wire.
121
124
  */
122
125
  resolveCliPath?: () => Promise<string | undefined>;
123
126
  }
@@ -134,7 +137,7 @@ export class CodexSdkAgent extends BaseAgent {
134
137
  readonly capabilities = CAPABILITIES;
135
138
  private readonly threads = new Map<string, LiveThread>();
136
139
  // Instance-level config (credentials AND developer_instructions) is fixed
137
- // at construction, so one instance per distinct (apiKey, env, append) —
140
+ // at construction, so one instance per distinct (path, apiKey, env, append) —
138
141
  // keying by a fingerprint prevents one session's key or instructions from
139
142
  // bleeding into another's.
140
143
  private readonly codexByCreds = new Map<string, Codex>();
@@ -143,11 +146,13 @@ export class CodexSdkAgent extends BaseAgent {
143
146
  super();
144
147
  }
145
148
 
146
- private async getCodex(options: AgentExecuteOptions): Promise<Codex> {
147
- const fingerprint = instanceFingerprint(options);
149
+ private async getCodex(
150
+ options: AgentExecuteOptions,
151
+ codexPathOverride: string | undefined,
152
+ ): Promise<Codex> {
153
+ const fingerprint = instanceFingerprint(options, codexPathOverride);
148
154
  const cached = this.codexByCreds.get(fingerprint);
149
155
  if (cached) return cached;
150
- const codexPathOverride = await this.agentOptions.resolveCliPath?.();
151
156
  const { Codex } = await import("@openai/codex-sdk");
152
157
  const codex = new Codex({
153
158
  ...(codexPathOverride ? { codexPathOverride } : {}),
@@ -161,12 +166,23 @@ export class CodexSdkAgent extends BaseAgent {
161
166
  return codex;
162
167
  }
163
168
 
164
- private async resolveThread(options: AgentExecuteOptions): Promise<LiveThread> {
169
+ private async resolveThread(
170
+ options: AgentExecuteOptions,
171
+ signal?: AbortSignal,
172
+ ): Promise<LiveThread> {
173
+ // The SDK spawns again on every run, even when the Thread is reused.
174
+ const codexPathOverride = await this.agentOptions.resolveCliPath?.();
165
175
  const existing = this.threads.get(options.sessionId);
166
176
  const effort = options.thinkingLevel;
167
- if (existing && codexThreadCompatible(existing, options)) return existing;
177
+ if (
178
+ existing &&
179
+ existing.codexPathOverride === codexPathOverride &&
180
+ codexThreadCompatible(existing, options)
181
+ )
182
+ return existing;
168
183
 
169
- const codex = await this.getCodex(options);
184
+ const codex = await this.getCodex(options, codexPathOverride);
185
+ if (signal?.aborted) throw new Error("turn aborted");
170
186
  const fingerprint = credentialsFingerprint(options);
171
187
  // Preserve context within the same cwd/credential boundary (mirrors the
172
188
  // claude-code restart policy); an explicit resumeSessionId always wins.
@@ -180,6 +196,7 @@ export class CodexSdkAgent extends BaseAgent {
180
196
  : codex.startThread(threadOptions(options));
181
197
  const live: LiveThread = {
182
198
  thread,
199
+ codexPathOverride,
183
200
  model: options.model,
184
201
  thinkingLevel: effort,
185
202
  cwd: options.cwd,
@@ -198,26 +215,51 @@ export class CodexSdkAgent extends BaseAgent {
198
215
  ): AsyncIterableIterator<RawAgentEvent> {
199
216
  const controller = this.trackTurn(options.sessionId, options.signal);
200
217
  try {
201
- const live = await this.resolveThread(options);
202
- // Codex keeps the thread id on resume, so honored = same id came back.
203
- const resumeInfo = (id: string) => ({ resumed: id === options.resumeSessionId });
204
- if (live.thread.id) options.onNativeSession?.(live.thread.id, resumeInfo(live.thread.id));
205
-
206
- const { events } = await live.thread.runStreamed(toCodexInput(input), {
207
- signal: controller.signal,
208
- });
209
-
210
- let reported = Boolean(live.thread.id);
211
- for await (const event of events as AsyncGenerator<ThreadEvent>) {
212
- if (!reported && event.type === "thread.started" && event.thread_id) {
213
- reported = true;
214
- options.onNativeSession?.(event.thread_id, resumeInfo(event.thread_id));
218
+ for (let attempt = 0; attempt < 2; attempt++) {
219
+ if (controller.signal.aborted) throw new Error("turn aborted");
220
+ const attemptOptions = attempt === 0 ? options : { ...options, resumeSessionId: undefined };
221
+ const live = await this.resolveThread(attemptOptions, controller.signal);
222
+ if (controller.signal.aborted) throw new Error("turn aborted");
223
+ const resumeThreadId = live.thread.id;
224
+ let sawOutput = false;
225
+ let reported = false;
226
+ const reportSession = (id: string | null) => {
227
+ if (!reported && id) {
228
+ reported = true;
229
+ options.onNativeSession?.(id, { resumed: id === options.resumeSessionId });
230
+ }
231
+ };
232
+ try {
233
+ const { events } = await live.thread.runStreamed(toCodexInput(input), {
234
+ signal: controller.signal,
235
+ });
236
+ for await (const event of events as AsyncGenerator<ThreadEvent>) {
237
+ sawOutput = true;
238
+ reportSession(event.type === "thread.started" ? event.thread_id : live.thread.id);
239
+ yield event as RawAgentEvent;
240
+ }
241
+ reportSession(live.thread.id);
242
+ return;
243
+ } catch (error) {
244
+ // Only a proven missing rollout is safe to retry without its history.
245
+ // Temporary failures and cancellation must retain a valid native id.
246
+ const missingId =
247
+ error instanceof Error
248
+ ? /no rollout found for thread id ([^\s]+)/.exec(error.message)?.[1]
249
+ : undefined;
250
+ if (
251
+ attempt !== 0 ||
252
+ !resumeThreadId ||
253
+ missingId !== resumeThreadId ||
254
+ sawOutput ||
255
+ controller.signal.aborted ||
256
+ isCancellation(error)
257
+ ) {
258
+ throw error;
259
+ }
260
+ if (this.threads.get(options.sessionId) === live) this.threads.delete(options.sessionId);
215
261
  }
216
- yield event as RawAgentEvent;
217
262
  }
218
- // thread.id is populated once the turn has started.
219
- if (!reported && live.thread.id)
220
- options.onNativeSession?.(live.thread.id, resumeInfo(live.thread.id));
221
263
  } finally {
222
264
  this.endTurn(options.sessionId, controller);
223
265
  }