@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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/package.json +87 -0
  4. package/src/client/client.ts +589 -0
  5. package/src/client/index.ts +18 -0
  6. package/src/client/transports.ts +84 -0
  7. package/src/core/agents/acp/acp-agent.ts +322 -0
  8. package/src/core/agents/acp/adapter.ts +260 -0
  9. package/src/core/agents/acp/client.ts +212 -0
  10. package/src/core/agents/acp/known-agents.ts +29 -0
  11. package/src/core/agents/acp/mappings.ts +136 -0
  12. package/src/core/agents/base.ts +145 -0
  13. package/src/core/agents/claude-code/adapter.ts +451 -0
  14. package/src/core/agents/claude-code/claude-agent.ts +235 -0
  15. package/src/core/agents/claude-code/generator-session.ts +344 -0
  16. package/src/core/agents/claude-code/options.ts +161 -0
  17. package/src/core/agents/claude-code/session-manager.ts +159 -0
  18. package/src/core/agents/codex-app-server/adapter.ts +214 -0
  19. package/src/core/agents/codex-app-server/client.ts +221 -0
  20. package/src/core/agents/codex-app-server/codex-app-server-agent.ts +385 -0
  21. package/src/core/agents/codex-items.ts +122 -0
  22. package/src/core/agents/codex-sdk/adapter.ts +204 -0
  23. package/src/core/agents/codex-sdk/codex-sdk-agent.ts +236 -0
  24. package/src/core/agents/config-fingerprint.ts +19 -0
  25. package/src/core/agents/error-classifier.ts +68 -0
  26. package/src/core/agents/registry.ts +40 -0
  27. package/src/core/agents/session-store.ts +72 -0
  28. package/src/core/agents/tool-meta.ts +68 -0
  29. package/src/core/agents/types.ts +54 -0
  30. package/src/core/index.ts +114 -0
  31. package/src/core/presets.ts +78 -0
  32. package/src/core/provision/extract.ts +31 -0
  33. package/src/core/provision/index.ts +10 -0
  34. package/src/core/provision/npm.ts +114 -0
  35. package/src/core/provision/pins.ts +51 -0
  36. package/src/core/provision/platform.ts +73 -0
  37. package/src/core/provision/provisioner.ts +478 -0
  38. package/src/core/proxy/anthropic-proxy.ts +69 -0
  39. package/src/core/proxy/api-key-store.ts +34 -0
  40. package/src/core/proxy/index.ts +7 -0
  41. package/src/core/runtime/agent-runtime.ts +363 -0
  42. package/src/core/runtime/event-processor.ts +218 -0
  43. package/src/core/runtime/event-sink.ts +37 -0
  44. package/src/core/utils/errors.ts +41 -0
  45. package/src/index.ts +4 -0
  46. package/src/protocol/async-queue.ts +68 -0
  47. package/src/protocol/config.ts +100 -0
  48. package/src/protocol/factories.ts +125 -0
  49. package/src/protocol/harness.ts +50 -0
  50. package/src/protocol/ids.ts +53 -0
  51. package/src/protocol/index.ts +16 -0
  52. package/src/protocol/lifecycle.ts +309 -0
  53. package/src/protocol/models.ts +45 -0
  54. package/src/protocol/part-input.ts +58 -0
  55. package/src/protocol/parts.ts +60 -0
  56. package/src/protocol/thinking.ts +32 -0
  57. package/src/protocol/tokens.ts +39 -0
  58. package/src/protocol/tool-state.ts +89 -0
  59. package/src/protocol/wire.ts +313 -0
  60. package/src/server/acp/binding.ts +163 -0
  61. package/src/server/acp/translate.ts +160 -0
  62. package/src/server/agent-server.ts +357 -0
  63. package/src/server/bin.ts +174 -0
  64. package/src/server/index.ts +24 -0
  65. package/src/server/install.ts +51 -0
  66. package/src/server/session-log.ts +66 -0
  67. package/src/server/transports.ts +149 -0
