@ai-sdk/harness-claude-code 0.0.0 → 1.0.0-beta.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.
@@ -0,0 +1,881 @@
1
+ // Long-running bridge that runs inside a sandbox alongside the `claude` CLI.
2
+ // The generic transport — WebSocket server, token auth, single-flight
3
+ // reconnect, the in-memory event log + `seq`, resume replay, and the
4
+ // lifecycle/meta files — lives in the shared `@ai-sdk/harness/bridge` runtime.
5
+ // This file supplies only the Claude-specific turn driver.
6
+
7
+ import {
8
+ runBridge,
9
+ type BridgeEvent,
10
+ type BridgeTurn,
11
+ } from '@ai-sdk/harness/bridge';
12
+ import type { HarnessV1BuiltinToolName } from '@ai-sdk/harness';
13
+ import { createCompactionLatch } from './compaction-latch';
14
+ import type { StartMessage } from '../claude-code-bridge-protocol';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { argv, stdout } from 'node:process';
17
+
18
+ /*
19
+ * CONSTRAINT — the third-party imports below are NEVER bundled into the
20
+ * compiled `bridge/index.mjs`. They are declared `external` in
21
+ * tsup.config.ts and resolved at runtime from the node_modules that this
22
+ * bridge installs *inside the sandbox* from `src/bridge/package.json` (and
23
+ * its pinned `pnpm-lock.yaml`). That bridge package.json — NOT this host
24
+ * package — is the single source of truth for these packages and their
25
+ * versions; the published `@ai-sdk/harness-claude-code` package does not
26
+ * provide them at runtime.
27
+ *
28
+ * When adding or changing a third-party import here you MUST keep all three
29
+ * in sync, or the bridge will either get the dependency bundled in or fail
30
+ * to resolve it in the sandbox:
31
+ * 1. the import statement below,
32
+ * 2. the `external` array in tsup.config.ts, and
33
+ * 3. the dependency entry in `src/bridge/package.json`.
34
+ */
35
+ import * as claudeAgentSdk from '@anthropic-ai/claude-agent-sdk';
36
+ import * as mcpServerModule from '@modelcontextprotocol/sdk/server/mcp.js';
37
+ import { z } from 'zod';
38
+ import { toClaudeSkillsOption } from './claude-skills-option';
39
+
40
+ /*
41
+ * Native Claude Code tool name → cross-harness common name. Tools outside this
42
+ * map (e.g. `WebFetch`, `NotebookEdit`) have no common equivalent; their
43
+ * native name is forwarded as-is on `tool-call` events.
44
+ */
45
+ const NATIVE_TO_COMMON: Readonly<Record<string, HarnessV1BuiltinToolName>> = {
46
+ Read: 'read',
47
+ Write: 'write',
48
+ Edit: 'edit',
49
+ Bash: 'bash',
50
+ Glob: 'glob',
51
+ Grep: 'grep',
52
+ WebSearch: 'webSearch',
53
+ };
54
+
55
+ const NATIVE_TOOL_KINDS: Readonly<
56
+ Record<string, 'readonly' | 'edit' | 'bash'>
57
+ > = {
58
+ Read: 'readonly',
59
+ Glob: 'readonly',
60
+ Grep: 'readonly',
61
+ WebSearch: 'readonly',
62
+ WebFetch: 'readonly',
63
+ TaskGet: 'readonly',
64
+ TaskList: 'readonly',
65
+ TaskOutput: 'readonly',
66
+ ListMcpResources: 'readonly',
67
+ ReadMcpResource: 'readonly',
68
+ Write: 'edit',
69
+ Edit: 'edit',
70
+ NotebookEdit: 'edit',
71
+ TodoWrite: 'edit',
72
+ TaskCreate: 'edit',
73
+ TaskUpdate: 'edit',
74
+ TaskStop: 'edit',
75
+ EnterWorktree: 'edit',
76
+ ExitWorktree: 'edit',
77
+ ExitPlanMode: 'edit',
78
+ Skill: 'edit',
79
+ AskUserQuestion: 'readonly',
80
+ Bash: 'bash',
81
+ };
82
+
83
+ function toCommonName(nativeName: string): HarnessV1BuiltinToolName | string {
84
+ return NATIVE_TO_COMMON[nativeName] ?? nativeName;
85
+ }
86
+
87
+ /*
88
+ * The harness exposes a coarse `'off' | 'on' | 'adaptive'` thinking setting,
89
+ * but the Claude Agent SDK's `thinking` option takes a structured
90
+ * `ThinkingConfig` object. Passing the bare string silently disables extended
91
+ * thinking (the SDK ignores the malformed value), so the model never emits
92
+ * thinking blocks and no reasoning is streamed. Map to the SDK's shape:
93
+ * 'adaptive' → { type: 'adaptive' } (Claude decides depth; Opus 4.6+)
94
+ * 'on' → { type: 'enabled' } (extended thinking always on)
95
+ * 'off' → { type: 'disabled' }
96
+ *
97
+ * `display: 'summarized'` is required for the model's reasoning to actually be
98
+ * streamed: without it the thinking block arrives carrying only a signature
99
+ * and empty `thinking_delta`s, so `reasoningText` comes back empty. We default
100
+ * it on whenever thinking is enabled so reasoning is visible out of the box;
101
+ * `'off'` (disabled) takes no display.
102
+ */
103
+ function toThinkingConfig(
104
+ thinking: 'off' | 'on' | 'adaptive' | undefined,
105
+ ):
106
+ | { type: 'adaptive' | 'enabled'; display: 'summarized' }
107
+ | { type: 'disabled' }
108
+ | undefined {
109
+ switch (thinking) {
110
+ case 'adaptive':
111
+ return { type: 'adaptive', display: 'summarized' };
112
+ case 'on':
113
+ return { type: 'enabled', display: 'summarized' };
114
+ case 'off':
115
+ return { type: 'disabled' };
116
+ default:
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ const args = parseArgs(argv.slice(2));
122
+ const workdir = args.workdir;
123
+ const bridgeStateDir = args.bridgeStateDir;
124
+ if (!workdir) {
125
+ emitFatal('Missing --workdir argument.');
126
+ }
127
+ if (!bridgeStateDir) {
128
+ emitFatal('Missing --bridge-state-dir argument.');
129
+ }
130
+
131
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
132
+ const claudeSdk = claudeAgentSdk as any;
133
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
134
+ const mcpModule = mcpServerModule as any;
135
+
136
+ await runBridge<StartMessage>({
137
+ bridgeType: 'claude-code',
138
+ bridgeStateDir,
139
+ onStart: runTurn,
140
+ // Claude Code's session state lives in the workdir on the sandbox filesystem
141
+ // (captured by the sandbox snapshot on stop); the resume payload is empty.
142
+ onDetach: () => ({}),
143
+ });
144
+
145
+ type Emit = (msg: Record<string, unknown>) => void;
146
+
147
+ function createPermissionOptions(input: {
148
+ start: StartMessage;
149
+ turn: BridgeTurn;
150
+ emit: Emit;
151
+ nativeToolCallNames: Map<string, string>;
152
+ approvalRequestedToolUseIds: Set<string>;
153
+ }): Record<string, unknown> {
154
+ const permissionMode = input.start.permissionMode ?? 'allow-all';
155
+ if (permissionMode === 'allow-all') {
156
+ return {
157
+ permissionMode: 'bypassPermissions',
158
+ allowDangerouslySkipPermissions: true,
159
+ };
160
+ }
161
+
162
+ return {
163
+ permissionMode:
164
+ permissionMode === 'allow-edits' ? 'acceptEdits' : 'default',
165
+ allowDangerouslySkipPermissions: false,
166
+ settings: createPermissionSettings({ permissionMode }),
167
+ canUseTool: async (
168
+ toolName: string,
169
+ toolInput: Record<string, unknown>,
170
+ options: { toolUseID: string },
171
+ ) => {
172
+ if (toolName.startsWith('mcp__harness-tools__')) {
173
+ return { behavior: 'allow', updatedInput: toolInput };
174
+ }
175
+ if (
176
+ !nativeToolRequiresApproval({
177
+ nativeName: toolName,
178
+ permissionMode,
179
+ })
180
+ ) {
181
+ return { behavior: 'allow', updatedInput: toolInput };
182
+ }
183
+
184
+ const approvalId = options.toolUseID;
185
+ input.approvalRequestedToolUseIds.add(approvalId);
186
+ input.nativeToolCallNames.set(approvalId, toolName);
187
+ input.emit({
188
+ type: 'tool-call',
189
+ toolCallId: approvalId,
190
+ toolName: toCommonName(toolName),
191
+ nativeName: toolName,
192
+ input: JSON.stringify(toolInput ?? {}),
193
+ providerExecuted: true,
194
+ });
195
+ input.emit({
196
+ type: 'tool-approval-request',
197
+ approvalId,
198
+ toolCallId: approvalId,
199
+ });
200
+
201
+ const decision = await input.turn.requestToolApproval(approvalId);
202
+ return decision.approved
203
+ ? { behavior: 'allow', updatedInput: toolInput, toolUseID: approvalId }
204
+ : {
205
+ behavior: 'deny',
206
+ message: decision.reason ?? 'Denied',
207
+ toolUseID: approvalId,
208
+ };
209
+ },
210
+ };
211
+ }
212
+
213
+ function createPermissionSettings(input: {
214
+ permissionMode: 'allow-reads' | 'allow-edits' | 'allow-all';
215
+ }): Record<string, unknown> | undefined {
216
+ const askRules = Object.entries(NATIVE_TOOL_KINDS)
217
+ .filter(([, kind]) =>
218
+ input.permissionMode === 'allow-reads'
219
+ ? kind === 'edit' || kind === 'bash'
220
+ : input.permissionMode === 'allow-edits'
221
+ ? kind === 'bash'
222
+ : false,
223
+ )
224
+ .map(([nativeName]) => `${nativeName}(*)`);
225
+
226
+ if (askRules.length === 0) return undefined;
227
+
228
+ return {
229
+ permissions: { ask: askRules },
230
+ sandbox: { autoAllowBashIfSandboxed: false },
231
+ };
232
+ }
233
+
234
+ function nativeToolRequiresApproval(input: {
235
+ nativeName: string;
236
+ permissionMode: 'allow-reads' | 'allow-edits' | 'allow-all';
237
+ }): boolean {
238
+ if (input.permissionMode === 'allow-all') return false;
239
+ const kind = NATIVE_TOOL_KINDS[input.nativeName] ?? 'edit';
240
+ if (input.permissionMode === 'allow-edits') return kind === 'bash';
241
+ return kind === 'edit' || kind === 'bash';
242
+ }
243
+
244
+ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
245
+ const emit: Emit = msg => turn.emit(msg as BridgeEvent);
246
+
247
+ // Local controller for the Claude query. Aborted either by the host (via the
248
+ // shared runtime's `turn.abortSignal`) or by us on a terminal error.
249
+ const abortCtl = new AbortController();
250
+ if (turn.abortSignal.aborted) {
251
+ abortCtl.abort();
252
+ } else {
253
+ turn.abortSignal.addEventListener('abort', () => abortCtl.abort(), {
254
+ once: true,
255
+ });
256
+ }
257
+
258
+ /*
259
+ * Map of native tool-use id → tool name. Claude assistant messages emit
260
+ * `tool_use` blocks with both `id` and `name`; the matching `tool_result`
261
+ * block on a later user message carries only `tool_use_id`, so without this
262
+ * map the tool-result event would have to emit `toolName: 'unknown'`.
263
+ */
264
+ const nativeToolCallNames = new Map<string, string>();
265
+ const approvalRequestedToolUseIds = new Set<string>();
266
+
267
+ /*
268
+ * Tool-use ids that originated from the MCP server hosting user-supplied
269
+ * tools. The MCP handler emits its own `tool-call`/`tool-result` pair with
270
+ * the user-facing tool name and a synthetic id, so the duplicate
271
+ * `tool_result` block Claude reports for the underlying native id must be
272
+ * suppressed.
273
+ */
274
+ const mcpToolUseIds = new Set<string>();
275
+
276
+ const mcpServers: Record<string, unknown> = {};
277
+ if (start.tools && start.tools.length > 0) {
278
+ const server = new mcpModule.McpServer({
279
+ name: 'harness-tools',
280
+ version: '1.0.0',
281
+ });
282
+ for (const tool of start.tools) {
283
+ const shape = jsonSchemaToZodShape(tool.inputSchema, z);
284
+ server.tool(
285
+ tool.name,
286
+ tool.description ?? '',
287
+ shape,
288
+ async (input: Record<string, unknown>) => {
289
+ const toolCallId = randomUUID();
290
+ emit({
291
+ type: 'tool-call',
292
+ toolCallId,
293
+ toolName: tool.name,
294
+ input: JSON.stringify(input),
295
+ providerExecuted: false,
296
+ });
297
+ const { output, isError } = await turn.requestToolResult(toolCallId);
298
+ emit({
299
+ type: 'tool-result',
300
+ toolCallId,
301
+ toolName: tool.name,
302
+ result: output ?? null,
303
+ isError: !!isError,
304
+ });
305
+ return {
306
+ content: [{ type: 'text', text: JSON.stringify(output ?? null) }],
307
+ isError,
308
+ };
309
+ },
310
+ );
311
+ }
312
+ mcpServers['harness-tools'] = {
313
+ type: 'sdk',
314
+ name: 'harness-tools',
315
+ instance: server,
316
+ };
317
+ }
318
+
319
+ // Compaction observation: merge Claude's `compact_boundary` message and
320
+ // `PostCompact` hook (which arrive in either order) into one `compaction`
321
+ // event. See `createCompactionLatch`.
322
+ const compaction = createCompactionLatch(event => emit(event));
323
+
324
+ // `stream-start` is emitted lazily on the first SDK message (below) so it can
325
+ // carry the model the CLI resolved to, reported on the `system`/`init` message.
326
+
327
+ const queryInput = createQueryInput({
328
+ initialUserMessage: start.prompt,
329
+ pendingUserMessages: turn.pendingUserMessages,
330
+ abortSignal: abortCtl.signal,
331
+ });
332
+ const skillsOption = toClaudeSkillsOption(start.skills);
333
+ const permissionOptions = createPermissionOptions({
334
+ start,
335
+ turn,
336
+ emit,
337
+ nativeToolCallNames,
338
+ approvalRequestedToolUseIds,
339
+ });
340
+
341
+ const q = claudeSdk.query({
342
+ prompt: queryInput.input,
343
+ options: {
344
+ ...(start.model ? { model: start.model } : {}),
345
+ ...(start.maxTurns !== undefined ? { maxTurns: start.maxTurns } : {}),
346
+ ...(skillsOption ? { skills: skillsOption } : {}),
347
+ ...(toThinkingConfig(start.thinking)
348
+ ? { thinking: toThinkingConfig(start.thinking) }
349
+ : {}),
350
+ includePartialMessages: true,
351
+ // The `PostCompact` hook carries the compaction summary, which the
352
+ // `compact_boundary` system message does not. Latch it for the unified
353
+ // `compaction` event; return an empty output so compaction proceeds.
354
+ hooks: {
355
+ PostCompact: [
356
+ {
357
+ hooks: [
358
+ async (input: { compact_summary?: unknown }) => {
359
+ if (typeof input?.compact_summary === 'string') {
360
+ compaction.onSummary(input.compact_summary);
361
+ }
362
+ return {};
363
+ },
364
+ ],
365
+ },
366
+ ],
367
+ },
368
+ // Continuation rule: the host can force-continue (resume after a
369
+ // cross-process detach) by setting `start.continue: true`; otherwise
370
+ // we continue every subsequent turn after the first one in this
371
+ // bridge process.
372
+ ...(start.continue === true || !turn.firstTurn ? { continue: true } : {}),
373
+ ...permissionOptions,
374
+ mcpServers,
375
+ cwd: workdir,
376
+ abortSignal: abortCtl.signal,
377
+ },
378
+ });
379
+
380
+ let stepUsage: Record<string, unknown> | undefined;
381
+ let totalCostUsd: number | undefined;
382
+ let observedTerminalError: string | undefined;
383
+ let emittedTerminalError = false;
384
+ let emittedTerminalFinish = false;
385
+ let streamStarted = false;
386
+ const partialBlocks = new Map<
387
+ number,
388
+ { id: string; kind: 'text' | 'thinking' }
389
+ >();
390
+
391
+ const emitTerminalError = (message: string | undefined): void => {
392
+ const normalized = message?.trim();
393
+ if (!normalized || emittedTerminalError || emittedTerminalFinish) return;
394
+ observedTerminalError = normalized;
395
+ emittedTerminalError = true;
396
+ emit({ type: 'error', error: normalized });
397
+ queryInput.close();
398
+ abortCtl.abort();
399
+ };
400
+
401
+ try {
402
+ for await (const msg of q as AsyncIterable<ClaudeMessage>) {
403
+ if (abortCtl.signal.aborted) break;
404
+
405
+ if (typeof msg.error === 'string' && msg.error.trim()) {
406
+ observedTerminalError = msg.error.trim();
407
+ }
408
+
409
+ const type = msg.type;
410
+
411
+ // Emit `stream-start` once, on the first message, carrying the model the
412
+ // CLI resolved to (the `system`/`init` message reports it — this is the
413
+ // default model when none was configured).
414
+ if (!streamStarted) {
415
+ const initModel =
416
+ type === 'system' &&
417
+ msg.subtype === 'init' &&
418
+ typeof (msg as { model?: unknown }).model === 'string'
419
+ ? (msg as { model: string }).model
420
+ : undefined;
421
+ emit({
422
+ type: 'stream-start',
423
+ ...(initModel ? { modelId: initModel } : {}),
424
+ });
425
+ streamStarted = true;
426
+ }
427
+
428
+ if (
429
+ type === 'auth_status' &&
430
+ typeof msg.error === 'string' &&
431
+ msg.error.trim()
432
+ ) {
433
+ emitTerminalError(msg.error);
434
+ continue;
435
+ }
436
+
437
+ if (
438
+ type === 'system' &&
439
+ msg.subtype === 'api_retry' &&
440
+ typeof msg.error_status === 'number' &&
441
+ [401, 403, 404].includes(msg.error_status)
442
+ ) {
443
+ emitTerminalError(
444
+ `HTTP ${msg.error_status}: ${msg.error ?? 'provider request failed'}`,
445
+ );
446
+ continue;
447
+ }
448
+
449
+ if (
450
+ type === 'system' &&
451
+ msg.subtype === 'task_updated' &&
452
+ msg.patch?.status === 'failed' &&
453
+ typeof msg.patch.error === 'string'
454
+ ) {
455
+ emitTerminalError(msg.patch.error);
456
+ continue;
457
+ }
458
+
459
+ if (type === 'system' && msg.subtype === 'compact_boundary') {
460
+ const meta = msg.compact_metadata;
461
+ if (meta) {
462
+ compaction.onBoundary({
463
+ trigger: meta.trigger,
464
+ ...(typeof meta.pre_tokens === 'number'
465
+ ? { tokensBefore: meta.pre_tokens }
466
+ : {}),
467
+ ...(typeof meta.post_tokens === 'number'
468
+ ? { tokensAfter: meta.post_tokens }
469
+ : {}),
470
+ });
471
+ }
472
+ continue;
473
+ }
474
+
475
+ if (type === 'stream_event') {
476
+ handleStreamEvent(msg.event, partialBlocks, emit);
477
+ continue;
478
+ }
479
+
480
+ if (type === 'assistant' && msg.message?.content) {
481
+ for (const block of msg.message.content) {
482
+ if (
483
+ block.type === 'tool_use' &&
484
+ typeof block.id === 'string' &&
485
+ typeof block.name === 'string'
486
+ ) {
487
+ const mcpPrefix = 'mcp__harness-tools__';
488
+ if (block.name.startsWith(mcpPrefix)) {
489
+ mcpToolUseIds.add(block.id);
490
+ continue;
491
+ }
492
+ nativeToolCallNames.set(block.id, block.name);
493
+ if (approvalRequestedToolUseIds.has(block.id)) {
494
+ continue;
495
+ }
496
+ emit({
497
+ type: 'tool-call',
498
+ toolCallId: block.id,
499
+ toolName: toCommonName(block.name),
500
+ nativeName: block.name,
501
+ input: JSON.stringify(block.input ?? {}),
502
+ providerExecuted: true,
503
+ });
504
+ }
505
+ }
506
+ continue;
507
+ }
508
+
509
+ if (type === 'user' && msg.message?.content) {
510
+ for (const block of msg.message.content) {
511
+ if (
512
+ block.type === 'tool_result' &&
513
+ typeof block.tool_use_id === 'string'
514
+ ) {
515
+ if (mcpToolUseIds.has(block.tool_use_id)) {
516
+ mcpToolUseIds.delete(block.tool_use_id);
517
+ continue;
518
+ }
519
+ approvalRequestedToolUseIds.delete(block.tool_use_id);
520
+ const nativeName =
521
+ nativeToolCallNames.get(block.tool_use_id) ?? 'unknown';
522
+ nativeToolCallNames.delete(block.tool_use_id);
523
+ const toolName = toCommonName(nativeName);
524
+ const isError = !!block.is_error;
525
+ const content = stringifyContent(block.content);
526
+ /*
527
+ * Claude Code's Bash tool does not report the command's real
528
+ * numeric exit code — the SDK exposes only stdout/stderr text and
529
+ * an is_error flag. Consumers (and the example UI) render bash
530
+ * failures from an `exitCode` field on a structured result, the
531
+ * shape Codex's shell tool provides natively. To match it, derive
532
+ * a binary code from is_error: 1 on failure, 0 on success. This is
533
+ * a stand-in for failed/succeeded, not the process's true exit
534
+ * status.
535
+ */
536
+ const result =
537
+ toolName === 'bash'
538
+ ? { exitCode: isError ? 1 : 0, stdout: content }
539
+ : content;
540
+ emit({
541
+ type: 'tool-result',
542
+ toolCallId: block.tool_use_id,
543
+ toolName,
544
+ result,
545
+ isError,
546
+ });
547
+ }
548
+ }
549
+ continue;
550
+ }
551
+
552
+ if (type === 'result') {
553
+ if (msg.subtype === 'success') {
554
+ const emptyResult = !msg.result?.trim?.();
555
+ if (emptyResult && observedTerminalError) {
556
+ emitTerminalError(observedTerminalError);
557
+ continue;
558
+ }
559
+ const usage = msg.usage ?? msg.message?.usage;
560
+ const harnessUsage = mapUsage(usage);
561
+ if (harnessUsage) stepUsage = harnessUsage;
562
+ if (typeof msg.total_cost_usd === 'number') {
563
+ totalCostUsd = (totalCostUsd ?? 0) + msg.total_cost_usd;
564
+ }
565
+ const metadata =
566
+ typeof msg.total_cost_usd === 'number'
567
+ ? { 'claude-code': { costUsd: msg.total_cost_usd } }
568
+ : undefined;
569
+ emit({
570
+ type: 'finish-step',
571
+ finishReason: { unified: 'stop', raw: 'stop' },
572
+ usage: harnessUsage ?? defaultUsage(),
573
+ ...(metadata ? { harnessMetadata: metadata } : {}),
574
+ });
575
+ queryInput.close();
576
+ break;
577
+ } else {
578
+ emitTerminalError(
579
+ (Array.isArray(msg.errors) ? msg.errors.join('\n') : undefined) ||
580
+ observedTerminalError ||
581
+ msg.result ||
582
+ 'Unknown error',
583
+ );
584
+ }
585
+ continue;
586
+ }
587
+ }
588
+ } catch (err) {
589
+ if (!(abortCtl.signal.aborted && emittedTerminalError)) {
590
+ emit({ type: 'error', error: serialiseError(err) });
591
+ }
592
+ return;
593
+ } finally {
594
+ queryInput.close();
595
+ }
596
+
597
+ if (emittedTerminalError) return;
598
+ emittedTerminalFinish = true;
599
+ void emittedTerminalFinish;
600
+ emit({
601
+ type: 'finish',
602
+ finishReason: { unified: 'stop', raw: 'stop' },
603
+ totalUsage: stepUsage ?? defaultUsage(),
604
+ ...(totalCostUsd !== undefined
605
+ ? { harnessMetadata: { 'claude-code': { costUsd: totalCostUsd } } }
606
+ : {}),
607
+ });
608
+ }
609
+
610
+ type ClaudeMessage = {
611
+ type?: string;
612
+ subtype?: string;
613
+ error?: string;
614
+ error_status?: number;
615
+ patch?: { status?: string; error?: string };
616
+ compact_metadata?: {
617
+ trigger: 'manual' | 'auto';
618
+ pre_tokens?: number;
619
+ post_tokens?: number;
620
+ };
621
+ event?: {
622
+ type?: string;
623
+ index?: number;
624
+ content_block?: { type?: string };
625
+ delta?: { type?: string; text?: string; thinking?: string };
626
+ };
627
+ message?: {
628
+ content?: ReadonlyArray<MessageBlock>;
629
+ usage?: Record<string, unknown>;
630
+ };
631
+ result?: string;
632
+ errors?: ReadonlyArray<string>;
633
+ usage?: Record<string, unknown>;
634
+ total_cost_usd?: number;
635
+ };
636
+
637
+ function handleStreamEvent(
638
+ event: ClaudeMessage['event'] | undefined,
639
+ partialBlocks: Map<number, { id: string; kind: 'text' | 'thinking' }>,
640
+ send: Emit,
641
+ ): void {
642
+ if (!event || typeof event.index !== 'number') return;
643
+ const index = event.index;
644
+
645
+ if (event.type === 'content_block_start') {
646
+ const blockType = event.content_block?.type;
647
+ if (blockType === 'text') {
648
+ const id = randomUUID();
649
+ partialBlocks.set(index, { id, kind: 'text' });
650
+ send({ type: 'text-start', id });
651
+ } else if (blockType === 'thinking') {
652
+ const id = randomUUID();
653
+ partialBlocks.set(index, { id, kind: 'thinking' });
654
+ send({ type: 'reasoning-start', id });
655
+ }
656
+ return;
657
+ }
658
+
659
+ if (event.type === 'content_block_delta') {
660
+ const block = partialBlocks.get(index);
661
+ if (!block) return;
662
+ if (
663
+ block.kind === 'text' &&
664
+ event.delta?.type === 'text_delta' &&
665
+ typeof event.delta.text === 'string'
666
+ ) {
667
+ send({ type: 'text-delta', id: block.id, delta: event.delta.text });
668
+ } else if (
669
+ block.kind === 'thinking' &&
670
+ event.delta?.type === 'thinking_delta' &&
671
+ typeof event.delta.thinking === 'string'
672
+ ) {
673
+ send({
674
+ type: 'reasoning-delta',
675
+ id: block.id,
676
+ delta: event.delta.thinking,
677
+ });
678
+ }
679
+ return;
680
+ }
681
+
682
+ if (event.type === 'content_block_stop') {
683
+ const block = partialBlocks.get(index);
684
+ if (!block) return;
685
+ partialBlocks.delete(index);
686
+ if (block.kind === 'text') {
687
+ send({ type: 'text-end', id: block.id });
688
+ } else {
689
+ send({ type: 'reasoning-end', id: block.id });
690
+ }
691
+ }
692
+ }
693
+
694
+ function createQueryInput({
695
+ initialUserMessage,
696
+ pendingUserMessages,
697
+ abortSignal,
698
+ }: {
699
+ initialUserMessage: string;
700
+ pendingUserMessages: string[];
701
+ abortSignal: AbortSignal;
702
+ }): {
703
+ input: AsyncIterable<unknown>;
704
+ close(): void;
705
+ } {
706
+ let closed = false;
707
+ const close = (): void => {
708
+ closed = true;
709
+ };
710
+ if (abortSignal.aborted) {
711
+ close();
712
+ } else {
713
+ abortSignal.addEventListener('abort', close, { once: true });
714
+ }
715
+
716
+ const toUserMessage = (text: string): unknown => ({
717
+ type: 'user',
718
+ message: {
719
+ role: 'user',
720
+ content: [{ type: 'text', text }],
721
+ },
722
+ });
723
+
724
+ return {
725
+ close,
726
+ input: {
727
+ [Symbol.asyncIterator]() {
728
+ let sentInitial = false;
729
+ return {
730
+ async next() {
731
+ // eslint-disable-next-line no-unmodified-loop-condition
732
+ while (!closed && !abortSignal.aborted) {
733
+ if (!sentInitial) {
734
+ sentInitial = true;
735
+ return {
736
+ value: toUserMessage(initialUserMessage),
737
+ done: false,
738
+ };
739
+ }
740
+ if (pendingUserMessages.length > 0) {
741
+ return {
742
+ value: toUserMessage(pendingUserMessages.shift()!),
743
+ done: false,
744
+ };
745
+ }
746
+ await new Promise(resolve => setTimeout(resolve, 50));
747
+ }
748
+ return { value: undefined, done: true } as IteratorResult<unknown>;
749
+ },
750
+ };
751
+ },
752
+ },
753
+ };
754
+ }
755
+
756
+ type MessageBlock = {
757
+ type: string;
758
+ text?: string;
759
+ thinking?: string;
760
+ id?: string;
761
+ name?: string;
762
+ input?: unknown;
763
+ tool_use_id?: string;
764
+ content?: unknown;
765
+ is_error?: boolean;
766
+ };
767
+
768
+ function stringifyContent(content: unknown): string {
769
+ if (typeof content === 'string') return content;
770
+ if (Array.isArray(content)) {
771
+ return content
772
+ .map(entry =>
773
+ entry && typeof entry === 'object' && 'text' in entry
774
+ ? String((entry as { text?: unknown }).text ?? '')
775
+ : JSON.stringify(entry),
776
+ )
777
+ .join('');
778
+ }
779
+ return JSON.stringify(content);
780
+ }
781
+
782
+ function mapUsage(usage: unknown): Record<string, unknown> | undefined {
783
+ if (!usage || typeof usage !== 'object') return undefined;
784
+ const u = usage as {
785
+ input_tokens?: number;
786
+ cache_creation_input_tokens?: number;
787
+ cache_read_input_tokens?: number;
788
+ output_tokens?: number;
789
+ };
790
+ return {
791
+ inputTokens: {
792
+ total:
793
+ (u.input_tokens ?? 0) +
794
+ (u.cache_creation_input_tokens ?? 0) +
795
+ (u.cache_read_input_tokens ?? 0),
796
+ noCache: u.input_tokens ?? 0,
797
+ cacheRead: u.cache_read_input_tokens ?? 0,
798
+ cacheWrite: u.cache_creation_input_tokens ?? 0,
799
+ },
800
+ outputTokens: {
801
+ total: u.output_tokens ?? 0,
802
+ text: u.output_tokens ?? 0,
803
+ },
804
+ };
805
+ }
806
+
807
+ function defaultUsage(): Record<string, unknown> {
808
+ return {
809
+ inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
810
+ outputTokens: { total: 0, text: 0 },
811
+ };
812
+ }
813
+
814
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
815
+ function jsonSchemaToZodShape(
816
+ schema: unknown,
817
+ z: any,
818
+ ): Record<string, unknown> {
819
+ if (!schema || typeof schema !== 'object') return {};
820
+ const s = schema as {
821
+ properties?: Record<string, { type?: string; description?: string }>;
822
+ required?: string[];
823
+ };
824
+ const shape: Record<string, unknown> = {};
825
+ const required = new Set(s.required ?? []);
826
+ for (const [key, val] of Object.entries(s.properties ?? {})) {
827
+ let z_: unknown;
828
+ switch (val.type) {
829
+ case 'string':
830
+ z_ = z.string();
831
+ break;
832
+ case 'number':
833
+ case 'integer':
834
+ z_ = z.number();
835
+ break;
836
+ case 'boolean':
837
+ z_ = z.boolean();
838
+ break;
839
+ case 'array':
840
+ z_ = z.array(z.any());
841
+ break;
842
+ default:
843
+ z_ = z.any();
844
+ }
845
+ if (val.description)
846
+ z_ = (z_ as { describe: (s: string) => unknown }).describe(
847
+ val.description,
848
+ );
849
+ shape[key] = required.has(key)
850
+ ? z_
851
+ : (z_ as { optional: () => unknown }).optional();
852
+ }
853
+ return shape;
854
+ }
855
+
856
+ function parseArgs(args: string[]): {
857
+ workdir?: string;
858
+ bridgeStateDir?: string;
859
+ } {
860
+ const out: { workdir?: string; bridgeStateDir?: string } = {};
861
+ for (let i = 0; i < args.length; i++) {
862
+ if (args[i] === '--workdir' && i + 1 < args.length) {
863
+ out.workdir = args[++i];
864
+ } else if (args[i] === '--bridge-state-dir' && i + 1 < args.length) {
865
+ out.bridgeStateDir = args[++i];
866
+ }
867
+ }
868
+ return out;
869
+ }
870
+
871
+ function serialiseError(err: unknown): unknown {
872
+ if (err instanceof Error) {
873
+ return { name: err.name, message: err.message, stack: err.stack };
874
+ }
875
+ return err;
876
+ }
877
+
878
+ function emitFatal(message: string): never {
879
+ stdout.write(JSON.stringify({ type: 'bridge-fatal', message }) + '\n');
880
+ process.exit(1);
881
+ }