@zvada/agent-server 0.3.9 → 0.3.11
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 +53 -0
- package/docs/consuming.md +110 -14
- 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 +17 -8
- package/src/core/agents/claude-code/adapter.ts +17 -14
- package/src/core/agents/claude-code/claude-agent.ts +71 -93
- package/src/core/agents/claude-code/generator-session.ts +77 -45
- package/src/core/agents/claude-code/session-manager.ts +20 -36
- 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 +8 -10
- 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,100 +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
|
-
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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,11 @@ export class ClaudeGeneratorSession {
|
|
|
127
120
|
private mcpUpdate = Promise.resolve();
|
|
128
121
|
private mcpUpdateFailed = false;
|
|
129
122
|
private readonly failedMcpServers = new Set<string>();
|
|
130
|
-
private
|
|
131
|
-
private connectedMcpGeneration = 0;
|
|
123
|
+
private processInvalidated = false;
|
|
132
124
|
private nativeSessionId: string | null = null;
|
|
125
|
+
/** Set by the CLI's first message; until then it has begun no turn. */
|
|
126
|
+
private cliStarted = false;
|
|
127
|
+
private startupError: unknown;
|
|
133
128
|
/** SDK cost is cumulative within this query; token usage is already per turn. */
|
|
134
129
|
private cumulativeCost: number | undefined = 0;
|
|
135
130
|
|
|
@@ -153,7 +148,10 @@ export class ClaudeGeneratorSession {
|
|
|
153
148
|
private readonly onTerminated?: (reason: ClaudeSessionEndReason) => void,
|
|
154
149
|
private readonly extras?: ClaudeSessionExtras,
|
|
155
150
|
) {
|
|
156
|
-
this.config = {
|
|
151
|
+
this.config = {
|
|
152
|
+
...config,
|
|
153
|
+
mcpServers: config.mcpServers ? snapshotMcpServers(config.mcpServers) : undefined,
|
|
154
|
+
};
|
|
157
155
|
this.idleTimeoutMs = config.idleTimeoutMs ?? 5 * 60_000;
|
|
158
156
|
}
|
|
159
157
|
|
|
@@ -167,18 +165,18 @@ export class ClaudeGeneratorSession {
|
|
|
167
165
|
return this.config;
|
|
168
166
|
}
|
|
169
167
|
get mcpConnectionsStale(): boolean {
|
|
170
|
-
return
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
168
|
+
return this.mcpUpdateFailed || this.processInvalidated;
|
|
169
|
+
}
|
|
170
|
+
/** MCP client replacement cannot clear the subprocess-wide HTTP pool. */
|
|
171
|
+
get needsProcessResume(): boolean {
|
|
172
|
+
return this.processInvalidated;
|
|
175
173
|
}
|
|
176
174
|
|
|
177
175
|
/** A thawed VM can retain clients whose sockets still appear connected. */
|
|
178
176
|
invalidateMcpConnections(): void {
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
this.
|
|
177
|
+
// Latch until process replacement, even if an in-flight MCP update
|
|
178
|
+
// succeeds or the current config no longer contains remote servers.
|
|
179
|
+
this.processInvalidated = true;
|
|
182
180
|
}
|
|
183
181
|
|
|
184
182
|
/** Hot-swap the model without losing context (Claude `setModel`). */
|
|
@@ -198,7 +196,9 @@ export class ClaudeGeneratorSession {
|
|
|
198
196
|
servers: Record<string, McpServerConfig>,
|
|
199
197
|
): Promise<McpSetServersResult | undefined> {
|
|
200
198
|
// Explicit swaps and turn preparation share the same control channel.
|
|
201
|
-
|
|
199
|
+
// Snapshot before queuing: callers may reuse and mutate their config.
|
|
200
|
+
const snapshot = snapshotMcpServers(servers);
|
|
201
|
+
const update = this.mcpUpdate.then(() => this.updateMcpServers(snapshot));
|
|
202
202
|
this.mcpUpdate = update.then(
|
|
203
203
|
() => {},
|
|
204
204
|
() => {},
|
|
@@ -210,14 +210,11 @@ export class ClaudeGeneratorSession {
|
|
|
210
210
|
servers: Record<string, McpServerConfig>,
|
|
211
211
|
): Promise<McpSetServersResult | undefined> {
|
|
212
212
|
if (this.config.disableTools || this.state === "terminated" || !this.query) return undefined;
|
|
213
|
+
if (this.needsProcessResume)
|
|
214
|
+
throw new Error("Claude process must resume after host suspension");
|
|
213
215
|
const query = this.query;
|
|
214
|
-
const reconnect = this.
|
|
215
|
-
const
|
|
216
|
-
const resetNames = Object.keys(servers).filter(
|
|
217
|
-
(name) =>
|
|
218
|
-
this.failedMcpServers.has(name) ||
|
|
219
|
-
(generation !== this.connectedMcpGeneration && remoteMcpServer(servers[name]!)),
|
|
220
|
-
);
|
|
216
|
+
const reconnect = this.mcpUpdateFailed;
|
|
217
|
+
const resetNames = Object.keys(servers).filter((name) => this.failedMcpServers.has(name));
|
|
221
218
|
const desired = { ...servers, ...this.sdkServers };
|
|
222
219
|
const added = new Set<string>();
|
|
223
220
|
const removed = new Set<string>();
|
|
@@ -263,23 +260,16 @@ export class ClaudeGeneratorSession {
|
|
|
263
260
|
result = await reattach(failed);
|
|
264
261
|
}
|
|
265
262
|
check(result.errors ?? {});
|
|
266
|
-
|
|
263
|
+
// In-process SDK servers initialize lazily at the first prompt and are
|
|
264
|
+
// absent from startup status. This barrier checks external connections.
|
|
265
|
+
const required = Object.keys(desired).filter((name) => desired[name]?.type !== "sdk");
|
|
266
|
+
if (required.length) {
|
|
267
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
|
-
),
|
|
277
|
-
);
|
|
268
|
+
check(mcpConnectionErrors(required, statuses));
|
|
278
269
|
}
|
|
279
270
|
this.config = { ...this.config, mcpServers: servers };
|
|
280
271
|
this.mcpUpdateFailed = false;
|
|
281
272
|
this.failedMcpServers.clear();
|
|
282
|
-
this.connectedMcpGeneration = generation;
|
|
283
273
|
return {
|
|
284
274
|
added: [...added],
|
|
285
275
|
removed: [...removed],
|
|
@@ -315,15 +305,31 @@ export class ClaudeGeneratorSession {
|
|
|
315
305
|
policy: this.extras?.toolPolicy,
|
|
316
306
|
getBroker: () => this.permissionHandler,
|
|
317
307
|
});
|
|
308
|
+
// Release/shutdown may finish while an async options/tool factory is
|
|
309
|
+
// pending. Never create a query after its owner has already terminated.
|
|
310
|
+
if (this.currentState === "terminated") throw new Error("Session terminated during startup");
|
|
318
311
|
this.query = sdk.query({
|
|
319
312
|
prompt: this.promptQueue as AsyncIterable<SDKUserMessage>,
|
|
320
313
|
options,
|
|
321
314
|
});
|
|
322
|
-
|
|
315
|
+
const consumption = this.consumeEvents();
|
|
323
316
|
if (!this.config.disableTools && Object.keys(this.config.mcpServers ?? {}).length) {
|
|
324
|
-
|
|
317
|
+
try {
|
|
318
|
+
await this.setMcpServers(this.config.mcpServers!);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
// Closing after an early result also rejects pending MCP controls.
|
|
321
|
+
// The SDK rejects controls before its iterator delivers that result.
|
|
322
|
+
if (error instanceof Error && error.message === "Query closed before response received") {
|
|
323
|
+
await consumption;
|
|
324
|
+
}
|
|
325
|
+
throw this.startupError ?? error;
|
|
326
|
+
}
|
|
325
327
|
}
|
|
326
|
-
if (this.
|
|
328
|
+
if (this.startupError) throw this.startupError;
|
|
329
|
+
// TypeScript retains the pre-query narrowing across await; termination can
|
|
330
|
+
// happen while the initial MCP attachment is in flight.
|
|
331
|
+
if ((this.currentState as SessionState) === "terminated")
|
|
332
|
+
throw new Error("Session terminated during startup");
|
|
327
333
|
this.state = "idle";
|
|
328
334
|
this.resetIdleTimer();
|
|
329
335
|
}
|
|
@@ -331,9 +337,17 @@ export class ClaudeGeneratorSession {
|
|
|
331
337
|
/** Push a user message; returns a tap that streams this turn's events. */
|
|
332
338
|
async sendMessage(content: ClaudeContent, turnId?: string): Promise<ClaudeEventTap> {
|
|
333
339
|
while (this.state !== "idle") {
|
|
334
|
-
if (this.state === "terminated")
|
|
340
|
+
if (this.state === "terminated")
|
|
341
|
+
throw this.startupError ?? new Error("Session is terminated");
|
|
335
342
|
await this.waitForIdle();
|
|
336
343
|
}
|
|
344
|
+
// Invalidation can land during preparation or the idle wait. Never
|
|
345
|
+
// enqueue a prompt on a process whose network pool predates suspension.
|
|
346
|
+
if (this.needsProcessResume || (!this.config.disableTools && this.mcpConnectionsStale)) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
"MCP network connections changed during turn preparation; session is not ready",
|
|
349
|
+
);
|
|
350
|
+
}
|
|
337
351
|
this.clearIdleTimer();
|
|
338
352
|
this.state = "busy";
|
|
339
353
|
// After the idle wait, so a queued turn can't relabel the in-flight one.
|
|
@@ -363,9 +377,17 @@ export class ClaudeGeneratorSession {
|
|
|
363
377
|
* SDK acknowledged it; false when the round-trip timed out or failed — the
|
|
364
378
|
* subprocess may still be running its turn and the caller must report that
|
|
365
379
|
* honestly instead of assuming a clean cancel.
|
|
380
|
+
*
|
|
381
|
+
* A session still starting, or whose CLI has sent nothing yet, is terminated
|
|
382
|
+
* instead: it has begun no turn, an interrupt would wait out its startup, and
|
|
383
|
+
* a prompt the CLI has not dequeued survives an interrupt and runs anyway.
|
|
366
384
|
*/
|
|
367
385
|
async interruptTurn(timeoutMs = 2000): Promise<boolean> {
|
|
368
|
-
if (
|
|
386
|
+
if (this.state === "idle" || this.state === "terminated") return true;
|
|
387
|
+
if (this.state === "starting" || !this.cliStarted || !this.query) {
|
|
388
|
+
await this.terminate();
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
369
391
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
370
392
|
try {
|
|
371
393
|
await Promise.race([
|
|
@@ -410,11 +432,20 @@ export class ClaudeGeneratorSession {
|
|
|
410
432
|
if (!this.query) return;
|
|
411
433
|
try {
|
|
412
434
|
for await (const event of this.query) {
|
|
435
|
+
this.cliStarted = true;
|
|
413
436
|
if (event.type === "system" && event.subtype === "init" && event.session_id) {
|
|
414
437
|
this.nativeSessionId = event.session_id;
|
|
415
438
|
}
|
|
416
439
|
if (event.type === "conversation_reset") this.cumulativeCost = 0;
|
|
417
440
|
if (event.type === "result") {
|
|
441
|
+
if (!this.currentTap && event.subtype !== "success") {
|
|
442
|
+
const reason = event.errors.join("; ") || "Claude failed during startup";
|
|
443
|
+
throw this.config.resumeSessionId
|
|
444
|
+
? new SessionResumeError(this.config.resumeSessionId, reason, {
|
|
445
|
+
cause: new Error(reason),
|
|
446
|
+
})
|
|
447
|
+
: new Error(reason);
|
|
448
|
+
}
|
|
418
449
|
// Keep the native total intact for raw diagnostics. A crash can
|
|
419
450
|
// report a zeroed total; that does not establish this turn's cost.
|
|
420
451
|
const result = {
|
|
@@ -435,6 +466,7 @@ export class ClaudeGeneratorSession {
|
|
|
435
466
|
}
|
|
436
467
|
}
|
|
437
468
|
} catch (err) {
|
|
469
|
+
if (!this.currentTap) this.startupError = err;
|
|
438
470
|
this.currentTap?.fail(err);
|
|
439
471
|
} finally {
|
|
440
472
|
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);
|
|
@@ -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
|
-
|
|
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,
|