@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.
- package/CHANGELOG.md +51 -0
- package/docs/consuming.md +122 -9
- package/docs/harnesses.md +8 -3
- package/package.json +1 -1
- package/src/core/agents/acp/acp-agent.ts +29 -29
- package/src/core/agents/base.ts +19 -8
- package/src/core/agents/claude-code/claude-agent.ts +78 -90
- package/src/core/agents/claude-code/generator-session.ts +160 -12
- package/src/core/agents/claude-code/session-manager.ts +27 -37
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +136 -35
- package/src/core/agents/codex-app-server/mcp.ts +139 -0
- package/src/core/agents/codex-sdk/codex-sdk-agent.ts +31 -57
- package/src/core/agents/mcp-config.ts +31 -0
- package/src/core/agents/session-agent.ts +91 -0
- package/src/core/agents/session-store.ts +6 -0
- package/src/core/diagnostics.ts +1 -8
- package/src/core/index.ts +2 -0
- package/src/core/presets.ts +1 -1
- package/src/core/runtime/agent-runtime.ts +17 -8
- package/src/core/runtime/event-processor.ts +10 -5
- package/src/core/utils/errors.ts +21 -0
- package/src/protocol/config.ts +1 -1
- package/src/protocol/errors.ts +11 -0
- package/src/protocol/lifecycle.ts +2 -1
- package/src/server/acp/binding.ts +12 -0
|
@@ -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 {
|
|
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
|
|
104
|
-
*
|
|
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
|
|
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
|
|
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,98 +145,73 @@ export class ClaudeCodeAgent extends BaseAgent {
|
|
|
143
145
|
};
|
|
144
146
|
}
|
|
145
147
|
|
|
146
|
-
async *
|
|
148
|
+
protected override async *executeTurn(
|
|
147
149
|
input: AgentInput,
|
|
148
150
|
options: AgentExecuteOptions,
|
|
151
|
+
controller: AbortController,
|
|
149
152
|
): AsyncIterableIterator<RawAgentEvent> {
|
|
150
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
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[];
|
|
189
193
|
};
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
session.permissionHandler = options.onPermissionRequest;
|
|
202
|
-
// An abort that landed while we were spawning/sending has no listener
|
|
203
|
-
// yet — interrupt directly, then arm the listener for later aborts.
|
|
204
|
-
if (controller.signal.aborted) void session.interruptTurn();
|
|
205
|
-
controller.signal.addEventListener("abort", () => void session.interruptTurn(), {
|
|
206
|
-
once: true,
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
let sawOutput = false;
|
|
210
|
-
let resumeFailed = false;
|
|
211
|
-
for await (const event of tap.events) {
|
|
212
|
-
const msg = event as { type?: string; session_id?: string };
|
|
213
|
-
if (msg.type === "assistant" || msg.type === "stream_event") sawOutput = true;
|
|
214
|
-
if (
|
|
215
|
-
attemptedResume &&
|
|
216
|
-
!sawOutput &&
|
|
217
|
-
!controller.signal.aborted &&
|
|
218
|
-
claudeResumeFailed(event)
|
|
219
|
-
) {
|
|
220
|
-
resumeFailed = true;
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
if (msg.session_id && (!resuming || sawOutput)) report(msg.session_id);
|
|
224
|
-
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
|
+
});
|
|
225
205
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
detail: { resumeSessionId: failedResumeId },
|
|
236
|
-
});
|
|
237
|
-
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
|
+
);
|
|
238
215
|
}
|
|
239
216
|
|
|
240
217
|
// Ground truth for the adapter: an interrupted turn and a turn that
|
|
@@ -242,8 +219,14 @@ export class ClaudeCodeAgent extends BaseAgent {
|
|
|
242
219
|
// result shape (error_during_execution, null stop_reason). Only the
|
|
243
220
|
// agent knows whether an interrupt was actually requested.
|
|
244
221
|
if (controller.signal.aborted) yield { type: "turn_interrupted" };
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (!controller.signal.aborted) throw error;
|
|
224
|
+
yield { type: "turn_interrupted" };
|
|
245
225
|
} finally {
|
|
246
|
-
|
|
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();
|
|
247
230
|
}
|
|
248
231
|
}
|
|
249
232
|
|
|
@@ -259,6 +242,11 @@ export class ClaudeCodeAgent extends BaseAgent {
|
|
|
259
242
|
return this.manager.setMcpServers(sessionId, servers);
|
|
260
243
|
}
|
|
261
244
|
|
|
245
|
+
/** Resume saved history in a fresh subprocess on the next turn after host suspension. */
|
|
246
|
+
invalidateMcpConnections(): void {
|
|
247
|
+
this.manager.invalidateMcpConnections();
|
|
248
|
+
}
|
|
249
|
+
|
|
262
250
|
override async cancel(sessionId: string): Promise<CancelResult> {
|
|
263
251
|
// Abort FIRST: the controller ends the engine's execute loop, so this
|
|
264
252
|
// turn classifies as cancelled even when the generator drains to a
|
|
@@ -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,
|
|
@@ -75,6 +77,22 @@ export interface ClaudeSessionExtras {
|
|
|
75
77
|
type SessionState = "starting" | "idle" | "busy" | "terminated";
|
|
76
78
|
type ClaudeContent = SDKUserMessage["message"]["content"];
|
|
77
79
|
|
|
80
|
+
function remoteMcpServer(server: McpServerConfig): boolean {
|
|
81
|
+
return server.type === "http" || server.type === "sse";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Connection setup only: never retry a model turn or a tool invocation. */
|
|
85
|
+
function transientMcpFailure(errors: Record<string, string>): boolean {
|
|
86
|
+
return (
|
|
87
|
+
Object.keys(errors).length > 0 &&
|
|
88
|
+
Object.values(errors).every((error) =>
|
|
89
|
+
/socket connection was closed unexpectedly|\bECONNRESET\b|\bEPIPE\b|\bETIMEDOUT\b/i.test(
|
|
90
|
+
error,
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
78
96
|
/** Per-turn handle: iterate `events` until the turn's `result` arrives (or the turn errors). */
|
|
79
97
|
export interface ClaudeEventTap {
|
|
80
98
|
readonly events: AsyncIterable<SDKMessage>;
|
|
@@ -99,7 +117,12 @@ export class ClaudeGeneratorSession {
|
|
|
99
117
|
currentTurnId?: string;
|
|
100
118
|
/** Resolved once at spawn; re-merged into every hot-swap. */
|
|
101
119
|
private sdkServers?: SdkMcpServers;
|
|
120
|
+
private mcpUpdate = Promise.resolve();
|
|
121
|
+
private mcpUpdateFailed = false;
|
|
122
|
+
private readonly failedMcpServers = new Set<string>();
|
|
123
|
+
private processInvalidated = false;
|
|
102
124
|
private nativeSessionId: string | null = null;
|
|
125
|
+
private startupError: unknown;
|
|
103
126
|
/** SDK cost is cumulative within this query; token usage is already per turn. */
|
|
104
127
|
private cumulativeCost: number | undefined = 0;
|
|
105
128
|
|
|
@@ -123,7 +146,10 @@ export class ClaudeGeneratorSession {
|
|
|
123
146
|
private readonly onTerminated?: (reason: ClaudeSessionEndReason) => void,
|
|
124
147
|
private readonly extras?: ClaudeSessionExtras,
|
|
125
148
|
) {
|
|
126
|
-
this.config = {
|
|
149
|
+
this.config = {
|
|
150
|
+
...config,
|
|
151
|
+
mcpServers: config.mcpServers ? snapshotMcpServers(config.mcpServers) : undefined,
|
|
152
|
+
};
|
|
127
153
|
this.idleTimeoutMs = config.idleTimeoutMs ?? 5 * 60_000;
|
|
128
154
|
}
|
|
129
155
|
|
|
@@ -136,6 +162,20 @@ export class ClaudeGeneratorSession {
|
|
|
136
162
|
get currentConfig(): Readonly<ClaudeSessionConfig> {
|
|
137
163
|
return this.config;
|
|
138
164
|
}
|
|
165
|
+
get mcpConnectionsStale(): boolean {
|
|
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;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A thawed VM can retain clients whose sockets still appear connected. */
|
|
174
|
+
invalidateMcpConnections(): void {
|
|
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;
|
|
178
|
+
}
|
|
139
179
|
|
|
140
180
|
/** Hot-swap the model without losing context (Claude `setModel`). */
|
|
141
181
|
async setModel(model: string | undefined): Promise<void> {
|
|
@@ -152,19 +192,87 @@ export class ClaudeGeneratorSession {
|
|
|
152
192
|
*/
|
|
153
193
|
async setMcpServers(
|
|
154
194
|
servers: Record<string, McpServerConfig>,
|
|
195
|
+
): Promise<McpSetServersResult | undefined> {
|
|
196
|
+
// Explicit swaps and turn preparation share the same control channel.
|
|
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));
|
|
200
|
+
this.mcpUpdate = update.then(
|
|
201
|
+
() => {},
|
|
202
|
+
() => {},
|
|
203
|
+
);
|
|
204
|
+
return update;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private async updateMcpServers(
|
|
208
|
+
servers: Record<string, McpServerConfig>,
|
|
155
209
|
): Promise<McpSetServersResult | undefined> {
|
|
156
210
|
if (this.config.disableTools || this.state === "terminated" || !this.query) return undefined;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
211
|
+
if (this.needsProcessResume)
|
|
212
|
+
throw new Error("Claude process must resume after host suspension");
|
|
213
|
+
const query = this.query;
|
|
214
|
+
const reconnect = this.mcpUpdateFailed;
|
|
215
|
+
const resetNames = Object.keys(servers).filter((name) => this.failedMcpServers.has(name));
|
|
216
|
+
const desired = { ...servers, ...this.sdkServers };
|
|
217
|
+
const added = new Set<string>();
|
|
218
|
+
const removed = new Set<string>();
|
|
219
|
+
const applyDesired = async () => {
|
|
220
|
+
const result = await query.setMcpServers(desired);
|
|
221
|
+
for (const name of result.added) added.add(name);
|
|
222
|
+
for (const name of result.removed) removed.add(name);
|
|
223
|
+
return result;
|
|
224
|
+
};
|
|
225
|
+
// A failed SDK update is not transactional: it can cache the new config
|
|
226
|
+
// with a failed client, then return errors:{} for the identical retry.
|
|
227
|
+
// Keep that uncertainty until a verified reattachment succeeds.
|
|
228
|
+
this.mcpUpdateFailed = true;
|
|
229
|
+
const check = (errors: Record<string, string>) => {
|
|
230
|
+
for (const name of Object.keys(errors)) this.failedMcpServers.add(name);
|
|
231
|
+
assertMcpConnected(errors);
|
|
232
|
+
};
|
|
233
|
+
const reattach = async (names: string[]) => {
|
|
234
|
+
const detached = { ...desired };
|
|
235
|
+
for (const name of names) {
|
|
236
|
+
if (!this.sdkServers?.[name]) delete detached[name];
|
|
237
|
+
}
|
|
238
|
+
check((await query.setMcpServers(detached)).errors ?? {});
|
|
239
|
+
return applyDesired();
|
|
240
|
+
};
|
|
241
|
+
let result = await applyDesired();
|
|
242
|
+
let retried = false;
|
|
243
|
+
if (reconnect) {
|
|
244
|
+
if (!transientMcpFailure(result.errors ?? {})) check(result.errors ?? {});
|
|
245
|
+
retried = Object.keys(result.errors ?? {}).length > 0;
|
|
246
|
+
result = await reattach([...new Set([...resetNames, ...Object.keys(result.errors ?? {})])]);
|
|
247
|
+
}
|
|
248
|
+
const failed = Object.keys(result.errors ?? {});
|
|
249
|
+
const retry =
|
|
250
|
+
!retried &&
|
|
251
|
+
transientMcpFailure(result.errors ?? {}) &&
|
|
252
|
+
failed.every(
|
|
253
|
+
(name) => servers[name] && remoteMcpServer(servers[name]!) && !this.sdkServers?.[name],
|
|
164
254
|
);
|
|
255
|
+
if (retry) {
|
|
256
|
+
// reconnectMcpServer(name) can restore the CLI's STARTUP credentials.
|
|
257
|
+
// Removing and adding uses this turn's exact configuration instead.
|
|
258
|
+
result = await reattach(failed);
|
|
259
|
+
}
|
|
260
|
+
check(result.errors ?? {});
|
|
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) {
|
|
265
|
+
const statuses = await query.mcpServerStatus();
|
|
266
|
+
check(mcpConnectionErrors(required, statuses));
|
|
165
267
|
}
|
|
166
268
|
this.config = { ...this.config, mcpServers: servers };
|
|
167
|
-
|
|
269
|
+
this.mcpUpdateFailed = false;
|
|
270
|
+
this.failedMcpServers.clear();
|
|
271
|
+
return {
|
|
272
|
+
added: [...added],
|
|
273
|
+
removed: [...removed],
|
|
274
|
+
errors: result.errors,
|
|
275
|
+
};
|
|
168
276
|
}
|
|
169
277
|
|
|
170
278
|
async start(): Promise<void> {
|
|
@@ -175,7 +283,10 @@ export class ClaudeGeneratorSession {
|
|
|
175
283
|
cwd: this.config.cwd,
|
|
176
284
|
});
|
|
177
285
|
const options = buildClaudeOptions(
|
|
178
|
-
|
|
286
|
+
// CLI startup MCP entries can survive the first dynamic removal and
|
|
287
|
+
// reconnect-by-name can reuse their original credentials. Give every
|
|
288
|
+
// wire server one owner: register it dynamically before the first turn.
|
|
289
|
+
{ ...this.config, mcpServers: {} },
|
|
179
290
|
this.sdkServers,
|
|
180
291
|
await this.extras?.sdkOptions?.({ sessionId: this.id, cwd: this.config.cwd }),
|
|
181
292
|
);
|
|
@@ -192,11 +303,31 @@ export class ClaudeGeneratorSession {
|
|
|
192
303
|
policy: this.extras?.toolPolicy,
|
|
193
304
|
getBroker: () => this.permissionHandler,
|
|
194
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");
|
|
195
309
|
this.query = sdk.query({
|
|
196
310
|
prompt: this.promptQueue as AsyncIterable<SDKUserMessage>,
|
|
197
311
|
options,
|
|
198
312
|
});
|
|
199
|
-
|
|
313
|
+
const consumption = this.consumeEvents();
|
|
314
|
+
if (!this.config.disableTools && Object.keys(this.config.mcpServers ?? {}).length) {
|
|
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
|
+
}
|
|
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");
|
|
200
331
|
this.state = "idle";
|
|
201
332
|
this.resetIdleTimer();
|
|
202
333
|
}
|
|
@@ -204,9 +335,17 @@ export class ClaudeGeneratorSession {
|
|
|
204
335
|
/** Push a user message; returns a tap that streams this turn's events. */
|
|
205
336
|
async sendMessage(content: ClaudeContent, turnId?: string): Promise<ClaudeEventTap> {
|
|
206
337
|
while (this.state !== "idle") {
|
|
207
|
-
if (this.state === "terminated")
|
|
338
|
+
if (this.state === "terminated")
|
|
339
|
+
throw this.startupError ?? new Error("Session is terminated");
|
|
208
340
|
await this.waitForIdle();
|
|
209
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
|
+
}
|
|
210
349
|
this.clearIdleTimer();
|
|
211
350
|
this.state = "busy";
|
|
212
351
|
// After the idle wait, so a queued turn can't relabel the in-flight one.
|
|
@@ -288,6 +427,14 @@ export class ClaudeGeneratorSession {
|
|
|
288
427
|
}
|
|
289
428
|
if (event.type === "conversation_reset") this.cumulativeCost = 0;
|
|
290
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
|
+
}
|
|
291
438
|
// Keep the native total intact for raw diagnostics. A crash can
|
|
292
439
|
// report a zeroed total; that does not establish this turn's cost.
|
|
293
440
|
const result = {
|
|
@@ -308,6 +455,7 @@ export class ClaudeGeneratorSession {
|
|
|
308
455
|
}
|
|
309
456
|
}
|
|
310
457
|
} catch (err) {
|
|
458
|
+
if (!this.currentTap) this.startupError = err;
|
|
311
459
|
this.currentTap?.fail(err);
|
|
312
460
|
} finally {
|
|
313
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
|
|
8
|
+
/** The selected session and whether this invocation owns its new startup. */
|
|
9
9
|
export interface ClaudeSessionSpawn {
|
|
10
10
|
session: ClaudeGeneratorSession;
|
|
11
|
-
|
|
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
|
|
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).
|
|
70
|
-
*
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
+
config,
|
|
105
95
|
(reason) => {
|
|
106
96
|
if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId);
|
|
107
97
|
this.onSessionEnd?.(sessionId, reason);
|
|
@@ -113,12 +103,11 @@ export class ClaudeSessionManager {
|
|
|
113
103
|
await session.start();
|
|
114
104
|
} catch (err) {
|
|
115
105
|
// Don't leave a half-started session in the map — it could never serve a turn.
|
|
116
|
-
|
|
106
|
+
await session.terminate();
|
|
107
|
+
if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId);
|
|
117
108
|
throw err;
|
|
118
109
|
}
|
|
119
|
-
|
|
120
|
-
// OR the manager's auto-injected resume on an immutable-config restart.
|
|
121
|
-
return { session, attemptedResume: Boolean(startConfig.resumeSessionId) };
|
|
110
|
+
return { session, spawned: true };
|
|
122
111
|
}
|
|
123
112
|
|
|
124
113
|
get(sessionId: string): ClaudeGeneratorSession | undefined {
|
|
@@ -137,8 +126,8 @@ export class ClaudeSessionManager {
|
|
|
137
126
|
this.sessions.clear();
|
|
138
127
|
}
|
|
139
128
|
|
|
140
|
-
|
|
141
|
-
|
|
129
|
+
invalidateMcpConnections(): void {
|
|
130
|
+
for (const session of this.sessions.values()) session.invalidateMcpConnections();
|
|
142
131
|
}
|
|
143
132
|
|
|
144
133
|
private async hotSwapIfNeeded(
|
|
@@ -151,8 +140,9 @@ export class ClaudeSessionManager {
|
|
|
151
140
|
// Text-only sessions must stay tool-free when the MCP configuration changes.
|
|
152
141
|
if (
|
|
153
142
|
!next.disableTools &&
|
|
154
|
-
|
|
155
|
-
configFingerprint(
|
|
143
|
+
(session.mcpConnectionsStale ||
|
|
144
|
+
configFingerprint(next.mcpServers ?? {}) !==
|
|
145
|
+
configFingerprint(session.currentConfig.mcpServers ?? {}))
|
|
156
146
|
) {
|
|
157
147
|
await session.setMcpServers(next.mcpServers ?? {});
|
|
158
148
|
}
|