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