@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.
- package/CHANGELOG.md +89 -0
- package/LICENSE +13 -0
- package/README.md +62 -0
- package/dist/bridge/index.mjs +1023 -0
- package/dist/bridge/index.mjs.map +1 -0
- package/dist/bridge/package.json +13 -0
- package/dist/bridge/pnpm-lock.yaml +1035 -0
- package/dist/index.d.ts +376 -0
- package/dist/index.js +1307 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -1
- package/src/bridge/claude-skills-option.ts +11 -0
- package/src/bridge/compaction-latch.ts +62 -0
- package/src/bridge/index.ts +881 -0
- package/src/bridge/package.json +13 -0
- package/src/bridge/pnpm-lock.yaml +1035 -0
- package/src/claude-code-auth.ts +131 -0
- package/src/claude-code-bridge-protocol.ts +38 -0
- package/src/claude-code-harness.ts +1586 -0
- package/src/index.ts +12 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1307 @@
|
|
|
1
|
+
// src/claude-code-harness.ts
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { readFile } from "fs/promises";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import {
|
|
7
|
+
commonTool,
|
|
8
|
+
harnessV1DiagnosticFromBridgeFrame,
|
|
9
|
+
HarnessCapabilityUnsupportedError
|
|
10
|
+
} from "@ai-sdk/harness";
|
|
11
|
+
import {
|
|
12
|
+
classifyDiskLog,
|
|
13
|
+
markBridgeStarting,
|
|
14
|
+
SandboxChannel,
|
|
15
|
+
waitForBridgeReady
|
|
16
|
+
} from "@ai-sdk/harness/utils";
|
|
17
|
+
import {
|
|
18
|
+
tool
|
|
19
|
+
} from "@ai-sdk/provider-utils";
|
|
20
|
+
import { WebSocket } from "ws";
|
|
21
|
+
import { z as z2 } from "zod";
|
|
22
|
+
|
|
23
|
+
// src/claude-code-auth.ts
|
|
24
|
+
import { execFileSync } from "child_process";
|
|
25
|
+
import { readFileSync } from "fs";
|
|
26
|
+
import { homedir } from "os";
|
|
27
|
+
import { join } from "path";
|
|
28
|
+
function resolveClaudeCodeEnv(auth, processEnv = process.env, options = {}) {
|
|
29
|
+
const readApiKey = options.readApiKeyHelper ?? readApiKeyHelper;
|
|
30
|
+
if (auth?.anthropic) {
|
|
31
|
+
return pickAnthropic({ explicit: auth.anthropic, processEnv, readApiKey });
|
|
32
|
+
}
|
|
33
|
+
if (auth?.gateway) {
|
|
34
|
+
return pickGateway(auth.gateway, processEnv);
|
|
35
|
+
}
|
|
36
|
+
if (processEnv.AI_GATEWAY_API_KEY) {
|
|
37
|
+
return pickGateway({}, processEnv);
|
|
38
|
+
}
|
|
39
|
+
return pickAnthropic({ processEnv, readApiKey });
|
|
40
|
+
}
|
|
41
|
+
function pickAnthropic({
|
|
42
|
+
explicit,
|
|
43
|
+
processEnv,
|
|
44
|
+
readApiKey
|
|
45
|
+
}) {
|
|
46
|
+
const env = {};
|
|
47
|
+
const helperKey = explicit ? void 0 : readApiKey();
|
|
48
|
+
const apiKey = explicit?.apiKey ?? processEnv.ANTHROPIC_API_KEY ?? helperKey;
|
|
49
|
+
const authToken = explicit?.authToken ?? processEnv.ANTHROPIC_AUTH_TOKEN ?? helperKey;
|
|
50
|
+
if (apiKey) env.ANTHROPIC_API_KEY = apiKey;
|
|
51
|
+
if (authToken) env.ANTHROPIC_AUTH_TOKEN = authToken;
|
|
52
|
+
const baseUrl = explicit?.baseUrl ?? processEnv.ANTHROPIC_BASE_URL;
|
|
53
|
+
if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl;
|
|
54
|
+
return env;
|
|
55
|
+
}
|
|
56
|
+
function readApiKeyHelper() {
|
|
57
|
+
const home = homedir();
|
|
58
|
+
if (!home) return void 0;
|
|
59
|
+
let raw;
|
|
60
|
+
try {
|
|
61
|
+
raw = readFileSync(join(home, ".claude", "settings.json"), "utf8");
|
|
62
|
+
} catch {
|
|
63
|
+
return void 0;
|
|
64
|
+
}
|
|
65
|
+
let settings;
|
|
66
|
+
try {
|
|
67
|
+
settings = JSON.parse(raw);
|
|
68
|
+
} catch {
|
|
69
|
+
return void 0;
|
|
70
|
+
}
|
|
71
|
+
const command = settings.apiKeyHelper;
|
|
72
|
+
if (typeof command !== "string" || command.length === 0) return void 0;
|
|
73
|
+
try {
|
|
74
|
+
const output = execFileSync("sh", ["-c", command], {
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
77
|
+
});
|
|
78
|
+
const trimmed = output.trim();
|
|
79
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
80
|
+
} catch {
|
|
81
|
+
return void 0;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function pickGateway(explicit, processEnv) {
|
|
85
|
+
const apiKey = explicit.apiKey ?? processEnv.AI_GATEWAY_API_KEY;
|
|
86
|
+
const baseUrl = explicit.baseUrl ?? processEnv.AI_GATEWAY_BASE_URL ?? "https://ai-gateway.vercel.sh";
|
|
87
|
+
const env = {};
|
|
88
|
+
if (apiKey) {
|
|
89
|
+
env.AI_GATEWAY_API_KEY = apiKey;
|
|
90
|
+
env.ANTHROPIC_API_KEY = apiKey;
|
|
91
|
+
}
|
|
92
|
+
env.AI_GATEWAY_BASE_URL = baseUrl;
|
|
93
|
+
env.ANTHROPIC_BASE_URL = baseUrl;
|
|
94
|
+
return env;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/claude-code-bridge-protocol.ts
|
|
98
|
+
import {
|
|
99
|
+
harnessV1BridgeInboundCommandSchemas,
|
|
100
|
+
harnessV1BridgeOutboundMessageSchema,
|
|
101
|
+
harnessV1BridgeReadySchema,
|
|
102
|
+
harnessV1BridgeStartBaseSchema
|
|
103
|
+
} from "@ai-sdk/harness";
|
|
104
|
+
import { z } from "zod/v4";
|
|
105
|
+
var outboundMessageSchema = harnessV1BridgeOutboundMessageSchema;
|
|
106
|
+
var startMessageSchema = harnessV1BridgeStartBaseSchema.extend({
|
|
107
|
+
thinking: z.enum(["off", "on", "adaptive"]).optional(),
|
|
108
|
+
maxTurns: z.number().optional(),
|
|
109
|
+
skills: z.array(z.string()).optional(),
|
|
110
|
+
// Resume signal. When true, the bridge passes `{ continue: true }` to the
|
|
111
|
+
// Claude SDK so the in-workdir thread state is rehydrated. The host sets this
|
|
112
|
+
// on the first prompt after a cross-process resume.
|
|
113
|
+
continue: z.boolean().optional()
|
|
114
|
+
});
|
|
115
|
+
var inboundMessageSchema = z.discriminatedUnion("type", [
|
|
116
|
+
startMessageSchema,
|
|
117
|
+
...harnessV1BridgeInboundCommandSchemas
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
// src/claude-code-harness.ts
|
|
121
|
+
var CLAUDE_CODE_BUILTIN_TOOLS = {
|
|
122
|
+
read: commonTool("read", {
|
|
123
|
+
nativeName: "Read",
|
|
124
|
+
toolUseKind: "readonly",
|
|
125
|
+
description: "Read file contents (text, image, PDF, notebook)",
|
|
126
|
+
inputSchema: z2.object({
|
|
127
|
+
file_path: z2.string(),
|
|
128
|
+
offset: z2.number().optional(),
|
|
129
|
+
limit: z2.number().optional(),
|
|
130
|
+
pages: z2.string().optional()
|
|
131
|
+
})
|
|
132
|
+
}),
|
|
133
|
+
write: commonTool("write", {
|
|
134
|
+
nativeName: "Write",
|
|
135
|
+
toolUseKind: "edit",
|
|
136
|
+
description: "Overwrite or create a file at an absolute path",
|
|
137
|
+
inputSchema: z2.object({
|
|
138
|
+
file_path: z2.string(),
|
|
139
|
+
content: z2.string()
|
|
140
|
+
})
|
|
141
|
+
}),
|
|
142
|
+
edit: commonTool("edit", {
|
|
143
|
+
nativeName: "Edit",
|
|
144
|
+
toolUseKind: "edit",
|
|
145
|
+
description: "Edit a file by exact string replacement",
|
|
146
|
+
inputSchema: z2.object({
|
|
147
|
+
file_path: z2.string(),
|
|
148
|
+
old_string: z2.string(),
|
|
149
|
+
new_string: z2.string(),
|
|
150
|
+
replace_all: z2.boolean().optional()
|
|
151
|
+
})
|
|
152
|
+
}),
|
|
153
|
+
bash: commonTool("bash", {
|
|
154
|
+
nativeName: "Bash",
|
|
155
|
+
toolUseKind: "bash",
|
|
156
|
+
description: "Execute a shell command, optionally in background",
|
|
157
|
+
inputSchema: z2.object({
|
|
158
|
+
command: z2.string(),
|
|
159
|
+
timeout: z2.number().optional(),
|
|
160
|
+
description: z2.string().optional(),
|
|
161
|
+
run_in_background: z2.boolean().optional(),
|
|
162
|
+
dangerouslyDisableSandbox: z2.boolean().optional()
|
|
163
|
+
})
|
|
164
|
+
}),
|
|
165
|
+
glob: commonTool("glob", {
|
|
166
|
+
nativeName: "Glob",
|
|
167
|
+
toolUseKind: "readonly",
|
|
168
|
+
description: "Fast file-pattern search using glob syntax",
|
|
169
|
+
inputSchema: z2.object({
|
|
170
|
+
pattern: z2.string(),
|
|
171
|
+
path: z2.string().optional()
|
|
172
|
+
})
|
|
173
|
+
}),
|
|
174
|
+
grep: commonTool("grep", {
|
|
175
|
+
nativeName: "Grep",
|
|
176
|
+
toolUseKind: "readonly",
|
|
177
|
+
description: "Regex search over file contents via ripgrep",
|
|
178
|
+
inputSchema: z2.object({
|
|
179
|
+
pattern: z2.string(),
|
|
180
|
+
path: z2.string().optional(),
|
|
181
|
+
glob: z2.string().optional(),
|
|
182
|
+
output_mode: z2.enum(["content", "files_with_matches", "count"]).optional(),
|
|
183
|
+
"-B": z2.number().optional(),
|
|
184
|
+
"-A": z2.number().optional(),
|
|
185
|
+
"-C": z2.number().optional(),
|
|
186
|
+
context: z2.number().optional(),
|
|
187
|
+
"-n": z2.boolean().optional(),
|
|
188
|
+
"-i": z2.boolean().optional(),
|
|
189
|
+
"-o": z2.boolean().optional(),
|
|
190
|
+
type: z2.string().optional(),
|
|
191
|
+
head_limit: z2.number().optional(),
|
|
192
|
+
offset: z2.number().optional(),
|
|
193
|
+
multiline: z2.boolean().optional()
|
|
194
|
+
})
|
|
195
|
+
}),
|
|
196
|
+
webSearch: commonTool("webSearch", {
|
|
197
|
+
nativeName: "WebSearch",
|
|
198
|
+
toolUseKind: "readonly",
|
|
199
|
+
description: "Issue web search queries with optional domain filters",
|
|
200
|
+
inputSchema: z2.object({
|
|
201
|
+
query: z2.string(),
|
|
202
|
+
allowed_domains: z2.array(z2.string()).optional(),
|
|
203
|
+
blocked_domains: z2.array(z2.string()).optional()
|
|
204
|
+
})
|
|
205
|
+
}),
|
|
206
|
+
WebFetch: tool({
|
|
207
|
+
description: "Fetch a URL and run a prompt against its content",
|
|
208
|
+
inputSchema: z2.object({
|
|
209
|
+
url: z2.string(),
|
|
210
|
+
prompt: z2.string()
|
|
211
|
+
})
|
|
212
|
+
}),
|
|
213
|
+
NotebookEdit: tool({
|
|
214
|
+
description: "Edit, insert, or delete a Jupyter notebook cell",
|
|
215
|
+
inputSchema: z2.object({
|
|
216
|
+
notebook_path: z2.string(),
|
|
217
|
+
new_source: z2.string(),
|
|
218
|
+
cell_id: z2.string().optional(),
|
|
219
|
+
cell_type: z2.enum(["code", "markdown"]).optional(),
|
|
220
|
+
edit_mode: z2.enum(["replace", "insert", "delete"]).optional()
|
|
221
|
+
})
|
|
222
|
+
}),
|
|
223
|
+
TodoWrite: tool({
|
|
224
|
+
description: "Replace the session todo list",
|
|
225
|
+
inputSchema: z2.object({
|
|
226
|
+
todos: z2.array(
|
|
227
|
+
z2.object({
|
|
228
|
+
content: z2.string(),
|
|
229
|
+
status: z2.enum(["pending", "in_progress", "completed"]),
|
|
230
|
+
activeForm: z2.string()
|
|
231
|
+
})
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
}),
|
|
235
|
+
Agent: tool({
|
|
236
|
+
description: "Spawn a subagent with a task",
|
|
237
|
+
inputSchema: z2.object({
|
|
238
|
+
description: z2.string(),
|
|
239
|
+
prompt: z2.string(),
|
|
240
|
+
subagent_type: z2.string().optional(),
|
|
241
|
+
model: z2.enum(["sonnet", "opus", "haiku"]).optional(),
|
|
242
|
+
run_in_background: z2.boolean().optional(),
|
|
243
|
+
name: z2.string().optional(),
|
|
244
|
+
team_name: z2.string().optional(),
|
|
245
|
+
mode: z2.enum([
|
|
246
|
+
"acceptEdits",
|
|
247
|
+
"auto",
|
|
248
|
+
"bypassPermissions",
|
|
249
|
+
"default",
|
|
250
|
+
"dontAsk",
|
|
251
|
+
"plan"
|
|
252
|
+
]).optional(),
|
|
253
|
+
isolation: z2.literal("worktree").optional()
|
|
254
|
+
})
|
|
255
|
+
}),
|
|
256
|
+
TaskCreate: tool({
|
|
257
|
+
description: "Create a task in the session-local task list",
|
|
258
|
+
inputSchema: z2.object({
|
|
259
|
+
subject: z2.string(),
|
|
260
|
+
description: z2.string(),
|
|
261
|
+
activeForm: z2.string().optional(),
|
|
262
|
+
metadata: z2.record(z2.unknown()).optional()
|
|
263
|
+
})
|
|
264
|
+
}),
|
|
265
|
+
TaskGet: tool({
|
|
266
|
+
description: "Retrieve a task by id",
|
|
267
|
+
inputSchema: z2.object({ taskId: z2.string() })
|
|
268
|
+
}),
|
|
269
|
+
TaskUpdate: tool({
|
|
270
|
+
description: "Update fields of an existing task",
|
|
271
|
+
inputSchema: z2.object({
|
|
272
|
+
taskId: z2.string(),
|
|
273
|
+
subject: z2.string().optional(),
|
|
274
|
+
description: z2.string().optional(),
|
|
275
|
+
activeForm: z2.string().optional(),
|
|
276
|
+
status: z2.union([
|
|
277
|
+
z2.enum(["pending", "in_progress", "completed"]),
|
|
278
|
+
z2.literal("deleted")
|
|
279
|
+
]).optional(),
|
|
280
|
+
addBlocks: z2.array(z2.string()).optional(),
|
|
281
|
+
addBlockedBy: z2.array(z2.string()).optional(),
|
|
282
|
+
owner: z2.string().optional(),
|
|
283
|
+
metadata: z2.record(z2.unknown()).optional()
|
|
284
|
+
})
|
|
285
|
+
}),
|
|
286
|
+
TaskList: tool({
|
|
287
|
+
description: "Return all tasks in the session-local task list",
|
|
288
|
+
inputSchema: z2.object({})
|
|
289
|
+
}),
|
|
290
|
+
TaskStop: tool({
|
|
291
|
+
description: "Stop a running background task by id",
|
|
292
|
+
inputSchema: z2.object({
|
|
293
|
+
task_id: z2.string().optional(),
|
|
294
|
+
shell_id: z2.string().optional()
|
|
295
|
+
})
|
|
296
|
+
}),
|
|
297
|
+
TaskOutput: tool({
|
|
298
|
+
description: "Poll for output from a background task",
|
|
299
|
+
inputSchema: z2.object({
|
|
300
|
+
task_id: z2.string(),
|
|
301
|
+
block: z2.boolean(),
|
|
302
|
+
timeout: z2.number()
|
|
303
|
+
})
|
|
304
|
+
}),
|
|
305
|
+
ListMcpResources: tool({
|
|
306
|
+
description: "List resources available from MCP servers",
|
|
307
|
+
inputSchema: z2.object({ server: z2.string().optional() })
|
|
308
|
+
}),
|
|
309
|
+
ReadMcpResource: tool({
|
|
310
|
+
description: "Read a specific MCP resource by URI",
|
|
311
|
+
inputSchema: z2.object({ server: z2.string(), uri: z2.string() })
|
|
312
|
+
}),
|
|
313
|
+
ExitPlanMode: tool({
|
|
314
|
+
description: "Exit plan mode with optional permission approvals",
|
|
315
|
+
inputSchema: z2.object({
|
|
316
|
+
allowedPrompts: z2.array(
|
|
317
|
+
z2.object({
|
|
318
|
+
tool: z2.literal("Bash"),
|
|
319
|
+
prompt: z2.string()
|
|
320
|
+
})
|
|
321
|
+
).optional()
|
|
322
|
+
}).passthrough()
|
|
323
|
+
}),
|
|
324
|
+
EnterWorktree: tool({
|
|
325
|
+
description: "Create or enter an isolated git worktree",
|
|
326
|
+
inputSchema: z2.object({
|
|
327
|
+
name: z2.string().optional(),
|
|
328
|
+
path: z2.string().optional()
|
|
329
|
+
})
|
|
330
|
+
}),
|
|
331
|
+
ExitWorktree: tool({
|
|
332
|
+
description: "Exit the current worktree session",
|
|
333
|
+
inputSchema: z2.object({
|
|
334
|
+
action: z2.enum(["keep", "remove"]),
|
|
335
|
+
discard_changes: z2.boolean().optional()
|
|
336
|
+
})
|
|
337
|
+
}),
|
|
338
|
+
AskUserQuestion: tool({
|
|
339
|
+
description: "Ask the user multiple-choice questions via a structured UI",
|
|
340
|
+
inputSchema: z2.object({
|
|
341
|
+
questions: z2.array(
|
|
342
|
+
z2.object({
|
|
343
|
+
question: z2.string(),
|
|
344
|
+
header: z2.string(),
|
|
345
|
+
options: z2.array(
|
|
346
|
+
z2.object({
|
|
347
|
+
label: z2.string(),
|
|
348
|
+
description: z2.string(),
|
|
349
|
+
preview: z2.string().optional()
|
|
350
|
+
})
|
|
351
|
+
),
|
|
352
|
+
multiSelect: z2.boolean()
|
|
353
|
+
})
|
|
354
|
+
).min(1).max(4),
|
|
355
|
+
answers: z2.record(z2.string()).optional(),
|
|
356
|
+
annotations: z2.record(
|
|
357
|
+
z2.object({
|
|
358
|
+
preview: z2.string().optional(),
|
|
359
|
+
notes: z2.string().optional()
|
|
360
|
+
})
|
|
361
|
+
).optional(),
|
|
362
|
+
metadata: z2.object({ source: z2.string().optional() }).optional()
|
|
363
|
+
})
|
|
364
|
+
}),
|
|
365
|
+
Skill: tool({
|
|
366
|
+
description: "Activate a skill by name",
|
|
367
|
+
inputSchema: z2.object({
|
|
368
|
+
skill: z2.string(),
|
|
369
|
+
args: z2.string().optional()
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
};
|
|
373
|
+
var BOOTSTRAP_DIR = "/tmp/harness/claude-code";
|
|
374
|
+
var bridgeCoordsSchema = z2.object({
|
|
375
|
+
port: z2.number(),
|
|
376
|
+
token: z2.string(),
|
|
377
|
+
lastSeenEventId: z2.number(),
|
|
378
|
+
sandboxId: z2.string().optional()
|
|
379
|
+
});
|
|
380
|
+
var claudeCodeResumeStateSchema = z2.object({ bridge: bridgeCoordsSchema.optional() }).passthrough();
|
|
381
|
+
function createClaudeCode(settings = {}) {
|
|
382
|
+
let cachedBootstrap;
|
|
383
|
+
return {
|
|
384
|
+
specificationVersion: "harness-v1",
|
|
385
|
+
harnessId: "claude-code",
|
|
386
|
+
builtinTools: CLAUDE_CODE_BUILTIN_TOOLS,
|
|
387
|
+
supportsBuiltinToolApprovals: true,
|
|
388
|
+
lifecycleStateSchema: claudeCodeResumeStateSchema,
|
|
389
|
+
getBootstrap: async () => {
|
|
390
|
+
if (cachedBootstrap != null) return cachedBootstrap;
|
|
391
|
+
const [pkg, lock, bridge] = await Promise.all([
|
|
392
|
+
readBridgeAsset("package.json"),
|
|
393
|
+
readBridgeAsset("pnpm-lock.yaml"),
|
|
394
|
+
readBridgeAsset("index.mjs")
|
|
395
|
+
]);
|
|
396
|
+
cachedBootstrap = {
|
|
397
|
+
harnessId: "claude-code",
|
|
398
|
+
bootstrapDir: BOOTSTRAP_DIR,
|
|
399
|
+
files: [
|
|
400
|
+
{ path: `${BOOTSTRAP_DIR}/package.json`, content: pkg },
|
|
401
|
+
{ path: `${BOOTSTRAP_DIR}/pnpm-lock.yaml`, content: lock },
|
|
402
|
+
{ path: `${BOOTSTRAP_DIR}/bridge.mjs`, content: bridge }
|
|
403
|
+
],
|
|
404
|
+
commands: [
|
|
405
|
+
{ command: `mkdir -p ${BOOTSTRAP_DIR}` },
|
|
406
|
+
{
|
|
407
|
+
command: `pnpm --dir ${BOOTSTRAP_DIR} install --frozen-lockfile --store-dir ${BOOTSTRAP_DIR}/.pnpm-store`
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
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`
|
|
411
|
+
}
|
|
412
|
+
]
|
|
413
|
+
};
|
|
414
|
+
return cachedBootstrap;
|
|
415
|
+
},
|
|
416
|
+
doStart: async (startOpts) => {
|
|
417
|
+
const sandboxSession = startOpts.sandboxSession;
|
|
418
|
+
const session = sandboxSession.restricted();
|
|
419
|
+
const sandboxId = sandboxSession.id;
|
|
420
|
+
const lifecycleState = startOpts.continueFrom ?? startOpts.resumeFrom;
|
|
421
|
+
const isResume = lifecycleState != null;
|
|
422
|
+
const isContinue = startOpts.continueFrom != null;
|
|
423
|
+
const coords = isResume ? lifecycleState?.data?.bridge : void 0;
|
|
424
|
+
const workDir = startOpts.sessionWorkDir;
|
|
425
|
+
const sessionDataDir = `${sandboxSession.defaultWorkingDirectory}/.agent-runs/${startOpts.sessionId}`;
|
|
426
|
+
const bridgeStateDir = `${sessionDataDir}/bridge`;
|
|
427
|
+
const timeoutMs = settings.startupTimeoutMs ?? 12e4;
|
|
428
|
+
const report = startOpts.observability?.report;
|
|
429
|
+
const onDiagnostic = report ? (frame) => report(
|
|
430
|
+
harnessV1DiagnosticFromBridgeFrame(frame, {
|
|
431
|
+
sessionId: startOpts.sessionId,
|
|
432
|
+
timestamp: Date.now()
|
|
433
|
+
})
|
|
434
|
+
) : void 0;
|
|
435
|
+
const buildConnect = (wsUrl2) => async () => {
|
|
436
|
+
return openBridgeWebSocket({ wsUrl: wsUrl2, timeoutMs });
|
|
437
|
+
};
|
|
438
|
+
if (coords) {
|
|
439
|
+
try {
|
|
440
|
+
const attachUrl = await sandboxSession.getPortUrl({
|
|
441
|
+
port: coords.port,
|
|
442
|
+
protocol: "ws"
|
|
443
|
+
}) + `?agent_bridge_token=${encodeURIComponent(coords.token)}`;
|
|
444
|
+
const attachChannel = new SandboxChannel({
|
|
445
|
+
connect: buildConnect(attachUrl),
|
|
446
|
+
outboundSchema: outboundMessageSchema,
|
|
447
|
+
initialLastSeenEventId: coords.lastSeenEventId,
|
|
448
|
+
onDiagnostic
|
|
449
|
+
});
|
|
450
|
+
await attachChannel.open(isContinue ? { resume: true } : void 0);
|
|
451
|
+
return createSession({
|
|
452
|
+
sessionId: startOpts.sessionId,
|
|
453
|
+
channel: attachChannel,
|
|
454
|
+
// The live bridge was spawned by another process; this one owns no
|
|
455
|
+
// process handle. The session lifecycle method decides whether the
|
|
456
|
+
// sandbox is left running, stopped, or destroyed.
|
|
457
|
+
proc: void 0,
|
|
458
|
+
model: settings.model,
|
|
459
|
+
maxTurns: settings.maxTurns,
|
|
460
|
+
thinking: settings.thinking,
|
|
461
|
+
isResume: true,
|
|
462
|
+
continueOnFirstPrompt: false,
|
|
463
|
+
rerunContinue: false,
|
|
464
|
+
bridgePort: coords.port,
|
|
465
|
+
bridgeToken: coords.token,
|
|
466
|
+
sandboxId,
|
|
467
|
+
debug: startOpts.observability?.debug,
|
|
468
|
+
permissionMode: startOpts.permissionMode,
|
|
469
|
+
skills: startOpts.skills ?? []
|
|
470
|
+
});
|
|
471
|
+
} catch {
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
let respawnStrategy = isResume ? "rerun" : void 0;
|
|
475
|
+
if (coords && isContinue) {
|
|
476
|
+
const logRaw = await Promise.resolve(
|
|
477
|
+
session.readTextFile({
|
|
478
|
+
path: `${bridgeStateDir}/event-log.ndjson`,
|
|
479
|
+
abortSignal: startOpts.abortSignal
|
|
480
|
+
})
|
|
481
|
+
).catch(() => null);
|
|
482
|
+
if (await classifyDiskLog(logRaw) === "replay") {
|
|
483
|
+
respawnStrategy = "replay";
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const sandboxHomeDir = startOpts.skills && startOpts.skills.length > 0 ? await resolveSandboxHomeDir({
|
|
487
|
+
sandbox: session,
|
|
488
|
+
abortSignal: startOpts.abortSignal
|
|
489
|
+
}) : void 0;
|
|
490
|
+
const port = resolveBridgePort(sandboxSession, settings.port);
|
|
491
|
+
const token = randomBytes(32).toString("hex");
|
|
492
|
+
const env = {
|
|
493
|
+
...resolveClaudeCodeEnv(settings.auth),
|
|
494
|
+
BRIDGE_CHANNEL_TOKEN: token,
|
|
495
|
+
BRIDGE_WS_PORT: String(port),
|
|
496
|
+
...sandboxHomeDir ? { HOME: sandboxHomeDir } : {},
|
|
497
|
+
...respawnStrategy === "replay" ? { BRIDGE_REPLAY_FROM_DISK: "1" } : {}
|
|
498
|
+
};
|
|
499
|
+
if (respawnStrategy === void 0) {
|
|
500
|
+
await session.run({
|
|
501
|
+
command: `mkdir -p ${workDir} ${bridgeStateDir}`,
|
|
502
|
+
abortSignal: startOpts.abortSignal
|
|
503
|
+
});
|
|
504
|
+
if (startOpts.skills && startOpts.skills.length > 0) {
|
|
505
|
+
if (!sandboxHomeDir) {
|
|
506
|
+
throw new Error("Unable to resolve sandbox HOME directory.");
|
|
507
|
+
}
|
|
508
|
+
await writeSkills({
|
|
509
|
+
sandbox: session,
|
|
510
|
+
homeDir: sandboxHomeDir,
|
|
511
|
+
skills: startOpts.skills,
|
|
512
|
+
abortSignal: startOpts.abortSignal
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
await markBridgeStarting({
|
|
517
|
+
sandbox: session,
|
|
518
|
+
bridgeStateDir,
|
|
519
|
+
bridgeType: "claude-code",
|
|
520
|
+
abortSignal: startOpts.abortSignal
|
|
521
|
+
});
|
|
522
|
+
const proc = await session.spawn({
|
|
523
|
+
command: `node ${BOOTSTRAP_DIR}/bridge.mjs --workdir ${workDir} --bridge-state-dir ${bridgeStateDir}`,
|
|
524
|
+
env,
|
|
525
|
+
abortSignal: startOpts.abortSignal
|
|
526
|
+
});
|
|
527
|
+
const bridgeStartupStderr = [];
|
|
528
|
+
const bridgeStartupStderrDone = forwardBridgeStderr({
|
|
529
|
+
stream: proc.stderr,
|
|
530
|
+
collectTail: bridgeStartupStderr
|
|
531
|
+
});
|
|
532
|
+
void bridgeStartupStderrDone;
|
|
533
|
+
const { port: boundPort } = await waitForBridgeReady({
|
|
534
|
+
proc,
|
|
535
|
+
sandbox: session,
|
|
536
|
+
bridgeStateDir,
|
|
537
|
+
bridgeType: "claude-code",
|
|
538
|
+
timeoutMs,
|
|
539
|
+
abortSignal: startOpts.abortSignal,
|
|
540
|
+
createTimeoutError: ({ proc: proc2, stdoutTail }) => createBridgeStartupError({
|
|
541
|
+
message: "claude-code bridge did not become ready in time.",
|
|
542
|
+
proc: proc2,
|
|
543
|
+
stdoutTail,
|
|
544
|
+
stderrTail: bridgeStartupStderr,
|
|
545
|
+
stderrDone: bridgeStartupStderrDone
|
|
546
|
+
}),
|
|
547
|
+
createExitError: ({ proc: proc2, stdoutTail }) => createBridgeStartupError({
|
|
548
|
+
message: "claude-code bridge exited before becoming ready.",
|
|
549
|
+
proc: proc2,
|
|
550
|
+
stdoutTail,
|
|
551
|
+
stderrTail: bridgeStartupStderr,
|
|
552
|
+
stderrDone: bridgeStartupStderrDone
|
|
553
|
+
})
|
|
554
|
+
});
|
|
555
|
+
void drainRest(proc.stdout);
|
|
556
|
+
const wsUrl = await sandboxSession.getPortUrl({
|
|
557
|
+
port: boundPort,
|
|
558
|
+
protocol: "ws"
|
|
559
|
+
}) + `?agent_bridge_token=${encodeURIComponent(token)}`;
|
|
560
|
+
const channel = new SandboxChannel({
|
|
561
|
+
connect: buildConnect(wsUrl),
|
|
562
|
+
outboundSchema: outboundMessageSchema,
|
|
563
|
+
onDiagnostic,
|
|
564
|
+
// In replay mode the respawned bridge reloaded the finished turn from
|
|
565
|
+
// disk; seed the cursor and resume so it streams the tail (incl.
|
|
566
|
+
// `finish`) rather than starting empty.
|
|
567
|
+
...respawnStrategy === "replay" ? { initialLastSeenEventId: coords?.lastSeenEventId ?? 0 } : {}
|
|
568
|
+
});
|
|
569
|
+
await channel.open(
|
|
570
|
+
respawnStrategy === "replay" ? { resume: true } : void 0
|
|
571
|
+
);
|
|
572
|
+
return createSession({
|
|
573
|
+
sessionId: startOpts.sessionId,
|
|
574
|
+
channel,
|
|
575
|
+
proc,
|
|
576
|
+
model: settings.model,
|
|
577
|
+
maxTurns: settings.maxTurns,
|
|
578
|
+
thinking: settings.thinking,
|
|
579
|
+
isResume: respawnStrategy !== void 0,
|
|
580
|
+
continueOnFirstPrompt: respawnStrategy !== void 0,
|
|
581
|
+
rerunContinue: respawnStrategy === "rerun",
|
|
582
|
+
bridgePort: boundPort,
|
|
583
|
+
bridgeToken: token,
|
|
584
|
+
sandboxId,
|
|
585
|
+
debug: startOpts.observability?.debug,
|
|
586
|
+
permissionMode: startOpts.permissionMode,
|
|
587
|
+
skills: startOpts.skills ?? []
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
function resolveBridgePort(sandboxSession, override) {
|
|
593
|
+
if (override !== void 0) return override;
|
|
594
|
+
if (sandboxSession.ports.length > 0) return sandboxSession.ports[0];
|
|
595
|
+
throw new HarnessCapabilityUnsupportedError({
|
|
596
|
+
harnessId: "claude-code",
|
|
597
|
+
message: "The claude-code harness needs a TCP port exposed by the sandbox. Create the sandbox with `ports: [<port>]` or pass `createClaudeCode({ port })`."
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
async function writeSkills({
|
|
601
|
+
sandbox,
|
|
602
|
+
homeDir,
|
|
603
|
+
skills,
|
|
604
|
+
abortSignal
|
|
605
|
+
}) {
|
|
606
|
+
for (const skill of skills) {
|
|
607
|
+
safeClaudeSkillName(skill.name);
|
|
608
|
+
for (const file of skill.files ?? []) {
|
|
609
|
+
safeClaudeSkillFilePath({
|
|
610
|
+
skillName: skill.name,
|
|
611
|
+
filePath: file.path
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
await sandbox.run({
|
|
616
|
+
command: `mkdir -p ${shellQuote(homeDir)}/.claude/skills`,
|
|
617
|
+
abortSignal
|
|
618
|
+
});
|
|
619
|
+
for (const skill of skills) {
|
|
620
|
+
const name = safeClaudeSkillName(skill.name);
|
|
621
|
+
const skillDir = `${homeDir}/.claude/skills/${name}`;
|
|
622
|
+
const path2 = `${skillDir}/SKILL.md`;
|
|
623
|
+
const content = `---
|
|
624
|
+
name: ${skill.name}
|
|
625
|
+
description: ${skill.description}
|
|
626
|
+
---
|
|
627
|
+
|
|
628
|
+
${skill.content}
|
|
629
|
+
`;
|
|
630
|
+
await sandbox.writeTextFile({ path: path2, content, abortSignal });
|
|
631
|
+
for (const file of skill.files ?? []) {
|
|
632
|
+
const filePath = safeClaudeSkillFilePath({
|
|
633
|
+
skillName: skill.name,
|
|
634
|
+
filePath: file.path
|
|
635
|
+
});
|
|
636
|
+
await sandbox.writeTextFile({
|
|
637
|
+
path: `${skillDir}/${filePath}`,
|
|
638
|
+
content: file.content,
|
|
639
|
+
abortSignal
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
async function resolveSandboxHomeDir({
|
|
645
|
+
sandbox,
|
|
646
|
+
abortSignal
|
|
647
|
+
}) {
|
|
648
|
+
const result = await sandbox.run({
|
|
649
|
+
command: 'printf "%s" "$HOME"',
|
|
650
|
+
abortSignal
|
|
651
|
+
});
|
|
652
|
+
const homeDir = result.stdout.trim();
|
|
653
|
+
if (result.exitCode !== 0 || !homeDir || !path.posix.isAbsolute(homeDir)) {
|
|
654
|
+
throw new Error(
|
|
655
|
+
`Unable to resolve sandbox HOME directory: ${result.stderr || result.stdout}`
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
return homeDir;
|
|
659
|
+
}
|
|
660
|
+
function safeClaudeSkillName(name) {
|
|
661
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name) || name === "." || name === "..") {
|
|
662
|
+
throw new Error(`Invalid Claude Code skill name: ${name}`);
|
|
663
|
+
}
|
|
664
|
+
return name;
|
|
665
|
+
}
|
|
666
|
+
function safeClaudeSkillFilePath({
|
|
667
|
+
skillName,
|
|
668
|
+
filePath
|
|
669
|
+
}) {
|
|
670
|
+
const normalized = path.posix.normalize(filePath);
|
|
671
|
+
if (normalized === "." || normalized.startsWith("../") || path.posix.isAbsolute(normalized)) {
|
|
672
|
+
throw new Error(
|
|
673
|
+
`Invalid Claude Code skill file path for ${skillName}: ${filePath}`
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
return normalized;
|
|
677
|
+
}
|
|
678
|
+
function shellQuote(value) {
|
|
679
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
680
|
+
}
|
|
681
|
+
async function readBridgeAsset(name) {
|
|
682
|
+
const candidates = [
|
|
683
|
+
new URL(`./bridge/${name}`, import.meta.url),
|
|
684
|
+
new URL(`../bridge/${name}`, import.meta.url)
|
|
685
|
+
];
|
|
686
|
+
let lastErr;
|
|
687
|
+
for (const url of candidates) {
|
|
688
|
+
try {
|
|
689
|
+
return await readFile(fileURLToPath(url), "utf8");
|
|
690
|
+
} catch (err) {
|
|
691
|
+
const code = err.code;
|
|
692
|
+
if (code !== "ENOENT") throw err;
|
|
693
|
+
lastErr = err;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
throw lastErr ?? new Error(`bridge asset not found: ${name}`);
|
|
697
|
+
}
|
|
698
|
+
async function createBridgeStartupError({
|
|
699
|
+
message,
|
|
700
|
+
proc,
|
|
701
|
+
stdoutTail,
|
|
702
|
+
stderrTail,
|
|
703
|
+
stderrDone
|
|
704
|
+
}) {
|
|
705
|
+
await Promise.race([
|
|
706
|
+
stderrDone,
|
|
707
|
+
new Promise((resolve) => setTimeout(resolve, 250))
|
|
708
|
+
]).catch(() => {
|
|
709
|
+
});
|
|
710
|
+
let exitStatus = "";
|
|
711
|
+
try {
|
|
712
|
+
const result = await Promise.race([
|
|
713
|
+
proc.wait(),
|
|
714
|
+
new Promise((resolve) => setTimeout(resolve, 250))
|
|
715
|
+
]);
|
|
716
|
+
if (result?.exitCode !== void 0) {
|
|
717
|
+
exitStatus = ` Exit code: ${result.exitCode}.`;
|
|
718
|
+
}
|
|
719
|
+
} catch {
|
|
720
|
+
}
|
|
721
|
+
const details = [];
|
|
722
|
+
if (stdoutTail.length > 0) {
|
|
723
|
+
details.push(`stdout:
|
|
724
|
+
${stdoutTail.join("\n")}`);
|
|
725
|
+
}
|
|
726
|
+
if (stderrTail.length > 0) {
|
|
727
|
+
details.push(`stderr:
|
|
728
|
+
${stderrTail.join("\n")}`);
|
|
729
|
+
}
|
|
730
|
+
return new Error(
|
|
731
|
+
`${message}${exitStatus}${details.length > 0 ? `
|
|
732
|
+
|
|
733
|
+
${details.join("\n\n")}` : ""}`
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
function lineDecoder() {
|
|
737
|
+
let buffer = "";
|
|
738
|
+
return {
|
|
739
|
+
push(chunk) {
|
|
740
|
+
buffer += chunk;
|
|
741
|
+
const lines = [];
|
|
742
|
+
let nl;
|
|
743
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
744
|
+
const raw = buffer.slice(0, nl);
|
|
745
|
+
buffer = buffer.slice(nl + 1);
|
|
746
|
+
const line = raw.replace(/\r$/, "").trim();
|
|
747
|
+
if (line.length > 0) lines.push(line);
|
|
748
|
+
}
|
|
749
|
+
return lines;
|
|
750
|
+
},
|
|
751
|
+
flush() {
|
|
752
|
+
const line = buffer.replace(/\r$/, "").trim();
|
|
753
|
+
buffer = "";
|
|
754
|
+
return line.length > 0 ? [line] : [];
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
async function forwardBridgeStderr({
|
|
759
|
+
stream,
|
|
760
|
+
collectTail
|
|
761
|
+
}) {
|
|
762
|
+
try {
|
|
763
|
+
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
|
|
764
|
+
const decoder = lineDecoder();
|
|
765
|
+
while (true) {
|
|
766
|
+
const { value, done } = await reader.read();
|
|
767
|
+
if (done) {
|
|
768
|
+
for (const line of decoder.flush()) {
|
|
769
|
+
const trimmed = line.trim();
|
|
770
|
+
if (!trimmed) continue;
|
|
771
|
+
if (collectTail) {
|
|
772
|
+
collectTail.push(trimmed);
|
|
773
|
+
if (collectTail.length > 20) collectTail.shift();
|
|
774
|
+
}
|
|
775
|
+
console.log(`[bridge stderr] ${trimmed}`);
|
|
776
|
+
}
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (value) {
|
|
780
|
+
for (const line of decoder.push(value)) {
|
|
781
|
+
const trimmed = line.trim();
|
|
782
|
+
if (!trimmed) continue;
|
|
783
|
+
if (collectTail) {
|
|
784
|
+
collectTail.push(trimmed);
|
|
785
|
+
if (collectTail.length > 20) collectTail.shift();
|
|
786
|
+
}
|
|
787
|
+
console.log(`[bridge stderr] ${trimmed}`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
} catch {
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
async function drainRest(stream) {
|
|
795
|
+
try {
|
|
796
|
+
const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
|
|
797
|
+
while (true) {
|
|
798
|
+
const { done } = await reader.read();
|
|
799
|
+
if (done) return;
|
|
800
|
+
}
|
|
801
|
+
} catch {
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
async function waitForBridgeHello({
|
|
805
|
+
ws,
|
|
806
|
+
timeoutMs
|
|
807
|
+
}) {
|
|
808
|
+
await new Promise((resolve, reject) => {
|
|
809
|
+
let settled = false;
|
|
810
|
+
const cleanup = () => {
|
|
811
|
+
ws.off("message", onMessage);
|
|
812
|
+
ws.off("close", onClose);
|
|
813
|
+
ws.off("error", onError);
|
|
814
|
+
if (timer) clearTimeout(timer);
|
|
815
|
+
};
|
|
816
|
+
const settle = (err) => {
|
|
817
|
+
if (settled) return;
|
|
818
|
+
settled = true;
|
|
819
|
+
cleanup();
|
|
820
|
+
if (err) reject(err);
|
|
821
|
+
else resolve();
|
|
822
|
+
};
|
|
823
|
+
const onMessage = (raw) => {
|
|
824
|
+
try {
|
|
825
|
+
const text = typeof raw === "string" ? raw : raw.toString ? raw.toString("utf8") : String(raw);
|
|
826
|
+
const parsed = JSON.parse(text);
|
|
827
|
+
if (parsed?.type === "bridge-hello") settle();
|
|
828
|
+
} catch {
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
const onClose = () => {
|
|
832
|
+
settle(
|
|
833
|
+
new Error("claude-code bridge closed before sending bridge-hello")
|
|
834
|
+
);
|
|
835
|
+
};
|
|
836
|
+
const onError = (err) => settle(err);
|
|
837
|
+
const timer = setTimeout(
|
|
838
|
+
() => settle(
|
|
839
|
+
new Error(
|
|
840
|
+
`claude-code bridge did not send bridge-hello within ${timeoutMs}ms`
|
|
841
|
+
)
|
|
842
|
+
),
|
|
843
|
+
timeoutMs
|
|
844
|
+
);
|
|
845
|
+
timer.unref?.();
|
|
846
|
+
ws.on("message", onMessage);
|
|
847
|
+
ws.on("close", onClose);
|
|
848
|
+
ws.on("error", onError);
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
async function openBridgeWebSocket({
|
|
852
|
+
wsUrl,
|
|
853
|
+
timeoutMs
|
|
854
|
+
}) {
|
|
855
|
+
const deadline = Date.now() + timeoutMs;
|
|
856
|
+
let attempt = 0;
|
|
857
|
+
let lastError;
|
|
858
|
+
while (Date.now() < deadline) {
|
|
859
|
+
attempt++;
|
|
860
|
+
let ws;
|
|
861
|
+
try {
|
|
862
|
+
const remaining = Math.max(1, deadline - Date.now());
|
|
863
|
+
ws = await openWebSocket({
|
|
864
|
+
url: wsUrl,
|
|
865
|
+
timeoutMs: Math.min(1e4, remaining)
|
|
866
|
+
});
|
|
867
|
+
await waitForBridgeHello({
|
|
868
|
+
ws,
|
|
869
|
+
timeoutMs: Math.min(5e3, Math.max(1, deadline - Date.now()))
|
|
870
|
+
});
|
|
871
|
+
return ws;
|
|
872
|
+
} catch (err) {
|
|
873
|
+
lastError = err;
|
|
874
|
+
try {
|
|
875
|
+
ws?.close();
|
|
876
|
+
} catch {
|
|
877
|
+
}
|
|
878
|
+
const remaining = deadline - Date.now();
|
|
879
|
+
if (remaining <= 0) break;
|
|
880
|
+
await sleep(Math.min(250 * attempt, 1e3, remaining));
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
throw new Error(
|
|
884
|
+
`claude-code bridge did not complete WebSocket handshake within ${timeoutMs}ms after ${attempt} attempt(s). Last error: ${formatUnknownError(lastError)}`
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
function openWebSocket({
|
|
888
|
+
url,
|
|
889
|
+
timeoutMs
|
|
890
|
+
}) {
|
|
891
|
+
return new Promise((resolve, reject) => {
|
|
892
|
+
const ws = new WebSocket(url);
|
|
893
|
+
const timer = setTimeout(() => {
|
|
894
|
+
cleanup();
|
|
895
|
+
try {
|
|
896
|
+
ws.terminate();
|
|
897
|
+
} catch {
|
|
898
|
+
}
|
|
899
|
+
reject(new Error(`WebSocket open timed out after ${timeoutMs}ms`));
|
|
900
|
+
}, timeoutMs);
|
|
901
|
+
timer.unref?.();
|
|
902
|
+
const cleanup = () => {
|
|
903
|
+
clearTimeout(timer);
|
|
904
|
+
ws.off("open", onOpen);
|
|
905
|
+
ws.off("error", onError);
|
|
906
|
+
};
|
|
907
|
+
const onOpen = () => {
|
|
908
|
+
cleanup();
|
|
909
|
+
resolve(ws);
|
|
910
|
+
};
|
|
911
|
+
const onError = (err) => {
|
|
912
|
+
cleanup();
|
|
913
|
+
reject(err);
|
|
914
|
+
};
|
|
915
|
+
ws.once("open", onOpen);
|
|
916
|
+
ws.once("error", onError);
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
function sleep(ms) {
|
|
920
|
+
return new Promise((resolve) => {
|
|
921
|
+
const timer = setTimeout(resolve, ms);
|
|
922
|
+
timer.unref?.();
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
function formatUnknownError(error) {
|
|
926
|
+
if (error instanceof Error) return error.message;
|
|
927
|
+
return String(error);
|
|
928
|
+
}
|
|
929
|
+
function createSession({
|
|
930
|
+
sessionId,
|
|
931
|
+
channel,
|
|
932
|
+
proc,
|
|
933
|
+
model,
|
|
934
|
+
maxTurns,
|
|
935
|
+
thinking,
|
|
936
|
+
isResume,
|
|
937
|
+
continueOnFirstPrompt,
|
|
938
|
+
rerunContinue,
|
|
939
|
+
bridgePort,
|
|
940
|
+
bridgeToken,
|
|
941
|
+
sandboxId,
|
|
942
|
+
debug,
|
|
943
|
+
permissionMode,
|
|
944
|
+
skills
|
|
945
|
+
}) {
|
|
946
|
+
let stopped = false;
|
|
947
|
+
let stopPromise;
|
|
948
|
+
let pendingResumeFlag = continueOnFirstPrompt;
|
|
949
|
+
let instructionsApplied = isResume;
|
|
950
|
+
const wireTurn = (turnOpts) => {
|
|
951
|
+
let pendingResolve;
|
|
952
|
+
let pendingReject;
|
|
953
|
+
const done = new Promise((resolve, reject) => {
|
|
954
|
+
pendingResolve = resolve;
|
|
955
|
+
pendingReject = reject;
|
|
956
|
+
});
|
|
957
|
+
const unsubs = [];
|
|
958
|
+
const forward = (event) => {
|
|
959
|
+
try {
|
|
960
|
+
turnOpts.emit(event);
|
|
961
|
+
} catch {
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
let isSettled = false;
|
|
965
|
+
const settleSuccess = () => {
|
|
966
|
+
if (isSettled) return;
|
|
967
|
+
isSettled = true;
|
|
968
|
+
for (const u of unsubs) u();
|
|
969
|
+
pendingResolve();
|
|
970
|
+
};
|
|
971
|
+
const settleError = (err) => {
|
|
972
|
+
if (isSettled) return;
|
|
973
|
+
isSettled = true;
|
|
974
|
+
for (const u of unsubs) u();
|
|
975
|
+
pendingReject(err);
|
|
976
|
+
};
|
|
977
|
+
const eventTypes = [
|
|
978
|
+
"stream-start",
|
|
979
|
+
"text-start",
|
|
980
|
+
"text-delta",
|
|
981
|
+
"text-end",
|
|
982
|
+
"reasoning-start",
|
|
983
|
+
"reasoning-delta",
|
|
984
|
+
"reasoning-end",
|
|
985
|
+
"tool-call",
|
|
986
|
+
"tool-approval-request",
|
|
987
|
+
"tool-result",
|
|
988
|
+
"finish-step",
|
|
989
|
+
"raw"
|
|
990
|
+
];
|
|
991
|
+
for (const type of eventTypes) {
|
|
992
|
+
unsubs.push(
|
|
993
|
+
channel.on(type, (msg) => {
|
|
994
|
+
forward(msg);
|
|
995
|
+
})
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
unsubs.push(
|
|
999
|
+
channel.on("finish", (msg) => {
|
|
1000
|
+
forward(msg);
|
|
1001
|
+
settleSuccess();
|
|
1002
|
+
})
|
|
1003
|
+
);
|
|
1004
|
+
unsubs.push(
|
|
1005
|
+
channel.on("error", (msg) => {
|
|
1006
|
+
forward(msg);
|
|
1007
|
+
settleError(msg.error);
|
|
1008
|
+
})
|
|
1009
|
+
);
|
|
1010
|
+
const onClose = (_code, reason) => {
|
|
1011
|
+
if (isSettled) return;
|
|
1012
|
+
if (reason === "suspended") {
|
|
1013
|
+
settleSuccess();
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
settleError(
|
|
1017
|
+
new Error("claude-code bridge closed before the turn finished.")
|
|
1018
|
+
);
|
|
1019
|
+
};
|
|
1020
|
+
channel.onClose(onClose);
|
|
1021
|
+
const onAbort = () => {
|
|
1022
|
+
if (isSettled) return;
|
|
1023
|
+
try {
|
|
1024
|
+
channel.send({ type: "abort" });
|
|
1025
|
+
} catch {
|
|
1026
|
+
}
|
|
1027
|
+
settleError(
|
|
1028
|
+
turnOpts.abortSignal?.reason ?? new DOMException("Aborted", "AbortError")
|
|
1029
|
+
);
|
|
1030
|
+
};
|
|
1031
|
+
if (turnOpts.abortSignal) {
|
|
1032
|
+
if (turnOpts.abortSignal.aborted) {
|
|
1033
|
+
onAbort();
|
|
1034
|
+
} else {
|
|
1035
|
+
turnOpts.abortSignal.addEventListener("abort", onAbort, {
|
|
1036
|
+
once: true
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
return {
|
|
1041
|
+
submitToolResult: async (input) => {
|
|
1042
|
+
channel.send({
|
|
1043
|
+
type: "tool-result",
|
|
1044
|
+
toolCallId: input.toolCallId,
|
|
1045
|
+
output: input.output,
|
|
1046
|
+
isError: input.isError
|
|
1047
|
+
});
|
|
1048
|
+
},
|
|
1049
|
+
submitToolApproval: async (input) => {
|
|
1050
|
+
channel.send({
|
|
1051
|
+
type: "tool-approval-response",
|
|
1052
|
+
approvalId: input.approvalId,
|
|
1053
|
+
approved: input.approved,
|
|
1054
|
+
reason: input.reason
|
|
1055
|
+
});
|
|
1056
|
+
},
|
|
1057
|
+
submitUserMessage: async (text) => {
|
|
1058
|
+
channel.send({ type: "user-message", text });
|
|
1059
|
+
},
|
|
1060
|
+
done
|
|
1061
|
+
};
|
|
1062
|
+
};
|
|
1063
|
+
return {
|
|
1064
|
+
sessionId,
|
|
1065
|
+
isResume,
|
|
1066
|
+
modelId: model,
|
|
1067
|
+
doPromptTurn: async (promptOpts) => {
|
|
1068
|
+
const control = wireTurn({
|
|
1069
|
+
emit: promptOpts.emit,
|
|
1070
|
+
abortSignal: promptOpts.abortSignal
|
|
1071
|
+
});
|
|
1072
|
+
let promptText = extractUserText(promptOpts.prompt);
|
|
1073
|
+
if (!instructionsApplied && promptOpts.instructions) {
|
|
1074
|
+
promptText = frameInstructions(promptOpts.instructions, promptText);
|
|
1075
|
+
}
|
|
1076
|
+
instructionsApplied = true;
|
|
1077
|
+
const startMessage = {
|
|
1078
|
+
type: "start",
|
|
1079
|
+
prompt: promptText,
|
|
1080
|
+
tools: (promptOpts.tools ?? []).map((t) => ({
|
|
1081
|
+
name: t.name,
|
|
1082
|
+
description: t.description,
|
|
1083
|
+
inputSchema: t.inputSchema
|
|
1084
|
+
})),
|
|
1085
|
+
model,
|
|
1086
|
+
maxTurns,
|
|
1087
|
+
thinking,
|
|
1088
|
+
...skills.length > 0 ? { skills: skills.map((skill) => skill.name) } : {},
|
|
1089
|
+
...permissionMode ? { permissionMode } : {},
|
|
1090
|
+
...debug ? { debug } : {},
|
|
1091
|
+
...pendingResumeFlag ? { continue: true } : {}
|
|
1092
|
+
};
|
|
1093
|
+
pendingResumeFlag = false;
|
|
1094
|
+
channel.send(startMessage);
|
|
1095
|
+
return control;
|
|
1096
|
+
},
|
|
1097
|
+
doContinueTurn: async (continueOpts) => {
|
|
1098
|
+
const control = wireTurn({
|
|
1099
|
+
emit: continueOpts.emit,
|
|
1100
|
+
abortSignal: continueOpts.abortSignal
|
|
1101
|
+
});
|
|
1102
|
+
if (rerunContinue) {
|
|
1103
|
+
pendingResumeFlag = false;
|
|
1104
|
+
channel.send({
|
|
1105
|
+
type: "start",
|
|
1106
|
+
/*
|
|
1107
|
+
* A continuation nudge rather than an empty prompt: `continue: true`
|
|
1108
|
+
* rehydrates the prior thread, and this is the new user turn that
|
|
1109
|
+
* drives it forward. It must be non-empty — an empty text block is
|
|
1110
|
+
* rejected by the Anthropic API once the SDK stamps it with
|
|
1111
|
+
* `cache_control`.
|
|
1112
|
+
*/
|
|
1113
|
+
prompt: "Continue.",
|
|
1114
|
+
tools: (continueOpts.tools ?? []).map((t) => ({
|
|
1115
|
+
name: t.name,
|
|
1116
|
+
description: t.description,
|
|
1117
|
+
inputSchema: t.inputSchema
|
|
1118
|
+
})),
|
|
1119
|
+
model,
|
|
1120
|
+
maxTurns,
|
|
1121
|
+
thinking,
|
|
1122
|
+
...skills.length > 0 ? { skills: skills.map((skill) => skill.name) } : {},
|
|
1123
|
+
...permissionMode ? { permissionMode } : {},
|
|
1124
|
+
...debug ? { debug } : {},
|
|
1125
|
+
continue: true
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
return control;
|
|
1129
|
+
},
|
|
1130
|
+
doCompact: async (customInstructions) => {
|
|
1131
|
+
const text = customInstructions && customInstructions.trim() ? `/compact ${customInstructions.trim()}` : "/compact";
|
|
1132
|
+
channel.send({ type: "user-message", text });
|
|
1133
|
+
},
|
|
1134
|
+
doDetach: async () => {
|
|
1135
|
+
if (stopped) {
|
|
1136
|
+
throw new Error(
|
|
1137
|
+
`claude-code session ${sessionId} is already stopped; cannot detach.`
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
stopped = true;
|
|
1141
|
+
const lastSeenEventId = await channel.suspend();
|
|
1142
|
+
const payload = {
|
|
1143
|
+
type: "resume-session",
|
|
1144
|
+
harnessId: "claude-code",
|
|
1145
|
+
specificationVersion: "harness-v1",
|
|
1146
|
+
data: {
|
|
1147
|
+
bridge: {
|
|
1148
|
+
port: bridgePort,
|
|
1149
|
+
token: bridgeToken,
|
|
1150
|
+
lastSeenEventId,
|
|
1151
|
+
sandboxId
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
return payload;
|
|
1156
|
+
},
|
|
1157
|
+
doDestroy: async () => {
|
|
1158
|
+
if (stopped) return stopPromise;
|
|
1159
|
+
stopped = true;
|
|
1160
|
+
stopPromise = (async () => {
|
|
1161
|
+
channel.beginClose();
|
|
1162
|
+
try {
|
|
1163
|
+
if (!channel.isClosed()) {
|
|
1164
|
+
channel.send({ type: "shutdown" });
|
|
1165
|
+
}
|
|
1166
|
+
} catch {
|
|
1167
|
+
}
|
|
1168
|
+
let stopTimer;
|
|
1169
|
+
try {
|
|
1170
|
+
if (proc) {
|
|
1171
|
+
await Promise.race([
|
|
1172
|
+
proc.wait(),
|
|
1173
|
+
new Promise((resolve) => {
|
|
1174
|
+
stopTimer = setTimeout(resolve, 5e3);
|
|
1175
|
+
stopTimer.unref?.();
|
|
1176
|
+
})
|
|
1177
|
+
]);
|
|
1178
|
+
}
|
|
1179
|
+
} finally {
|
|
1180
|
+
if (stopTimer) clearTimeout(stopTimer);
|
|
1181
|
+
try {
|
|
1182
|
+
await proc?.kill();
|
|
1183
|
+
} catch {
|
|
1184
|
+
}
|
|
1185
|
+
channel.close();
|
|
1186
|
+
}
|
|
1187
|
+
})();
|
|
1188
|
+
return stopPromise;
|
|
1189
|
+
},
|
|
1190
|
+
doStop: async () => {
|
|
1191
|
+
if (stopped) {
|
|
1192
|
+
throw new Error(
|
|
1193
|
+
`claude-code session ${sessionId} is already stopped; cannot stop.`
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
stopped = true;
|
|
1197
|
+
channel.beginClose();
|
|
1198
|
+
const data = channel.isClosed() ? {} : await new Promise((resolve, reject) => {
|
|
1199
|
+
const timer = setTimeout(() => {
|
|
1200
|
+
unsub();
|
|
1201
|
+
reject(
|
|
1202
|
+
new Error(
|
|
1203
|
+
`claude-code session ${sessionId} did not reply to detach within 5s.`
|
|
1204
|
+
)
|
|
1205
|
+
);
|
|
1206
|
+
}, 5e3);
|
|
1207
|
+
timer.unref?.();
|
|
1208
|
+
const unsub = channel.on("bridge-detach", (msg) => {
|
|
1209
|
+
clearTimeout(timer);
|
|
1210
|
+
unsub();
|
|
1211
|
+
resolve(msg.data);
|
|
1212
|
+
});
|
|
1213
|
+
try {
|
|
1214
|
+
channel.send({ type: "detach" });
|
|
1215
|
+
} catch (err) {
|
|
1216
|
+
clearTimeout(timer);
|
|
1217
|
+
unsub();
|
|
1218
|
+
reject(err);
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
let stopTimer;
|
|
1222
|
+
try {
|
|
1223
|
+
if (proc) {
|
|
1224
|
+
await Promise.race([
|
|
1225
|
+
proc.wait(),
|
|
1226
|
+
new Promise((resolve) => {
|
|
1227
|
+
stopTimer = setTimeout(resolve, 5e3);
|
|
1228
|
+
stopTimer.unref?.();
|
|
1229
|
+
})
|
|
1230
|
+
]);
|
|
1231
|
+
}
|
|
1232
|
+
} finally {
|
|
1233
|
+
if (stopTimer) clearTimeout(stopTimer);
|
|
1234
|
+
try {
|
|
1235
|
+
await proc?.kill();
|
|
1236
|
+
} catch {
|
|
1237
|
+
}
|
|
1238
|
+
channel.close();
|
|
1239
|
+
}
|
|
1240
|
+
const payload = {
|
|
1241
|
+
type: "resume-session",
|
|
1242
|
+
harnessId: "claude-code",
|
|
1243
|
+
specificationVersion: "harness-v1",
|
|
1244
|
+
data: data ?? {}
|
|
1245
|
+
};
|
|
1246
|
+
return payload;
|
|
1247
|
+
},
|
|
1248
|
+
doSuspendTurn: async () => {
|
|
1249
|
+
if (stopped) {
|
|
1250
|
+
throw new Error(
|
|
1251
|
+
`claude-code session ${sessionId} is stopped; cannot suspend.`
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
1254
|
+
stopped = true;
|
|
1255
|
+
const lastSeenEventId = await channel.suspend();
|
|
1256
|
+
const payload = {
|
|
1257
|
+
type: "continue-turn",
|
|
1258
|
+
harnessId: "claude-code",
|
|
1259
|
+
specificationVersion: "harness-v1",
|
|
1260
|
+
data: {
|
|
1261
|
+
bridge: {
|
|
1262
|
+
port: bridgePort,
|
|
1263
|
+
token: bridgeToken,
|
|
1264
|
+
lastSeenEventId,
|
|
1265
|
+
sandboxId
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
return payload;
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
function frameInstructions(instructions, userText) {
|
|
1274
|
+
return `<session-instructions>
|
|
1275
|
+
The block below is operating guidance from the system, not a message from the user \u2014 follow it, but do not mention it or attribute it to the user.
|
|
1276
|
+
|
|
1277
|
+
${instructions}
|
|
1278
|
+
</session-instructions>
|
|
1279
|
+
|
|
1280
|
+
<user-message>
|
|
1281
|
+
${userText}
|
|
1282
|
+
</user-message>`;
|
|
1283
|
+
}
|
|
1284
|
+
function extractUserText(prompt) {
|
|
1285
|
+
if (typeof prompt === "string") return prompt;
|
|
1286
|
+
const { content } = prompt;
|
|
1287
|
+
if (typeof content === "string") return content;
|
|
1288
|
+
const parts = [];
|
|
1289
|
+
for (const part of content) {
|
|
1290
|
+
if (part.type !== "text") {
|
|
1291
|
+
throw new HarnessCapabilityUnsupportedError({
|
|
1292
|
+
harnessId: "claude-code",
|
|
1293
|
+
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.`
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
parts.push(part.text);
|
|
1297
|
+
}
|
|
1298
|
+
return parts.join("\n\n");
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// src/index.ts
|
|
1302
|
+
var claudeCode = createClaudeCode();
|
|
1303
|
+
export {
|
|
1304
|
+
claudeCode,
|
|
1305
|
+
createClaudeCode
|
|
1306
|
+
};
|
|
1307
|
+
//# sourceMappingURL=index.js.map
|