@zvada/agent-server 0.3.9 → 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.
@@ -2,9 +2,11 @@ import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"
2
2
  import type { McpSetServersResult } from "@anthropic-ai/claude-agent-sdk";
3
3
  import type { AgentCapabilities, AgentInput, McpServerConfig } from "../../../protocol/index.ts";
4
4
  import { type DiagnosticHandler, emitDiagnostic } from "../../diagnostics.ts";
5
+ import { SessionResumeError } from "../../utils/errors.ts";
5
6
  import type { AgentExecuteOptions, CancelResult, RawAgentEvent } from "../base.ts";
6
- import { BaseAgent } from "../base.ts";
7
+ import { SessionAgent } from "../session-agent.ts";
7
8
  import type {
9
+ ClaudeGeneratorSession,
8
10
  ClaudeHooksFactory,
9
11
  ClaudeSessionEndReason,
10
12
  ClaudeSessionExtras,
@@ -100,15 +102,15 @@ export interface ClaudeCodeAgentOptions {
100
102
  hooks?: ClaudeHooksFactory;
101
103
  /**
102
104
  * Called exactly once when a live session's subprocess ends, with why:
103
- * `idle` (idle-timeout eviction), `replaced` (config change forced a
104
- * restart), `released` (explicit release/close), `shutdown`
105
+ * `idle` (idle-timeout eviction), `replaced` (config change or host resume
106
+ * required a new process), `released` (explicit release/close), `shutdown`
105
107
  * (terminateAll). The seam for host-managed per-session resources — BYOK
106
108
  * proxy keys, recorders — instead of re-deriving termination from side
107
109
  * effects. NOTE: `replaced` sessions usually respawn immediately with
108
110
  * context preserved; drop only resources bound to the dead subprocess.
109
111
  */
110
112
  onSessionEnd?: (sessionId: string, reason: ClaudeSessionEndReason) => void;
111
- /** Operational diagnostics (interrupt timeouts, resume fallbacks). */
113
+ /** Operational diagnostics (interrupt timeouts). */
112
114
  onDiagnostic?: DiagnosticHandler;
113
115
  /**
114
116
  * Raw SDK option overrides merged over the engine's options at session
@@ -124,7 +126,7 @@ export interface ClaudeCodeAgentOptions {
124
126
  * in-session multi-turn and model hot-swap; falls back to SDK `resume` when a
125
127
  * session must be reconstructed (e.g. after idle eviction or across processes).
126
128
  */
127
- export class ClaudeCodeAgent extends BaseAgent {
129
+ export class ClaudeCodeAgent extends SessionAgent {
128
130
  readonly harness = "claude-code" as const;
129
131
  readonly capabilities = CAPABILITIES;
130
132
  private readonly manager: ClaudeSessionManager;
@@ -143,100 +145,73 @@ export class ClaudeCodeAgent extends BaseAgent {
143
145
  };
144
146
  }
145
147
 
146
- async *execute(
148
+ protected override async *executeTurn(
147
149
  input: AgentInput,
148
150
  options: AgentExecuteOptions,
151
+ controller: AbortController,
149
152
  ): AsyncIterableIterator<RawAgentEvent> {
150
- const controller = this.trackTurn(options.sessionId, options.signal);
153
+ let unconfirmedSession: ClaudeGeneratorSession | undefined;
154
+ let removeInterrupt: (() => void) | undefined;
151
155
  try {
156
+ controller.signal.throwIfAborted();
152
157
  const cliPath = await this.agentOptions.resolveCliPath?.();
153
158
  const content = toClaudeContent(input);
154
-
155
- // Attempt 0 honors resumeSessionId. The SDK does not degrade a bad
156
- // resume id into a fresh session — it fails the whole turn (result
157
- // error_during_execution before any output) — so a classified resume
158
- // failure is swallowed and the turn reruns ONCE on a fresh session,
159
- // reported as resumed:false (the structured fallback signal, acp parity).
160
- //
161
- // The fallback is gated on whether THIS spawn actually attempted a
162
- // resume (`attemptedResume`, surfaced by the manager) — NOT on the
163
- // caller's `resumeSessionId`. Restarting a live session on an
164
- // immutable-config change silently spawns a resume via
165
- // `claudeRestartConfig` (an auto-injected same-process resume), even
166
- // when the caller passed no `resumeSessionId`; gating on caller intent
167
- // would leave that invisible resume failure un-fallback'd. `attemptedResume`
168
- // is per-spawn and false on the warm hot-swap path (no new spawn), so a
169
- // hot-swapped session's stale stored `resumeSessionId` can't misfire it.
170
- // `resuming` (caller intent) below still drives the report/strip
171
- // semantics; only the resume-failure classifier reads the spawn truth.
172
- for (let attempt = 0; attempt < 2; attempt++) {
173
- if (controller.signal.aborted) break;
174
- const resuming = attempt === 0 && Boolean(options.resumeSessionId);
175
- const { session, attemptedResume } = await this.manager.getOrCreate(
176
- options.sessionId,
177
- {
178
- ...sessionConfigFrom(options, cliPath),
179
- ...(resuming ? {} : { resumeSessionId: undefined, resumeSessionAt: undefined }),
180
- },
181
- this.sessionExtras(),
182
- );
183
- // A cancel during connection repair must never submit the prompt.
184
- if (controller.signal.aborted) break;
185
-
186
- let reported = false;
187
- const report = (id: string) => {
188
- if (reported) return;
189
- reported = true;
190
- options.onNativeSession?.(id, { resumed: resuming });
159
+ controller.signal.throwIfAborted();
160
+ const { session, spawned } = await this.manager.getOrCreate(
161
+ options.sessionId,
162
+ sessionConfigFrom(options, cliPath),
163
+ this.sessionExtras(),
164
+ );
165
+ const attemptedResume = spawned && Boolean(options.resumeSessionId);
166
+ if (spawned) unconfirmedSession = session;
167
+ // A cancel during connection repair must never submit the prompt.
168
+ controller.signal.throwIfAborted();
169
+ let reported = false;
170
+ const report = (id: string) => {
171
+ if (reported) return;
172
+ options.onNativeSession?.(id, { resumed: Boolean(options.resumeSessionId) });
173
+ reported = true;
174
+ unconfirmedSession = undefined;
175
+ };
176
+ // A resume is only confirmed once the backend produces output.
177
+ if (!attemptedResume && session.currentSessionId) report(session.currentSessionId);
178
+ const tap = await session.sendMessage(content, options.turnId);
179
+ // Install the handler after sendMessage's idle wait so it cannot
180
+ // overwrite a preceding turn's permission handler.
181
+ session.permissionHandler = options.onPermissionRequest;
182
+ if (controller.signal.aborted) void session.interruptTurn();
183
+ const onAbort = () => void session.interruptTurn();
184
+ controller.signal.addEventListener("abort", onAbort, { once: true });
185
+ removeInterrupt = () => controller.signal.removeEventListener("abort", onAbort);
186
+ let sawOutput = false;
187
+ for await (const event of tap.events) {
188
+ const msg = event as {
189
+ type?: string;
190
+ session_id?: string;
191
+ subtype?: string;
192
+ errors?: string[];
191
193
  };
192
- // Resume turns defer the report until output proves the resume held —
193
- // a doomed attempt's id is never reported. Other turns report as soon
194
- // as an id is known.
195
- if (!resuming && session.currentSessionId) report(session.currentSessionId);
196
-
197
- // `sendMessage` waits until the session is idle (any prior turn
198
- // drained), then marks it busy — so installing the per-turn permission
199
- // handler AFTER it resolves can't clobber a still-running turn's
200
- // handler. The SDK processes the turn on later async tasks, so the
201
- // handler is in place before canUseTool can fire.
202
- const tap = await session.sendMessage(content, options.turnId);
203
- session.permissionHandler = options.onPermissionRequest;
204
- // An abort that landed while we were spawning/sending has no listener
205
- // yet — interrupt directly, then arm the listener for later aborts.
206
- if (controller.signal.aborted) void session.interruptTurn();
207
- controller.signal.addEventListener("abort", () => void session.interruptTurn(), {
208
- once: true,
209
- });
210
-
211
- let sawOutput = false;
212
- let resumeFailed = false;
213
- for await (const event of tap.events) {
214
- const msg = event as { type?: string; session_id?: string };
215
- if (msg.type === "assistant" || msg.type === "stream_event") sawOutput = true;
216
- if (
217
- attemptedResume &&
218
- !sawOutput &&
219
- !controller.signal.aborted &&
220
- claudeResumeFailed(event)
221
- ) {
222
- resumeFailed = true;
223
- continue;
224
- }
225
- if (msg.session_id && (!resuming || sawOutput)) report(msg.session_id);
226
- yield event as RawAgentEvent;
194
+ if (msg.type === "assistant" || msg.type === "stream_event") sawOutput = true;
195
+ if (
196
+ attemptedResume &&
197
+ !sawOutput &&
198
+ !controller.signal.aborted &&
199
+ claudeResumeFailed(event)
200
+ ) {
201
+ const reason = msg.errors?.join("; ") || "Claude failed before loading the conversation";
202
+ throw new SessionResumeError(session.currentConfig.resumeSessionId!, reason, {
203
+ cause: new Error(reason),
204
+ });
227
205
  }
228
-
229
- if (!resumeFailed) break;
230
- const failedResumeId = session.currentConfig.resumeSessionId;
231
- emitDiagnostic(this.agentOptions.onDiagnostic, {
232
- type: "resumeFallback",
233
- sessionId: options.sessionId,
234
- // Read the spawn's actual resume id (the auto-injected case has no
235
- // caller `resumeSessionId`), not the caller's value.
236
- message: `resume of ${failedResumeId} failed; re-running on a fresh session`,
237
- detail: { resumeSessionId: failedResumeId },
238
- });
239
- await this.manager.terminate(options.sessionId);
206
+ if (msg.session_id && (!attemptedResume || sawOutput || msg.subtype === "success"))
207
+ report(msg.session_id);
208
+ yield event as RawAgentEvent;
209
+ }
210
+ if (attemptedResume && unconfirmedSession && !controller.signal.aborted) {
211
+ throw new SessionResumeError(
212
+ session.currentConfig.resumeSessionId!,
213
+ "Claude ended before confirming the conversation was loaded",
214
+ );
240
215
  }
241
216
 
242
217
  // Ground truth for the adapter: an interrupted turn and a turn that
@@ -248,7 +223,10 @@ export class ClaudeCodeAgent extends BaseAgent {
248
223
  if (!controller.signal.aborted) throw error;
249
224
  yield { type: "turn_interrupted" };
250
225
  } finally {
251
- this.endTurn(options.sessionId, controller);
226
+ removeInterrupt?.();
227
+ // A cancelled/failed preparation must not leave an unconfirmed query
228
+ // looking like a healthy warm conversation on the next attempt.
229
+ await unconfirmedSession?.terminate();
252
230
  }
253
231
  }
254
232
 
@@ -264,7 +242,7 @@ export class ClaudeCodeAgent extends BaseAgent {
264
242
  return this.manager.setMcpServers(sessionId, servers);
265
243
  }
266
244
 
267
- /** Reattach remote MCP servers before the next turn after a host resumes. */
245
+ /** Resume saved history in a fresh subprocess on the next turn after host suspension. */
268
246
  invalidateMcpConnections(): void {
269
247
  this.manager.invalidateMcpConnections();
270
248
  }
@@ -8,7 +8,9 @@ import type {
8
8
  } from "@anthropic-ai/claude-agent-sdk";
9
9
  import { AsyncQueue } from "../../../protocol/index.ts";
10
10
  import type { McpServerConfig } from "../../../protocol/index.ts";
11
+ import { SessionResumeError } from "../../utils/errors.ts";
11
12
  import type { PermissionRequestHandler } from "../base.ts";
13
+ import { assertMcpConnected, mcpConnectionErrors, snapshotMcpServers } from "../mcp-config.ts";
12
14
  import { claudeToolMeta } from "../tool-meta.ts";
13
15
  import {
14
16
  type ClaudeSdkOptionOverrides,
@@ -91,15 +93,6 @@ function transientMcpFailure(errors: Record<string, string>): boolean {
91
93
  );
92
94
  }
93
95
 
94
- function assertMcpConnected(errors: Record<string, string>): void {
95
- const failed = Object.entries(errors);
96
- if (failed.length) {
97
- throw new Error(
98
- `mcp servers failed to connect: ${failed.map(([name, error]) => `${name} (${error})`).join(", ")}`,
99
- );
100
- }
101
- }
102
-
103
96
  /** Per-turn handle: iterate `events` until the turn's `result` arrives (or the turn errors). */
104
97
  export interface ClaudeEventTap {
105
98
  readonly events: AsyncIterable<SDKMessage>;
@@ -127,9 +120,9 @@ export class ClaudeGeneratorSession {
127
120
  private mcpUpdate = Promise.resolve();
128
121
  private mcpUpdateFailed = false;
129
122
  private readonly failedMcpServers = new Set<string>();
130
- private mcpConnectionGeneration = 0;
131
- private connectedMcpGeneration = 0;
123
+ private processInvalidated = false;
132
124
  private nativeSessionId: string | null = null;
125
+ private startupError: unknown;
133
126
  /** SDK cost is cumulative within this query; token usage is already per turn. */
134
127
  private cumulativeCost: number | undefined = 0;
135
128
 
@@ -153,7 +146,10 @@ export class ClaudeGeneratorSession {
153
146
  private readonly onTerminated?: (reason: ClaudeSessionEndReason) => void,
154
147
  private readonly extras?: ClaudeSessionExtras,
155
148
  ) {
156
- this.config = { ...config };
149
+ this.config = {
150
+ ...config,
151
+ mcpServers: config.mcpServers ? snapshotMcpServers(config.mcpServers) : undefined,
152
+ };
157
153
  this.idleTimeoutMs = config.idleTimeoutMs ?? 5 * 60_000;
158
154
  }
159
155
 
@@ -167,18 +163,18 @@ export class ClaudeGeneratorSession {
167
163
  return this.config;
168
164
  }
169
165
  get mcpConnectionsStale(): boolean {
170
- return (
171
- this.mcpUpdateFailed ||
172
- (this.mcpConnectionGeneration !== this.connectedMcpGeneration &&
173
- Object.values(this.config.mcpServers ?? {}).some(remoteMcpServer))
174
- );
166
+ return this.mcpUpdateFailed || this.processInvalidated;
167
+ }
168
+ /** MCP client replacement cannot clear the subprocess-wide HTTP pool. */
169
+ get needsProcessResume(): boolean {
170
+ return this.processInvalidated;
175
171
  }
176
172
 
177
173
  /** A thawed VM can retain clients whose sockets still appear connected. */
178
174
  invalidateMcpConnections(): void {
179
- // Also retain a signal that lands while the first remote server is
180
- // being attached and currentConfig still has no remote servers.
181
- this.mcpConnectionGeneration++;
175
+ // Latch until process replacement, even if an in-flight MCP update
176
+ // succeeds or the current config no longer contains remote servers.
177
+ this.processInvalidated = true;
182
178
  }
183
179
 
184
180
  /** Hot-swap the model without losing context (Claude `setModel`). */
@@ -198,7 +194,9 @@ export class ClaudeGeneratorSession {
198
194
  servers: Record<string, McpServerConfig>,
199
195
  ): Promise<McpSetServersResult | undefined> {
200
196
  // Explicit swaps and turn preparation share the same control channel.
201
- const update = this.mcpUpdate.then(() => this.updateMcpServers(servers));
197
+ // Snapshot before queuing: callers may reuse and mutate their config.
198
+ const snapshot = snapshotMcpServers(servers);
199
+ const update = this.mcpUpdate.then(() => this.updateMcpServers(snapshot));
202
200
  this.mcpUpdate = update.then(
203
201
  () => {},
204
202
  () => {},
@@ -210,14 +208,11 @@ export class ClaudeGeneratorSession {
210
208
  servers: Record<string, McpServerConfig>,
211
209
  ): Promise<McpSetServersResult | undefined> {
212
210
  if (this.config.disableTools || this.state === "terminated" || !this.query) return undefined;
211
+ if (this.needsProcessResume)
212
+ throw new Error("Claude process must resume after host suspension");
213
213
  const query = this.query;
214
- const reconnect = this.mcpConnectionsStale;
215
- const generation = this.mcpConnectionGeneration;
216
- const resetNames = Object.keys(servers).filter(
217
- (name) =>
218
- this.failedMcpServers.has(name) ||
219
- (generation !== this.connectedMcpGeneration && remoteMcpServer(servers[name]!)),
220
- );
214
+ const reconnect = this.mcpUpdateFailed;
215
+ const resetNames = Object.keys(servers).filter((name) => this.failedMcpServers.has(name));
221
216
  const desired = { ...servers, ...this.sdkServers };
222
217
  const added = new Set<string>();
223
218
  const removed = new Set<string>();
@@ -263,23 +258,16 @@ export class ClaudeGeneratorSession {
263
258
  result = await reattach(failed);
264
259
  }
265
260
  check(result.errors ?? {});
266
- if (reconnect || retry) {
261
+ // In-process SDK servers initialize lazily at the first prompt and are
262
+ // absent from startup status. This barrier checks external connections.
263
+ const required = Object.keys(desired).filter((name) => desired[name]?.type !== "sdk");
264
+ if (required.length) {
267
265
  const statuses = await query.mcpServerStatus();
268
- check(
269
- Object.fromEntries(
270
- Object.keys(desired).flatMap((name) => {
271
- const status = statuses.find((entry) => entry.name === name);
272
- return status?.status === "connected"
273
- ? []
274
- : [[name, status?.error ?? `server is ${status?.status ?? "missing"}`]];
275
- }),
276
- ),
277
- );
266
+ check(mcpConnectionErrors(required, statuses));
278
267
  }
279
268
  this.config = { ...this.config, mcpServers: servers };
280
269
  this.mcpUpdateFailed = false;
281
270
  this.failedMcpServers.clear();
282
- this.connectedMcpGeneration = generation;
283
271
  return {
284
272
  added: [...added],
285
273
  removed: [...removed],
@@ -315,15 +303,31 @@ export class ClaudeGeneratorSession {
315
303
  policy: this.extras?.toolPolicy,
316
304
  getBroker: () => this.permissionHandler,
317
305
  });
306
+ // Release/shutdown may finish while an async options/tool factory is
307
+ // pending. Never create a query after its owner has already terminated.
308
+ if (this.currentState === "terminated") throw new Error("Session terminated during startup");
318
309
  this.query = sdk.query({
319
310
  prompt: this.promptQueue as AsyncIterable<SDKUserMessage>,
320
311
  options,
321
312
  });
322
- void this.consumeEvents();
313
+ const consumption = this.consumeEvents();
323
314
  if (!this.config.disableTools && Object.keys(this.config.mcpServers ?? {}).length) {
324
- await this.setMcpServers(this.config.mcpServers!);
315
+ try {
316
+ await this.setMcpServers(this.config.mcpServers!);
317
+ } catch (error) {
318
+ // Closing after an early result also rejects pending MCP controls.
319
+ // The SDK rejects controls before its iterator delivers that result.
320
+ if (error instanceof Error && error.message === "Query closed before response received") {
321
+ await consumption;
322
+ }
323
+ throw this.startupError ?? error;
324
+ }
325
325
  }
326
- if (this.currentState === "terminated") throw new Error("Session terminated during startup");
326
+ if (this.startupError) throw this.startupError;
327
+ // TypeScript retains the pre-query narrowing across await; termination can
328
+ // happen while the initial MCP attachment is in flight.
329
+ if ((this.currentState as SessionState) === "terminated")
330
+ throw new Error("Session terminated during startup");
327
331
  this.state = "idle";
328
332
  this.resetIdleTimer();
329
333
  }
@@ -331,9 +335,17 @@ export class ClaudeGeneratorSession {
331
335
  /** Push a user message; returns a tap that streams this turn's events. */
332
336
  async sendMessage(content: ClaudeContent, turnId?: string): Promise<ClaudeEventTap> {
333
337
  while (this.state !== "idle") {
334
- if (this.state === "terminated") throw new Error("Session is terminated");
338
+ if (this.state === "terminated")
339
+ throw this.startupError ?? new Error("Session is terminated");
335
340
  await this.waitForIdle();
336
341
  }
342
+ // Invalidation can land during preparation or the idle wait. Never
343
+ // enqueue a prompt on a process whose network pool predates suspension.
344
+ if (this.needsProcessResume || (!this.config.disableTools && this.mcpConnectionsStale)) {
345
+ throw new Error(
346
+ "MCP network connections changed during turn preparation; session is not ready",
347
+ );
348
+ }
337
349
  this.clearIdleTimer();
338
350
  this.state = "busy";
339
351
  // After the idle wait, so a queued turn can't relabel the in-flight one.
@@ -415,6 +427,14 @@ export class ClaudeGeneratorSession {
415
427
  }
416
428
  if (event.type === "conversation_reset") this.cumulativeCost = 0;
417
429
  if (event.type === "result") {
430
+ if (!this.currentTap && event.subtype !== "success") {
431
+ const reason = event.errors.join("; ") || "Claude failed during startup";
432
+ throw this.config.resumeSessionId
433
+ ? new SessionResumeError(this.config.resumeSessionId, reason, {
434
+ cause: new Error(reason),
435
+ })
436
+ : new Error(reason);
437
+ }
418
438
  // Keep the native total intact for raw diagnostics. A crash can
419
439
  // report a zeroed total; that does not establish this turn's cost.
420
440
  const result = {
@@ -435,6 +455,7 @@ export class ClaudeGeneratorSession {
435
455
  }
436
456
  }
437
457
  } catch (err) {
458
+ if (!this.currentTap) this.startupError = err;
438
459
  this.currentTap?.fail(err);
439
460
  } finally {
440
461
  await this.terminate();
@@ -5,10 +5,10 @@ import { ClaudeGeneratorSession } from "./generator-session.ts";
5
5
  import type { ClaudeSessionEndReason, ClaudeSessionExtras } from "./generator-session.ts";
6
6
  import type { ClaudeSessionConfig } from "./options.ts";
7
7
 
8
- /** The selected session and whether this invocation spawned it with a resume target. */
8
+ /** The selected session and whether this invocation owns its new startup. */
9
9
  export interface ClaudeSessionSpawn {
10
10
  session: ClaudeGeneratorSession;
11
- attemptedResume: boolean;
11
+ spawned: boolean;
12
12
  }
13
13
 
14
14
  /** Directory-set identity: order and duplicates don't change the sandbox surface. */
@@ -27,7 +27,7 @@ export function claudeSessionNeedsRestart(
27
27
  (prev.permissionMode ?? "") !== (next.permissionMode ?? "") ||
28
28
  // thinking/effort are fixed at spawn in the modern SDK vocabulary (the
29
29
  // old setMaxThinkingTokens hot-swap is deprecated and effort-blind), so
30
- // a level change restarts — context survives via claudeRestartConfig.
30
+ // a level change restarts with the prepared resume target.
31
31
  (prev.thinkingLevel ?? "") !== (next.thinkingLevel ?? "") ||
32
32
  (prev.systemPromptAppend ?? "") !== (next.systemPromptAppend ?? "") ||
33
33
  // A rewind (resumeSessionAt) is applied by the SDK only at spawn, so a
@@ -48,26 +48,12 @@ export function claudeSessionNeedsRestart(
48
48
  );
49
49
  }
50
50
 
51
- /** Preserve context within the same cwd/credential boundary; explicit resume always wins. */
52
- export function claudeRestartConfig(
53
- previous: ClaudeSessionConfig,
54
- next: ClaudeSessionConfig,
55
- nativeSessionId: string | null,
56
- ): ClaudeSessionConfig {
57
- if (next.resumeSessionId || !nativeSessionId) return next;
58
- const sameResumeBoundary =
59
- previous.cwd === next.cwd &&
60
- (previous.apiKey ?? "") === (next.apiKey ?? "") &&
61
- configFingerprint(previous.env) === configFingerprint(next.env);
62
- return sameResumeBoundary ? { ...next, resumeSessionId: nativeSessionId } : next;
63
- }
64
-
65
51
  /**
66
52
  * One live `ClaudeGeneratorSession` per logical session id. Decides, per turn,
67
53
  * whether the existing session can be reused (hot-swapping model/mcp) or must
68
54
  * be torn down and recreated (cwd / permission / thinking / system-prompt
69
- * changes alter the subprocess in ways the live query can't). `resumeSessionId`
70
- * is read only at spawn — it never forces a restart on a warm session.
55
+ * changes alter the subprocess in ways the live query can't). A different
56
+ * resume target must also spawn; a warm process cannot switch conversations.
71
57
  */
72
58
  export class ClaudeSessionManager {
73
59
  private readonly sessions = new Map<string, ClaudeGeneratorSession>();
@@ -81,27 +67,31 @@ export class ClaudeSessionManager {
81
67
  config: ClaudeSessionConfig,
82
68
  extras?: ClaudeSessionExtras,
83
69
  ): Promise<ClaudeSessionSpawn> {
84
- let startConfig = config;
85
70
  const existing = this.sessions.get(sessionId);
86
71
  if (existing && existing.currentState !== "terminated") {
87
- if (this.needsRestart(existing.currentConfig, config)) {
88
- startConfig = claudeRestartConfig(
89
- existing.currentConfig,
90
- config,
91
- existing.currentSessionId,
92
- );
72
+ const differentTarget =
73
+ config.resumeSessionId &&
74
+ config.resumeSessionId !== existing.currentSessionId &&
75
+ config.resumeSessionId !== existing.currentConfig.resumeSessionId;
76
+ if (
77
+ differentTarget ||
78
+ existing.needsProcessResume ||
79
+ claudeSessionNeedsRestart(existing.currentConfig, config)
80
+ ) {
81
+ // Host suspension invalidates the process-wide HTTP pool, including
82
+ // sockets for removed MCP servers. Use the same strict saved-history
83
+ // resume as idle eviction, with the next turn's prepared config.
93
84
  await existing.terminate("replaced");
94
85
  this.sessions.delete(sessionId);
95
86
  } else {
96
87
  await this.hotSwapIfNeeded(existing, config);
97
- // A warm session's stored resume id describes its original spawn.
98
- return { session: existing, attemptedResume: false };
88
+ return { session: existing, spawned: false };
99
89
  }
100
90
  }
101
91
 
102
92
  const session = new ClaudeGeneratorSession(
103
93
  sessionId,
104
- startConfig,
94
+ config,
105
95
  (reason) => {
106
96
  if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId);
107
97
  this.onSessionEnd?.(sessionId, reason);
@@ -117,9 +107,7 @@ export class ClaudeSessionManager {
117
107
  if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId);
118
108
  throw err;
119
109
  }
120
- // Whether THIS spawn carried a `resumeSessionId`: an explicit caller resume
121
- // OR the manager's auto-injected resume on an immutable-config restart.
122
- return { session, attemptedResume: Boolean(startConfig.resumeSessionId) };
110
+ return { session, spawned: true };
123
111
  }
124
112
 
125
113
  get(sessionId: string): ClaudeGeneratorSession | undefined {
@@ -142,10 +130,6 @@ export class ClaudeSessionManager {
142
130
  for (const session of this.sessions.values()) session.invalidateMcpConnections();
143
131
  }
144
132
 
145
- private needsRestart(prev: ClaudeSessionConfig, next: ClaudeSessionConfig): boolean {
146
- return claudeSessionNeedsRestart(prev, next);
147
- }
148
-
149
133
  private async hotSwapIfNeeded(
150
134
  session: ClaudeGeneratorSession,
151
135
  next: ClaudeSessionConfig,