@zvada/agent-server 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.9
4
+
5
+ - Recover transient Claude MCP setup failures by reattaching with the current
6
+ credentials, preserving the process and conversation. Failed updates remain
7
+ dirty until recovery is verified; an identical SDK retry cannot silently
8
+ admit a turn with unavailable tools. Cancelling during preparation never
9
+ submits the prompt.
10
+ - Add the embed-tier `runtime.invalidateMcpConnections()` lifecycle signal for
11
+ resumed hosts, including turns whose remote MCP configuration is unchanged.
12
+
13
+ ## 0.3.8
14
+
15
+ - Report each Claude turn's cost as the increase in the SDK's cumulative query cost. Preserve the native total in raw diagnostics, reset the baseline with a fresh query or conversation reset, and keep unknown cost distinct from reported zero.
16
+
3
17
  ## 0.3.7
4
18
 
5
19
  - Retain Claude's reported 5-minute and 1-hour cache-creation token counts in the existing token usage contract. Missing durations stay absent and reported zeroes are preserved.
package/docs/consuming.md CHANGED
@@ -235,6 +235,23 @@ and unknown event/part types are forward-compat rather than violations. Pass
235
235
 
236
236
  ## Own your session resources
237
237
 
238
+ ### Resume a suspended host
239
+
240
+ After thawing a VM, call `runtime.invalidateMcpConnections()` before admitting
241
+ new turns. This embed-tier lifecycle signal retains conversation state and
242
+ turn-admission receipts. The Claude harness reattaches remote MCP servers
243
+ before the next prompt, using that turn's current `mcpServers` credentials,
244
+ even when the configuration has not changed. It leaves in-process host tools
245
+ attached. Other harnesses without this lifecycle hook are unaffected.
246
+
247
+ Claude MCP updates also repair a transient socket setup failure once. Recovery
248
+ removes and re-adds the affected connection: an identical SDK update can cache
249
+ a failed client, and the SDK's reconnect-by-name command can restore startup
250
+ credentials. Persistent connection failures and invalid credentials fail the
251
+ turn before prompt submission. Tool calls and model turns are never replayed.
252
+
253
+ ### Release a session
254
+
238
255
  Register `onSessionEnd` (claude options) to release per-session resources —
239
256
  BYOK proxy keys, recorders — instead of re-deriving termination from side
240
257
  effects. Reasons: `idle` (idle-timeout eviction), `replaced` (config change
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvada/agent-server",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "Harness-agnostic agent execution engine: run Claude Code, Codex (SDK/CLI + app-server), and any ACP agent behind one interface with a normalized event stream, multi-turn sessions, and resume. Root export is the wire contract; /core, /server, /client are the seats.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -105,6 +105,8 @@ export interface Agent {
105
105
  cancel(sessionId: string): Promise<CancelResult>;
106
106
  /** Release all harness-owned state for one idle logical session. */
107
107
  release?(sessionId: string): Promise<void>;
108
+ /** Mark retained MCP connections stale after host suspension; repair before the next turn. */
109
+ invalidateMcpConnections?(): void;
108
110
  /** Tear down every live session (process shutdown). */
109
111
  terminateAll(): Promise<void>;
110
112
  }
@@ -93,6 +93,8 @@ type ClaudeMessage =
93
93
  usage?: RawUsage;
94
94
  modelUsage?: Record<string, { contextWindow?: number }>;
95
95
  total_cost_usd?: number;
96
+ /** Per-turn delta from the generator tap; null means unreported. */
97
+ turn_cost_usd?: number | null;
96
98
  stop_reason?: string | null;
97
99
  is_error?: boolean;
98
100
  };
@@ -519,7 +521,7 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
519
521
  },
520
522
  };
521
523
  }
522
- if (typeof msg.total_cost_usd === "number") this.cost = msg.total_cost_usd;
524
+ this.cost = "turn_cost_usd" in msg ? (msg.turn_cost_usd ?? undefined) : msg.total_cost_usd;
523
525
  this.finishReason = msg.stop_reason ?? msg.subtype ?? this.finishReason;
524
526
  this.stopReason = mapClaudeStopReason(msg.stop_reason, msg.subtype);
525
527
  if (msg.subtype && msg.subtype !== "success") {
@@ -180,6 +180,8 @@ export class ClaudeCodeAgent extends BaseAgent {
180
180
  },
181
181
  this.sessionExtras(),
182
182
  );
