@zvada/agent-server 0.3.8 → 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 +10 -0
- package/docs/consuming.md +17 -0
- package/package.json +1 -1
- package/src/core/agents/base.ts +2 -0
- package/src/core/agents/claude-code/claude-agent.ts +10 -0
- package/src/core/agents/claude-code/generator-session.ts +136 -9
- package/src/core/agents/claude-code/session-manager.ts +9 -3
- package/src/core/runtime/agent-runtime.ts +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
3
13
|
## 0.3.8
|
|
4
14
|
|
|
5
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.
|
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.
|
|
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",
|
package/src/core/agents/base.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -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,6 +124,11 @@ 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;
|
|
103
133
|
/** SDK cost is cumulative within this query; token usage is already per turn. */
|
|
104
134
|
private cumulativeCost: number | undefined = 0;
|
|
@@ -136,6 +166,20 @@ export class ClaudeGeneratorSession {
|
|
|
136
166
|
get currentConfig(): Readonly<ClaudeSessionConfig> {
|
|
137
167
|
return this.config;
|
|
138
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
|
+
}
|
|
139
183
|
|
|
140
184
|
/** Hot-swap the model without losing context (Claude `setModel`). */
|
|
141
185
|
async setModel(model: string | undefined): Promise<void> {
|
|
@@ -152,19 +196,95 @@ export class ClaudeGeneratorSession {
|
|
|
152
196
|
*/
|
|
153
197
|
async setMcpServers(
|
|
154
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>,
|
|
155
211
|
): Promise<McpSetServersResult | undefined> {
|
|
156
212
|
if (this.config.disableTools || this.state === "terminated" || !this.query) return undefined;
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
+
),
|
|
164
277
|
);
|
|
165
278
|
}
|
|
166
279
|
this.config = { ...this.config, mcpServers: servers };
|
|
167
|
-
|
|
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
|
+
};
|
|
168
288
|
}
|
|
169
289
|
|
|
170
290
|
async start(): Promise<void> {
|
|
@@ -175,7 +295,10 @@ export class ClaudeGeneratorSession {
|
|
|
175
295
|
cwd: this.config.cwd,
|
|
176
296
|
});
|
|
177
297
|
const options = buildClaudeOptions(
|
|
178
|
-
|
|
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: {} },
|
|
179
302
|
this.sdkServers,
|
|
180
303
|
await this.extras?.sdkOptions?.({ sessionId: this.id, cwd: this.config.cwd }),
|
|
181
304
|
);
|
|
@@ -197,6 +320,10 @@ export class ClaudeGeneratorSession {
|
|
|
197
320
|
options,
|
|
198
321
|
});
|
|
199
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");
|
|
200
327
|
this.state = "idle";
|
|
201
328
|
this.resetIdleTimer();
|
|
202
329
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
155
|
-
configFingerprint(
|
|
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);
|