@ai-sdk/harness-claude-code 1.0.104 → 1.0.106

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/harness-claude-code",
3
- "version": "1.0.104",
3
+ "version": "1.0.106",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -26,7 +26,7 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@ai-sdk/harness": "1.0.100",
29
+ "@ai-sdk/harness": "1.0.102",
30
30
  "@ai-sdk/provider-utils": "5.0.36",
31
31
  "ws": "^8.21.0"
32
32
  },
@@ -38,6 +38,9 @@ export type ClaudeMessage = {
38
38
  usage?: Record<string, unknown>;
39
39
  };
40
40
  result?: string;
41
+ /** Result-message error flags; distinct from `error_status` (`api_retry`). */
42
+ is_error?: boolean;
43
+ api_error_status?: number | null;
41
44
  errors?: ReadonlyArray<string>;
42
45
  usage?: Record<string, unknown>;
43
46
  total_cost_usd?: number;
@@ -246,6 +249,11 @@ export function createEmitStreamEvent({
246
249
  continue;
247
250
  }
248
251
  state.nativeToolCallNames.set(block.id, block.name);
252
+ if (block.name === 'AskUserQuestion') {
253
+ state.pendingStepToolUseIds.add(block.id);
254
+ opensStep = true;
255
+ continue;
256
+ }
249
257
  const dynamic = isExternalMcpTool(block.name);
250
258
  if (dynamic) state.externalMcpToolUseIds.add(block.id);
251
259
  if (state.approvalRequestedToolUseIds.has(block.id)) {
@@ -428,6 +436,9 @@ function handleStreamEvent({
428
436
  if (nativeName === 'StructuredOutput') {
429
437
  return;
430
438
  }
439
+ if (nativeName === 'AskUserQuestion') {
440
+ return;
441
+ }
431
442
  const hostToolName = nativeName.startsWith(HOST_TOOL_PREFIX)
432
443
  ? nativeName.slice(HOST_TOOL_PREFIX.length)
433
444
  : undefined;
@@ -34,6 +34,10 @@ import { argv, env as procEnv, stdout } from 'node:process';
34
34
  * 3. the dependency entry in `src/bridge/package.json`.
35
35
  */
36
36
  import * as claudeAgentSdk from '@anthropic-ai/claude-agent-sdk';
37
+ import type {
38
+ HookCallback,
39
+ HookJSONOutput,
40
+ } from '@anthropic-ai/claude-agent-sdk';
37
41
  import * as mcpServerModule from '@modelcontextprotocol/sdk/server/mcp.js';
38
42
  import { createClaudeCodeSystemPrompt } from './claude-code-system-prompt';
39
43
  import { toClaudeSkillsOption } from './claude-skills-option';
@@ -52,6 +56,11 @@ import {
52
56
  resolveInactiveNativeTools,
53
57
  resolveNativeTools,
54
58
  } from './tool-filtering';
59
+ import {
60
+ claudeCodeQuestionKey,
61
+ toClaudeCodeQuestionResult,
62
+ toHarnessQuestionsInput,
63
+ } from './question-tool';
55
64
 
56
65
  /*
57
66
  * Native Claude Code tool name → cross-harness common name. Tools outside this
@@ -65,7 +74,8 @@ type CommonBuiltinToolName =
65
74
  | 'bash'
66
75
  | 'glob'
67
76
  | 'grep'
68
- | 'webSearch';
77
+ | 'webSearch'
78
+ | 'askUserQuestions';
69
79
 
70
80
  const NATIVE_TO_COMMON: Readonly<Record<string, CommonBuiltinToolName>> = {
71
81
  Read: 'read',
@@ -75,6 +85,7 @@ const NATIVE_TO_COMMON: Readonly<Record<string, CommonBuiltinToolName>> = {
75
85
  Glob: 'glob',
76
86
  Grep: 'grep',
77
87
  WebSearch: 'webSearch',
88
+ AskUserQuestion: 'askUserQuestions',
78
89
  };
79
90
 
80
91
  const NATIVE_TOOL_KINDS: Readonly<
@@ -162,17 +173,15 @@ function createPermissionOptions(input: {
162
173
  permissionMode,
163
174
  inactiveNativeTools,
164
175
  });
165
- if (permissionMode === 'allow-all' && inactiveNativeTools.size === 0) {
166
- return {
167
- permissionMode: 'bypassPermissions',
168
- allowDangerouslySkipPermissions: true,
169
- };
170
- }
171
176
 
172
177
  return {
173
178
  permissionMode:
174
- permissionMode === 'allow-edits' ? 'acceptEdits' : 'default',
175
- allowDangerouslySkipPermissions: false,
179
+ permissionMode === 'allow-all'
180
+ ? 'bypassPermissions'
181
+ : permissionMode === 'allow-edits'
182
+ ? 'acceptEdits'
183
+ : 'default',
184
+ allowDangerouslySkipPermissions: permissionMode === 'allow-all',
176
185
  ...(permissionSettings ? { settings: permissionSettings } : {}),
177
186
  canUseTool: async (
178
187
  toolName: string,
@@ -223,6 +232,80 @@ function createPermissionOptions(input: {
223
232
  };
224
233
  }
225
234
 
235
+ function createQuestionPreToolUseHook(input: {
236
+ turn: BridgeTurn;
237
+ emit: Emit;
238
+ nativeToolCallNames: Map<string, string>;
239
+ }): HookCallback {
240
+ return async (hookInput, toolUseID): Promise<HookJSONOutput> => {
241
+ if (
242
+ hookInput.hook_event_name !== 'PreToolUse' ||
243
+ hookInput.tool_name !== 'AskUserQuestion'
244
+ ) {
245
+ return {};
246
+ }
247
+
248
+ const nativeInput = hookInput.tool_input as Parameters<
249
+ typeof toHarnessQuestionsInput
250
+ >[0];
251
+ const canonicalInput = toHarnessQuestionsInput(nativeInput);
252
+ const toolCallId = toolUseID ?? hookInput.tool_use_id;
253
+ input.nativeToolCallNames.set(toolCallId, hookInput.tool_name);
254
+ input.emit({
255
+ type: 'tool-call',
256
+ toolCallId,
257
+ toolName: 'askUserQuestions',
258
+ nativeName: hookInput.tool_name,
259
+ input: JSON.stringify(canonicalInput),
260
+ providerExecuted: false,
261
+ providerMetadata: {
262
+ 'claude-code': {
263
+ nativeRequest: nativeInput,
264
+ },
265
+ },
266
+ });
267
+
268
+ const questionKey = claudeCodeQuestionKey(nativeInput);
269
+ const result = await input.turn.requestToolResult({
270
+ toolCallId,
271
+ matches: candidate => {
272
+ const nativeRequest = candidate.toolResult?.providerOptions?.[
273
+ 'claude-code'
274
+ ]?.nativeRequest as
275
+ | Parameters<typeof claudeCodeQuestionKey>[0]
276
+ | undefined;
277
+ return (
278
+ nativeRequest != null &&
279
+ claudeCodeQuestionKey(nativeRequest) === questionKey
280
+ );
281
+ },
282
+ });
283
+ const nativeResult = toClaudeCodeQuestionResult({
284
+ nativeInput,
285
+ output: result.output as Parameters<
286
+ typeof toClaudeCodeQuestionResult
287
+ >[0]['output'],
288
+ });
289
+
290
+ return {
291
+ hookSpecificOutput: {
292
+ hookEventName: 'PreToolUse',
293
+ ...(nativeResult.behavior === 'allow'
294
+ ? {
295
+ permissionDecision: 'allow',
296
+ updatedInput: {
297
+ ...nativeResult.updatedInput,
298
+ } as Record<string, unknown>,
299
+ }
300
+ : {
301
+ permissionDecision: 'deny',
302
+ permissionDecisionReason: nativeResult.message,
303
+ }),
304
+ },
305
+ };
306
+ };
307
+ }
308
+
226
309
  function createPermissionSettings(input: {
227
310
  permissionMode: 'allow-reads' | 'allow-edits' | 'allow-all';
228
311
  inactiveNativeTools: ReadonlySet<string>;
@@ -372,6 +455,11 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
372
455
  nativeToolCallNames: streamEventState.nativeToolCallNames,
373
456
  approvalRequestedToolUseIds: streamEventState.approvalRequestedToolUseIds,
374
457
  });
458
+ const questionPreToolUseHook = createQuestionPreToolUseHook({
459
+ turn,
460
+ emit,
461
+ nativeToolCallNames: streamEventState.nativeToolCallNames,
462
+ });
375
463
 
376
464
  const q = claudeSdk.query({
377
465
  prompt: queryInput.input,
@@ -401,6 +489,12 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
401
489
  // `compact_boundary` system message does not. Latch it for the unified
402
490
  // `compaction` event; return an empty output so compaction proceeds.
403
491
  hooks: {
492
+ PreToolUse: [
493
+ {
494
+ matcher: 'AskUserQuestion',
495
+ hooks: [questionPreToolUseHook],
496
+ },
497
+ ],
404
498
  PostCompact: [
405
499
  {
406
500
  hooks: [
@@ -507,6 +601,19 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
507
601
 
508
602
  if (type === 'result') {
509
603
  if (msg.subtype === 'success') {
604
+ // `success` does not mean the turn succeeded: the CLI flags a rejected
605
+ // request with `is_error` and puts the message in `result`, which the
606
+ // empty-result rescue below cannot catch.
607
+ if (msg.is_error) {
608
+ emitTerminalError(
609
+ msg.result?.trim() ||
610
+ streamEventState.observedTerminalError ||
611
+ (typeof msg.api_error_status === 'number'
612
+ ? `Claude Code reported an API error (HTTP ${msg.api_error_status})`
613
+ : 'Claude Code reported a failed result'),
614
+ );
615
+ continue;
616
+ }
510
617
  const emptyResult = !msg.result?.trim?.();
511
618
  if (emptyResult && streamEventState.observedTerminalError) {
512
619
  emitTerminalError(streamEventState.observedTerminalError);
@@ -0,0 +1,104 @@
1
+ import type {
2
+ HarnessV1QuestionsToolInput,
3
+ HarnessV1QuestionsToolOutput,
4
+ } from '@ai-sdk/harness';
5
+ import type { AskUserQuestionInput } from '@anthropic-ai/claude-agent-sdk/sdk-tools.js';
6
+
7
+ type ClaudeCodeQuestion = AskUserQuestionInput['questions'][number];
8
+
9
+ export function claudeCodeQuestionKey(
10
+ nativeInput: AskUserQuestionInput,
11
+ ): string {
12
+ return JSON.stringify(nativeInput.questions);
13
+ }
14
+
15
+ export function toHarnessQuestionsInput(
16
+ nativeInput: AskUserQuestionInput,
17
+ ): HarnessV1QuestionsToolInput {
18
+ return {
19
+ allowPartialAnswers: true,
20
+ questions: nativeInput.questions.map((question, questionIndex) => ({
21
+ id: `question-${questionIndex + 1}`,
22
+ question: question.question,
23
+ header: question.header,
24
+ options: question.options.map((option, optionIndex) => ({
25
+ id: `option-${optionIndex + 1}`,
26
+ label: option.label,
27
+ description: option.description,
28
+ ...(option.preview !== undefined ? { preview: option.preview } : {}),
29
+ })),
30
+ allowMultiple: question.multiSelect,
31
+ allowFreeForm: true,
32
+ })),
33
+ };
34
+ }
35
+
36
+ export function toClaudeCodeQuestionResult(input: {
37
+ nativeInput: AskUserQuestionInput;
38
+ output: HarnessV1QuestionsToolOutput;
39
+ }):
40
+ | {
41
+ behavior: 'allow';
42
+ updatedInput: AskUserQuestionInput & { answers: Record<string, string> };
43
+ }
44
+ | { behavior: 'deny'; message: string } {
45
+ const output = input.output;
46
+ if (output.action === 'declined' || output.action === 'cancelled') {
47
+ return {
48
+ behavior: 'deny',
49
+ message: `The user ${output.action} the questions.`,
50
+ };
51
+ }
52
+
53
+ const canonicalInput = toHarnessQuestionsInput(input.nativeInput);
54
+ const answers = Object.fromEntries(
55
+ canonicalInput.questions.flatMap((question, questionIndex) => {
56
+ const answer = output.answers[question.id];
57
+ if (answer == null) return [];
58
+ const nativeQuestion = input.nativeInput.questions[questionIndex];
59
+ return [
60
+ [
61
+ nativeQuestion.question,
62
+ toClaudeCodeAnswer({
63
+ nativeQuestion,
64
+ optionIds: answer.optionIds,
65
+ freeform: answer.freeform,
66
+ }),
67
+ ],
68
+ ];
69
+ }),
70
+ );
71
+
72
+ return {
73
+ behavior: 'allow',
74
+ updatedInput: { ...input.nativeInput, answers },
75
+ };
76
+ }
77
+
78
+ function toClaudeCodeAnswer(input: {
79
+ nativeQuestion: ClaudeCodeQuestion;
80
+ optionIds: string[];
81
+ freeform: string | undefined;
82
+ }): string {
83
+ if (input.freeform !== undefined) return input.freeform;
84
+ const labels = input.optionIds.flatMap(optionId => {
85
+ const index = positionalIdIndex({ id: optionId, prefix: 'option-' });
86
+ const option =
87
+ index == null ? undefined : input.nativeQuestion.options[index];
88
+ return option == null ? [] : [option.label];
89
+ });
90
+ return input.nativeQuestion.multiSelect
91
+ ? labels.join(', ')
92
+ : (labels[0] ?? '');
93
+ }
94
+
95
+ function positionalIdIndex(input: {
96
+ id: string;
97
+ prefix: string;
98
+ }): number | undefined {
99
+ if (!input.id.startsWith(input.prefix)) return undefined;
100
+ const oneBasedIndex = Number(input.id.slice(input.prefix.length));
101
+ return Number.isInteger(oneBasedIndex) && oneBasedIndex > 0
102
+ ? oneBasedIndex - 1
103
+ : undefined;
104
+ }
@@ -26,7 +26,7 @@ const PUBLIC_TO_NATIVE: Readonly<Record<string, string>> = {
26
26
  ExitPlanMode: 'ExitPlanMode',
27
27
  EnterWorktree: 'EnterWorktree',
28
28
  ExitWorktree: 'ExitWorktree',
29
- AskUserQuestion: 'AskUserQuestion',
29
+ askUserQuestions: 'AskUserQuestion',
30
30
  Skill: 'Skill',
31
31
  ToolSearch: 'ToolSearch',
32
32
  };
@@ -1,6 +1,7 @@
1
1
  import { posix } from 'node:path';
2
2
  import {
3
3
  commonTool,
4
+ HARNESS_V1_BUILTIN_TOOLS,
4
5
  harnessV1DiagnosticFromBridgeFrame,
5
6
  HarnessCapabilityUnsupportedError,
6
7
  type HarnessV1,
@@ -12,6 +13,7 @@ import {
12
13
  type HarnessV1PermissionMode,
13
14
  type HarnessV1Prompt,
14
15
  type HarnessV1PromptControl,
16
+ type HarnessV1ResponseFormat,
15
17
  type HarnessV1PortEndpoint,
16
18
  type HarnessV1ResumeSessionState,
17
19
  type HarnessV1NetworkSandboxSession,
@@ -38,7 +40,7 @@ import {
38
40
  warnCredentialBrokeringUnavailable,
39
41
  waitForBridgeReady,
40
42
  withBridgeToken,
41
- writeSkills as writeHarnessSkills,
43
+ writeSkills,
42
44
  } from '@ai-sdk/harness/utils';
43
45
  import {
44
46
  safeParseJSON,
@@ -461,39 +463,14 @@ const CLAUDE_CODE_BUILTIN_TOOLS = {
461
463
  discard_changes: z.boolean().optional(),
462
464
  }),
463
465
  }),
464
- AskUserQuestion: tool({
465
- description: 'Ask the user multiple-choice questions via a structured UI',
466
- inputSchema: z.object({
467
- questions: z
468
- .array(
469
- z.object({
470
- question: z.string(),
471
- header: z.string(),
472
- options: z.array(
473
- z.object({
474
- label: z.string(),
475
- description: z.string(),
476
- preview: z.string().optional(),
477
- }),
478
- ),
479
- multiSelect: z.boolean(),
480
- }),
481
- )
482
- .min(1)
483
- .max(4),
484
- answers: z.record(z.string(), z.string()).optional(),
485
- annotations: z
486
- .record(
487
- z.string(),
488
- z.object({
489
- preview: z.string().optional(),
490
- notes: z.string().optional(),
491
- }),
492
- )
493
- .optional(),
494
- metadata: z.object({ source: z.string().optional() }).optional(),
495
- }),
496
- }),
466
+ askUserQuestions: {
467
+ ...HARNESS_V1_BUILTIN_TOOLS.askUserQuestions,
468
+ nativeName: 'AskUserQuestion',
469
+ toolUseKind: 'readonly',
470
+ } as typeof HARNESS_V1_BUILTIN_TOOLS.askUserQuestions & {
471
+ readonly nativeName: 'AskUserQuestion';
472
+ readonly toolUseKind: 'readonly';
473
+ },
497
474
  Skill: {
498
475
  ...tool({
499
476
  description: 'Activate a skill by name',
@@ -891,6 +868,13 @@ export function createClaudeCode(
891
868
  ? { CLAUDE_AGENT_SDK_CLIENT_APP: CLAUDE_CODE_CLIENT_APP }
892
869
  : {}),
893
870
  ...settings.env,
871
+ ...(startOpts.headers != null
872
+ ? {
873
+ ANTHROPIC_CUSTOM_HEADERS: Object.entries(startOpts.headers)
874
+ .map(([name, value]) => `${name}: ${value}`)
875
+ .join('\n'),
876
+ }
877
+ : {}),
894
878
  };
895
879
  let sandboxClaudeEnvironment = claudeEnvironment;
896
880
  let sandboxCredentialEnvironment: Record<string, string> | undefined;
@@ -1268,30 +1252,6 @@ async function resolveBridgeEndpoint({
1268
1252
  * be in place before the bridge is spawned without mutating the session
1269
1253
  * workdir. Each file uses the YAML-frontmatter shape the CLI expects.
1270
1254
  */
1271
- async function writeClaudeCodeSkills({
1272
- sandbox,
1273
- homeDir,
1274
- skills,
1275
- abortSignal,
1276
- }: {
1277
- sandbox: SandboxSession;
1278
- homeDir: string;
1279
- skills: ReadonlyArray<HarnessV1Skill>;
1280
- abortSignal?: AbortSignal;
1281
- }): Promise<void> {
1282
- await writeHarnessSkills({
1283
- sandbox,
1284
- rootDir: `${homeDir}/.claude/skills`,
1285
- skills,
1286
- abortSignal,
1287
- invalidSkillNameMessage: ({ name }) =>
1288
- `Invalid Claude Code skill name: ${name}`,
1289
- invalidSkillFilePathMessage: ({ skillName, filePath }) =>
1290
- `Invalid Claude Code skill file path for ${skillName}: ${filePath}`,
1291
- trailingNewline: true,
1292
- });
1293
- }
1294
-
1295
1255
  /**
1296
1256
  * Wait for the bridge's `bridge-hello` message to arrive on the freshly
1297
1257
  * opened WebSocket before any other host-side code touches it.
@@ -1697,6 +1657,9 @@ function createSession({
1697
1657
  toolCallId: input.toolCallId,
1698
1658
  output: input.output,
1699
1659
  isError: input.isError,
1660
+ ...(input.toolResult !== undefined
1661
+ ? { toolResult: input.toolResult }
1662
+ : {}),
1700
1663
  });
1701
1664
  },
1702
1665
  submitToolApproval: async input => {
@@ -1718,30 +1681,45 @@ function createSession({
1718
1681
  };
1719
1682
  };
1720
1683
 
1684
+ const prepareTurn = async (turnOpts: {
1685
+ responseFormat?: HarnessV1ResponseFormat;
1686
+ skills: ReadonlyArray<HarnessV1Skill>;
1687
+ emit: (event: HarnessV1StreamPart) => void;
1688
+ abortSignal?: AbortSignal;
1689
+ }): Promise<HarnessV1PromptControl> => {
1690
+ if (
1691
+ turnOpts.responseFormat?.type === 'json' &&
1692
+ turnOpts.responseFormat.schema == null
1693
+ ) {
1694
+ throw new HarnessCapabilityUnsupportedError({
1695
+ message:
1696
+ "Harness 'claude-code' requires a JSON schema for structured output.",
1697
+ harnessId: 'claude-code',
1698
+ });
1699
+ }
1700
+ await writeSkills({
1701
+ sandbox,
1702
+ homePath: sandboxHomeDir,
1703
+ skillsDir: '.claude/skills',
1704
+ skills: turnOpts.skills,
1705
+ abortSignal: turnOpts.abortSignal,
1706
+ invalidSkillNameMessage: ({ name }) =>
1707
+ `Invalid Claude Code skill name: ${name}`,
1708
+ invalidSkillFilePathMessage: ({ skillName, filePath }) =>
1709
+ `Invalid Claude Code skill file path for ${skillName}: ${filePath}`,
1710
+ trailingNewline: true,
1711
+ });
1712
+ return wireTurn({
1713
+ emit: turnOpts.emit,
1714
+ abortSignal: turnOpts.abortSignal,
1715
+ });
1716
+ };
1717
+
1721
1718
  return {
1722
1719
  sessionId,
1723
1720
  isResume,
1724
1721
  doPromptTurn: async promptOpts => {
1725
- if (
1726
- promptOpts.responseFormat?.type === 'json' &&
1727
- promptOpts.responseFormat.schema == null
1728
- ) {
1729
- throw new HarnessCapabilityUnsupportedError({
1730
- message:
1731
- "Harness 'claude-code' requires a JSON schema for structured output.",
1732
- harnessId: 'claude-code',
1733
- });
1734
- }
1735
- await writeClaudeCodeSkills({
1736
- sandbox,
1737
- homeDir: sandboxHomeDir,
1738
- skills: promptOpts.skills,
1739
- abortSignal: promptOpts.abortSignal,
1740
- });
1741
- const control = wireTurn({
1742
- emit: promptOpts.emit,
1743
- abortSignal: promptOpts.abortSignal,
1744
- });
1722
+ const control = await prepareTurn(promptOpts);
1745
1723
 
1746
1724
  /*
1747
1725
  * A signal that was already aborted has settled the turn inside
@@ -1791,26 +1769,7 @@ function createSession({
1791
1769
  return control;
1792
1770
  },
1793
1771
  doContinueTurn: async continueOpts => {
1794
- if (
1795
- continueOpts.responseFormat?.type === 'json' &&
1796
- continueOpts.responseFormat.schema == null
1797
- ) {
1798
- throw new HarnessCapabilityUnsupportedError({
1799
- message:
1800
- "Harness 'claude-code' requires a JSON schema for structured output.",
1801
- harnessId: 'claude-code',
1802
- });
1803
- }
1804
- await writeClaudeCodeSkills({
1805
- sandbox,
1806
- homeDir: sandboxHomeDir,
1807
- skills: continueOpts.skills,
1808
- abortSignal: continueOpts.abortSignal,
1809
- });
1810
- const control = wireTurn({
1811
- emit: continueOpts.emit,
1812
- abortSignal: continueOpts.abortSignal,
1813
- });
1772
+ const control = await prepareTurn(continueOpts);
1814
1773
 
1815
1774
  /*
1816
1775
  * attach / replay: the still-running (or disk-replayed) turn streams into