183
+ // A cancel during connection repair must never submit the prompt.
184
+ if (controller.signal.aborted) break;
183
185
 
184
186
  let reported = false;
185
187
  const report = (id: string) => {
@@ -242,6 +244,9 @@ export class ClaudeCodeAgent extends BaseAgent {
242
244
  // result shape (error_during_execution, null stop_reason). Only the
243
245
  // agent knows whether an interrupt was actually requested.
244
246
  if (controller.signal.aborted) yield { type: "turn_interrupted" };
247
+ } catch (error) {
248
+ if (!controller.signal.aborted) throw error;
249
+ yield { type: "turn_interrupted" };
245
250
  } finally {
246
251
  this.endTurn(options.sessionId, controller);
247
252
  }
@@ -259,6 +264,11 @@ export class ClaudeCodeAgent extends BaseAgent {
259
264
  return this.manager.setMcpServers(sessionId, servers);
260
265
  }
261
266
 
267
+ /** Reattach remote MCP servers before the next turn after a host resumes. */
268
+ invalidateMcpConnections(): void {
269
+ this.manager.invalidateMcpConnections();
270
+ }
271
+
262
272
  override async cancel(sessionId: string): Promise<CancelResult> {
263
273
  // Abort FIRST: the controller ends the engine's execute loop, so this
264
274
  // turn classifies as cancelled even when the generator drains to a
@@ -75,6 +75,31 @@ export interface ClaudeSessionExtras {
75
75
  type SessionState = "starting" | "idle" | "busy" | "terminated";
76
76
  type ClaudeContent = SDKUserMessage["message"]["content"];
77
77
 
78
+ function remoteMcpServer(server: McpServerConfig): boolean {
79
+ return server.type === "http" || server.type === "sse";
80
+ }
81
+
82
+ /** Connection setup only: never retry a model turn or a tool invocation. */
83
+ function transientMcpFailure(errors: Record<string, string>): boolean {
84
+ return (
85
+ Object.keys(errors).length > 0 &&
86
+ Object.values(errors).every((error) =>
87
+ /socket connection was closed unexpectedly|\bECONNRESET\b|\bEPIPE\b|\bETIMEDOUT\b/i.test(
88
+ error,
89
+ ),
90
+ )
91
+ );
92
+ }
93
+
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
+
78
103
  /** Per-turn handle: iterate `events` until the turn's `result` arrives (or the turn errors). */
79
104
  export interface ClaudeEventTap {
80
105
  readonly events: AsyncIterable<SDKMessage>;
@@ -99,7 +124,14 @@ export class ClaudeGeneratorSession {
99
124
  currentTurnId?: string;
100
125
  /** Resolved once at spawn; re-merged into every hot-swap. */
101
126
  private sdkServers?: SdkMcpServers;
127
+ private mcpUpdate = Promise.resolve();
128
+ private mcpUpdateFailed = false;
129
+ private readonly failedMcpServers = new Set<string>();
130
+ private mcpConnectionGeneration = 0;
131
+ private connectedMcpGeneration = 0;
102
132
  private nativeSessionId: string | null = null;
133
+ /** SDK cost is cumulative within this query; token usage is already per turn. */
134
+ private cumulativeCost: number | undefined = 0;
103
135
 
104
136
  private currentTap: AsyncQueue<SDKMessage> | null = null;
105
137
  private readonly idleWaiters: Array<() => void> = [];
@@ -134,6 +166,20 @@ export class ClaudeGeneratorSession {
134
166
  get currentConfig(): Readonly<ClaudeSessionConfig> {
135
167
  return this.config;
136
168
  }
169
+ get mcpConnectionsStale(): boolean {
170
+ return (
171
+ this.mcpUpdateFailed ||
172
+ (this.mcpConnectionGeneration !== this.connectedMcpGeneration &&
173
+ Object.values(this.config.mcpServers ?? {}).some(remoteMcpServer))
174
+ );
175
+ }
176
+
177
+ /** A thawed VM can retain clients whose sockets still appear connected. */
178
+ 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++;
182
+ }
137
183
 
138
184
  /** Hot-swap the model without losing context (Claude `setModel`). */
139
185
  async setModel(model: string | undefined): Promise<void> {
@@ -150,19 +196,95 @@ export class ClaudeGeneratorSession {
150
196
  */
151
197
  async setMcpServers(
152
198
  servers: Record<string, McpServerConfig>,
199
+ ): Promise<McpSetServersResult | undefined> {
200
+ // Explicit swaps and turn preparation share the same control channel.
201
+ const update = this.mcpUpdate.then(() => this.updateMcpServers(servers));
202
+ this.mcpUpdate = update.then(
203
+ () => {},
204
+ () => {},
205
+ );
206
+ return update;
207
+ }
208
+
209
+ private async updateMcpServers(
210
+ servers: Record<string, McpServerConfig>,
153
211
  ): Promise<McpSetServersResult | undefined> {
154
212
  if (this.config.disableTools || this.state === "terminated" || !this.query) return undefined;
155
- const result = await this.query.setMcpServers({ ...servers, ...this.sdkServers });
156
- const failed = Object.entries(result.errors ?? {});
157
- if (failed.length) {
158
- // Do NOT sync config: the fingerprint stays different, so the next
159
- // turn retries instead of reporting a failed set as attached.
160
- throw new Error(
161
- `mcp servers failed to connect: ${failed.map(([n, e]) => `${n} (${e})`).join(", ")}`,
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
+ );
221
+ const desired = { ...servers, ...this.sdkServers };
222
+ const added = new Set<string>();
223
+ const removed = new Set<string>();
224
+ const applyDesired = async () => {
225
+ const result = await query.setMcpServers(desired);
226
+ for (const name of result.added) added.add(name);
227
+ for (const name of result.removed) removed.add(name);
228
+ return result;
229
+ };
230
+ // A failed SDK update is not transactional: it can cache the new config
231
+ // with a failed client, then return errors:{} for the identical retry.
232
+ // Keep that uncertainty until a verified reattachment succeeds.
233
+ this.mcpUpdateFailed = true;
234
+ const check = (errors: Record<string, string>) => {
235
+ for (const name of Object.keys(errors)) this.failedMcpServers.add(name);
236
+ assertMcpConnected(errors);
237
+ };
238
+ const reattach = async (names: string[]) => {
239
+ const detached = { ...desired };
240
+ for (const name of names) {
241
+ if (!this.sdkServers?.[name]) delete detached[name];
242
+ }
243
+ check((await query.setMcpServers(detached)).errors ?? {});
244
+ return applyDesired();
245
+ };
246
+ let result = await applyDesired();
247
+ let retried = false;
248
+ if (reconnect) {
249
+ if (!transientMcpFailure(result.errors ?? {})) check(result.errors ?? {});
250
+ retried = Object.keys(result.errors ?? {}).length > 0;
251
+ result = await reattach([...new Set([...resetNames, ...Object.keys(result.errors ?? {})])]);
252
+ }
253
+ const failed = Object.keys(result.errors ?? {});
254
+ const retry =
255
+ !retried &&
256
+ transientMcpFailure(result.errors ?? {}) &&
257
+ failed.every(
258
+ (name) => servers[name] && remoteMcpServer(servers[name]!) && !this.sdkServers?.[name],
259
+ );
260
+ if (retry) {
261
+ // reconnectMcpServer(name) can restore the CLI's STARTUP credentials.
262
+ // Removing and adding uses this turn's exact configuration instead.
263
+ result = await reattach(failed);
264
+ }
265
+ check(result.errors ?? {});
266
+ if (reconnect || retry) {
267
+ 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
+ ),
162
277
  );
163
278
  }
164
279
  this.config = { ...this.config, mcpServers: servers };
165
- return result;
280
+ this.mcpUpdateFailed = false;
281
+ this.failedMcpServers.clear();
282
+ this.connectedMcpGeneration = generation;
283
+ return {
284
+ added: [...added],
285
+ removed: [...removed],
286
+ errors: result.errors,
287
+ };
166
288
  }
167
289
 
168
290
  async start(): Promise<void> {
@@ -173,7 +295,10 @@ export class ClaudeGeneratorSession {
173
295
  cwd: this.config.cwd,
174
296
  });
175
297
  const options = buildClaudeOptions(
176
- this.config,
298
+ // CLI startup MCP entries can survive the first dynamic removal and
299
+ // reconnect-by-name can reuse their original credentials. Give every
300
+ // wire server one owner: register it dynamically before the first turn.
301
+ { ...this.config, mcpServers: {} },
177
302
  this.sdkServers,
178
303
  await this.extras?.sdkOptions?.({ sessionId: this.id, cwd: this.config.cwd }),
179
304
  );
@@ -195,6 +320,10 @@ export class ClaudeGeneratorSession {
195
320
  options,
196
321
  });
197
322
  void this.consumeEvents();
323
+ if (!this.config.disableTools && Object.keys(this.config.mcpServers ?? {}).length) {
324
+ await this.setMcpServers(this.config.mcpServers!);
325
+ }
326
+ if (this.currentState === "terminated") throw new Error("Session terminated during startup");
198
327
  this.state = "idle";
199
328
  this.resetIdleTimer();
200
329
  }
@@ -284,8 +413,26 @@ export class ClaudeGeneratorSession {
284
413
  if (event.type === "system" && event.subtype === "init" && event.session_id) {
285
414
  this.nativeSessionId = event.session_id;
286
415
  }
287
- this.currentTap?.push(event);
288
- if (event.type === "result") this.onTurnComplete();
416
+ if (event.type === "conversation_reset") this.cumulativeCost = 0;
417
+ if (event.type === "result") {
418
+ // Keep the native total intact for raw diagnostics. A crash can
419
+ // report a zeroed total; that does not establish this turn's cost.
420
+ const result = {
421
+ ...event,
422
+ turn_cost_usd:
423
+ this.cumulativeCost !== undefined && event.total_cost_usd >= this.cumulativeCost
424
+ ? event.total_cost_usd - this.cumulativeCost
425
+ : null,
426
+ };
427
+ this.cumulativeCost =
428
+ this.cumulativeCost !== undefined && event.total_cost_usd < this.cumulativeCost
429
+ ? undefined
430
+ : event.total_cost_usd;
431
+ this.currentTap?.push(result);
432
+ this.onTurnComplete();
433
+ } else {
434
+ this.currentTap?.push(event);
435
+ }
289
436
  }
290
437
  } catch (err) {
291
438
  this.currentTap?.fail(err);
@@ -113,7 +113,8 @@ export class ClaudeSessionManager {
113
113
  await session.start();
114
114
  } catch (err) {
115
115
  // Don't leave a half-started session in the map — it could never serve a turn.
116
- this.sessions.delete(sessionId);
116
+ await session.terminate();
117
+ if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId);
117
118
  throw err;
118
119
  }
119
120
  // Whether THIS spawn carried a `resumeSessionId`: an explicit caller resume
@@ -137,6 +138,10 @@ export class ClaudeSessionManager {
137
138
  this.sessions.clear();
138
139
  }
139
140
 
141
+ invalidateMcpConnections(): void {
142
+ for (const session of this.sessions.values()) session.invalidateMcpConnections();
143
+ }
144
+
140
145
  private needsRestart(prev: ClaudeSessionConfig, next: ClaudeSessionConfig): boolean {
141
146
  return claudeSessionNeedsRestart(prev, next);
142
147
  }
@@ -151,8 +156,9 @@ export class ClaudeSessionManager {
151
156
  // Text-only sessions must stay tool-free when the MCP configuration changes.
152
157
  if (
153
158
  !next.disableTools &&
154
- configFingerprint(next.mcpServers ?? {}) !==
155
- configFingerprint(session.currentConfig.mcpServers ?? {})
159
+ (session.mcpConnectionsStale ||
160
+ configFingerprint(next.mcpServers ?? {}) !==
161
+ configFingerprint(session.currentConfig.mcpServers ?? {}))
156
162
  ) {
157
163
  await session.setMcpServers(next.mcpServers ?? {});
158
164
  }
@@ -508,6 +508,17 @@ export class AgentRuntime {
508
508
  return await this.registry.getAgent(harness).cancel(sessionId);
509
509
  }
510
510
 
511
+ /**
512
+ * Call after the host resumes from suspension, before admitting new turns.
513
+ * Retains conversations and admission receipts; supported harnesses repair
514
+ * remote MCP connections lazily with the next turn's current credentials.
515
+ */
516
+ invalidateMcpConnections(): void {
517
+ for (const harness of this.registry.list()) {
518
+ this.registry.getAgent(harness).invalidateMcpConnections?.();
519
+ }
520
+ }
521
+
511
522
  /** Dispose one idle logical session and its harness-native resources. */
512
523
  async closeSession(harness: AgentHarness, sessionId: string): Promise<void> {
513
524
  this.completedTurns.delete(sessionId);