@ai-sdk/harness-claude-code 1.0.103 → 1.0.105

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.103",
3
+ "version": "1.0.105",
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.99",
29
+ "@ai-sdk/harness": "1.0.101",
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)) {
@@ -298,25 +306,15 @@ export function createEmitStreamEvent({
298
306
  const toolName = toCommonName(nativeName);
299
307
  const dynamic = state.externalMcpToolUseIds.delete(block.tool_use_id);
300
308
  const isError = !!block.is_error;
301
- const content = stringifyContent(block.content);
302
- /*
303
- * Claude Code's Bash tool does not report the command's real
304
- * numeric exit code — the SDK exposes only stdout/stderr text and
305
- * an is_error flag. Consumers (and the example UI) render bash
306
- * failures from an `exitCode` field on a structured result, the
307
- * shape Codex's shell tool provides natively. When Claude omits
308
- * `tool_use_result`, derive a binary code from is_error: 1 on
309
- * failure, 0 on success. This fallback is a stand-in for
310
- * failed/succeeded, not the process's true exit status.
311
- */
312
- const contentResult =
313
- toolName === 'bash'
314
- ? { exitCode: isError ? 1 : 0, stdout: content }
315
- : dynamic
316
- ? parseMcpToolResult(content)
317
- : content;
318
309
  const result =
319
- toolUseResult !== undefined ? toolUseResult : contentResult;
310
+ toolUseResult !== undefined
311
+ ? toolUseResult
312
+ : resolveToolResult({
313
+ toolName,
314
+ dynamic,
315
+ isError,
316
+ rawContent: block.content,
317
+ });
320
318
  emit({
321
319
  type: 'tool-result',
322
320
  toolCallId: block.tool_use_id,
@@ -438,6 +436,9 @@ function handleStreamEvent({
438
436
  if (nativeName === 'StructuredOutput') {
439
437
  return;
440
438
  }
439
+ if (nativeName === 'AskUserQuestion') {
440
+ return;
441
+ }
441
442
  const hostToolName = nativeName.startsWith(HOST_TOOL_PREFIX)
442
443
  ? nativeName.slice(HOST_TOOL_PREFIX.length)
443
444
  : undefined;
@@ -501,23 +502,61 @@ function handleStreamEvent({
501
502
  }
502
503
  }
503
504
 
505
+ function isTextEntry(entry: unknown): entry is { text?: unknown } {
506
+ return entry != null && typeof entry === 'object' && 'text' in entry;
507
+ }
508
+
504
509
  function stringifyContent(content: unknown): string {
505
510
  if (typeof content === 'string') return content;
506
511
  if (Array.isArray(content)) {
507
512
  return content
508
513
  .map(entry =>
509
- entry && typeof entry === 'object' && 'text' in entry
510
- ? String((entry as { text?: unknown }).text ?? '')
511
- : JSON.stringify(entry),
514
+ isTextEntry(entry) ? String(entry.text ?? '') : JSON.stringify(entry),
512
515
  )
513
516
  .join('');
514
517
  }
515
518
  return JSON.stringify(content);
516
519
  }
517
520
 
521
+ function hasNonTextContent(content: unknown): boolean {
522
+ return Array.isArray(content) && content.some(entry => !isTextEntry(entry));
523
+ }
524
+
525
+ function resolveToolResult({
526
+ toolName,
527
+ dynamic,
528
+ isError,
529
+ rawContent,
530
+ }: {
531
+ toolName: string;
532
+ dynamic: boolean;
533
+ isError: boolean;
534
+ rawContent: unknown;
535
+ }): unknown {
536
+ /*
537
+ * Claude Code's Bash tool does not report the command's real numeric exit
538
+ * code — the SDK exposes only stdout/stderr text and an is_error flag.
539
+ * Consumers (and the example UI) render bash failures from an `exitCode`
540
+ * field on a structured result, the shape Codex's shell tool provides
541
+ * natively. When Claude omits `tool_use_result`, derive a binary code from
542
+ * is_error: 1 on failure, 0 on success. This fallback is a stand-in for
543
+ * failed/succeeded, not the process's true exit status.
544
+ */
545
+ if (toolName === 'bash') {
546
+ return { exitCode: isError ? 1 : 0, stdout: stringifyContent(rawContent) };
547
+ }
548
+ // Must precede the MCP branch: flattening a non-text block to base64 text
549
+ // is not recoverable by parsing it back.
550
+ if (hasNonTextContent(rawContent)) return rawContent;
551
+ const content = stringifyContent(rawContent);
552
+ return dynamic ? parseMcpToolResult(content) : content;
553
+ }
554
+
518
555
  function parseMcpToolResult(content: string): unknown {
519
556
  try {
520
- return JSON.parse(content);
557
+ const parsed = JSON.parse(content);
558
+ // `JSON.parse` also succeeds on scalars; keep those as the string sent.
559
+ return parsed !== null && typeof parsed === 'object' ? parsed : content;
521
560
  } catch {
522
561
  return content;
523
562
  }
@@ -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,
@@ -461,39 +462,14 @@ const CLAUDE_CODE_BUILTIN_TOOLS = {
461
462
  discard_changes: z.boolean().optional(),
462
463
  }),
463
464
  }),
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
- }),
465
+ askUserQuestions: {
466
+ ...HARNESS_V1_BUILTIN_TOOLS.askUserQuestions,
467
+ nativeName: 'AskUserQuestion',
468
+ toolUseKind: 'readonly',
469
+ } as typeof HARNESS_V1_BUILTIN_TOOLS.askUserQuestions & {
470
+ readonly nativeName: 'AskUserQuestion';
471
+ readonly toolUseKind: 'readonly';
472
+ },
497
473
  Skill: {
498
474
  ...tool({
499
475
  description: 'Activate a skill by name',
@@ -921,13 +897,18 @@ export function createClaudeCode(
921
897
  );
922
898
  }
923
899
  } else {
924
- warnCredentialBrokeringUnavailable();
925
900
  sandboxClaudeEnvironment = await applyCredentialForwarding({
926
901
  environment: sandboxClaudeEnvironment,
927
902
  credentialEnvironmentVariables:
928
903
  CLAUDE_CODE_CREDENTIAL_ENVIRONMENT_VARIABLES,
929
904
  credentialForwarding: settings.credentialForwarding,
930
905
  });
906
+ warnCredentialBrokeringUnavailable({
907
+ environment: claudeEnvironment,
908
+ forwardedEnvironment: sandboxClaudeEnvironment,
909
+ credentialEnvironmentVariables:
910
+ CLAUDE_CODE_CREDENTIAL_ENVIRONMENT_VARIABLES,
911
+ });
931
912
  }
932
913
  const bootstrapDir = posix.resolve(
933
914
  defaultWorkingDirectory,
@@ -1692,6 +1673,9 @@ function createSession({
1692
1673
  toolCallId: input.toolCallId,
1693
1674
  output: input.output,
1694
1675
  isError: input.isError,
1676
+ ...(input.toolResult !== undefined
1677
+ ? { toolResult: input.toolResult }
1678
+ : {}),
1695
1679
  });
1696
1680
  },
1697
1681
  submitToolApproval: async input => {