@zvada/agent-server 0.2.0
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/LICENSE +21 -0
- package/README.md +71 -0
- package/package.json +87 -0
- package/src/client/client.ts +589 -0
- package/src/client/index.ts +18 -0
- package/src/client/transports.ts +84 -0
- package/src/core/agents/acp/acp-agent.ts +322 -0
- package/src/core/agents/acp/adapter.ts +260 -0
- package/src/core/agents/acp/client.ts +212 -0
- package/src/core/agents/acp/known-agents.ts +29 -0
- package/src/core/agents/acp/mappings.ts +136 -0
- package/src/core/agents/base.ts +145 -0
- package/src/core/agents/claude-code/adapter.ts +451 -0
- package/src/core/agents/claude-code/claude-agent.ts +235 -0
- package/src/core/agents/claude-code/generator-session.ts +344 -0
- package/src/core/agents/claude-code/options.ts +161 -0
- package/src/core/agents/claude-code/session-manager.ts +159 -0
- package/src/core/agents/codex-app-server/adapter.ts +214 -0
- package/src/core/agents/codex-app-server/client.ts +221 -0
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +385 -0
- package/src/core/agents/codex-items.ts +122 -0
- package/src/core/agents/codex-sdk/adapter.ts +204 -0
- package/src/core/agents/codex-sdk/codex-sdk-agent.ts +236 -0
- package/src/core/agents/config-fingerprint.ts +19 -0
- package/src/core/agents/error-classifier.ts +68 -0
- package/src/core/agents/registry.ts +40 -0
- package/src/core/agents/session-store.ts +72 -0
- package/src/core/agents/tool-meta.ts +68 -0
- package/src/core/agents/types.ts +54 -0
- package/src/core/index.ts +114 -0
- package/src/core/presets.ts +78 -0
- package/src/core/provision/extract.ts +31 -0
- package/src/core/provision/index.ts +10 -0
- package/src/core/provision/npm.ts +114 -0
- package/src/core/provision/pins.ts +51 -0
- package/src/core/provision/platform.ts +73 -0
- package/src/core/provision/provisioner.ts +478 -0
- package/src/core/proxy/anthropic-proxy.ts +69 -0
- package/src/core/proxy/api-key-store.ts +34 -0
- package/src/core/proxy/index.ts +7 -0
- package/src/core/runtime/agent-runtime.ts +363 -0
- package/src/core/runtime/event-processor.ts +218 -0
- package/src/core/runtime/event-sink.ts +37 -0
- package/src/core/utils/errors.ts +41 -0
- package/src/index.ts +4 -0
- package/src/protocol/async-queue.ts +68 -0
- package/src/protocol/config.ts +100 -0
- package/src/protocol/factories.ts +125 -0
- package/src/protocol/harness.ts +50 -0
- package/src/protocol/ids.ts +53 -0
- package/src/protocol/index.ts +16 -0
- package/src/protocol/lifecycle.ts +309 -0
- package/src/protocol/models.ts +45 -0
- package/src/protocol/part-input.ts +58 -0
- package/src/protocol/parts.ts +60 -0
- package/src/protocol/thinking.ts +32 -0
- package/src/protocol/tokens.ts +39 -0
- package/src/protocol/tool-state.ts +89 -0
- package/src/protocol/wire.ts +313 -0
- package/src/server/acp/binding.ts +163 -0
- package/src/server/acp/translate.ts +160 -0
- package/src/server/agent-server.ts +357 -0
- package/src/server/bin.ts +174 -0
- package/src/server/index.ts +24 -0
- package/src/server/install.ts +51 -0
- package/src/server/session-log.ts +66 -0
- package/src/server/transports.ts +149 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import type { Query as CCQuery, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import type {
|
|
3
|
+
Options as CCOptions,
|
|
4
|
+
CanUseTool,
|
|
5
|
+
McpSetServersResult,
|
|
6
|
+
PermissionResult,
|
|
7
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
8
|
+
import { AsyncQueue } from "../../../protocol/index.ts";
|
|
9
|
+
import type { McpServerConfig } from "../../../protocol/index.ts";
|
|
10
|
+
import type { PermissionRequestHandler } from "../base.ts";
|
|
11
|
+
import { claudeToolMeta } from "../tool-meta.ts";
|
|
12
|
+
import {
|
|
13
|
+
type ClaudeSdkOptionOverrides,
|
|
14
|
+
type ClaudeSessionConfig,
|
|
15
|
+
type SdkMcpServers,
|
|
16
|
+
buildClaudeOptions,
|
|
17
|
+
} from "./options.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pre-broker tool policy: the host's programmatic say on every tool call
|
|
21
|
+
* BEFORE the interactive permission broker. Returns the SDK's own
|
|
22
|
+
* `PermissionResult` (so hosts keep full native power: `updatedInput`,
|
|
23
|
+
* `updatedPermissions`) or undefined to fall through to the broker.
|
|
24
|
+
* Operator embed-tier only — never wire-sourced.
|
|
25
|
+
*/
|
|
26
|
+
export type ClaudeToolPolicy = (
|
|
27
|
+
toolName: string,
|
|
28
|
+
input: Record<string, unknown>,
|
|
29
|
+
ctx: { sessionId: string; toolUseId: string; agentId?: string },
|
|
30
|
+
) => PermissionResult | undefined | Promise<PermissionResult | undefined>;
|
|
31
|
+
|
|
32
|
+
/** SDK lifecycle hooks factory (decision-capable); runs once at session spawn. */
|
|
33
|
+
export type ClaudeHooksFactory = (ctx: {
|
|
34
|
+
sessionId: string;
|
|
35
|
+
currentTurnId: () => string | undefined;
|
|
36
|
+
}) => CCOptions["hooks"];
|
|
37
|
+
|
|
38
|
+
/** Embed-tier extras threaded from ClaudeCodeAgentOptions into each session. */
|
|
39
|
+
export interface ClaudeSessionExtras {
|
|
40
|
+
/** Factory for in-process MCP servers; invoked ONCE at session spawn. */
|
|
41
|
+
sdkMcpServers?: (ctx: {
|
|
42
|
+
sessionId: string;
|
|
43
|
+
cwd: string;
|
|
44
|
+
}) => SdkMcpServers | Promise<SdkMcpServers>;
|
|
45
|
+
toolPolicy?: ClaudeToolPolicy;
|
|
46
|
+
/** See ClaudeHooksFactory — a Stop hook may return `{decision: "block"}`. */
|
|
47
|
+
hooks?: ClaudeHooksFactory;
|
|
48
|
+
/**
|
|
49
|
+
* Operator escape hatch: raw SDK option overrides merged over the engine's
|
|
50
|
+
* options at session spawn (embed-tier only — never wire-sourced). The
|
|
51
|
+
* ClaudeSdkOptionOverrides type excludes engine-wired fields.
|
|
52
|
+
*/
|
|
53
|
+
sdkOptions?: (ctx: {
|
|
54
|
+
sessionId: string;
|
|
55
|
+
cwd: string;
|
|
56
|
+
}) => ClaudeSdkOptionOverrides | Promise<ClaudeSdkOptionOverrides>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type SessionState = "starting" | "idle" | "busy" | "terminated";
|
|
60
|
+
type ClaudeContent = SDKUserMessage["message"]["content"];
|
|
61
|
+
|
|
62
|
+
/** Per-turn handle: iterate `events` until the turn's `result` arrives (or the turn errors). */
|
|
63
|
+
export interface ClaudeEventTap {
|
|
64
|
+
readonly events: AsyncIterable<SDKMessage>;
|
|
65
|
+
detach(): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Wraps one long-lived Claude `query()` call. The prompt input is an AsyncQueue
|
|
70
|
+
* that stays open across turns, so the same subprocess (and its context) is
|
|
71
|
+
* reused turn to turn. A background loop reads SDK output and routes it to the
|
|
72
|
+
* current turn's tap; the `result` event marks the turn boundary.
|
|
73
|
+
*
|
|
74
|
+
* Lifecycle: starting → idle ⇄ busy → terminated.
|
|
75
|
+
*/
|
|
76
|
+
export class ClaudeGeneratorSession {
|
|
77
|
+
private state: SessionState = "starting";
|
|
78
|
+
private config: ClaudeSessionConfig;
|
|
79
|
+
|
|
80
|
+
private readonly promptQueue = new AsyncQueue<SDKUserMessage>();
|
|
81
|
+
private query: CCQuery | null = null;
|
|
82
|
+
/** Set per turn (inside sendMessage, after the idle wait) for hook contexts. */
|
|
83
|
+
currentTurnId?: string;
|
|
84
|
+
/** Resolved once at spawn; re-merged into every hot-swap. */
|
|
85
|
+
private sdkServers?: SdkMcpServers;
|
|
86
|
+
private nativeSessionId: string | null = null;
|
|
87
|
+
|
|
88
|
+
private currentTap: AsyncQueue<SDKMessage> | null = null;
|
|
89
|
+
private readonly idleWaiters: Array<() => void> = [];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The engine's permission broker for the CURRENT turn. Mutable because the
|
|
93
|
+
* SDK's `canUseTool` is fixed at spawn while the handler changes per turn
|
|
94
|
+
* (the long-lived query outlives any one run). Unset (e.g. a turn driven
|
|
95
|
+
* outside the runtime) auto-allows — mode-level gating still applies.
|
|
96
|
+
*/
|
|
97
|
+
permissionHandler?: PermissionRequestHandler;
|
|
98
|
+
|
|
99
|
+
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
100
|
+
private readonly idleTimeoutMs: number;
|
|
101
|
+
|
|
102
|
+
constructor(
|
|
103
|
+
readonly id: string,
|
|
104
|
+
config: ClaudeSessionConfig,
|
|
105
|
+
private readonly onTerminated?: () => void,
|
|
106
|
+
private readonly extras?: ClaudeSessionExtras,
|
|
107
|
+
) {
|
|
108
|
+
this.config = { ...config };
|
|
109
|
+
this.idleTimeoutMs = config.idleTimeoutMs ?? 5 * 60_000;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get currentState(): SessionState {
|
|
113
|
+
return this.state;
|
|
114
|
+
}
|
|
115
|
+
get currentSessionId(): string | null {
|
|
116
|
+
return this.nativeSessionId;
|
|
117
|
+
}
|
|
118
|
+
get currentConfig(): Readonly<ClaudeSessionConfig> {
|
|
119
|
+
return this.config;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Hot-swap the model without losing context (Claude `setModel`). */
|
|
123
|
+
async setModel(model: string | undefined): Promise<void> {
|
|
124
|
+
if (this.state === "terminated" || !this.query) return;
|
|
125
|
+
await this.query.setModel(model);
|
|
126
|
+
this.config = { ...this.config, model };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Hot-swap the wire-configured MCP servers on the LIVE session (no restart,
|
|
131
|
+
* context intact). In-process servers are re-merged so a swap can never drop
|
|
132
|
+
* the host's own tools; `currentConfig` is synced so the next turn's
|
|
133
|
+
* hot-swap check doesn't re-apply.
|
|
134
|
+
*/
|
|
135
|
+
async setMcpServers(
|
|
136
|
+
servers: Record<string, McpServerConfig>,
|
|
137
|
+
): Promise<McpSetServersResult | undefined> {
|
|
138
|
+
if (this.state === "terminated" || !this.query) return undefined;
|
|
139
|
+
const result = await this.query.setMcpServers({ ...servers, ...this.sdkServers });
|
|
140
|
+
const failed = Object.entries(result.errors ?? {});
|
|
141
|
+
if (failed.length) {
|
|
142
|
+
// Do NOT sync config: the fingerprint stays different, so the next
|
|
143
|
+
// turn retries instead of reporting a failed set as attached.
|
|
144
|
+
throw new Error(
|
|
145
|
+
`mcp servers failed to connect: ${failed.map(([n, e]) => `${n} (${e})`).join(", ")}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
this.config = { ...this.config, mcpServers: servers };
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async start(): Promise<void> {
|
|
153
|
+
if (this.state !== "starting") throw new Error(`Cannot start in state: ${this.state}`);
|
|
154
|
+
const sdk = await import("@anthropic-ai/claude-agent-sdk");
|
|
155
|
+
this.sdkServers = await this.extras?.sdkMcpServers?.({
|
|
156
|
+
sessionId: this.id,
|
|
157
|
+
cwd: this.config.cwd,
|
|
158
|
+
});
|
|
159
|
+
const options = buildClaudeOptions(
|
|
160
|
+
this.config,
|
|
161
|
+
this.sdkServers,
|
|
162
|
+
await this.extras?.sdkOptions?.({ sessionId: this.id, cwd: this.config.cwd }),
|
|
163
|
+
);
|
|
164
|
+
if (this.extras?.hooks) {
|
|
165
|
+
options.hooks = this.extras.hooks({
|
|
166
|
+
sessionId: this.id,
|
|
167
|
+
currentTurnId: () => this.currentTurnId,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
// Bridge the SDK's approval callback to the engine's permission broker.
|
|
171
|
+
// The closure reads the mutable per-turn handler set by the agent.
|
|
172
|
+
options.canUseTool = createCanUseTool({
|
|
173
|
+
sessionId: this.id,
|
|
174
|
+
policy: this.extras?.toolPolicy,
|
|
175
|
+
getBroker: () => this.permissionHandler,
|
|
176
|
+
});
|
|
177
|
+
this.query = sdk.query({
|
|
178
|
+
prompt: this.promptQueue as AsyncIterable<SDKUserMessage>,
|
|
179
|
+
options,
|
|
180
|
+
});
|
|
181
|
+
void this.consumeEvents();
|
|
182
|
+
this.state = "idle";
|
|
183
|
+
this.resetIdleTimer();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Push a user message; returns a tap that streams this turn's events. */
|
|
187
|
+
async sendMessage(content: ClaudeContent, turnId?: string): Promise<ClaudeEventTap> {
|
|
188
|
+
while (this.state !== "idle") {
|
|
189
|
+
if (this.state === "terminated") throw new Error("Session is terminated");
|
|
190
|
+
await this.waitForIdle();
|
|
191
|
+
}
|
|
192
|
+
this.clearIdleTimer();
|
|
193
|
+
this.state = "busy";
|
|
194
|
+
// After the idle wait, so a queued turn can't relabel the in-flight one.
|
|
195
|
+
this.currentTurnId = turnId;
|
|
196
|
+
|
|
197
|
+
const tap = new AsyncQueue<SDKMessage>();
|
|
198
|
+
this.currentTap = tap;
|
|
199
|
+
this.promptQueue.push({
|
|
200
|
+
type: "user",
|
|
201
|
+
message: { role: "user", content },
|
|
202
|
+
parent_tool_use_id: null,
|
|
203
|
+
...(this.nativeSessionId ? { session_id: this.nativeSessionId } : {}),
|
|
204
|
+
} as SDKUserMessage);
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
events: tap,
|
|
208
|
+
detach: () => {
|
|
209
|
+
if (this.currentTap === tap) this.currentTap = null;
|
|
210
|
+
tap.end();
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Stop the in-flight turn without killing the session. */
|
|
216
|
+
async interruptTurn(): Promise<void> {
|
|
217
|
+
if ((this.state !== "busy" && this.state !== "starting") || !this.query) return;
|
|
218
|
+
try {
|
|
219
|
+
await Promise.race([
|
|
220
|
+
this.query.interrupt(),
|
|
221
|
+
new Promise<never>((_, reject) =>
|
|
222
|
+
setTimeout(() => reject(new Error("interrupt timeout")), 2000),
|
|
223
|
+
),
|
|
224
|
+
]);
|
|
225
|
+
} catch {
|
|
226
|
+
// best-effort
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async terminate(): Promise<void> {
|
|
231
|
+
if (this.state === "terminated") return;
|
|
232
|
+
this.clearIdleTimer();
|
|
233
|
+
this.state = "terminated";
|
|
234
|
+
const query = this.query;
|
|
235
|
+
this.query = null;
|
|
236
|
+
this.promptQueue.end();
|
|
237
|
+
this.currentTap?.end();
|
|
238
|
+
this.currentTap = null;
|
|
239
|
+
for (const w of this.idleWaiters) w();
|
|
240
|
+
this.idleWaiters.length = 0;
|
|
241
|
+
try {
|
|
242
|
+
// The SDK documents close() as the forceful subprocess/MCP cleanup path.
|
|
243
|
+
query?.close();
|
|
244
|
+
} finally {
|
|
245
|
+
this.onTerminated?.();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async consumeEvents(): Promise<void> {
|
|
250
|
+
if (!this.query) return;
|
|
251
|
+
try {
|
|
252
|
+
for await (const event of this.query) {
|
|
253
|
+
if (event.type === "system" && event.subtype === "init" && event.session_id) {
|
|
254
|
+
this.nativeSessionId = event.session_id;
|
|
255
|
+
}
|
|
256
|
+
this.currentTap?.push(event);
|
|
257
|
+
if (event.type === "result") this.onTurnComplete();
|
|
258
|
+
}
|
|
259
|
+
} catch (err) {
|
|
260
|
+
this.currentTap?.fail(err);
|
|
261
|
+
} finally {
|
|
262
|
+
await this.terminate();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private onTurnComplete(): void {
|
|
267
|
+
this.currentTap?.end();
|
|
268
|
+
this.currentTap = null;
|
|
269
|
+
this.state = "idle";
|
|
270
|
+
this.resetIdleTimer();
|
|
271
|
+
this.idleWaiters.shift()?.();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private waitForIdle(timeoutMs = 60_000): Promise<void> {
|
|
275
|
+
return new Promise<void>((resolve, reject) => {
|
|
276
|
+
if (this.state === "idle") return resolve();
|
|
277
|
+
if (this.state === "terminated") return reject(new Error("terminated while waiting"));
|
|
278
|
+
const timer = setTimeout(() => {
|
|
279
|
+
const idx = this.idleWaiters.indexOf(onIdle);
|
|
280
|
+
if (idx >= 0) this.idleWaiters.splice(idx, 1);
|
|
281
|
+
reject(new Error("timeout waiting for idle"));
|
|
282
|
+
}, timeoutMs);
|
|
283
|
+
const onIdle = () => {
|
|
284
|
+
clearTimeout(timer);
|
|
285
|
+
resolve();
|
|
286
|
+
};
|
|
287
|
+
this.idleWaiters.push(onIdle);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private resetIdleTimer(): void {
|
|
292
|
+
this.clearIdleTimer();
|
|
293
|
+
if (this.idleTimeoutMs > 0) {
|
|
294
|
+
this.idleTimer = setTimeout(() => void this.terminate(), this.idleTimeoutMs);
|
|
295
|
+
this.idleTimer.unref?.();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private clearIdleTimer(): void {
|
|
300
|
+
if (this.idleTimer) {
|
|
301
|
+
clearTimeout(this.idleTimer);
|
|
302
|
+
this.idleTimer = null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The SDK approval callback: host `toolPolicy` first (full native
|
|
309
|
+
* PermissionResult power), then the engine's interactive broker. The SDK
|
|
310
|
+
* passes a REAL `toolUseID` (>=0.2.138), so brokered requests correlate to
|
|
311
|
+
* their tool parts exactly.
|
|
312
|
+
*/
|
|
313
|
+
export function createCanUseTool(deps: {
|
|
314
|
+
sessionId: string;
|
|
315
|
+
policy?: ClaudeToolPolicy;
|
|
316
|
+
getBroker: () => PermissionRequestHandler | undefined;
|
|
317
|
+
}): CanUseTool {
|
|
318
|
+
return async (toolName, input, { signal, toolUseID, agentID }) => {
|
|
319
|
+
const verdict = await deps.policy?.(toolName, input, {
|
|
320
|
+
sessionId: deps.sessionId,
|
|
321
|
+
toolUseId: toolUseID,
|
|
322
|
+
agentId: agentID,
|
|
323
|
+
});
|
|
324
|
+
if (verdict) return verdict;
|
|
325
|
+
const handler = deps.getBroker();
|
|
326
|
+
if (!handler) return { behavior: "allow", updatedInput: input };
|
|
327
|
+
const decision = await handler(
|
|
328
|
+
{
|
|
329
|
+
toolCallId: toolUseID,
|
|
330
|
+
toolName,
|
|
331
|
+
rawInput: input,
|
|
332
|
+
...claudeToolMeta(toolName, input),
|
|
333
|
+
},
|
|
334
|
+
{ signal },
|
|
335
|
+
);
|
|
336
|
+
return decision.decision === "allow"
|
|
337
|
+
? { behavior: "allow", updatedInput: input }
|
|
338
|
+
: {
|
|
339
|
+
behavior: "deny",
|
|
340
|
+
message: (decision.decision === "deny" && decision.reason) || "Denied by user",
|
|
341
|
+
interrupt: decision.decision === "cancel",
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Options as CCOptions,
|
|
3
|
+
McpSdkServerConfigWithInstance,
|
|
4
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
5
|
+
import type { McpServerConfig, PermissionMode, ThinkingLevel } from "../../../protocol/index.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* In-process MCP servers (the SDK's `createSdkMcpServer` output). Operator
|
|
9
|
+
* embed-tier only — instances can't travel a wire, so these never appear in
|
|
10
|
+
* RunConfig; they merge into the SDK's `mcpServers` beside the wire-configured
|
|
11
|
+
* ones and win name conflicts (the host owns its own tools).
|
|
12
|
+
*/
|
|
13
|
+
export type SdkMcpServers = Record<string, McpSdkServerConfigWithInstance>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Operator SDK-option overrides (embed-tier escape hatch for surface the
|
|
17
|
+
* engine does not model: disallowedTools, forwardSubagentText, extraArgs, ...).
|
|
18
|
+
* The Omit list is the engine's own wiring — fields whose silent clobber would
|
|
19
|
+
* desync session diffing (cwd/env/permissionMode/resume), break the adapter
|
|
20
|
+
* (includePartialMessages), or bypass engine merges (mcpServers, hooks,
|
|
21
|
+
* canUseTool) — so "cannot be overridden" is compiler-enforced.
|
|
22
|
+
*/
|
|
23
|
+
export type ClaudeSdkOptionOverrides = Partial<
|
|
24
|
+
Omit<
|
|
25
|
+
CCOptions,
|
|
26
|
+
| "canUseTool"
|
|
27
|
+
| "hooks"
|
|
28
|
+
| "mcpServers"
|
|
29
|
+
| "resume"
|
|
30
|
+
| "resumeSessionAt"
|
|
31
|
+
| "cwd"
|
|
32
|
+
| "env"
|
|
33
|
+
| "permissionMode"
|
|
34
|
+
| "includePartialMessages"
|
|
35
|
+
| "pathToClaudeCodeExecutable"
|
|
36
|
+
>
|
|
37
|
+
>;
|
|
38
|
+
|
|
39
|
+
/** Internal config for one live Claude session (a value the session manager keys/diffs on). */
|
|
40
|
+
export interface ClaudeSessionConfig {
|
|
41
|
+
cwd: string;
|
|
42
|
+
additionalDirectories?: string[];
|
|
43
|
+
model?: string;
|
|
44
|
+
thinkingLevel?: ThinkingLevel;
|
|
45
|
+
permissionMode?: PermissionMode;
|
|
46
|
+
maxTurns?: number;
|
|
47
|
+
systemPromptAppend?: string;
|
|
48
|
+
resumeSessionId?: string;
|
|
49
|
+
resumeSessionAt?: string;
|
|
50
|
+
mcpServers?: Record<string, McpServerConfig>;
|
|
51
|
+
env?: Record<string, string>;
|
|
52
|
+
apiKey?: string;
|
|
53
|
+
idleTimeoutMs?: number;
|
|
54
|
+
/** Disable all built-in tools (used by text-only smoke runs). */
|
|
55
|
+
disableTools?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Explicit `claude` binary (from the provisioner / operator override).
|
|
58
|
+
* Unset lets the SDK resolve its sibling platform package — which fails
|
|
59
|
+
* inside compiled binaries, where this must be provided.
|
|
60
|
+
*/
|
|
61
|
+
cliPath?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Map our permission posture onto the Claude SDK's. `bypassPermissions`
|
|
66
|
+
* auto-approves every tool and is dangerous outside a sandbox, so it must be
|
|
67
|
+
* requested explicitly — an unset/unknown mode falls back to the safe `default`.
|
|
68
|
+
*/
|
|
69
|
+
function toSdkPermissionMode(mode: PermissionMode | undefined): CCOptions["permissionMode"] {
|
|
70
|
+
switch (mode) {
|
|
71
|
+
case "bypassPermissions":
|
|
72
|
+
return "bypassPermissions";
|
|
73
|
+
case "plan":
|
|
74
|
+
return "plan";
|
|
75
|
+
case "acceptEdits":
|
|
76
|
+
return "acceptEdits";
|
|
77
|
+
case "dontAsk":
|
|
78
|
+
// Native SDK mode: never prompt, deny unapproved. Unlike
|
|
79
|
+
// bypassPermissions it needs no dangerous flag (and the CLI keeps
|
|
80
|
+
// extended thinking enabled, which bypass turns off).
|
|
81
|
+
return "dontAsk";
|
|
82
|
+
default:
|
|
83
|
+
return "default";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build the env handed to the Claude subprocess. The SDK REPLACES the child env
|
|
89
|
+
* entirely when `env` is set, so we must spread `process.env` to preserve PATH/
|
|
90
|
+
* HOME (and thus keychain/subscription auth). We only inject ANTHROPIC_API_KEY
|
|
91
|
+
* when an explicit key is supplied — otherwise the CLI's own auth is used.
|
|
92
|
+
*/
|
|
93
|
+
function buildEnv(config: ClaudeSessionConfig): Record<string, string | undefined> {
|
|
94
|
+
return {
|
|
95
|
+
...process.env,
|
|
96
|
+
...(config.apiKey ? { ANTHROPIC_API_KEY: config.apiKey } : {}),
|
|
97
|
+
...config.env,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildClaudeOptions(
|
|
102
|
+
config: ClaudeSessionConfig,
|
|
103
|
+
sdkMcpServers?: SdkMcpServers,
|
|
104
|
+
overrides?: ClaudeSdkOptionOverrides,
|
|
105
|
+
): CCOptions {
|
|
106
|
+
const mode = toSdkPermissionMode(config.permissionMode);
|
|
107
|
+
|
|
108
|
+
const options: CCOptions = {
|
|
109
|
+
cwd: config.cwd,
|
|
110
|
+
maxTurns: config.maxTurns ?? 100,
|
|
111
|
+
permissionMode: mode,
|
|
112
|
+
// Streaming input mode + partial messages give us token-level deltas.
|
|
113
|
+
includePartialMessages: true,
|
|
114
|
+
systemPrompt: config.systemPromptAppend
|
|
115
|
+
? { type: "preset", preset: "claude_code", append: config.systemPromptAppend }
|
|
116
|
+
: { type: "preset", preset: "claude_code" },
|
|
117
|
+
env: buildEnv(config),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
if (config.cliPath) options.pathToClaudeCodeExecutable = config.cliPath;
|
|
121
|
+
if (config.model) options.model = config.model;
|
|
122
|
+
if (config.additionalDirectories?.length)
|
|
123
|
+
options.additionalDirectories = config.additionalDirectories;
|
|
124
|
+
if (mode === "bypassPermissions") options.allowDangerouslySkipPermissions = true;
|
|
125
|
+
if (config.thinkingLevel !== undefined) {
|
|
126
|
+
// Modern SDK vocabulary (maxThinkingTokens is deprecated): adaptive
|
|
127
|
+
// thinking with an effort level; `off` disables extended thinking.
|
|
128
|
+
if (config.thinkingLevel === "off") {
|
|
129
|
+
options.thinking = { type: "disabled" };
|
|
130
|
+
} else {
|
|
131
|
+
options.thinking = { type: "adaptive" };
|
|
132
|
+
options.effort = config.thinkingLevel;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (config.resumeSessionId) {
|
|
136
|
+
options.resume = config.resumeSessionId;
|
|
137
|
+
// SDK contract: resumeSessionAt is only meaningful WITH resume — never
|
|
138
|
+
// pass it alone (the CLI would get a bare --resume-session-at).
|
|
139
|
+
if (config.resumeSessionAt) options.resumeSessionAt = config.resumeSessionAt;
|
|
140
|
+
}
|
|
141
|
+
if (config.disableTools) {
|
|
142
|
+
// Text-only. `tools: []` drops built-in tools, but ambient tools (LSP, MCP
|
|
143
|
+
// servers) are injected by the user's filesystem settings, which that alone
|
|
144
|
+
// doesn't suppress — so also run in SDK isolation (`settingSources: []`) and
|
|
145
|
+
// honor only explicitly-passed MCP servers (none).
|
|
146
|
+
options.tools = [];
|
|
147
|
+
options.settingSources = [];
|
|
148
|
+
options.strictMcpConfig = true;
|
|
149
|
+
} else if (config.mcpServers || sdkMcpServers) {
|
|
150
|
+
// Our McpServerConfig is structurally assignable to the SDK's stdio/http/sse
|
|
151
|
+
// shapes; in-process servers merge beside them (operator wins conflicts).
|
|
152
|
+
options.mcpServers = { ...config.mcpServers, ...sdkMcpServers };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Operator overrides land last; the engine wires hooks/canUseTool after
|
|
156
|
+
// this function returns, so those can never be clobbered (and the
|
|
157
|
+
// ClaudeSdkOptionOverrides type excludes them anyway).
|
|
158
|
+
if (overrides) Object.assign(options, overrides);
|
|
159
|
+
|
|
160
|
+
return options;
|
|
161
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { McpSetServersResult } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import type { McpServerConfig } from "../../../protocol/index.ts";
|
|
3
|
+
import { configFingerprint } from "../config-fingerprint.ts";
|
|
4
|
+
import { ClaudeGeneratorSession } from "./generator-session.ts";
|
|
5
|
+
import type { ClaudeSessionExtras } from "./generator-session.ts";
|
|
6
|
+
import type { ClaudeSessionConfig } from "./options.ts";
|
|
7
|
+
|
|
8
|
+
/** Directory-set identity: order and duplicates don't change the sandbox surface. */
|
|
9
|
+
function directoriesFingerprint(dirs: string[] | undefined): string {
|
|
10
|
+
return configFingerprint([...new Set(dirs ?? [])].sort());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Whether immutable Claude subprocess config changed and requires a restart. */
|
|
14
|
+
export function claudeSessionNeedsRestart(
|
|
15
|
+
prev: ClaudeSessionConfig,
|
|
16
|
+
next: ClaudeSessionConfig,
|
|
17
|
+
): boolean {
|
|
18
|
+
return (
|
|
19
|
+
prev.cwd !== next.cwd ||
|
|
20
|
+
(prev.cliPath ?? "") !== (next.cliPath ?? "") ||
|
|
21
|
+
(prev.permissionMode ?? "") !== (next.permissionMode ?? "") ||
|
|
22
|
+
// thinking/effort are fixed at spawn in the modern SDK vocabulary (the
|
|
23
|
+
// old setMaxThinkingTokens hot-swap is deprecated and effort-blind), so
|
|
24
|
+
// a level change restarts — context survives via claudeRestartConfig.
|
|
25
|
+
(prev.thinkingLevel ?? "") !== (next.thinkingLevel ?? "") ||
|
|
26
|
+
(prev.systemPromptAppend ?? "") !== (next.systemPromptAppend ?? "") ||
|
|
27
|
+
// A rewind (resumeSessionAt) is applied by the SDK only at spawn, so a
|
|
28
|
+
// warm session cannot honor it — any change forces a restart. The
|
|
29
|
+
// symmetric diff deliberately also restarts on set->unset: it keeps the
|
|
30
|
+
// gate correct even if the stored config lags a reused turn, at the
|
|
31
|
+
// cost of one extra (context-preserving) respawn after a revert.
|
|
32
|
+
(prev.resumeSessionAt ?? "") !== (next.resumeSessionAt ?? "") ||
|
|
33
|
+
directoriesFingerprint(prev.additionalDirectories) !==
|
|
34
|
+
directoriesFingerprint(next.additionalDirectories) ||
|
|
35
|
+
(prev.apiKey ?? "") !== (next.apiKey ?? "") ||
|
|
36
|
+
(prev.maxTurns ?? 100) !== (next.maxTurns ?? 100) ||
|
|
37
|
+
(prev.idleTimeoutMs ?? 5 * 60_000) !== (next.idleTimeoutMs ?? 5 * 60_000) ||
|
|
38
|
+
Boolean(prev.disableTools) !== Boolean(next.disableTools) ||
|
|
39
|
+
configFingerprint(prev.env) !== configFingerprint(next.env)
|
|
40
|
+
// mcpServers deliberately absent: MCP changes hot-swap on the live
|
|
41
|
+
// session (`setMcpServers`) instead of restarting the subprocess.
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Preserve context within the same cwd/credential boundary; explicit resume always wins. */
|
|
46
|
+
export function claudeRestartConfig(
|
|
47
|
+
previous: ClaudeSessionConfig,
|
|
48
|
+
next: ClaudeSessionConfig,
|
|
49
|
+
nativeSessionId: string | null,
|
|
50
|
+
): ClaudeSessionConfig {
|
|
51
|
+
if (next.resumeSessionId || !nativeSessionId) return next;
|
|
52
|
+
const sameResumeBoundary =
|
|
53
|
+
previous.cwd === next.cwd &&
|
|
54
|
+
(previous.apiKey ?? "") === (next.apiKey ?? "") &&
|
|
55
|
+
configFingerprint(previous.env) === configFingerprint(next.env);
|
|
56
|
+
return sameResumeBoundary ? { ...next, resumeSessionId: nativeSessionId } : next;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One live `ClaudeGeneratorSession` per logical session id. Decides, per turn,
|
|
61
|
+
* whether the existing session can be reused (hot-swapping model/mcp) or must
|
|
62
|
+
* be torn down and recreated (cwd / permission / thinking / system-prompt
|
|
63
|
+
* changes alter the subprocess in ways the live query can't). `resumeSessionId`
|
|
64
|
+
* is read only at spawn — it never forces a restart on a warm session.
|
|
65
|
+
*/
|
|
66
|
+
export class ClaudeSessionManager {
|
|
67
|
+
private readonly sessions = new Map<string, ClaudeGeneratorSession>();
|
|
68
|
+
|
|
69
|
+
async getOrCreate(
|
|
70
|
+
sessionId: string,
|
|
71
|
+
config: ClaudeSessionConfig,
|
|
72
|
+
extras?: ClaudeSessionExtras,
|
|
73
|
+
): Promise<ClaudeGeneratorSession> {
|
|
74
|
+
let startConfig = config;
|
|
75
|
+
const existing = this.sessions.get(sessionId);
|
|
76
|
+
if (existing && existing.currentState !== "terminated") {
|
|
77
|
+
if (this.needsRestart(existing.currentConfig, config)) {
|
|
78
|
+
startConfig = claudeRestartConfig(
|
|
79
|
+
existing.currentConfig,
|
|
80
|
+
config,
|
|
81
|
+
existing.currentSessionId,
|
|
82
|
+
);
|
|
83
|
+
await existing.terminate();
|
|
84
|
+
this.sessions.delete(sessionId);
|
|
85
|
+
} else {
|
|
86
|
+
await this.hotSwapIfNeeded(existing, config);
|
|
87
|
+
return existing;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const session = new ClaudeGeneratorSession(
|
|
92
|
+
sessionId,
|
|
93
|
+
startConfig,
|
|
94
|
+
() => {
|
|
95
|
+
if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId);
|
|
96
|
+
},
|
|
97
|
+
extras,
|
|
98
|
+
);
|
|
99
|
+
this.sessions.set(sessionId, session);
|
|
100
|
+
try {
|
|
101
|
+
await session.start();
|
|
102
|
+
} catch (err) {
|
|
103
|
+
// Don't leave a half-started session in the map — it could never serve a turn.
|
|
104
|
+
this.sessions.delete(sessionId);
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
return session;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get(sessionId: string): ClaudeGeneratorSession | undefined {
|
|
111
|
+
return this.sessions.get(sessionId);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async terminate(sessionId: string): Promise<void> {
|
|
115
|
+
const session = this.sessions.get(sessionId);
|
|
116
|
+
if (!session) return;
|
|
117
|
+
await session.terminate();
|
|
118
|
+
if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async terminateAll(): Promise<void> {
|
|
122
|
+
await Promise.all([...this.sessions.values()].map((s) => s.terminate()));
|
|
123
|
+
this.sessions.clear();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private needsRestart(prev: ClaudeSessionConfig, next: ClaudeSessionConfig): boolean {
|
|
127
|
+
return claudeSessionNeedsRestart(prev, next);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async hotSwapIfNeeded(
|
|
131
|
+
session: ClaudeGeneratorSession,
|
|
132
|
+
next: ClaudeSessionConfig,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
if (next.model !== session.currentConfig.model) {
|
|
135
|
+
await session.setModel(next.model);
|
|
136
|
+
}
|
|
137
|
+
if (
|
|
138
|
+
configFingerprint(next.mcpServers ?? {}) !==
|
|
139
|
+
configFingerprint(session.currentConfig.mcpServers ?? {})
|
|
140
|
+
) {
|
|
141
|
+
await session.setMcpServers(next.mcpServers ?? {});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Explicit live MCP swap (e.g. mid-conversation app registration). NOTE:
|
|
147
|
+
* this syncs the session's config, so the NEXT turn's wire config (if it
|
|
148
|
+
* still carries the old server set) will swap back — explicit swappers
|
|
149
|
+
* must keep their per-turn `mcpServers` in step.
|
|
150
|
+
*/
|
|
151
|
+
async setMcpServers(
|
|
152
|
+
sessionId: string,
|
|
153
|
+
servers: Record<string, McpServerConfig>,
|
|
154
|
+
): Promise<McpSetServersResult | undefined> {
|
|
155
|
+
const session = this.sessions.get(sessionId);
|
|
156
|
+
if (!session || session.currentState === "terminated") return undefined;
|
|
157
|
+
return session.setMcpServers(servers);
|
|
158
|
+
}
|
|
159
|
+
}
|