@zvada/agent-server 0.3.8 → 0.3.10

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.
@@ -1,3 +1,6 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, resolve } from "node:path";
1
4
  import type {
2
5
  AgentCapabilities,
3
6
  AgentInput,
@@ -5,20 +8,28 @@ import type {
5
8
  PermissionToolCall,
6
9
  } from "../../../protocol/index.ts";
7
10
  import { AsyncQueue, codexReasoningEffort } from "../../../protocol/index.ts";
11
+ import { SessionResumeError } from "../../utils/errors.ts";
8
12
  import type {
9
13
  AgentExecuteOptions,
10
14
  CancelResult,
11
15
  PermissionRequestHandler,
12
16
  RawAgentEvent,
13
17
  } from "../base.ts";
14
- import { BaseAgent } from "../base.ts";
15
18
  import { configFingerprint } from "../config-fingerprint.ts";
19
+ import { snapshotMcpServers } from "../mcp-config.ts";
20
+ import { SessionAgent } from "../session-agent.ts";
16
21
  import { SessionStore } from "../session-store.ts";
17
22
  import {
18
23
  CodexAppServerClient,
19
24
  type CodexAppServerClientOptions,
20
25
  type CodexNotification,
21
26
  } from "./client.ts";
27
+ import {
28
+ assertCodexMcpConfig,
29
+ assertCodexMcpReady,
30
+ codexMcpServers,
31
+ writeCodexMcpServers,
32
+ } from "./mcp.ts";
22
33
 
23
34
  const CAPABILITIES: AgentCapabilities = {
24
35
  multiTurn: true,
@@ -27,7 +38,7 @@ const CAPABILITIES: AgentCapabilities = {
27
38
  modelSwitch: "in-session",
28
39
  thinkingLevels: true,
29
40
  images: true,
30
- mcpServers: false,
41
+ mcpServers: true,
31
42
  permissionRequests: true,
32
43
  };
33
44
 
@@ -41,6 +52,8 @@ export interface AppServerSession {
41
52
  envFingerprint: string;
42
53
  /** Native model connections retain the identity used at their first handshake. */
43
54
  authFingerprint?: string;
55
+ /** Last native config write; readiness is checked separately before every prompt. */
56
+ mcpFingerprint?: string;
44
57
  /** Credentials and permission broker belong only to the current execute. */
45
58
  turn?: AppServerTurn;
46
59
  idleTimer?: ReturnType<typeof setTimeout>;
@@ -213,10 +226,13 @@ export function appServerNotificationEndsTurn(notification: CodexNotification):
213
226
  * thread is created per logical session and reused for multi-turn; model/effort
214
227
  * are passed per `turn/start`. Resume uses `thread/resume` with the native id.
215
228
  */
216
- export class CodexAppServerAgent extends BaseAgent {
229
+ export class CodexAppServerAgent extends SessionAgent {
217
230
  readonly harness = "codex-app-server" as const;
218
231
  readonly capabilities = CAPABILITIES;
219
232
  private readonly sessions = new SessionStore<AppServerSession>();
233
+ private readonly invalidatedClients = new WeakSet<CodexAppServerClient>();
234
+ /** Home ownership outlives idle process eviction and failed preparation. */
235
+ private readonly mcpHomes = new Map<string, string>();
220
236
  /** A cancel acknowledgement belongs to one invocation, even after it drains. */
221
237
  private readonly turnInterruptState = new WeakMap<
222
238
  AbortController,
@@ -230,6 +246,7 @@ export class CodexAppServerAgent extends BaseAgent {
230
246
  private async resolveSession(
231
247
  options: AgentExecuteOptions,
232
248
  turn: AppServerTurn,
249
+ mcpServers: Record<string, unknown> | undefined,
233
250
  ): Promise<AppServerSession> {
234
251
  const { signal } = turn;
235
252
  signal.throwIfAborted();
@@ -240,9 +257,13 @@ export class CodexAppServerAgent extends BaseAgent {
240
257
  // Codex caches an authenticated model WebSocket across turns. Login alone
241
258
  // cannot switch its identity: restart on account/key changes and resume
242
259
  // the same thread. Same-account token renewal can keep the process warm.
243
- let resumeThreadId = options.resumeSessionId;
260
+ const resumeThreadId = options.resumeSessionId;
244
261
  if (existing && !existing.client.closed) {
245
- if (appServerSessionCompatible(existing, options) && existing.authFingerprint === identity) {
262
+ if (
263
+ appServerSessionCompatible(existing, options) &&
264
+ existing.authFingerprint === identity &&
265
+ !this.invalidatedClients.has(existing.client)
266
+ ) {
246
267
  this.sessions.clearIdle(existing);
247
268
  existing.turn = turn;
248
269
  const onAbort = () => existing.client.close();
@@ -258,15 +279,7 @@ export class CodexAppServerAgent extends BaseAgent {
258
279
  signal.removeEventListener("abort", onAbort);
259
280
  }
260
281
  }
261
- // Preserve context within the same cwd/env boundary (mirrors the
262
- // claude-code restart policy); an explicit resumeSessionId always wins.
263
- if (
264
- resumeThreadId === undefined &&
265
- existing.cwd === options.cwd &&
266
- existing.envFingerprint === configFingerprint(options.env)
267
- ) {
268
- resumeThreadId = existing.threadId;
269
- }
282
+
270
283
  this.sessions.close(options.sessionId);
271
284
  }
272
285
 
@@ -301,6 +314,8 @@ export class CodexAppServerAgent extends BaseAgent {
301
314
  signal.throwIfAborted();
302
315
  await client.initialize();
303
316
  await this.authenticate(session);
317
+ // Clear stale per-turn credentials before thread startup can use them.
318
+ await this.configureMcp(session, mcpServers);
304
319
 
305
320
  const startParams: Record<string, unknown> = {
306
321
  cwd: options.cwd,
@@ -313,24 +328,28 @@ export class CodexAppServerAgent extends BaseAgent {
313
328
  if (options.systemPromptAppend)
314
329
  startParams.developerInstructions = options.systemPromptAppend;
315
330
 
316
- // A dead/unknown resume target falls back to a fresh thread. Auth was
317
- // established above, outside this fallback, so a login failure cannot
318
- // silently start a different conversation or use ambient credentials.
319
- let threadId: string | undefined;
320
- if (resumeThreadId) {
321
- threadId = await client
322
- .request("thread/resume", { threadId: resumeThreadId, ...startParams })
323
- .then((r) => (r as { thread?: { id?: string } })?.thread?.id ?? resumeThreadId)
324
- .catch(() => undefined);
325
- }
326
- signal.throwIfAborted();
327
- if (!threadId) {
328
- const result = (await client.request("thread/start", startParams)) as {
329
- thread?: { id?: string };
330
- };
331
- threadId = result?.thread?.id;
332
- if (!threadId) throw new Error("codex app-server thread/start returned no thread id");
331
+ const method = resumeThreadId ? "thread/resume" : "thread/start";
332
+ const result = (await client
333
+ .request(
334
+ method,
335
+ resumeThreadId ? { threadId: resumeThreadId, ...startParams } : startParams,
336
+ )
337
+ .catch((cause: unknown) => {
338
+ if (!resumeThreadId) throw cause;
339
+ throw new SessionResumeError(
340
+ resumeThreadId,
341
+ cause instanceof Error ? cause.message : String(cause),
342
+ { cause },
343
+ );
344
+ })) as { thread?: { id?: string } };
345
+ const threadId = result?.thread?.id;
346
+ if (resumeThreadId && threadId !== resumeThreadId) {
347
+ throw new SessionResumeError(
348
+ resumeThreadId,
349
+ `Codex returned ${threadId ? `a different thread: ${threadId}` : "no thread id"}`,
350
+ );
333
351
  }
352
+ if (!threadId) throw new Error("codex app-server thread/start returned no thread id");
334
353
  signal.throwIfAborted();
335
354
  session.threadId = threadId;
336
355
  this.sessions.set(options.sessionId, session);
@@ -346,6 +365,45 @@ export class CodexAppServerAgent extends BaseAgent {
346
365
  }
347
366
  }
348
367
 
368
+ private managedMcpHome(options: AgentExecuteOptions, hasServers: boolean): boolean {
369
+ const home = options.env?.CODEX_HOME;
370
+ if (!home) {
371
+ if (hasServers)
372
+ throw new Error("Codex per-turn MCP requires a session-specific env.CODEX_HOME");
373
+ return false;
374
+ }
375
+ if (!isAbsolute(home)) throw new Error("Codex session CODEX_HOME must be an absolute path");
376
+ const shared = resolve(process.env.CODEX_HOME ?? `${homedir()}/.codex`);
377
+ if (resolve(home) === shared) {
378
+ throw new Error("Codex per-turn MCP cannot use the shared user CODEX_HOME");
379
+ }
380
+ const path = realpathSync(home);
381
+ if (existsSync(shared) && path === realpathSync(shared)) {
382
+ throw new Error("Codex per-turn MCP cannot use the shared user CODEX_HOME");
383
+ }
384
+ const owner = this.mcpHomes.get(path);
385
+ if (owner && owner !== options.sessionId) {
386
+ throw new Error("Codex CODEX_HOME is already owned by another session");
387
+ }
388
+ this.mcpHomes.set(path, options.sessionId);
389
+ return true;
390
+ }
391
+
392
+ private async configureMcp(
393
+ session: AppServerSession,
394
+ servers: Record<string, unknown> | undefined,
395
+ ): Promise<void> {
396
+ if (servers === undefined) return;
397
+ const fingerprint = configFingerprint(servers);
398
+ const changed = session.mcpFingerprint !== fingerprint;
399
+ // A rejected write or changed project layer invalidates the cached identity.
400
+ session.mcpFingerprint = undefined;
401
+ if (changed) await writeCodexMcpServers(session.client, servers);
402
+ await assertCodexMcpConfig(session.client, session.cwd, servers);
403
+ if (changed) await session.client.request("config/mcpServer/reload", undefined);
404
+ session.mcpFingerprint = fingerprint;
405
+ }
406
+
349
407
  private async authenticate(session: AppServerSession): Promise<void> {
350
408
  session.turn?.signal.throwIfAborted();
351
409
  const auth = session.turn?.auth;
@@ -430,11 +488,11 @@ export class CodexAppServerAgent extends BaseAgent {
430
488
  });
431
489
  }
432
490
 
433
- async *execute(
491
+ protected override async *executeTurn(
434
492
  input: AgentInput,
435
493
  options: AgentExecuteOptions,
494
+ controller: AbortController,
436
495
  ): AsyncIterableIterator<RawAgentEvent> {
437
- const controller = this.trackTurn(options.sessionId, options.signal);
438
496
  const turn: AppServerTurn = {
439
497
  permissionHandler: options.onPermissionRequest,
440
498
  signal: controller.signal,
@@ -455,16 +513,36 @@ export class CodexAppServerAgent extends BaseAgent {
455
513
  let turnEnded = false;
456
514
  try {
457
515
  controller.signal.throwIfAborted();
516
+ const requestedMcp = codexMcpServers(snapshotMcpServers(options.mcpServers));
517
+ const mcpServers = this.managedMcpHome(options, Object.keys(requestedMcp).length > 0)
518
+ ? requestedMcp
519
+ : undefined;
458
520
  if (this.agentOptions.resolveAuth) {
459
521
  turn.auth = await this.agentOptions.resolveAuth(options.sessionId);
460
522
  }
461
523
  controller.signal.throwIfAborted();
462
- session = await this.resolveSession(options, turn);
524
+ session = await this.resolveSession(options, turn, mcpServers);
463
525
  options.onNativeSession?.(session.threadId, {
464
526
  resumed: session.threadId === options.resumeSessionId,
465
527
  });
466
528
  const threadId = session.threadId;
467
529
  const client = session.client;
530
+ const abortPreparation = () => client.close();
531
+ controller.signal.addEventListener("abort", abortPreparation, { once: true });
532
+ try {
533
+ controller.signal.throwIfAborted();
534
+ await this.configureMcp(session, mcpServers);
535
+ try {
536
+ await assertCodexMcpReady(client, threadId, Object.keys(mcpServers ?? {}));
537
+ } catch (error) {
538
+ // A later explicit turn must reload failed clients even if config is unchanged.
539
+ session.mcpFingerprint = undefined;
540
+ throw error;
541
+ }
542
+ controller.signal.throwIfAborted();
543
+ } finally {
544
+ controller.signal.removeEventListener("abort", abortPreparation);
545
+ }
468
546
 
469
547
  let turnId: string | undefined;
470
548
  const forceCancel = () => {
@@ -544,6 +622,10 @@ export class CodexAppServerAgent extends BaseAgent {
544
622
  if (options.thinkingLevel) turnParams.effort = codexReasoningEffort(options.thinkingLevel);
545
623
 
546
624
  if (controller.signal.aborted) throw new Error("turn aborted");
625
+ if (this.invalidatedClients.has(client)) {
626
+ client.close();
627
+ throw new Error("Codex process was invalidated before turn admission");
628
+ }
547
629
  client.request("turn/start", turnParams).catch((err: unknown) => {
548
630
  if (controller.signal.aborted) {
549
631
  forceCancel();
@@ -563,7 +645,6 @@ export class CodexAppServerAgent extends BaseAgent {
563
645
  clearStall();
564
646
  if (cancelTimer) clearTimeout(cancelTimer);
565
647
  for (const off of unsubscribe) off();
566
- this.endTurn(options.sessionId, controller);
567
648
  if (session?.turn === turn) {
568
649
  session.turn = undefined;
569
650
  if (!session.client.closed) {
@@ -593,13 +674,33 @@ export class CodexAppServerAgent extends BaseAgent {
593
674
  return { confirmed, hadTurn: true };
594
675
  }
595
676
 
677
+ /** Unchanged MCP config reloads retain dead remote connections after VM resume. */
678
+ invalidateMcpConnections(): void {
679
+ // Keep SessionAgent's conversation targets and runtime admission receipts.
680
+ // Transport recovery can signal this while a turn is running: let that
681
+ // turn finish, then use the existing strict thread/resume path.
682
+ const active = new Set(
683
+ [...this.sessions.values()]
684
+ .filter((session) => session.turn)
685
+ .map((session) => session.client),
686
+ );
687
+ for (const client of this.sessions.clients()) {
688
+ this.invalidatedClients.add(client);
689
+ if (!active.has(client)) client.close();
690
+ }
691
+ }
692
+
596
693
  override async release(sessionId: string): Promise<void> {
597
694
  await super.release(sessionId);
598
695
  this.sessions.close(sessionId);
696
+ for (const [home, owner] of this.mcpHomes) {
697
+ if (owner === sessionId) this.mcpHomes.delete(home);
698
+ }
599
699
  }
600
700
 
601
701
  override async terminateAll(): Promise<void> {
602
702
  await super.terminateAll();
603
703
  this.sessions.closeAll();
704
+ this.mcpHomes.clear();
604
705
  }
605
706
  }
@@ -0,0 +1,139 @@
1
+ import { AsyncQueue, type McpServerConfig } from "../../../protocol/index.ts";
2
+ import { configFingerprint } from "../config-fingerprint.ts";
3
+ import { assertMcpConnected, mcpConnectionErrors } from "../mcp-config.ts";
4
+ import type { CodexAppServerClient } from "./client.ts";
5
+
6
+ /** Codex supports stdio and Streamable HTTP, not the legacy SSE transport. */
7
+ export function codexMcpServers(servers: Record<string, McpServerConfig>): Record<string, unknown> {
8
+ return Object.fromEntries(
9
+ Object.entries(servers).map(([name, server]) => {
10
+ if (name === "codex_apps") throw new Error("MCP server name codex_apps is reserved by Codex");
11
+ if (server.type === "sse") {
12
+ throw new Error(`Codex MCP server ${name}: legacy SSE is unsupported; use Streamable HTTP`);
13
+ }
14
+ return [
15
+ name,
16
+ server.type === "stdio"
17
+ ? {
18
+ command: server.command,
19
+ ...(server.args && { args: server.args }),
20
+ ...(server.env && { env: server.env }),
21
+ }
22
+ : { url: server.url, ...(server.headers && { http_headers: server.headers }) },
23
+ ];
24
+ }),
25
+ );
26
+ }
27
+
28
+ /** Only call against an explicitly isolated, engine-managed CODEX_HOME. */
29
+ export async function writeCodexMcpServers(
30
+ client: CodexAppServerClient,
31
+ servers: Record<string, unknown>,
32
+ ): Promise<void> {
33
+ const result = (await client.request("config/batchWrite", {
34
+ edits: [{ keyPath: "mcp_servers", value: servers, mergeStrategy: "replace" }],
35
+ // Check project-layer overrides before any connection can use credentials.
36
+ reloadUserConfig: false,
37
+ })) as { status?: string; overriddenMetadata?: unknown };
38
+ if (result.status !== "ok" || result.overriddenMetadata) {
39
+ throw new Error(
40
+ "Codex MCP configuration was not applied; check higher-priority native settings",
41
+ );
42
+ }
43
+ }
44
+
45
+ function normalizedConfig(value: unknown): unknown {
46
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
47
+ const config = { ...(value as Record<string, unknown>) };
48
+ // Defaults materialized by config/read; preserve every non-default field.
49
+ if (config.environment_id === "local") config.environment_id = undefined;
50
+ if (config.enabled === true) config.enabled = undefined;
51
+ if (config.tool_timeout_sec === null) config.tool_timeout_sec = undefined;
52
+ if (typeof config.command === "string") {
53
+ config.args ??= [];
54
+ config.env ??= {};
55
+ } else if (typeof config.url === "string") {
56
+ config.http_headers ??= {};
57
+ }
58
+ return config;
59
+ }
60
+
61
+ /** User-layer write acknowledgements do not account for repository overrides. */
62
+ export async function assertCodexMcpConfig(
63
+ client: CodexAppServerClient,
64
+ cwd: string,
65
+ servers: Record<string, unknown>,
66
+ ): Promise<void> {
67
+ if (!Object.keys(servers).length) return;
68
+ const result = (await client.request("config/read", { cwd })) as {
69
+ config?: { mcp_servers?: Record<string, unknown> };
70
+ };
71
+ for (const [name, server] of Object.entries(servers)) {
72
+ if (
73
+ configFingerprint(normalizedConfig(result.config?.mcp_servers?.[name])) !==
74
+ configFingerprint(normalizedConfig(server))
75
+ ) {
76
+ throw new Error(`Codex MCP server ${name} is overridden by native settings`);
77
+ }
78
+ }
79
+ }
80
+
81
+ export async function assertCodexMcpReady(
82
+ client: CodexAppServerClient,
83
+ threadId: string,
84
+ required: string[],
85
+ ): Promise<void> {
86
+ if (!required.length) return;
87
+ // A status snapshot can arrive while startup is still in progress. Subscribe
88
+ // first so completion during the request cannot be lost. Native notifications
89
+ // trigger another authoritative read; no setup, model or tool is retried.
90
+ const updates = new AsyncQueue<void>();
91
+ const offStatus = client.onNotification(({ method, params }) => {
92
+ if (
93
+ method === "mcpServer/startupStatus/updated" &&
94
+ params.threadId === threadId &&
95
+ required.includes(String(params.name))
96
+ )
97
+ updates.push(undefined);
98
+ });
99
+ const offClose = client.onClose(() => updates.fail(new Error("Codex closed during MCP startup")));
100
+ const deadline = Date.now() + 30_000;
101
+ const timer = setTimeout(() => updates.fail(new Error("Codex MCP startup timed out")), 30_000);
102
+ try {
103
+ for (;;) {
104
+ const statuses: Array<{ name: string; status: string | null }> = [];
105
+ let cursor: string | null | undefined;
106
+ do {
107
+ const page = (await client.request(
108
+ "mcpServerStatus/list",
109
+ { threadId, detail: "toolsAndAuthOnly", limit: 100, ...(cursor && { cursor }) },
110
+ Math.max(1, deadline - Date.now()),
111
+ )) as {
112
+ data?: Array<{ name: string; runtimeStatus: string | null }>;
113
+ nextCursor?: string | null;
114
+ };
115
+ if (!Array.isArray(page.data)) throw new Error("Codex returned no MCP readiness status");
116
+ statuses.push(
117
+ ...page.data.map(({ name, runtimeStatus }) => ({ name, status: runtimeStatus })),
118
+ );
119
+ cursor = page.nextCursor;
120
+ } while (cursor);
121
+ const starting = required.filter(
122
+ (name) => statuses.find((entry) => entry.name === name)?.status === "starting",
123
+ );
124
+ assertMcpConnected(
125
+ mcpConnectionErrors(
126
+ required.filter((name) => !starting.includes(name)),
127
+ statuses,
128
+ ),
129
+ );
130
+ if (!starting.length) return;
131
+ await updates.next();
132
+ }
133
+ } finally {
134
+ clearTimeout(timer);
135
+ offStatus();
136
+ offClose();
137
+ updates.end();
138
+ }
139
+ }
@@ -1,9 +1,10 @@
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, isCancellation } from "../../../protocol/index.ts";
3
+ import { codexReasoningEffort } from "../../../protocol/index.ts";
4
+ import { SessionResumeError } from "../../utils/errors.ts";
4
5
  import type { AgentExecuteOptions, RawAgentEvent } from "../base.ts";
5
- import { BaseAgent } from "../base.ts";
6
6
  import { configFingerprint } from "../config-fingerprint.ts";
7
+ import { SessionAgent } from "../session-agent.ts";
7
8
 
8
9
  const CAPABILITIES: AgentCapabilities = {
9
10
  multiTurn: true,
@@ -132,7 +133,7 @@ export interface CodexSdkAgentOptions {
132
133
  * `resumeThread(nativeId)`. Streams at item granularity — the adapter derives
133
134
  * text deltas by diffing successive item snapshots.
134
135
  */
135
- export class CodexSdkAgent extends BaseAgent {
136
+ export class CodexSdkAgent extends SessionAgent {
136
137
  readonly harness = "codex-sdk" as const;
137
138
  readonly capabilities = CAPABILITIES;
138
139
  private readonly threads = new Map<string, LiveThread>();
@@ -184,13 +185,7 @@ export class CodexSdkAgent extends BaseAgent {
184
185
  const codex = await this.getCodex(options, codexPathOverride);
185
186
  if (signal?.aborted) throw new Error("turn aborted");
186
187
  const fingerprint = credentialsFingerprint(options);
187
- // Preserve context within the same cwd/credential boundary (mirrors the
188
- // claude-code restart policy); an explicit resumeSessionId always wins.
189
- const resumeThreadId =
190
- options.resumeSessionId ??
191
- (existing && existing.cwd === options.cwd && existing.credentialsFingerprint === fingerprint
192
- ? existing.thread.id
193
- : undefined);
188
+ const resumeThreadId = options.resumeSessionId;
194
189
  const thread = resumeThreadId
195
190
  ? codex.resumeThread(resumeThreadId, threadOptions(options))
196
191
  : codex.startThread(threadOptions(options));
@@ -209,59 +204,38 @@ export class CodexSdkAgent extends BaseAgent {
209
204
  return live;
210
205
  }
211
206
 
212
- async *execute(
207
+ protected override async *executeTurn(
213
208
  input: AgentInput,
214
209
  options: AgentExecuteOptions,
210
+ controller: AbortController,
215
211
  ): AsyncIterableIterator<RawAgentEvent> {
216
- const controller = this.trackTurn(options.sessionId, options.signal);
217
- try {
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
- }
212
+ controller.signal.throwIfAborted();
213
+ const live = await this.resolveThread(options, controller.signal);
214
+ controller.signal.throwIfAborted();
215
+ let reported = false;
216
+ const { events } = await live.thread.runStreamed(toCodexInput(input), {
217
+ signal: controller.signal,
218
+ });
219
+ for await (const event of events as AsyncGenerator<ThreadEvent>) {
220
+ if (event.type === "thread.started") {
221
+ if (options.resumeSessionId && event.thread_id !== options.resumeSessionId) {
222
+ // The SDK mutates Thread.id before yielding this event. Discard
223
+ // that wrapper; the retained resume target remains unchanged.
260
224
  if (this.threads.get(options.sessionId) === live) this.threads.delete(options.sessionId);
225
+ controller.abort();
226
+ throw new SessionResumeError(
227
+ options.resumeSessionId,
228
+ `Codex returned a different thread: ${event.thread_id}`,
229
+ );
230
+ }
231
+ if (!reported) {
232
+ reported = true;
233
+ options.onNativeSession?.(event.thread_id, {
234
+ resumed: Boolean(options.resumeSessionId),
235
+ });
261
236
  }
262
237
  }
263
- } finally {
264
- this.endTurn(options.sessionId, controller);
238
+ yield event as RawAgentEvent;
265
239
  }
266
240
  }
267
241
 
@@ -0,0 +1,31 @@
1
+ import type { McpServerConfig } from "../../protocol/index.ts";
2
+
3
+ /** A turn owns its wire configuration even when the caller reuses its objects. */
4
+ export function snapshotMcpServers(
5
+ servers: Record<string, McpServerConfig> | undefined,
6
+ ): Record<string, McpServerConfig> {
7
+ return structuredClone(servers ?? {});
8
+ }
9
+
10
+ export function mcpConnectionErrors(
11
+ required: string[],
12
+ statuses: Array<{ name: string; status: string | null; error?: string }>,
13
+ ): Record<string, string> {
14
+ return Object.fromEntries(
15
+ required.flatMap((name) => {
16
+ const status = statuses.find((entry) => entry.name === name);
17
+ return status?.status === "connected"
18
+ ? []
19
+ : [[name, status?.error ?? `server is ${status?.status ?? "missing"}`]];
20
+ }),
21
+ );
22
+ }
23
+
24
+ export function assertMcpConnected(errors: Record<string, string>): void {
25
+ const failed = Object.entries(errors);
26
+ if (failed.length) {
27
+ throw new Error(
28
+ `mcp servers failed to connect: ${failed.map(([name, error]) => `${name} (${error})`).join(", ")}`,
29
+ );
30
+ }
31
+ }