@ai-sdk/harness-claude-code 0.0.0-6b196531-20260710185421

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