@ai-sdk/harness-claude-code 0.0.0 → 1.0.0-canary.1

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