@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,1586 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import {
6
+ commonTool,
7
+ harnessV1DiagnosticFromBridgeFrame,
8
+ HarnessCapabilityUnsupportedError,
9
+ type HarnessV1,
10
+ type HarnessV1Bootstrap,
11
+ type HarnessV1DebugConfig,
12
+ type HarnessV1BuiltinTool,
13
+ type HarnessV1ContinueTurnState,
14
+ type HarnessV1PermissionMode,
15
+ type HarnessV1Prompt,
16
+ type HarnessV1PromptControl,
17
+ type HarnessV1ResumeSessionState,
18
+ type HarnessV1NetworkSandboxSession,
19
+ type HarnessV1Session,
20
+ type HarnessV1Skill,
21
+ type HarnessV1StreamPart,
22
+ } from '@ai-sdk/harness';
23
+ import {
24
+ classifyDiskLog,
25
+ markBridgeStarting,
26
+ SandboxChannel,
27
+ waitForBridgeReady,
28
+ } from '@ai-sdk/harness/utils';
29
+ import {
30
+ tool,
31
+ type Experimental_SandboxSession,
32
+ type Experimental_SandboxProcess,
33
+ } from '@ai-sdk/provider-utils';
34
+ import { WebSocket } from 'ws';
35
+ import { z } from 'zod';
36
+ import {
37
+ resolveClaudeCodeEnv,
38
+ type ClaudeCodeAuthOptions,
39
+ } from './claude-code-auth';
40
+ import {
41
+ outboundMessageSchema,
42
+ type InboundMessage,
43
+ type OutboundMessage,
44
+ } from './claude-code-bridge-protocol';
45
+
46
+ type ClaudeCodeChannel = SandboxChannel<OutboundMessage, InboundMessage>;
47
+ type ClaudeCodeRespawnStrategy = 'replay' | 'rerun';
48
+
49
+ export type ClaudeCodeHarnessSettings = {
50
+ readonly auth?: ClaudeCodeAuthOptions;
51
+ /**
52
+ * Anthropic model id the underlying `claude` CLI should use. Leaving this
53
+ * unset defers to the CLI's default.
54
+ */
55
+ readonly model?: string;
56
+ /**
57
+ * Hard cap on how many internal turns the CLI can take before yielding
58
+ * back to the caller. Unset means the CLI's default.
59
+ */
60
+ readonly maxTurns?: number;
61
+ /**
62
+ * Controls extended-thinking behavior. `'off'` disables thinking,
63
+ * `'on'` forces it on, `'adaptive'` lets the runtime decide.
64
+ */
65
+ readonly thinking?: 'off' | 'on' | 'adaptive';
66
+ /**
67
+ * Override the port the bridge binds inside the sandbox. By default the
68
+ * adapter uses the first port the sandbox declares via `sandbox.ports`.
69
+ * Only set this if the sandbox declares multiple ports and the first one
70
+ * is reserved for something else.
71
+ */
72
+ readonly port?: number;
73
+ /** Maximum milliseconds to wait for the bridge to advertise its port. Defaults to 120000. */
74
+ readonly startupTimeoutMs?: number;
75
+ };
76
+
77
+ /*
78
+ * Every native tool the Claude Code CLI can invoke, declared as a `ToolSet`
79
+ * keyed by what the bridge emits as `toolName` on the wire
80
+ * (`commonName ?? nativeName`). Schemas transcribed from
81
+ * `@anthropic-ai/claude-agent-sdk`'s `agentSdkTypes.d.ts`.
82
+ *
83
+ * `MCP` (the generic proxy tool inside the Claude Code SDK) is intentionally
84
+ * omitted — the bridge filters out `mcp__harness-tools__*` tool names before
85
+ * emitting them, and other MCP invocations come through with their own
86
+ * server-tool names rather than the literal `'Mcp'` token.
87
+ */
88
+ const CLAUDE_CODE_BUILTIN_TOOLS = {
89
+ read: commonTool('read', {
90
+ nativeName: 'Read',
91
+ toolUseKind: 'readonly',
92
+ description: 'Read file contents (text, image, PDF, notebook)',
93
+ inputSchema: z.object({
94
+ file_path: z.string(),
95
+ offset: z.number().optional(),
96
+ limit: z.number().optional(),
97
+ pages: z.string().optional(),
98
+ }),
99
+ }),
100
+ write: commonTool('write', {
101
+ nativeName: 'Write',
102
+ toolUseKind: 'edit',
103
+ description: 'Overwrite or create a file at an absolute path',
104
+ inputSchema: z.object({
105
+ file_path: z.string(),
106
+ content: z.string(),
107
+ }),
108
+ }),
109
+ edit: commonTool('edit', {
110
+ nativeName: 'Edit',
111
+ toolUseKind: 'edit',
112
+ description: 'Edit a file by exact string replacement',
113
+ inputSchema: z.object({
114
+ file_path: z.string(),
115
+ old_string: z.string(),
116
+ new_string: z.string(),
117
+ replace_all: z.boolean().optional(),
118
+ }),
119
+ }),
120
+ bash: commonTool('bash', {
121
+ nativeName: 'Bash',
122
+ toolUseKind: 'bash',
123
+ description: 'Execute a shell command, optionally in background',
124
+ inputSchema: z.object({
125
+ command: z.string(),
126
+ timeout: z.number().optional(),
127
+ description: z.string().optional(),
128
+ run_in_background: z.boolean().optional(),
129
+ dangerouslyDisableSandbox: z.boolean().optional(),
130
+ }),
131
+ }),
132
+ glob: commonTool('glob', {
133
+ nativeName: 'Glob',
134
+ toolUseKind: 'readonly',
135
+ description: 'Fast file-pattern search using glob syntax',
136
+ inputSchema: z.object({
137
+ pattern: z.string(),
138
+ path: z.string().optional(),
139
+ }),
140
+ }),
141
+ grep: commonTool('grep', {
142
+ nativeName: 'Grep',
143
+ toolUseKind: 'readonly',
144
+ description: 'Regex search over file contents via ripgrep',
145
+ inputSchema: z.object({
146
+ pattern: z.string(),
147
+ path: z.string().optional(),
148
+ glob: z.string().optional(),
149
+ output_mode: z
150
+ .enum(['content', 'files_with_matches', 'count'])
151
+ .optional(),
152
+ '-B': z.number().optional(),
153
+ '-A': z.number().optional(),
154
+ '-C': z.number().optional(),
155
+ context: z.number().optional(),
156
+ '-n': z.boolean().optional(),
157
+ '-i': z.boolean().optional(),
158
+ '-o': z.boolean().optional(),
159
+ type: z.string().optional(),
160
+ head_limit: z.number().optional(),
161
+ offset: z.number().optional(),
162
+ multiline: z.boolean().optional(),
163
+ }),
164
+ }),
165
+ webSearch: commonTool('webSearch', {
166
+ nativeName: 'WebSearch',
167
+ toolUseKind: 'readonly',
168
+ description: 'Issue web search queries with optional domain filters',
169
+ inputSchema: z.object({
170
+ query: z.string(),
171
+ allowed_domains: z.array(z.string()).optional(),
172
+ blocked_domains: z.array(z.string()).optional(),
173
+ }),
174
+ }),
175
+
176
+ WebFetch: tool({
177
+ description: 'Fetch a URL and run a prompt against its content',
178
+ inputSchema: z.object({
179
+ url: z.string(),
180
+ prompt: z.string(),
181
+ }),
182
+ }),
183
+ NotebookEdit: tool({
184
+ description: 'Edit, insert, or delete a Jupyter notebook cell',
185
+ inputSchema: z.object({
186
+ notebook_path: z.string(),
187
+ new_source: z.string(),
188
+ cell_id: z.string().optional(),
189
+ cell_type: z.enum(['code', 'markdown']).optional(),
190
+ edit_mode: z.enum(['replace', 'insert', 'delete']).optional(),
191
+ }),
192
+ }),
193
+ TodoWrite: tool({
194
+ description: 'Replace the session todo list',
195
+ inputSchema: z.object({
196
+ todos: z.array(
197
+ z.object({
198
+ content: z.string(),
199
+ status: z.enum(['pending', 'in_progress', 'completed']),
200
+ activeForm: z.string(),
201
+ }),
202
+ ),
203
+ }),
204
+ }),
205
+ Agent: tool({
206
+ description: 'Spawn a subagent with a task',
207
+ inputSchema: z.object({
208
+ description: z.string(),
209
+ prompt: z.string(),
210
+ subagent_type: z.string().optional(),
211
+ model: z.enum(['sonnet', 'opus', 'haiku']).optional(),
212
+ run_in_background: z.boolean().optional(),
213
+ name: z.string().optional(),
214
+ team_name: z.string().optional(),
215
+ mode: z
216
+ .enum([
217
+ 'acceptEdits',
218
+ 'auto',
219
+ 'bypassPermissions',
220
+ 'default',
221
+ 'dontAsk',
222
+ 'plan',
223
+ ])
224
+ .optional(),
225
+ isolation: z.literal('worktree').optional(),
226
+ }),
227
+ }),
228
+ TaskCreate: tool({
229
+ description: 'Create a task in the session-local task list',
230
+ inputSchema: z.object({
231
+ subject: z.string(),
232
+ description: z.string(),
233
+ activeForm: z.string().optional(),
234
+ metadata: z.record(z.unknown()).optional(),
235
+ }),
236
+ }),
237
+ TaskGet: tool({
238
+ description: 'Retrieve a task by id',
239
+ inputSchema: z.object({ taskId: z.string() }),
240
+ }),
241
+ TaskUpdate: tool({
242
+ description: 'Update fields of an existing task',
243
+ inputSchema: z.object({
244
+ taskId: z.string(),
245
+ subject: z.string().optional(),
246
+ description: z.string().optional(),
247
+ activeForm: z.string().optional(),
248
+ status: z
249
+ .union([
250
+ z.enum(['pending', 'in_progress', 'completed']),
251
+ z.literal('deleted'),
252
+ ])
253
+ .optional(),
254
+ addBlocks: z.array(z.string()).optional(),
255
+ addBlockedBy: z.array(z.string()).optional(),
256
+ owner: z.string().optional(),
257
+ metadata: z.record(z.unknown()).optional(),
258
+ }),
259
+ }),
260
+ TaskList: tool({
261
+ description: 'Return all tasks in the session-local task list',
262
+ inputSchema: z.object({}),
263
+ }),
264
+ TaskStop: tool({
265
+ description: 'Stop a running background task by id',
266
+ inputSchema: z.object({
267
+ task_id: z.string().optional(),
268
+ shell_id: z.string().optional(),
269
+ }),
270
+ }),
271
+ TaskOutput: tool({
272
+ description: 'Poll for output from a background task',
273
+ inputSchema: z.object({
274
+ task_id: z.string(),
275
+ block: z.boolean(),
276
+ timeout: z.number(),
277
+ }),
278
+ }),
279
+ ListMcpResources: tool({
280
+ description: 'List resources available from MCP servers',
281
+ inputSchema: z.object({ server: z.string().optional() }),
282
+ }),
283
+ ReadMcpResource: tool({
284
+ description: 'Read a specific MCP resource by URI',
285
+ inputSchema: z.object({ server: z.string(), uri: z.string() }),
286
+ }),
287
+ ExitPlanMode: tool({
288
+ description: 'Exit plan mode with optional permission approvals',
289
+ inputSchema: z
290
+ .object({
291
+ allowedPrompts: z
292
+ .array(
293
+ z.object({
294
+ tool: z.literal('Bash'),
295
+ prompt: z.string(),
296
+ }),
297
+ )
298
+ .optional(),
299
+ })
300
+ .passthrough(),
301
+ }),
302
+ EnterWorktree: tool({
303
+ description: 'Create or enter an isolated git worktree',
304
+ inputSchema: z.object({
305
+ name: z.string().optional(),
306
+ path: z.string().optional(),
307
+ }),
308
+ }),
309
+ ExitWorktree: tool({
310
+ description: 'Exit the current worktree session',
311
+ inputSchema: z.object({
312
+ action: z.enum(['keep', 'remove']),
313
+ discard_changes: z.boolean().optional(),
314
+ }),
315
+ }),
316
+ AskUserQuestion: tool({
317
+ description: 'Ask the user multiple-choice questions via a structured UI',
318
+ inputSchema: z.object({
319
+ questions: z
320
+ .array(
321
+ z.object({
322
+ question: z.string(),
323
+ header: z.string(),
324
+ options: z.array(
325
+ z.object({
326
+ label: z.string(),
327
+ description: z.string(),
328
+ preview: z.string().optional(),
329
+ }),
330
+ ),
331
+ multiSelect: z.boolean(),
332
+ }),
333
+ )
334
+ .min(1)
335
+ .max(4),
336
+ answers: z.record(z.string()).optional(),
337
+ annotations: z
338
+ .record(
339
+ z.object({
340
+ preview: z.string().optional(),
341
+ notes: z.string().optional(),
342
+ }),
343
+ )
344
+ .optional(),
345
+ metadata: z.object({ source: z.string().optional() }).optional(),
346
+ }),
347
+ }),
348
+ Skill: tool({
349
+ description: 'Activate a skill by name',
350
+ inputSchema: z.object({
351
+ skill: z.string(),
352
+ args: z.string().optional(),
353
+ }),
354
+ }),
355
+ } as const satisfies Record<string, HarnessV1BuiltinTool<any, any>>;
356
+
357
+ /*
358
+ * Bootstrap lives in /tmp because it's pure derived state — the harness can
359
+ * reinstall the CLI and bridge files on any fresh sandbox from the recipe.
360
+ * Persistence comes from the sandbox provider's snapshot, not the path.
361
+ *
362
+ * The session work dir (`startOpts.sessionWorkDir`) and the bridge-state dir
363
+ * derived from `sandboxSession.defaultWorkingDirectory` both live under the sandbox's
364
+ * default working directory — the provider's persistent mount — so the
365
+ * workdir's CLI state (Claude's `~/.claude/projects/<dir>/*.jsonl` thread
366
+ * history is keyed by working directory) and the bridge state files survive
367
+ * both detach -> attach/replay and stop -> snapshot -> resume cycles.
368
+ */
369
+ const BOOTSTRAP_DIR = '/tmp/harness/claude-code';
370
+
371
+ /**
372
+ * Live bridge coordinates returned by `doDetach()` and `doSuspendTurn()`. A
373
+ * future process uses them to reopen a socket to the still-running bridge
374
+ * (`attach`) instead of re-spawning it. Absent on a `doStop()` payload.
375
+ */
376
+ const bridgeCoordsSchema = z.object({
377
+ port: z.number(),
378
+ token: z.string(),
379
+ lastSeenEventId: z.number(),
380
+ sandboxId: z.string().optional(),
381
+ });
382
+
383
+ /**
384
+ * Schema for the adapter-specific portion of lifecycle state `data`.
385
+ *
386
+ * A `doStop()` payload is structurally empty (`{}`): the framework derives the
387
+ * sandbox via `provider.resumeSession({ sessionId })`, and the Claude SDK's
388
+ * `{ continue: true }` flag rehydrates the thread from the workdir. A
389
+ * `doDetach()` payload additionally carries `bridge` coordinates for
390
+ * cross-process `attach`. `.passthrough()` keeps both shapes valid.
391
+ */
392
+ const claudeCodeResumeStateSchema = z
393
+ .object({ bridge: bridgeCoordsSchema.optional() })
394
+ .passthrough();
395
+
396
+ type ClaudeCodeBridgeCoords = z.infer<typeof bridgeCoordsSchema>;
397
+
398
+ export function createClaudeCode(
399
+ settings: ClaudeCodeHarnessSettings = {},
400
+ ): HarnessV1<typeof CLAUDE_CODE_BUILTIN_TOOLS> {
401
+ let cachedBootstrap: HarnessV1Bootstrap | undefined;
402
+
403
+ return {
404
+ specificationVersion: 'harness-v1',
405
+ harnessId: 'claude-code',
406
+ builtinTools: CLAUDE_CODE_BUILTIN_TOOLS,
407
+ supportsBuiltinToolApprovals: true,
408
+ lifecycleStateSchema: claudeCodeResumeStateSchema,
409
+ getBootstrap: async () => {
410
+ if (cachedBootstrap != null) return cachedBootstrap;
411
+ const [pkg, lock, bridge] = await Promise.all([
412
+ readBridgeAsset('package.json'),
413
+ readBridgeAsset('pnpm-lock.yaml'),
414
+ readBridgeAsset('index.mjs'),
415
+ ]);
416
+ cachedBootstrap = {
417
+ harnessId: 'claude-code',
418
+ bootstrapDir: BOOTSTRAP_DIR,
419
+ files: [
420
+ { path: `${BOOTSTRAP_DIR}/package.json`, content: pkg },
421
+ { path: `${BOOTSTRAP_DIR}/pnpm-lock.yaml`, content: lock },
422
+ { path: `${BOOTSTRAP_DIR}/bridge.mjs`, content: bridge },
423
+ ],
424
+ commands: [
425
+ { command: `mkdir -p ${BOOTSTRAP_DIR}` },
426
+ {
427
+ command: `pnpm --dir ${BOOTSTRAP_DIR} install --frozen-lockfile --store-dir ${BOOTSTRAP_DIR}/.pnpm-store`,
428
+ },
429
+ {
430
+ command: `cd ${BOOTSTRAP_DIR} && if [ -f node_modules/@anthropic-ai/claude-code/install.cjs ]; then node node_modules/@anthropic-ai/claude-code/install.cjs; fi && ./node_modules/.bin/claude --version`,
431
+ },
432
+ ],
433
+ };
434
+ return cachedBootstrap;
435
+ },
436
+ doStart: async startOpts => {
437
+ const sandboxSession = startOpts.sandboxSession;
438
+ const session = sandboxSession.restricted();
439
+ const sandboxId = sandboxSession.id;
440
+ const lifecycleState = startOpts.continueFrom ?? startOpts.resumeFrom;
441
+ const isResume = lifecycleState != null;
442
+ const isContinue = startOpts.continueFrom != null;
443
+ const coords = isResume
444
+ ? (lifecycleState?.data as { bridge?: ClaudeCodeBridgeCoords })?.bridge
445
+ : undefined;
446
+
447
+ const workDir = startOpts.sessionWorkDir;
448
+ const sessionDataDir = `${sandboxSession.defaultWorkingDirectory}/.agent-runs/${startOpts.sessionId}`;
449
+ const bridgeStateDir = `${sessionDataDir}/bridge`;
450
+ const timeoutMs = settings.startupTimeoutMs ?? 120_000;
451
+
452
+ // Normalize each forwarded bridge diagnostics frame into the general
453
+ // `HarnessV1Diagnostic` and report it. The adapter does no telemetry work
454
+ // beyond this transport→emission mapping.
455
+ const report = startOpts.observability?.report;
456
+ const onDiagnostic = report
457
+ ? (frame: Parameters<typeof harnessV1DiagnosticFromBridgeFrame>[0]) =>
458
+ report(
459
+ harnessV1DiagnosticFromBridgeFrame(frame, {
460
+ sessionId: startOpts.sessionId,
461
+ timestamp: Date.now(),
462
+ }),
463
+ )
464
+ : undefined;
465
+
466
+ // Builds the `connect` thunk a `SandboxChannel` re-invokes on every
467
+ // (re)connect: open the socket, then wait for `bridge-hello` so the
468
+ // end-to-end link is proven live before any frame is sent.
469
+ const buildConnect = (wsUrl: string) => async (): Promise<WebSocket> => {
470
+ return openBridgeWebSocket({ wsUrl, timeoutMs });
471
+ };
472
+
473
+ /*
474
+ * Rung 1 — ATTACH. When lifecycle state carries live bridge coordinates,
475
+ * try to reopen a socket to the still-running bridge. Parked sessions wait
476
+ * for the next `start`; suspended turns request replay of everything past
477
+ * the persisted cursor. No spawn, no fresh token (the existing bridge
478
+ * still authorises the persisted one). If the bridge is gone the open
479
+ * throws and we fall through to a spawn-based recovery.
480
+ */
481
+ if (coords) {
482
+ try {
483
+ const attachUrl =
484
+ (await sandboxSession.getPortUrl({
485
+ port: coords.port,
486
+ protocol: 'ws',
487
+ })) + `?agent_bridge_token=${encodeURIComponent(coords.token)}`;
488
+ const attachChannel: ClaudeCodeChannel = new SandboxChannel({
489
+ connect: buildConnect(attachUrl),
490
+ outboundSchema: outboundMessageSchema,
491
+ initialLastSeenEventId: coords.lastSeenEventId,
492
+ onDiagnostic,
493
+ });
494
+ await attachChannel.open(isContinue ? { resume: true } : undefined);
495
+ return createSession({
496
+ sessionId: startOpts.sessionId,
497
+ channel: attachChannel,
498
+ // The live bridge was spawned by another process; this one owns no
499
+ // process handle. The session lifecycle method decides whether the
500
+ // sandbox is left running, stopped, or destroyed.
501
+ proc: undefined,
502
+ model: settings.model,
503
+ maxTurns: settings.maxTurns,
504
+ thinking: settings.thinking,
505
+ isResume: true,
506
+ continueOnFirstPrompt: false,
507
+ rerunContinue: false,
508
+ bridgePort: coords.port,
509
+ bridgeToken: coords.token,
510
+ sandboxId,
511
+ debug: startOpts.observability?.debug,
512
+ permissionMode: startOpts.permissionMode,
513
+ skills: startOpts.skills ?? [],
514
+ });
515
+ } catch {
516
+ // Bridge no longer reachable — recover by respawning below.
517
+ }
518
+ }
519
+
520
+ /*
521
+ * Rungs 2/3 — REPLAY vs RERUN. Respawn the bridge. `replay` is only sound
522
+ * for `continueFrom`: those coordinates include the cursor the on-disk
523
+ * log is replayed *from*. `resumeFrom` is a between-turn resume; even when
524
+ * it carries bridge coordinates, replaying the previous turn would
525
+ * re-deliver stale events into the next turn. Those resumes always `rerun`
526
+ * when attach is unavailable (the CLI continues its own thread from the
527
+ * workdir snapshot via `continue: true`).
528
+ */
529
+ let respawnStrategy: ClaudeCodeRespawnStrategy | undefined = isResume
530
+ ? 'rerun'
531
+ : undefined;
532
+ if (coords && isContinue) {
533
+ const logRaw = await Promise.resolve(
534
+ session.readTextFile({
535
+ path: `${bridgeStateDir}/event-log.ndjson`,
536
+ abortSignal: startOpts.abortSignal,
537
+ }),
538
+ ).catch(() => null);
539
+ if ((await classifyDiskLog(logRaw)) === 'replay') {
540
+ respawnStrategy = 'replay';
541
+ }
542
+ }
543
+
544
+ const sandboxHomeDir =
545
+ startOpts.skills && startOpts.skills.length > 0
546
+ ? await resolveSandboxHomeDir({
547
+ sandbox: session,
548
+ abortSignal: startOpts.abortSignal,
549
+ })
550
+ : undefined;
551
+ const port = resolveBridgePort(sandboxSession, settings.port);
552
+ const token = randomBytes(32).toString('hex');
553
+ const env = {
554
+ ...resolveClaudeCodeEnv(settings.auth),
555
+ BRIDGE_CHANNEL_TOKEN: token,
556
+ BRIDGE_WS_PORT: String(port),
557
+ ...(sandboxHomeDir ? { HOME: sandboxHomeDir } : {}),
558
+ ...(respawnStrategy === 'replay'
559
+ ? { BRIDGE_REPLAY_FROM_DISK: '1' }
560
+ : {}),
561
+ };
562
+
563
+ /*
564
+ * On a fresh start the workdir, skill files, and bridge-state directory
565
+ * must be created. On any resume they already exist in the
566
+ * sandbox snapshot, so skip the rewrite. The env is sent fresh on every
567
+ * spawn — `BRIDGE_CHANNEL_TOKEN` rotates per start.
568
+ */
569
+ if (respawnStrategy === undefined) {
570
+ await session.run({
571
+ command: `mkdir -p ${workDir} ${bridgeStateDir}`,
572
+ abortSignal: startOpts.abortSignal,
573
+ });
574
+
575
+ if (startOpts.skills && startOpts.skills.length > 0) {
576
+ if (!sandboxHomeDir) {
577
+ throw new Error('Unable to resolve sandbox HOME directory.');
578
+ }
579
+ await writeSkills({
580
+ sandbox: session,
581
+ homeDir: sandboxHomeDir,
582
+ skills: startOpts.skills,
583
+ abortSignal: startOpts.abortSignal,
584
+ });
585
+ }
586
+ }
587
+
588
+ await markBridgeStarting({
589
+ sandbox: session,
590
+ bridgeStateDir,
591
+ bridgeType: 'claude-code',
592
+ abortSignal: startOpts.abortSignal,
593
+ });
594
+
595
+ const proc = await session.spawn({
596
+ command: `node ${BOOTSTRAP_DIR}/bridge.mjs --workdir ${workDir} --bridge-state-dir ${bridgeStateDir}`,
597
+ env,
598
+ abortSignal: startOpts.abortSignal,
599
+ });
600
+
601
+ const bridgeStartupStderr: string[] = [];
602
+ /*
603
+ * Bridge stderr is the only diagnostic channel for sandbox-side crashes
604
+ * and startup failures. Start forwarding before `bridge-ready`, otherwise
605
+ * module-resolution, auth, and syntax errors can be lost behind the
606
+ * generic startup failure below.
607
+ */
608
+ const bridgeStartupStderrDone = forwardBridgeStderr({
609
+ stream: proc.stderr,
610
+ collectTail: bridgeStartupStderr,
611
+ });
612
+ void bridgeStartupStderrDone;
613
+ const { port: boundPort } = await waitForBridgeReady({
614
+ proc,
615
+ sandbox: session,
616
+ bridgeStateDir,
617
+ bridgeType: 'claude-code',
618
+ timeoutMs,
619
+ abortSignal: startOpts.abortSignal,
620
+ createTimeoutError: ({ proc, stdoutTail }) =>
621
+ createBridgeStartupError({
622
+ message: 'claude-code bridge did not become ready in time.',
623
+ proc,
624
+ stdoutTail,
625
+ stderrTail: bridgeStartupStderr,
626
+ stderrDone: bridgeStartupStderrDone,
627
+ }),
628
+ createExitError: ({ proc, stdoutTail }) =>
629
+ createBridgeStartupError({
630
+ message: 'claude-code bridge exited before becoming ready.',
631
+ proc,
632
+ stdoutTail,
633
+ stderrTail: bridgeStartupStderr,
634
+ stderrDone: bridgeStartupStderrDone,
635
+ }),
636
+ });
637
+ void drainRest(proc.stdout);
638
+
639
+ const wsUrl =
640
+ (await sandboxSession.getPortUrl({
641
+ port: boundPort,
642
+ protocol: 'ws',
643
+ })) + `?agent_bridge_token=${encodeURIComponent(token)}`;
644
+
645
+ const channel: ClaudeCodeChannel = new SandboxChannel({
646
+ connect: buildConnect(wsUrl),
647
+ outboundSchema: outboundMessageSchema,
648
+ onDiagnostic,
649
+ // In replay mode the respawned bridge reloaded the finished turn from
650
+ // disk; seed the cursor and resume so it streams the tail (incl.
651
+ // `finish`) rather than starting empty.
652
+ ...(respawnStrategy === 'replay'
653
+ ? { initialLastSeenEventId: coords?.lastSeenEventId ?? 0 }
654
+ : {}),
655
+ });
656
+ await channel.open(
657
+ respawnStrategy === 'replay' ? { resume: true } : undefined,
658
+ );
659
+
660
+ return createSession({
661
+ sessionId: startOpts.sessionId,
662
+ channel,
663
+ proc,
664
+ model: settings.model,
665
+ maxTurns: settings.maxTurns,
666
+ thinking: settings.thinking,
667
+ isResume: respawnStrategy !== undefined,
668
+ continueOnFirstPrompt: respawnStrategy !== undefined,
669
+ rerunContinue: respawnStrategy === 'rerun',
670
+ bridgePort: boundPort,
671
+ bridgeToken: token,
672
+ sandboxId,
673
+ debug: startOpts.observability?.debug,
674
+ permissionMode: startOpts.permissionMode,
675
+ skills: startOpts.skills ?? [],
676
+ });
677
+ },
678
+ };
679
+ }
680
+
681
+ function resolveBridgePort(
682
+ sandboxSession: HarnessV1NetworkSandboxSession,
683
+ override: number | undefined,
684
+ ): number {
685
+ if (override !== undefined) return override;
686
+ if (sandboxSession.ports.length > 0) return sandboxSession.ports[0];
687
+ throw new HarnessCapabilityUnsupportedError({
688
+ harnessId: 'claude-code',
689
+ message:
690
+ 'The claude-code harness needs a TCP port exposed by the sandbox. ' +
691
+ 'Create the sandbox with `ports: [<port>]` or pass `createClaudeCode({ port })`.',
692
+ });
693
+ }
694
+
695
+ /**
696
+ * Materialise skill files into
697
+ * `$HOME/.claude/skills/<name>/SKILL.md`. The `claude` CLI
698
+ * auto-discovers skills from that directory on startup, so the files have to
699
+ * be in place before the bridge is spawned without mutating the session
700
+ * workdir. Each file uses the YAML-frontmatter shape the CLI expects.
701
+ */
702
+ async function writeSkills({
703
+ sandbox,
704
+ homeDir,
705
+ skills,
706
+ abortSignal,
707
+ }: {
708
+ sandbox: Experimental_SandboxSession;
709
+ homeDir: string;
710
+ skills: ReadonlyArray<HarnessV1Skill>;
711
+ abortSignal?: AbortSignal;
712
+ }): Promise<void> {
713
+ for (const skill of skills) {
714
+ safeClaudeSkillName(skill.name);
715
+ for (const file of skill.files ?? []) {
716
+ safeClaudeSkillFilePath({
717
+ skillName: skill.name,
718
+ filePath: file.path,
719
+ });
720
+ }
721
+ }
722
+
723
+ await sandbox.run({
724
+ command: `mkdir -p ${shellQuote(homeDir)}/.claude/skills`,
725
+ abortSignal,
726
+ });
727
+ for (const skill of skills) {
728
+ const name = safeClaudeSkillName(skill.name);
729
+ const skillDir = `${homeDir}/.claude/skills/${name}`;
730
+ const path = `${skillDir}/SKILL.md`;
731
+ const content = `---\nname: ${skill.name}\ndescription: ${skill.description}\n---\n\n${skill.content}\n`;
732
+ await sandbox.writeTextFile({ path, content, abortSignal });
733
+ for (const file of skill.files ?? []) {
734
+ const filePath = safeClaudeSkillFilePath({
735
+ skillName: skill.name,
736
+ filePath: file.path,
737
+ });
738
+ await sandbox.writeTextFile({
739
+ path: `${skillDir}/${filePath}`,
740
+ content: file.content,
741
+ abortSignal,
742
+ });
743
+ }
744
+ }
745
+ }
746
+
747
+ async function resolveSandboxHomeDir({
748
+ sandbox,
749
+ abortSignal,
750
+ }: {
751
+ sandbox: Experimental_SandboxSession;
752
+ abortSignal?: AbortSignal;
753
+ }): Promise<string> {
754
+ const result = await sandbox.run({
755
+ command: 'printf "%s" "$HOME"',
756
+ abortSignal,
757
+ });
758
+ const homeDir = result.stdout.trim();
759
+ if (result.exitCode !== 0 || !homeDir || !path.posix.isAbsolute(homeDir)) {
760
+ throw new Error(
761
+ `Unable to resolve sandbox HOME directory: ${result.stderr || result.stdout}`,
762
+ );
763
+ }
764
+ return homeDir;
765
+ }
766
+
767
+ function safeClaudeSkillName(name: string): string {
768
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '.' || name === '..') {
769
+ throw new Error(`Invalid Claude Code skill name: ${name}`);
770
+ }
771
+ return name;
772
+ }
773
+
774
+ function safeClaudeSkillFilePath({
775
+ skillName,
776
+ filePath,
777
+ }: {
778
+ skillName: string;
779
+ filePath: string;
780
+ }): string {
781
+ const normalized = path.posix.normalize(filePath);
782
+ if (
783
+ normalized === '.' ||
784
+ normalized.startsWith('../') ||
785
+ path.posix.isAbsolute(normalized)
786
+ ) {
787
+ throw new Error(
788
+ `Invalid Claude Code skill file path for ${skillName}: ${filePath}`,
789
+ );
790
+ }
791
+ return normalized;
792
+ }
793
+
794
+ function shellQuote(value: string): string {
795
+ return `'${value.replace(/'/g, `'\\''`)}'`;
796
+ }
797
+
798
+ async function readBridgeAsset(name: string): Promise<string> {
799
+ const candidates = [
800
+ new URL(`./bridge/${name}`, import.meta.url),
801
+ new URL(`../bridge/${name}`, import.meta.url),
802
+ ];
803
+ let lastErr: unknown;
804
+ for (const url of candidates) {
805
+ try {
806
+ return await readFile(fileURLToPath(url), 'utf8');
807
+ } catch (err) {
808
+ const code = (err as NodeJS.ErrnoException).code;
809
+ if (code !== 'ENOENT') throw err;
810
+ lastErr = err;
811
+ }
812
+ }
813
+ throw lastErr ?? new Error(`bridge asset not found: ${name}`);
814
+ }
815
+
816
+ async function createBridgeStartupError({
817
+ message,
818
+ proc,
819
+ stdoutTail,
820
+ stderrTail,
821
+ stderrDone,
822
+ }: {
823
+ message: string;
824
+ proc: Experimental_SandboxProcess;
825
+ stdoutTail: string[];
826
+ stderrTail: string[];
827
+ stderrDone: Promise<void>;
828
+ }): Promise<Error> {
829
+ await Promise.race([
830
+ stderrDone,
831
+ new Promise<void>(resolve => setTimeout(resolve, 250)),
832
+ ]).catch(() => {});
833
+
834
+ let exitStatus = '';
835
+ try {
836
+ const result = (await Promise.race([
837
+ proc.wait(),
838
+ new Promise<undefined>(resolve => setTimeout(resolve, 250)),
839
+ ])) as { exitCode?: number } | undefined;
840
+ if (result?.exitCode !== undefined) {
841
+ exitStatus = ` Exit code: ${result.exitCode}.`;
842
+ }
843
+ } catch {}
844
+
845
+ const details: string[] = [];
846
+ if (stdoutTail.length > 0) {
847
+ details.push(`stdout:\n${stdoutTail.join('\n')}`);
848
+ }
849
+ if (stderrTail.length > 0) {
850
+ details.push(`stderr:\n${stderrTail.join('\n')}`);
851
+ }
852
+
853
+ return new Error(
854
+ `${message}${exitStatus}${
855
+ details.length > 0 ? `\n\n${details.join('\n\n')}` : ''
856
+ }`,
857
+ );
858
+ }
859
+
860
+ function lineDecoder() {
861
+ let buffer = '';
862
+ return {
863
+ push(chunk: string): string[] {
864
+ buffer += chunk;
865
+ const lines: string[] = [];
866
+ let nl: number;
867
+ while ((nl = buffer.indexOf('\n')) !== -1) {
868
+ const raw = buffer.slice(0, nl);
869
+ buffer = buffer.slice(nl + 1);
870
+ const line = raw.replace(/\r$/, '').trim();
871
+ if (line.length > 0) lines.push(line);
872
+ }
873
+ return lines;
874
+ },
875
+ flush(): string[] {
876
+ const line = buffer.replace(/\r$/, '').trim();
877
+ buffer = '';
878
+ return line.length > 0 ? [line] : [];
879
+ },
880
+ };
881
+ }
882
+
883
+ async function forwardBridgeStderr({
884
+ stream,
885
+ collectTail,
886
+ }: {
887
+ stream: ReadableStream<Uint8Array>;
888
+ collectTail?: string[];
889
+ }): Promise<void> {
890
+ try {
891
+ const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
892
+ const decoder = lineDecoder();
893
+ while (true) {
894
+ const { value, done } = await reader.read();
895
+ if (done) {
896
+ for (const line of decoder.flush()) {
897
+ const trimmed = line.trim();
898
+ if (!trimmed) continue;
899
+ if (collectTail) {
900
+ collectTail.push(trimmed);
901
+ if (collectTail.length > 20) collectTail.shift();
902
+ }
903
+ // eslint-disable-next-line no-console
904
+ console.log(`[bridge stderr] ${trimmed}`);
905
+ }
906
+ return;
907
+ }
908
+ if (value) {
909
+ for (const line of decoder.push(value)) {
910
+ const trimmed = line.trim();
911
+ if (!trimmed) continue;
912
+ if (collectTail) {
913
+ collectTail.push(trimmed);
914
+ if (collectTail.length > 20) collectTail.shift();
915
+ }
916
+ // eslint-disable-next-line no-console
917
+ console.log(`[bridge stderr] ${trimmed}`);
918
+ }
919
+ }
920
+ }
921
+ } catch {
922
+ // Reader errors are non-fatal — best-effort diagnostic only.
923
+ }
924
+ }
925
+
926
+ async function drainRest(stream: ReadableStream<Uint8Array>): Promise<void> {
927
+ try {
928
+ const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
929
+ while (true) {
930
+ const { done } = await reader.read();
931
+ if (done) return;
932
+ }
933
+ } catch {}
934
+ }
935
+
936
+ /**
937
+ * Wait for the bridge's `bridge-hello` message to arrive on the freshly
938
+ * opened WebSocket before any other host-side code touches it.
939
+ *
940
+ * Some sandbox runtimes (Vercel in particular) complete the WS upgrade
941
+ * with the host long before the connection is actually forwarded to the
942
+ * sandbox-side bridge. Anything sent in that gap is silently dropped —
943
+ * including the `start` message we send next. The bridge emits
944
+ * `bridge-hello` the instant it accepts the connection, so receiving it
945
+ * is the only reliable evidence that the end-to-end link is live.
946
+ */
947
+ async function waitForBridgeHello({
948
+ ws,
949
+ timeoutMs,
950
+ }: {
951
+ ws: WebSocket;
952
+ timeoutMs: number;
953
+ }): Promise<void> {
954
+ await new Promise<void>((resolve, reject) => {
955
+ let settled = false;
956
+ const cleanup = () => {
957
+ ws.off('message', onMessage);
958
+ ws.off('close', onClose);
959
+ ws.off('error', onError);
960
+ if (timer) clearTimeout(timer);
961
+ };
962
+ const settle = (err?: unknown) => {
963
+ if (settled) return;
964
+ settled = true;
965
+ cleanup();
966
+ if (err) reject(err);
967
+ else resolve();
968
+ };
969
+ const onMessage = (raw: unknown) => {
970
+ try {
971
+ const text =
972
+ typeof raw === 'string'
973
+ ? raw
974
+ : (raw as Buffer | ArrayBufferLike).toString
975
+ ? (raw as Buffer).toString('utf8')
976
+ : String(raw);
977
+ const parsed = JSON.parse(text) as { type?: unknown };
978
+ if (parsed?.type === 'bridge-hello') settle();
979
+ } catch {
980
+ // Ignore malformed frames while waiting.
981
+ }
982
+ };
983
+ const onClose = () => {
984
+ settle(
985
+ new Error('claude-code bridge closed before sending bridge-hello'),
986
+ );
987
+ };
988
+ const onError = (err: Error) => settle(err);
989
+ const timer = setTimeout(
990
+ () =>
991
+ settle(
992
+ new Error(
993
+ `claude-code bridge did not send bridge-hello within ${timeoutMs}ms`,
994
+ ),
995
+ ),
996
+ timeoutMs,
997
+ );
998
+ timer.unref?.();
999
+ ws.on('message', onMessage);
1000
+ ws.on('close', onClose);
1001
+ ws.on('error', onError);
1002
+ });
1003
+ }
1004
+
1005
+ async function openBridgeWebSocket({
1006
+ wsUrl,
1007
+ timeoutMs,
1008
+ }: {
1009
+ wsUrl: string;
1010
+ timeoutMs: number;
1011
+ }): Promise<WebSocket> {
1012
+ const deadline = Date.now() + timeoutMs;
1013
+ let attempt = 0;
1014
+ let lastError: unknown;
1015
+
1016
+ while (Date.now() < deadline) {
1017
+ attempt++;
1018
+ let ws: WebSocket | undefined;
1019
+ try {
1020
+ const remaining = Math.max(1, deadline - Date.now());
1021
+ ws = await openWebSocket({
1022
+ url: wsUrl,
1023
+ timeoutMs: Math.min(10_000, remaining),
1024
+ });
1025
+ await waitForBridgeHello({
1026
+ ws,
1027
+ timeoutMs: Math.min(5_000, Math.max(1, deadline - Date.now())),
1028
+ });
1029
+ return ws;
1030
+ } catch (err) {
1031
+ lastError = err;
1032
+ try {
1033
+ ws?.close();
1034
+ } catch {}
1035
+ const remaining = deadline - Date.now();
1036
+ if (remaining <= 0) break;
1037
+ await sleep(Math.min(250 * attempt, 1_000, remaining));
1038
+ }
1039
+ }
1040
+
1041
+ throw new Error(
1042
+ `claude-code bridge did not complete WebSocket handshake within ${timeoutMs}ms after ${attempt} attempt(s). Last error: ${formatUnknownError(lastError)}`,
1043
+ );
1044
+ }
1045
+
1046
+ function openWebSocket({
1047
+ url,
1048
+ timeoutMs,
1049
+ }: {
1050
+ url: string;
1051
+ timeoutMs: number;
1052
+ }): Promise<WebSocket> {
1053
+ return new Promise((resolve, reject) => {
1054
+ const ws = new WebSocket(url);
1055
+ const timer = setTimeout(() => {
1056
+ cleanup();
1057
+ try {
1058
+ ws.terminate();
1059
+ } catch {}
1060
+ reject(new Error(`WebSocket open timed out after ${timeoutMs}ms`));
1061
+ }, timeoutMs);
1062
+ timer.unref?.();
1063
+ const cleanup = () => {
1064
+ clearTimeout(timer);
1065
+ ws.off('open', onOpen);
1066
+ ws.off('error', onError);
1067
+ };
1068
+ const onOpen = () => {
1069
+ cleanup();
1070
+ resolve(ws);
1071
+ };
1072
+ const onError = (err: Error) => {
1073
+ cleanup();
1074
+ reject(err);
1075
+ };
1076
+ ws.once('open', onOpen);
1077
+ ws.once('error', onError);
1078
+ });
1079
+ }
1080
+
1081
+ function sleep(ms: number): Promise<void> {
1082
+ return new Promise(resolve => {
1083
+ const timer = setTimeout(resolve, ms);
1084
+ timer.unref?.();
1085
+ });
1086
+ }
1087
+
1088
+ function formatUnknownError(error: unknown): string {
1089
+ if (error instanceof Error) return error.message;
1090
+ return String(error);
1091
+ }
1092
+
1093
+ function createSession({
1094
+ sessionId,
1095
+ channel,
1096
+ proc,
1097
+ model,
1098
+ maxTurns,
1099
+ thinking,
1100
+ isResume,
1101
+ continueOnFirstPrompt,
1102
+ rerunContinue,
1103
+ bridgePort,
1104
+ bridgeToken,
1105
+ sandboxId,
1106
+ debug,
1107
+ permissionMode,
1108
+ skills,
1109
+ }: {
1110
+ sessionId: string;
1111
+ channel: ClaudeCodeChannel;
1112
+ /** Undefined on `attach` — the live bridge was spawned by another process. */
1113
+ proc: Experimental_SandboxProcess | undefined;
1114
+ model: string | undefined;
1115
+ maxTurns: number | undefined;
1116
+ thinking: 'off' | 'on' | 'adaptive' | undefined;
1117
+ isResume: boolean;
1118
+ continueOnFirstPrompt: boolean;
1119
+ rerunContinue: boolean;
1120
+ bridgePort: number;
1121
+ bridgeToken: string;
1122
+ sandboxId: string;
1123
+ debug: HarnessV1DebugConfig | undefined;
1124
+ permissionMode: HarnessV1PermissionMode | undefined;
1125
+ skills: ReadonlyArray<HarnessV1Skill>;
1126
+ }): HarnessV1Session {
1127
+ let stopped = false;
1128
+ let stopPromise: Promise<void> | undefined;
1129
+ /*
1130
+ * Force the Claude SDK's `continue: true` on the first prompt only when the
1131
+ * bridge was respawned (rerun/replay): a fresh bridge process treats its
1132
+ * first turn as new, so it must be told to rehydrate the workdir thread. An
1133
+ * `attach`ed bridge is already past its first turn and continues on its own.
1134
+ */
1135
+ let pendingResumeFlag = continueOnFirstPrompt;
1136
+ /*
1137
+ * Instructions are prepended to the first user message of a fresh session
1138
+ * only. A resumed session (attach/replay/rerun) already carried them in its
1139
+ * original first message (preserved in the workdir snapshot), so it starts
1140
+ * "applied".
1141
+ */
1142
+ let instructionsApplied = isResume;
1143
+
1144
+ /*
1145
+ * Wire the channel into one turn's worth of events and return the control
1146
+ * surface. Shared by `doPromptTurn` (which sends a `start` afterwards) and
1147
+ * `doContinueTurn` (which attaches to an already-running/replayed turn, or sends
1148
+ * a rerun `start`). The only difference between the two entry points is the
1149
+ * `start` message, not the listener/abort/settle plumbing.
1150
+ */
1151
+ const wireTurn = (turnOpts: {
1152
+ emit: (event: HarnessV1StreamPart) => void;
1153
+ abortSignal?: AbortSignal;
1154
+ }): HarnessV1PromptControl => {
1155
+ let pendingResolve: (() => void) | undefined;
1156
+ let pendingReject: ((err: unknown) => void) | undefined;
1157
+ const done = new Promise<void>((resolve, reject) => {
1158
+ pendingResolve = resolve;
1159
+ pendingReject = reject;
1160
+ });
1161
+
1162
+ const unsubs: Array<() => void> = [];
1163
+ const forward = (event: HarnessV1StreamPart) => {
1164
+ try {
1165
+ turnOpts.emit(event);
1166
+ } catch {}
1167
+ };
1168
+
1169
+ let isSettled = false;
1170
+ const settleSuccess = () => {
1171
+ if (isSettled) return;
1172
+ isSettled = true;
1173
+ for (const u of unsubs) u();
1174
+ pendingResolve!();
1175
+ };
1176
+ const settleError = (err: unknown) => {
1177
+ if (isSettled) return;
1178
+ isSettled = true;
1179
+ for (const u of unsubs) u();
1180
+ pendingReject!(err);
1181
+ };
1182
+
1183
+ const eventTypes = [
1184
+ 'stream-start',
1185
+ 'text-start',
1186
+ 'text-delta',
1187
+ 'text-end',
1188
+ 'reasoning-start',
1189
+ 'reasoning-delta',
1190
+ 'reasoning-end',
1191
+ 'tool-call',
1192
+ 'tool-approval-request',
1193
+ 'tool-result',
1194
+ 'finish-step',
1195
+ 'raw',
1196
+ ] as const;
1197
+ for (const type of eventTypes) {
1198
+ unsubs.push(
1199
+ channel.on(type, msg => {
1200
+ forward(msg);
1201
+ }),
1202
+ );
1203
+ }
1204
+ unsubs.push(
1205
+ channel.on('finish', msg => {
1206
+ forward(msg);
1207
+ settleSuccess();
1208
+ }),
1209
+ );
1210
+ unsubs.push(
1211
+ channel.on('error', msg => {
1212
+ forward(msg);
1213
+ settleError(msg.error);
1214
+ }),
1215
+ );
1216
+
1217
+ /*
1218
+ * A `'suspended'` close is a graceful slice-boundary freeze the host
1219
+ * initiated (`doSuspendTurn`): the turn keeps running in the bridge and its
1220
+ * tail is replayed to the next process, so wind this turn down cleanly
1221
+ * rather than failing it. Any other close mid-turn is an unexpected drop.
1222
+ */
1223
+ const onClose = (_code?: number, reason?: string) => {
1224
+ if (isSettled) return;
1225
+ if (reason === 'suspended') {
1226
+ settleSuccess();
1227
+ return;
1228
+ }
1229
+ settleError(
1230
+ new Error('claude-code bridge closed before the turn finished.'),
1231
+ );
1232
+ };
1233
+ channel.onClose(onClose);
1234
+
1235
+ const onAbort = () => {
1236
+ if (isSettled) return;
1237
+ try {
1238
+ channel.send({ type: 'abort' });
1239
+ } catch {}
1240
+ settleError(
1241
+ turnOpts.abortSignal?.reason ??
1242
+ new DOMException('Aborted', 'AbortError'),
1243
+ );
1244
+ };
1245
+ if (turnOpts.abortSignal) {
1246
+ if (turnOpts.abortSignal.aborted) {
1247
+ onAbort();
1248
+ } else {
1249
+ turnOpts.abortSignal.addEventListener('abort', onAbort, {
1250
+ once: true,
1251
+ });
1252
+ }
1253
+ }
1254
+
1255
+ return {
1256
+ submitToolResult: async input => {
1257
+ channel.send({
1258
+ type: 'tool-result',
1259
+ toolCallId: input.toolCallId,
1260
+ output: input.output,
1261
+ isError: input.isError,
1262
+ });
1263
+ },
1264
+ submitToolApproval: async input => {
1265
+ channel.send({
1266
+ type: 'tool-approval-response',
1267
+ approvalId: input.approvalId,
1268
+ approved: input.approved,
1269
+ reason: input.reason,
1270
+ });
1271
+ },
1272
+ submitUserMessage: async text => {
1273
+ channel.send({ type: 'user-message', text });
1274
+ },
1275
+ done,
1276
+ };
1277
+ };
1278
+
1279
+ return {
1280
+ sessionId,
1281
+ isResume,
1282
+ modelId: model,
1283
+ doPromptTurn: async promptOpts => {
1284
+ const control = wireTurn({
1285
+ emit: promptOpts.emit,
1286
+ abortSignal: promptOpts.abortSignal,
1287
+ });
1288
+
1289
+ let promptText = extractUserText(promptOpts.prompt);
1290
+ if (!instructionsApplied && promptOpts.instructions) {
1291
+ promptText = frameInstructions(promptOpts.instructions, promptText);
1292
+ }
1293
+ instructionsApplied = true;
1294
+
1295
+ const startMessage = {
1296
+ type: 'start' as const,
1297
+ prompt: promptText,
1298
+ tools: (promptOpts.tools ?? []).map(t => ({
1299
+ name: t.name,
1300
+ description: t.description,
1301
+ inputSchema: t.inputSchema,
1302
+ })),
1303
+ model,
1304
+ maxTurns,
1305
+ thinking,
1306
+ ...(skills.length > 0
1307
+ ? { skills: skills.map(skill => skill.name) }
1308
+ : {}),
1309
+ ...(permissionMode ? { permissionMode } : {}),
1310
+ ...(debug ? { debug } : {}),
1311
+ ...(pendingResumeFlag ? { continue: true } : {}),
1312
+ };
1313
+ pendingResumeFlag = false;
1314
+ channel.send(startMessage);
1315
+
1316
+ return control;
1317
+ },
1318
+ doContinueTurn: async continueOpts => {
1319
+ const control = wireTurn({
1320
+ emit: continueOpts.emit,
1321
+ abortSignal: continueOpts.abortSignal,
1322
+ });
1323
+
1324
+ /*
1325
+ * attach / replay: the still-running (or disk-replayed) turn streams into
1326
+ * the wired listeners — `doStart` opened the channel with `{ resume: true }`
1327
+ * so the bridge replays everything past the persisted cursor (including a
1328
+ * `finish` if the turn ended during the gap). No `start` is sent: issuing
1329
+ * one would clear the bridge's replay log and begin a new turn. Lossless.
1330
+ *
1331
+ * rerun: the bridge was respawned with no in-flight turn to attach to, so
1332
+ * re-drive the runtime's own thread from the workdir snapshot via
1333
+ * `continue: true`. Lossy — work in flight at the interruption is
1334
+ * recomputed. This is the rare bridge-died fallback; the common slice path
1335
+ * is `attach`.
1336
+ */
1337
+ if (rerunContinue) {
1338
+ pendingResumeFlag = false;
1339
+ channel.send({
1340
+ type: 'start' as const,
1341
+ /*
1342
+ * A continuation nudge rather than an empty prompt: `continue: true`
1343
+ * rehydrates the prior thread, and this is the new user turn that
1344
+ * drives it forward. It must be non-empty — an empty text block is
1345
+ * rejected by the Anthropic API once the SDK stamps it with
1346
+ * `cache_control`.
1347
+ */
1348
+ prompt: 'Continue.',
1349
+ tools: (continueOpts.tools ?? []).map(t => ({
1350
+ name: t.name,
1351
+ description: t.description,
1352
+ inputSchema: t.inputSchema,
1353
+ })),
1354
+ model,
1355
+ maxTurns,
1356
+ thinking,
1357
+ ...(skills.length > 0
1358
+ ? { skills: skills.map(skill => skill.name) }
1359
+ : {}),
1360
+ ...(permissionMode ? { permissionMode } : {}),
1361
+ ...(debug ? { debug } : {}),
1362
+ continue: true,
1363
+ });
1364
+ }
1365
+
1366
+ return control;
1367
+ },
1368
+ doCompact: async (customInstructions?: string) => {
1369
+ /*
1370
+ * Claude Code has no SDK/control method for compaction — the supported
1371
+ * trigger is the `/compact` slash command submitted as user input. Ride
1372
+ * the existing user-message rail; the bridge feeds it into the streaming
1373
+ * query input and Claude's native compaction handles the rest, emitting a
1374
+ * `compact_boundary` + `PostCompact` we observe as a `compaction` event.
1375
+ */
1376
+ const text =
1377
+ customInstructions && customInstructions.trim()
1378
+ ? `/compact ${customInstructions.trim()}`
1379
+ : '/compact';
1380
+ channel.send({ type: 'user-message', text });
1381
+ },
1382
+ doDetach: async () => {
1383
+ if (stopped) {
1384
+ throw new Error(
1385
+ `claude-code session ${sessionId} is already stopped; cannot detach.`,
1386
+ );
1387
+ }
1388
+ stopped = true;
1389
+ const lastSeenEventId = await channel.suspend();
1390
+ const payload: HarnessV1ResumeSessionState = {
1391
+ type: 'resume-session',
1392
+ harnessId: 'claude-code',
1393
+ specificationVersion: 'harness-v1',
1394
+ data: {
1395
+ bridge: {
1396
+ port: bridgePort,
1397
+ token: bridgeToken,
1398
+ lastSeenEventId,
1399
+ sandboxId,
1400
+ },
1401
+ },
1402
+ };
1403
+ return payload;
1404
+ },
1405
+ doDestroy: async () => {
1406
+ if (stopped) return stopPromise;
1407
+ stopped = true;
1408
+ stopPromise = (async () => {
1409
+ // Tell the channel we are tearing down so the bridge's post-shutdown
1410
+ // socket close finalises instead of triggering a reconnect.
1411
+ channel.beginClose();
1412
+ try {
1413
+ if (!channel.isClosed()) {
1414
+ channel.send({ type: 'shutdown' });
1415
+ }
1416
+ } catch {}
1417
+ let stopTimer: ReturnType<typeof setTimeout> | undefined;
1418
+ try {
1419
+ if (proc) {
1420
+ await Promise.race([
1421
+ proc.wait(),
1422
+ new Promise<void>(resolve => {
1423
+ stopTimer = setTimeout(resolve, 5000);
1424
+ stopTimer.unref?.();
1425
+ }),
1426
+ ]);
1427
+ }
1428
+ } finally {
1429
+ if (stopTimer) clearTimeout(stopTimer);
1430
+ try {
1431
+ await proc?.kill();
1432
+ } catch {}
1433
+ channel.close();
1434
+ }
1435
+ })();
1436
+ return stopPromise;
1437
+ },
1438
+ doStop: async () => {
1439
+ if (stopped) {
1440
+ throw new Error(
1441
+ `claude-code session ${sessionId} is already stopped; cannot stop.`,
1442
+ );
1443
+ }
1444
+ stopped = true;
1445
+ /*
1446
+ * If the bridge's channel already closed (e.g. mid-turn WS drop)
1447
+ * there is no one to ack a `detach` message. Synthesize an empty
1448
+ * payload — for Claude Code the resume state structurally is `{}`
1449
+ * (the conversation lives in the workdir, captured by the sandbox
1450
+ * snapshot during the subsequent `sandboxSession.stop()`), so we
1451
+ * lose nothing by skipping the round-trip.
1452
+ */
1453
+ // Tell the channel we are tearing down so the bridge's post-detach
1454
+ // socket close finalises instead of triggering a reconnect.
1455
+ channel.beginClose();
1456
+ const data: unknown = channel.isClosed()
1457
+ ? {}
1458
+ : await new Promise<unknown>((resolve, reject) => {
1459
+ const timer = setTimeout(() => {
1460
+ unsub();
1461
+ reject(
1462
+ new Error(
1463
+ `claude-code session ${sessionId} did not reply to detach within 5s.`,
1464
+ ),
1465
+ );
1466
+ }, 5000);
1467
+ timer.unref?.();
1468
+ const unsub = channel.on('bridge-detach', msg => {
1469
+ clearTimeout(timer);
1470
+ unsub();
1471
+ resolve(msg.data);
1472
+ });
1473
+ try {
1474
+ channel.send({ type: 'detach' });
1475
+ } catch (err) {
1476
+ clearTimeout(timer);
1477
+ unsub();
1478
+ reject(err);
1479
+ }
1480
+ });
1481
+
1482
+ // The bridge exits itself ~50ms after sending bridge-detach. Give
1483
+ // it a moment, then ensure the process is reaped and the channel
1484
+ // closed.
1485
+ let stopTimer: ReturnType<typeof setTimeout> | undefined;
1486
+ try {
1487
+ if (proc) {
1488
+ await Promise.race([
1489
+ proc.wait(),
1490
+ new Promise<void>(resolve => {
1491
+ stopTimer = setTimeout(resolve, 5000);
1492
+ stopTimer.unref?.();
1493
+ }),
1494
+ ]);
1495
+ }
1496
+ } finally {
1497
+ if (stopTimer) clearTimeout(stopTimer);
1498
+ try {
1499
+ await proc?.kill();
1500
+ } catch {}
1501
+ channel.close();
1502
+ }
1503
+
1504
+ const payload: HarnessV1ResumeSessionState = {
1505
+ type: 'resume-session',
1506
+ harnessId: 'claude-code',
1507
+ specificationVersion: 'harness-v1',
1508
+ data: (data ?? {}) as HarnessV1ResumeSessionState['data'],
1509
+ };
1510
+ return payload;
1511
+ },
1512
+ doSuspendTurn: async () => {
1513
+ if (stopped) {
1514
+ throw new Error(
1515
+ `claude-code session ${sessionId} is stopped; cannot suspend.`,
1516
+ );
1517
+ }
1518
+ stopped = true;
1519
+ /*
1520
+ * Gracefully freeze the active turn at a precise cursor. `channel.suspend`
1521
+ * stops processing inbound frames (the cursor stops advancing exactly at
1522
+ * the last delivered event), drains what was already dispatched, then
1523
+ * closes the host socket with reason `'suspended'` — which `wireTurn`'s
1524
+ * `onClose` treats as a clean turn end. The bridge keeps the turn running
1525
+ * and accumulates events past the cursor for the next slice to replay. The
1526
+ * sandbox process is deliberately left alive (no `shutdown`/`detach`).
1527
+ */
1528
+ const lastSeenEventId = await channel.suspend();
1529
+ const payload: HarnessV1ContinueTurnState = {
1530
+ type: 'continue-turn',
1531
+ harnessId: 'claude-code',
1532
+ specificationVersion: 'harness-v1',
1533
+ data: {
1534
+ bridge: {
1535
+ port: bridgePort,
1536
+ token: bridgeToken,
1537
+ lastSeenEventId,
1538
+ sandboxId,
1539
+ },
1540
+ },
1541
+ };
1542
+ return payload;
1543
+ },
1544
+ };
1545
+ }
1546
+
1547
+ /*
1548
+ * Frame session instructions and the user's text so the runtime treats the
1549
+ * instructions as system-provided operating guidance, not something the user
1550
+ * wrote. Without the wrapper the agent can echo the prepended text back as if
1551
+ * the user had asked for it, which is confusing since the user never typed it.
1552
+ * Applied only to the first user message of a fresh session.
1553
+ */
1554
+ function frameInstructions(instructions: string, userText: string): string {
1555
+ return (
1556
+ '<session-instructions>\n' +
1557
+ 'The block below is operating guidance from the system, not a message from the user — follow it, but do not mention it or attribute it to the user.\n\n' +
1558
+ `${instructions}\n` +
1559
+ '</session-instructions>\n\n' +
1560
+ `<user-message>\n${userText}\n</user-message>`
1561
+ );
1562
+ }
1563
+
1564
+ /*
1565
+ * Reduce a `HarnessV1Prompt` to the plain user text the bridge forwards
1566
+ * to the Claude SDK. File and image parts on the message are not yet
1567
+ * supported by the underlying runtime — throw rather than silently drop
1568
+ * them so callers learn about the gap instead of seeing mysteriously
1569
+ * truncated prompts.
1570
+ */
1571
+ function extractUserText(prompt: HarnessV1Prompt): string {
1572
+ if (typeof prompt === 'string') return prompt;
1573
+ const { content } = prompt;
1574
+ if (typeof content === 'string') return content;
1575
+ const parts: string[] = [];
1576
+ for (const part of content) {
1577
+ if (part.type !== 'text') {
1578
+ throw new HarnessCapabilityUnsupportedError({
1579
+ harnessId: 'claude-code',
1580
+ message: `The claude-code harness does not yet support user message parts of type '${part.type}'. Pass a string or a user message whose content contains only text parts.`,
1581
+ });
1582
+ }
1583
+ parts.push(part.text);
1584
+ }
1585
+ return parts.join('\n\n');
1586
+ }