@@ -0,0 +1,451 @@
1
+ import {
2
+ DEFAULT_TOKEN_USAGE,
3
+ type ReasoningPart,
4
+ type StopReason,
5
+ type StreamContext,
6
+ type TextPart,
7
+ type TokenUsage,
8
+ type ToolPart,
9
+ appendToolInput,
10
+ completeToolPart,
11
+ createPendingToolPart,
12
+ createReasoningPart,
13
+ createTextPart,
14
+ createToolPart,
15
+ setToolMeta,
16
+ startToolPart,
17
+ } from "../../../protocol/index.ts";
18
+ import { claudeToolMeta, toolLocationsFromInput } from "../tool-meta.ts";
19
+ import type { AdapterEvent, EventTransformer, TransformResult } from "../types.ts";
20
+
21
+ /**
22
+ * Parses raw Claude Code SDK messages into normalized AdapterEvents.
23
+ *
24
+ * The main signal is the `stream_event` message (Anthropic raw streaming):
25
+ * content_block_start opens a part, content_block_delta streams text/thinking/
26
+ * tool-input, content_block_stop finalizes it. Tool results arrive later as
27
+ * `user` messages and complete the matching tool part in place. The terminal
28
+ * `result` message carries authoritative usage/cost. We rely on streaming for
29
+ * content, so the non-streaming `assistant` message is processed only as a
30
+ * fallback when partial messages were absent for it (e.g. some sub-agent paths)
31
+ * — see `streamedThisMessage`.
32
+ */
33
+
34
+ // --- Minimal structural types for the Anthropic stream events we consume. ---
35
+ // (Kept local so the adapter doesn't hard-depend on @anthropic-ai/sdk types.)
36
+ interface StreamEvent {
37
+ type: string;
38
+ index?: number;
39
+ content_block?: { type: string; id?: string; name?: string };
40
+ delta?: {
41
+ type?: string;
42
+ text?: string;
43
+ thinking?: string;
44
+ partial_json?: string;
45
+ stop_reason?: string;
46
+ };
47
+ usage?: { output_tokens?: number };
48
+ }
49
+ interface RawUsage {
50
+ input_tokens?: number;
51
+ output_tokens?: number;
52
+ cache_read_input_tokens?: number;
53
+ cache_creation_input_tokens?: number;
54
+ }
55
+ interface ContentBlock {
56
+ type: string;
57
+ text?: string;
58
+ thinking?: string;
59
+ id?: string;
60
+ name?: string;
61
+ input?: Record<string, unknown>;
62
+ tool_use_id?: string;
63
+ content?: unknown;
64
+ is_error?: boolean;
65
+ }
66
+ type ClaudeMessage =
67
+ | {
68
+ type: "system";
69
+ subtype?: string;
70
+ compact_metadata?: { trigger?: string; pre_tokens?: number; post_tokens?: number };
71
+ }
72
+ | { type: "stream_event"; event: StreamEvent; parent_tool_use_id?: string | null }
73
+ // Synthetic, appended by ClaudeCodeAgent when the turn's abort controller
74
+ // fired — disambiguates interrupt from execution failure (same SDK result).
75
+ | { type: "turn_interrupted" }
76
+ | {
77
+ type: "assistant";
78
+ message?: { content?: ContentBlock[]; usage?: RawUsage };
79
+ parent_tool_use_id?: string | null;
80
+ }
81
+ | { type: "user"; message?: { content?: ContentBlock[] | string } }
82
+ | {
83
+ type: "result";
84
+ subtype?: string;
85
+ usage?: RawUsage;
86
+ modelUsage?: Record<string, { contextWindow?: number }>;
87
+ total_cost_usd?: number;
88
+ stop_reason?: string | null;
89
+ is_error?: boolean;
90
+ };
91
+
92
+ type BlockKind = "text" | "reasoning" | "tool";
93
+ interface BlockEntry {
94
+ partId: string;
95
+ kind: BlockKind;
96
+ part: TextPart | ReasoningPart | ToolPart;
97
+ }
98
+
99
+ function safeParseJson(input: string): Record<string, unknown> {
100
+ if (!input.trim()) return {};
101
+ try {
102
+ const v = JSON.parse(input);
103
+ return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
104
+ } catch {
105
+ return {};
106
+ }
107
+ }
108
+
109
+ /** Map Anthropic stop reasons / result subtypes onto the normalized enum. */
110
+ function mapClaudeStopReason(
111
+ stopReason: string | null | undefined,
112
+ subtype: string | undefined,
113
+ ): StopReason | undefined {
114
+ if (subtype === "error_max_turns") return "max_turn_requests";
115
+ switch (stopReason) {
116
+ case "end_turn":
117
+ case "stop_sequence":
118
+ case "tool_use":
119
+ case "pause_turn":
120
+ return "end_turn";
121
+ case "max_tokens":
122
+ return "max_tokens";
123
+ case "refusal":
124
+ return "refusal";
125
+ default:
126
+ return undefined;
127
+ }
128
+ }
129
+
130
+ /** Context occupancy from an Anthropic usage block: full prompt + output. */
131
+ function contextTokens(usage: RawUsage): number {
132
+ return (
133
+ (usage.input_tokens ?? 0) +
134
+ (usage.cache_read_input_tokens ?? 0) +
135
+ (usage.cache_creation_input_tokens ?? 0) +
136
+ (usage.output_tokens ?? 0)
137
+ );
138
+ }
139
+
140
+ function stringifyToolResult(content: unknown): string {
141
+ if (typeof content === "string") return content;
142
+ if (Array.isArray(content)) {
143
+ return content
144
+ .map((b) =>
145
+ b && typeof b === "object" && "text" in b ? String((b as { text: unknown }).text) : "",
146
+ )
147
+ .join("");
148
+ }
149
+ if (content == null) return "";
150
+ return JSON.stringify(content);
151
+ }
152
+
153
+ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
154
+ private readonly ctx: StreamContext;
155
+ /** Anthropic content blocks of the in-flight message, by stream index. */
156
+ private blocks = new Map<number, BlockEntry>();
157
+ /** Tool parts by tool_use_id, so a later tool_result can complete them. */
158
+ private readonly toolPartsById = new Map<string, ToolPart>();
159
+ private currentParent?: string;
160
+ /**
161
+ * Set by ANY stream_event in this turn (the transformer is per-turn). When
162
+ * set, the trailing complete `assistant` snapshot just repeats content we
163
+ * already streamed, so it is skipped — except sub-agent replies, which never
164
+ * stream and arrive only as a complete message. Mirrors the source sidecars'
165
+ * sticky `hasReceivedStreamEvents` flag; a per-message flag (reset on the
166
+ * first assistant) double-emits text when a message mixes text + tool_use.
167
+ */
168
+ private hasStreamed = false;
169
+
170
+ private usage: TokenUsage = { ...DEFAULT_TOKEN_USAGE };
171
+ private stopReason?: StopReason;
172
+ private finishReason?: string;
173
+ private cost?: number;
174
+ private error?: string;
175
+ private interrupted = false;
176
+ private execFailure = false;
177
+ private sawResult = false;
178
+
179
+ constructor(ctx: { sessionId: string }) {
180
+ this.ctx = { sessionId: ctx.sessionId, messageId: "" };
181
+ }
182
+
183
+ process(raw: unknown): AdapterEvent[] {
184
+ const msg = raw as ClaudeMessage;
185
+ switch (msg.type) {
186
+ case "stream_event":
187
+ return this.handleStream(msg.event, msg.parent_tool_use_id ?? undefined);
188
+ case "assistant":
189
+ return this.handleAssistant(msg);
190
+ case "user":
191
+ return this.handleToolResults(msg.message?.content);
192
+ case "result":
193
+ return this.captureResult(msg);
194
+ case "turn_interrupted":
195
+ // Synthetic marker from ClaudeCodeAgent: an interrupt WAS requested
196
+ // for this turn (arrives after the result, so decide in finish()).
197
+ this.interrupted = true;
198
+ return [];
199
+ case "system":
200
+ if (msg.subtype === "compact_boundary") {
201
+ const meta = msg.compact_metadata ?? {};
202
+ return [
203
+ {
204
+ kind: "compacted",
205
+ trigger: meta.trigger,
206
+ preTokens: meta.pre_tokens,
207
+ postTokens: meta.post_tokens,
208
+ },
209
+ ];
210
+ }
211
+ return [];
212
+ default:
213
+ return [];
214
+ }
215
+ }
216
+
217
+ finish(): TransformResult {
218
+ // error_during_execution + null stop_reason is cancellation ONLY when the
219
+ // agent confirmed an interrupt; otherwise it is a real failure (a silent
220
+ // "cancelled" here is how a bad resume id used to vanish without a trace).
221
+ // A confirmed interrupt with NO result at all (aborted before the SDK
222
+ // emitted one) is also a cancellation, not a completed turn.
223
+ const cancelled = this.interrupted && (this.execFailure || !this.sawResult);
224
+ const error =
225
+ this.error ??
226
+ (this.execFailure && !this.interrupted
227
+ ? "Claude turn failed during execution (error_during_execution — e.g. an invalid resumeSessionId fails this way)"
228
+ : undefined);
229
+ return {
230
+ usage: this.usage,
231
+ stopReason: this.stopReason,
232
+ finishReason: this.finishReason,
233
+ cost: this.cost,
234
+ error,
235
+ cancelled,
236
+ };
237
+ }
238
+
239
+ private ctxFor(): StreamContext {
240
+ return { ...this.ctx, ...(this.currentParent ? { parentToolUseId: this.currentParent } : {}) };
241
+ }
242
+
243
+ /**
244
+ * Fallback for messages delivered without streaming (notably some sub-agent
245
+ * paths). If the in-flight message already streamed, its parts are live —
246
+ * skip the duplicate. Otherwise emit its content blocks as finalized parts.
247
+ */
248
+ private handleAssistant(msg: Extract<ClaudeMessage, { type: "assistant" }>): AdapterEvent[] {
249
+ // Context gauge: every top-level model message carries fresh usage (the
250
+ // sub-agent's context is its own and would misreport the session's).
251
+ const gauge: AdapterEvent[] =
252
+ msg.message?.usage && !msg.parent_tool_use_id
253
+ ? [{ kind: "usage", used: contextTokens(msg.message.usage) }]
254
+ : [];
255
+ // Streaming already emitted this content live via stream_event deltas; the
256
+ // trailing complete snapshot would duplicate it. Skip — except sub-agent
257
+ // replies (parent_tool_use_id), which never stream and arrive only here.
258
+ if (this.hasStreamed && !msg.parent_tool_use_id) return gauge;
259
+ const content = msg.message?.content;
260
+ if (!Array.isArray(content)) return [];
261
+ const prevParent = this.currentParent;
262
+ this.currentParent = msg.parent_tool_use_id ?? undefined;
263
+ const events: AdapterEvent[] = [];
264
+ for (const block of content) {
265
+ if (block.type === "text" && block.text) {
266
+ events.push({ kind: "part-open", part: createTextPart(this.ctxFor(), block.text, false) });
267
+ } else if (
268
+ (block.type === "thinking" || block.type === "redacted_thinking") &&
269
+ block.thinking
270
+ ) {
271
+ events.push({
272
+ kind: "part-open",
273
+ part: createReasoningPart(this.ctxFor(), block.thinking, false),
274
+ });
275
+ } else if (block.type === "tool_use" && block.id) {
276
+ const name = block.name ?? "tool";
277
+ const input = block.input ?? {};
278
+ const part = createToolPart(
279
+ this.ctxFor(),
280
+ block.id,
281
+ name,
282
+ input,
283
+ claudeToolMeta(name, input),
284
+ );
285
+ this.toolPartsById.set(block.id, part);
286
+ events.push({ kind: "part-open", part });
287
+ }
288
+ }
289
+ this.currentParent = prevParent;
290
+ return [...gauge, ...events];
291
+ }
292
+
293
+ private handleStream(event: StreamEvent, parentToolUseId?: string): AdapterEvent[] {
294
+ // Sticky for the whole turn: any stream event means the trailing complete
295
+ // `assistant` snapshot is a duplicate of what we streamed.
296
+ this.hasStreamed = true;
297
+ switch (event.type) {
298
+ case "message_start":
299
+ this.blocks.clear();
300
+ this.currentParent = parentToolUseId;
301
+ // Each top-level model message becomes a wire message. Sub-agent
302
+ // messages don't open one — their parts nest via parentToolUseId.
303
+ return parentToolUseId ? [] : [{ kind: "message-start", role: "assistant" }];
304
+ case "content_block_start":
305
+ return this.openBlock(event);
306
+ case "content_block_delta":
307
+ return this.deltaBlock(event);
308
+ case "content_block_stop":
309
+ return this.closeBlock(event);
310
+ case "message_delta":
311
+ if (typeof event.delta?.stop_reason === "string")
312
+ this.finishReason = event.delta.stop_reason;
313
+ if (event.usage?.output_tokens) this.usage.output = event.usage.output_tokens;
314
+ return [];
315
+ case "message_stop":
316
+ return parentToolUseId || this.currentParent ? [] : [{ kind: "message-end" }];
317
+ default:
318
+ return [];
319
+ }
320
+ }
321
+
322
+ private openBlock(event: StreamEvent): AdapterEvent[] {
323
+ const index = event.index ?? 0;
324
+ const cb = event.content_block;
325
+ if (!cb) return [];
326
+ if (cb.type === "text") {
327
+ const part = createTextPart(this.ctxFor(), "", true);
328
+ this.blocks.set(index, { partId: part.id, kind: "text", part });
329
+ return [{ kind: "part-open", part }];
330
+ }
331
+ if (cb.type === "thinking" || cb.type === "redacted_thinking") {
332
+ const part = createReasoningPart(this.ctxFor(), "", true);
333
+ this.blocks.set(index, { partId: part.id, kind: "reasoning", part });
334
+ return [{ kind: "part-open", part }];
335
+ }
336
+ if (cb.type === "tool_use") {
337
+ const name = cb.name ?? "tool";
338
+ const part = createPendingToolPart(this.ctxFor(), cb.id ?? "", name, claudeToolMeta(name));
339
+ this.blocks.set(index, { partId: part.id, kind: "tool", part });
340
+ if (cb.id) this.toolPartsById.set(cb.id, part);
341
+ return [{ kind: "part-open", part }];
342
+ }
343
+ return [];
344
+ }
345
+
346
+ private deltaBlock(event: StreamEvent): AdapterEvent[] {
347
+ const entry = this.blocks.get(event.index ?? 0);
348
+ const d = event.delta;
349
+ if (!entry || !d) return [];
350
+ if (d.type === "text_delta" && entry.kind === "text" && d.text) {
351
+ (entry.part as TextPart).text += d.text;
352
+ return [{ kind: "text-delta", partId: entry.partId, text: d.text }];
353
+ }
354
+ if (d.type === "thinking_delta" && entry.kind === "reasoning" && d.thinking) {
355
+ (entry.part as ReasoningPart).text += d.thinking;
356
+ return [{ kind: "reasoning-delta", partId: entry.partId, text: d.thinking }];
357
+ }
358
+ if (d.type === "input_json_delta" && entry.kind === "tool" && d.partial_json) {
359
+ const tool = entry.part as ToolPart;
360
+ appendToolInput(tool, d.partial_json);
361
+ return [
362
+ {
363
+ kind: "tool-input-delta",
364
+ partId: entry.partId,
365
+ toolCallId: tool.toolCallId,
366
+ toolName: tool.toolName,
367
+ input: d.partial_json,
368
+ },
369
+ ];
370
+ }
371
+ return [];
372
+ }
373
+
374
+ private closeBlock(event: StreamEvent): AdapterEvent[] {
375
+ const entry = this.blocks.get(event.index ?? 0);
376
+ if (!entry) return [];
377
+ if (entry.kind === "text" || entry.kind === "reasoning") {
378
+ (entry.part as TextPart | ReasoningPart).state = "done";
379
+ return [{ kind: "part-update", part: entry.part }];
380
+ }
381
+ const tool = entry.part as ToolPart;
382
+ const partial = tool.state.status === "pending" ? tool.state.partialInput : "";
383
+ const input = safeParseJson(partial);
384
+ startToolPart(tool, input);
385
+ const locations = toolLocationsFromInput(input);
386
+ if (locations) setToolMeta(tool, { locations });
387
+ return [{ kind: "part-update", part: tool }];
388
+ }
389
+
390
+ private handleToolResults(content: ContentBlock[] | string | undefined): AdapterEvent[] {
391
+ if (!Array.isArray(content)) return [];
392
+ const events: AdapterEvent[] = [];
393
+ for (const block of content) {
394
+ if (block.type !== "tool_result" || !block.tool_use_id) continue;
395
+ const tool = this.toolPartsById.get(block.tool_use_id);
396
+ if (!tool) continue;
397
+ completeToolPart(tool, {
398
+ output: stringifyToolResult(block.content),
399
+ isError: block.is_error === true,
400
+ });
401
+ events.push({ kind: "part-update", part: tool });
402
+ }
403
+ return events;
404
+ }
405
+
406
+ private captureResult(msg: Extract<ClaudeMessage, { type: "result" }>): AdapterEvent[] {
407
+ this.sawResult = true;
408
+ if (msg.usage) {
409
+ this.usage = {
410
+ input: msg.usage.input_tokens ?? 0,
411
+ output: msg.usage.output_tokens ?? this.usage.output,
412
+ cache: {
413
+ read: msg.usage.cache_read_input_tokens ?? 0,
414
+ write: msg.usage.cache_creation_input_tokens ?? 0,
415
+ },
416
+ };
417
+ }
418
+ if (typeof msg.total_cost_usd === "number") this.cost = msg.total_cost_usd;
419
+ this.finishReason = msg.stop_reason ?? msg.subtype ?? this.finishReason;
420
+ this.stopReason = mapClaudeStopReason(msg.stop_reason, msg.subtype);
421
+ if (msg.subtype && msg.subtype !== "success") {
422
+ if (msg.subtype === "error_during_execution" && msg.stop_reason === null) {
423
+ // Ambiguous shape: an interrupt AND an execution failure (e.g. a bad
424
+ // resume id) both surface this way. The agent's `turn_interrupted`
425
+ // marker (which arrives after this message) disambiguates in finish().
426
+ this.execFailure = true;
427
+ } else if (msg.subtype === "error_max_turns") {
428
+ // Hitting the turn budget is a stop condition, not a failure.
429
+ } else {
430
+ this.error = `Claude turn ended: ${msg.subtype}`;
431
+ }
432
+ }
433
+ // Final authoritative gauge: the result knows the context-window size.
434
+ if (!msg.usage) return [];
435
+ const size = Object.values(msg.modelUsage ?? {})
436
+ .map((m) => m.contextWindow ?? 0)
437
+ .reduce((a, b) => Math.max(a, b), 0);
438
+ return [
439
+ {
440
+ kind: "usage",
441
+ used: contextTokens(msg.usage),
442
+ ...(size > 0 && { size }),
443
+ ...(this.cost !== undefined && { cost: this.cost }),
444
+ },
445
+ ];
446
+ }
447
+ }
448
+
449
+ export function createClaudeCodeTransformer(ctx: { sessionId: string }): EventTransformer {
450
+ return new ClaudeCodeTransformer(ctx);
451
+ }
@@ -0,0 +1,235 @@
1
+ import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
2
+ import type { McpSetServersResult } from "@anthropic-ai/claude-agent-sdk";
3
+ import type { AgentCapabilities, AgentInput, McpServerConfig } from "../../../protocol/index.ts";
4
+ import type { AgentExecuteOptions, RawAgentEvent } from "../base.ts";
5
+ import { BaseAgent } from "../base.ts";
6
+ import type {
7
+ ClaudeHooksFactory,
8
+ ClaudeSessionExtras,
9
+ ClaudeToolPolicy,
10
+ } from "./generator-session.ts";
11
+ import type { ClaudeSessionConfig } from "./options.ts";
12
+ import { ClaudeSessionManager } from "./session-manager.ts";
13
+
14
+ type ClaudeContent = SDKUserMessage["message"]["content"];
15
+
16
+ const CAPABILITIES: AgentCapabilities = {
17
+ multiTurn: true,
18
+ sessionResume: true,
19
+ modelSwitch: "in-session",
20
+ thinkingLevels: true,
21
+ images: true,
22
+ mcpServers: true,
23
+ permissionRequests: true,
24
+ };
25
+
26
+ /** Convert our normalized input into Claude's message content. */
27
+ function toClaudeContent(input: AgentInput): ClaudeContent {
28
+ if (typeof input === "string") return input;
29
+ const blocks = input.map((part) => {
30
+ if (part.type === "text") return { type: "text", text: part.text };
31
+ const source = part.url
32
+ ? { type: "url", url: part.url }
33
+ : { type: "base64", media_type: part.mediaType, data: part.data ?? "" };
34
+ // `file` inputs (PDFs etc.) are document blocks, not images.
35
+ return { type: part.type === "file" ? "document" : "image", source };
36
+ });
37
+ // Kept cast: the SDK's block sources require literal media_type unions
38
+ // ("image/jpeg" | ... and "application/pdf") while our PartInput carries an
39
+ // open string mediaType, so these blocks cannot satisfy the union as typed.
40
+ return blocks as unknown as ClaudeContent;
41
+ }
42
+
43
+ /**
44
+ * The SDK shape of a hard resume failure: the turn dies with
45
+ * `error_during_execution` and a null stop reason before any output. (An
46
+ * interrupt produces the same result message — callers must also check that no
47
+ * interrupt was requested.)
48
+ */
49
+ export function claudeResumeFailed(event: unknown): boolean {
50
+ const msg = event as { type?: string; subtype?: string; stop_reason?: unknown };
51
+ return (
52
+ msg.type === "result" && msg.subtype === "error_during_execution" && msg.stop_reason === null
53
+ );
54
+ }
55
+
56
+ function sessionConfigFrom(
57
+ options: AgentExecuteOptions,
58
+ cliPath: string | undefined,
59
+ ): ClaudeSessionConfig {
60
+ return {
61
+ cwd: options.cwd,
62
+ additionalDirectories: options.additionalDirectories,
63
+ model: options.model,
64
+ thinkingLevel: options.thinkingLevel,
65
+ permissionMode: options.permissionMode,
66
+ maxTurns: options.maxTurns,
67
+ systemPromptAppend: options.systemPromptAppend,
68
+ resumeSessionId: options.resumeSessionId,
69
+ resumeSessionAt: options.resumeSessionAt,
70
+ mcpServers: options.mcpServers,
71
+ env: options.env,
72
+ apiKey: options.apiKey,
73
+ disableTools: options.disableTools === true,
74
+ cliPath,
75
+ };
76
+ }
77
+
78
+ export interface ClaudeCodeAgentOptions {
79
+ /**
80
+ * Resolve the `claude` binary to spawn (typically the provisioner's,
81
+ * memoized). `undefined` defers to the SDK's own platform-package
82
+ * resolution. Operator-level only — never sourced from the wire.
83
+ */
84
+ resolveCliPath?: () => Promise<string | undefined>;
85
+ /**
86
+ * In-process MCP servers to attach to every session (the SDK's
87
+ * `createSdkMcpServer` output). Called once per session spawn. Operator
88
+ * embed-tier only — never wire-sourced; instances win name conflicts with
89
+ * wire-configured servers.
90
+ */
91
+ sdkMcpServers?: ClaudeSessionExtras["sdkMcpServers"];
92
+ /**
93
+ * Pre-broker tool policy (SDK PermissionResult or undefined = fall through
94
+ * to the interactive broker). Operator embed-tier only.
95
+ */
96
+ toolPolicy?: ClaudeToolPolicy;
97
+ /** SDK lifecycle hooks factory (decision-capable). Operator embed-tier only. */
98
+ hooks?: ClaudeHooksFactory;
99
+ /**
100
+ * Raw SDK option overrides merged over the engine's options at session
101
+ * spawn (embedder escape hatch for SDK surface the engine does not model:
102
+ * disallowedTools, forwardSubagentText, extraArgs, ...). Operator
103
+ * embed-tier only — never wire-sourced.
104
+ */
105
+ sdkOptions?: ClaudeSessionExtras["sdkOptions"];
106
+ }
107
+
108
+ /**
109
+ * Claude Code harness. Reuses a warm `query()` per logical session for fast
110
+ * in-session multi-turn and model hot-swap; falls back to SDK `resume` when a
111
+ * session must be reconstructed (e.g. after idle eviction or across processes).
112
+ */
113
+ export class ClaudeCodeAgent extends BaseAgent {
114
+ readonly harness = "claude-code" as const;
115
+ readonly capabilities = CAPABILITIES;
116
+ private readonly manager = new ClaudeSessionManager();
117
+
118
+ constructor(private readonly agentOptions: ClaudeCodeAgentOptions = {}) {
119
+ super();
120
+ }
121
+
122
+ private sessionExtras(): ClaudeSessionExtras {
123
+ return {
124
+ sdkMcpServers: this.agentOptions.sdkMcpServers,
125
+ toolPolicy: this.agentOptions.toolPolicy,
126
+ hooks: this.agentOptions.hooks,
127
+ sdkOptions: this.agentOptions.sdkOptions,
128
+ };
129
+ }
130
+
131
+ async *execute(
132
+ input: AgentInput,
133
+ options: AgentExecuteOptions,
134
+ ): AsyncIterableIterator<RawAgentEvent> {
135
+ const controller = this.trackTurn(options.sessionId, options.signal);
136
+ try {
137
+ const cliPath = await this.agentOptions.resolveCliPath?.();
138
+ const content = toClaudeContent(input);
139
+
140
+ // Attempt 0 honors resumeSessionId. The SDK does not degrade a bad
141
+ // resume id into a fresh session — it fails the whole turn (result
142
+ // error_during_execution before any output) — so a classified resume
143
+ // failure is swallowed and the turn reruns ONCE on a fresh session,
144
+ // reported as resumed:false (the structured fallback signal, acp parity).
145
+ for (let attempt = 0; attempt < 2; attempt++) {
146
+ if (controller.signal.aborted) break;
147
+ const resuming = attempt === 0 && Boolean(options.resumeSessionId);
148
+ const session = await this.manager.getOrCreate(
149
+ options.sessionId,
150
+ {
151
+ ...sessionConfigFrom(options, cliPath),
152
+ ...(resuming ? {} : { resumeSessionId: undefined, resumeSessionAt: undefined }),
153
+ },
154
+ this.sessionExtras(),
155
+ );
156
+
157
+ let reported = false;
158
+ const report = (id: string) => {
159
+ if (reported) return;
160
+ reported = true;
161
+ options.onNativeSession?.(id, { resumed: resuming });
162
+ };
163
+ // Resume turns defer the report until output proves the resume held —
164
+ // a doomed attempt's id is never reported. Other turns report as soon
165
+ // as an id is known.
166
+ if (!resuming && session.currentSessionId) report(session.currentSessionId);
167
+
168
+ // `sendMessage` waits until the session is idle (any prior turn
169
+ // drained), then marks it busy — so installing the per-turn permission
170
+ // handler AFTER it resolves can't clobber a still-running turn's
171
+ // handler. The SDK processes the turn on later async tasks, so the
172
+ // handler is in place before canUseTool can fire.
173
+ const tap = await session.sendMessage(content, options.turnId);
174
+ session.permissionHandler = options.onPermissionRequest;
175
+ // An abort that landed while we were spawning/sending has no listener
176
+ // yet — interrupt directly, then arm the listener for later aborts.
177
+ if (controller.signal.aborted) void session.interruptTurn();
178
+ controller.signal.addEventListener("abort", () => void session.interruptTurn(), {
179
+ once: true,
180
+ });
181
+
182
+ let sawOutput = false;
183
+ let resumeFailed = false;
184
+ for await (const event of tap.events) {
185
+ const msg = event as { type?: string; session_id?: string };
186
+ if (msg.type === "assistant" || msg.type === "stream_event") sawOutput = true;
187
+ if (resuming && !sawOutput && !controller.signal.aborted && claudeResumeFailed(event)) {
188
+ resumeFailed = true;
189
+ continue;
190
+ }
191
+ if (msg.session_id && (!resuming || sawOutput)) report(msg.session_id);
192
+ yield event as RawAgentEvent;
193
+ }
194
+
195
+ if (!resumeFailed) break;
196
+ await this.manager.terminate(options.sessionId);
197
+ }
198
+
199
+ // Ground truth for the adapter: an interrupted turn and a turn that
200
+ // failed during execution (e.g. a bad resume id) end with the SAME
201
+ // result shape (error_during_execution, null stop_reason). Only the
202
+ // agent knows whether an interrupt was actually requested.
203
+ if (controller.signal.aborted) yield { type: "turn_interrupted" };
204
+ } finally {
205
+ this.endTurn(options.sessionId, controller);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Hot-swap the live session's wire MCP servers without a restart (context
211
+ * intact). Returns false when the session isn't live. In-process servers
212
+ * are preserved across swaps.
213
+ */
214
+ async setMcpServers(
215
+ sessionId: string,
216
+ servers: Record<string, McpServerConfig>,
217
+ ): Promise<McpSetServersResult | undefined> {
218
+ return this.manager.setMcpServers(sessionId, servers);
219
+ }
220
+
221
+ override async cancel(sessionId: string): Promise<void> {
222
+ await this.manager.get(sessionId)?.interruptTurn();
223
+ await super.cancel(sessionId);
224
+ }
225
+
226
+ override async release(sessionId: string): Promise<void> {
227
+ await super.release(sessionId);
228
+ await this.manager.terminate(sessionId);
229
+ }
230
+
231
+ override async terminateAll(): Promise<void> {
232
+ await super.terminateAll();
233
+ await this.manager.terminateAll();
234
+ }
235
+ }