@ai-sdk/harness-claude-code 1.0.171 → 1.0.173

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.171",
3
+ "version": "1.0.173",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -26,9 +26,9 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@ai-sdk/harness": "1.0.167",
30
- "@ai-sdk/provider": "4.0.34",
31
- "@ai-sdk/provider-utils": "5.0.70",
29
+ "@ai-sdk/harness": "1.0.169",
30
+ "@ai-sdk/provider": "4.0.35",
31
+ "@ai-sdk/provider-utils": "5.0.72",
32
32
  "ws": "^8.21.0"
33
33
  },
34
34
  "peerDependencies": {
@@ -51,6 +51,12 @@ export type ClaudeMessage = {
51
51
  total_cost_usd?: number;
52
52
  structured_output?: unknown;
53
53
  tool_use_result?: unknown;
54
+ terminal_reason?: string;
55
+ deferred_tool_use?: {
56
+ id: string;
57
+ name: string;
58
+ input: Record<string, unknown>;
59
+ };
54
60
  };
55
61
 
56
62
  type MessageBlock = {
@@ -172,6 +172,7 @@ function createPermissionOptions(input: {
172
172
  nativeToolCallNames: Map<string, string>;
173
173
  approvalRequestedToolUseIds: Set<string>;
174
174
  emittedToolCallIds: Set<string>;
175
+ approvalDecisions: Map<string, { approved: boolean; reason?: string }>;
175
176
  }): Record<string, unknown> {
176
177
  const permissionMode = input.start.permissionMode ?? 'allow-all';
177
178
  const inactiveNativeTools = new Set(input.inactiveNativeTools);
@@ -211,20 +212,26 @@ function createPermissionOptions(input: {
211
212
  toolInput: Record<string, unknown>,
212
213
  options: { toolUseID: string },
213
214
  ) => {
214
- if (toolName.startsWith('mcp__harness-tools__')) {
215
- return { behavior: 'allow', updatedInput: toolInput };
216
- }
217
215
  if (
218
- !inactiveNativeTools.has(toolName) &&
219
- !nativeToolRequiresApproval({
220
- nativeName: toolName,
221
- permissionMode,
222
- })
216
+ !needsHostApproval({ toolName, permissionMode, inactiveNativeTools })
223
217
  ) {
224
218
  return { behavior: 'allow', updatedInput: toolInput };
225
219
  }
226
220
 
227
221
  const approvalId = options.toolUseID;
222
+ /*
223
+ * The permission ask rules still apply after the approval hook allowed a
224
+ * resumed deferred call, so apply that decision instead of asking the
225
+ * host again.
226
+ */
227
+ if (input.approvalDecisions.get(approvalId)?.approved === true) {
228
+ input.approvalDecisions.delete(approvalId);
229
+ return {
230
+ behavior: 'allow',
231
+ updatedInput: toolInput,
232
+ toolUseID: approvalId,
233
+ };
234
+ }
228
235
  input.approvalRequestedToolUseIds.add(approvalId);
229
236
  input.nativeToolCallNames.set(approvalId, toolName);
230
237
  if (!input.emittedToolCallIds.has(approvalId)) {
@@ -366,6 +373,98 @@ function createPermissionSettings(input: {
366
373
  };
367
374
  }
368
375
 
376
+ /**
377
+ * Claude Code picks a model's output limit, system prompt and default effort
378
+ * from its own reading of the model id, and misreads AI Gateway ids with a
379
+ * dotted version such as `anthropic/claude-opus-5.5`. `modelOverrides` maps
380
+ * the id Claude Code expects (`claude-opus-5-5`) to the gateway id, which
381
+ * stays the id sent to the gateway.
382
+ */
383
+ function createGatewayModelOverrides(
384
+ model: string | undefined,
385
+ ): Record<string, string> | undefined {
386
+ if (!model?.startsWith('anthropic/')) return undefined;
387
+ // Claude Code matches the value against the model id, suffix included, so a
388
+ // suffix such as `[1m]` stays on the value.
389
+ const id = model.slice('anthropic/'.length).replace(/\[[^\]]*\]$/, '');
390
+ return id.includes('.') ? { [id.replace(/\./g, '-')]: model } : undefined;
391
+ }
392
+
393
+ function needsHostApproval(input: {
394
+ toolName: string;
395
+ permissionMode: 'allow-reads' | 'allow-edits' | 'allow-all';
396
+ inactiveNativeTools: ReadonlySet<string>;
397
+ }): boolean {
398
+ if (input.toolName.startsWith('mcp__harness-tools__')) return false;
399
+ return (
400
+ input.inactiveNativeTools.has(input.toolName) ||
401
+ nativeToolRequiresApproval({
402
+ nativeName: input.toolName,
403
+ permissionMode: input.permissionMode,
404
+ })
405
+ );
406
+ }
407
+
408
+ /*
409
+ * Defers a call that needs host approval, so Claude Code ends the turn and
410
+ * keeps the call in the transcript instead of holding it in this process.
411
+ * Resuming the session without a prompt fires the hook again for the same
412
+ * call, and the host's decision is applied then. This survives a bridge
413
+ * restart because nothing waits in memory.
414
+ */
415
+ function createApprovalPreToolUseHook(input: {
416
+ permissionMode: 'allow-reads' | 'allow-edits' | 'allow-all';
417
+ inactiveNativeTools: ReadonlySet<string>;
418
+ decisions: Map<string, { approved: boolean; reason?: string }>;
419
+ deferredToolUseIds: Set<string>;
420
+ nativeToolCallNames: Map<string, string>;
421
+ }): HookCallback {
422
+ return async (hookInput, toolUseID): Promise<HookJSONOutput> => {
423
+ const toolName = (hookInput as { tool_name?: unknown }).tool_name;
424
+ if (
425
+ toolUseID == null ||
426
+ typeof toolName !== 'string' ||
427
+ toolName === 'AskUserQuestion'
428
+ ) {
429
+ return {};
430
+ }
431
+ const decision = input.decisions.get(toolUseID);
432
+ if (decision != null) {
433
+ // A bridge that resumes another bridge's deferred call never saw its
434
+ // tool_use, so record the name for the tool result that follows.
435
+ input.nativeToolCallNames.set(toolUseID, toolName);
436
+ if (!decision.approved) input.decisions.delete(toolUseID);
437
+ return {
438
+ hookSpecificOutput: {
439
+ hookEventName: 'PreToolUse',
440
+ ...(decision.approved
441
+ ? { permissionDecision: 'allow' }
442
+ : {
443
+ permissionDecision: 'deny',
444
+ permissionDecisionReason: decision.reason ?? 'Denied',
445
+ }),
446
+ },
447
+ };
448
+ }
449
+ if (
450
+ !needsHostApproval({
451
+ toolName,
452
+ permissionMode: input.permissionMode,
453
+ inactiveNativeTools: input.inactiveNativeTools,
454
+ })
455
+ ) {
456
+ return {};
457
+ }
458
+ input.deferredToolUseIds.add(toolUseID);
459
+ return {
460
+ hookSpecificOutput: {
461
+ hookEventName: 'PreToolUse',
462
+ permissionDecision: 'defer',
463
+ },
464
+ };
465
+ };
466
+ }
467
+
369
468
  function nativeToolRequiresApproval(input: {
370
469
  nativeName: string;
371
470
  permissionMode: 'allow-reads' | 'allow-edits' | 'allow-all';
@@ -414,11 +513,18 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
414
513
  version: '1.0.0',
415
514
  });
416
515
  for (const tool of start.tools) {
516
+ const hints = start.toolHints?.[tool.name];
417
517
  server.registerTool(
418
518
  tool.name,
419
519
  {
420
520
  description: tool.description ?? '',
421
521
  inputSchema: jsonSchemaToZodObject(tool.inputSchema),
522
+ ...(hints?.readOnly == null
523
+ ? {}
524
+ : { annotations: { readOnlyHint: hints.readOnly } }),
525
+ ...(hints?.alwaysLoad == null
526
+ ? {}
527
+ : { _meta: { 'anthropic/alwaysLoad': hints.alwaysLoad } }),
422
528
  },
423
529
  async (
424
530
  ...handlerArgs: [
@@ -542,7 +648,13 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
542
648
  const inactiveNativeTools = resolveInactiveNativeTools(
543
649
  start.builtinToolFiltering,
544
650
  );
651
+ const permissionMode = start.permissionMode ?? 'allow-all';
652
+ const approvalDecisions = new Map<
653
+ string,
654
+ { approved: boolean; reason?: string }
655
+ >();
545
656
  const permissionOptions = createPermissionOptions({
657
+ approvalDecisions,
546
658
  start,
547
659
  inactiveNativeTools,
548
660
  turn,
@@ -555,6 +667,15 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
555
667
  approvalRequestedToolUseIds: streamEventState.approvalRequestedToolUseIds,
556
668
  emittedToolCallIds: streamEventState.emittedToolCallIds,
557
669
  });
670
+ const modelOverrides = createGatewayModelOverrides(start.model);
671
+ const deferredToolUseIds = new Set<string>();
672
+ const approvalPreToolUseHook = createApprovalPreToolUseHook({
673
+ permissionMode,
674
+ inactiveNativeTools: new Set(inactiveNativeTools),
675
+ decisions: approvalDecisions,
676
+ deferredToolUseIds,
677
+ nativeToolCallNames: streamEventState.nativeToolCallNames,
678
+ });
558
679
  const questionPreToolUseHook = createQuestionPreToolUseHook({
559
680
  turn,
560
681
  emit,
@@ -565,7 +686,7 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
565
686
  clearWaitingOnHostInput({ state: streamEventState, toolUseId }),
566
687
  });
567
688
 
568
- const q = claudeSdk.query({
689
+ const queryArgs = {
569
690
  prompt: queryInput.input,
570
691
  options: {
571
692
  ...(start.model ? { model: start.model } : {}),
@@ -608,6 +729,9 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
608
729
  // `compaction` event; return an empty output so compaction proceeds.
609
730
  hooks: {
610
731
  PreToolUse: [
732
+ ...(permissionMode === 'allow-all'
733
+ ? []
734
+ : [{ hooks: [approvalPreToolUseHook] }]),
611
735
  {
612
736
  matcher: 'AskUserQuestion',
613
737
  hooks: [questionPreToolUseHook],
@@ -648,10 +772,29 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
648
772
  ? { continue: true }
649
773
  : {}),
650
774
  ...permissionOptions,
775
+ ...(modelOverrides
776
+ ? {
777
+ settings: {
778
+ ...(permissionOptions.settings as
779
+ | Record<string, unknown>
780
+ | undefined),
781
+ modelOverrides,
782
+ },
783
+ }
784
+ : {}),
651
785
  mcpServers,
652
786
  cwd: workdir,
653
787
  abortSignal: abortCtl.signal,
654
788
  },
789
+ };
790
+ let deferredToolUseId = start.deferredToolUseId;
791
+ let resumingDeferredCall = false;
792
+ let q = deferredToolUseId == null ? claudeSdk.query(queryArgs) : undefined;
793
+ const hostAborted = new Promise<undefined>(resolve => {
794
+ if (abortCtl.signal.aborted) resolve(undefined);
795
+ abortCtl.signal.addEventListener('abort', () => resolve(undefined), {
796
+ once: true,
797
+ });
655
798
  });
656
799
 
657
800
  gracefulAbort = () => {
@@ -663,11 +806,68 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
663
806
  hardAbortTimer = setTimeout(() => abortCtl.abort(), 5000);
664
807
  hardAbortTimer.unref?.();
665
808
  void Promise.resolve()
666
- .then(() => q.interrupt())
809
+ .then(() => q?.interrupt())
667
810
  .catch(() => abortCtl.abort());
668
811
  };
669
812
  let turnUsage: Record<string, unknown> | undefined;
670
813
  let totalCostUsd: number | undefined;
814
+
815
+ /*
816
+ * Claude Code ended the turn at a call that needs host approval. Every other
817
+ * call the hook deferred in the same message was dropped by Claude Code,
818
+ * which can only resume one deferred call, so report those as not run.
819
+ */
820
+ let deferredDecision:
821
+ | Promise<{ approved: boolean; reason?: string }>
822
+ | undefined;
823
+ const requestDeferredApproval = (call: {
824
+ toolUseId: string;
825
+ toolName: string;
826
+ toolInput: Record<string, unknown>;
827
+ }) => {
828
+ for (const droppedToolUseId of deferredToolUseIds) {
829
+ if (droppedToolUseId === call.toolUseId) continue;
830
+ const nativeName =
831
+ streamEventState.nativeToolCallNames.get(droppedToolUseId) ?? 'unknown';
832
+ emit({
833
+ type: 'tool-result',
834
+ toolCallId: droppedToolUseId,
835
+ toolName: toCommonName(nativeName),
836
+ result:
837
+ 'Claude Code can wait for only one approval at a time, so this call did not run.',
838
+ isError: true,
839
+ });
840
+ streamEventState.pendingStepToolUseIds.delete(droppedToolUseId);
841
+ }
842
+ deferredToolUseIds.clear();
843
+ streamEventState.approvalRequestedToolUseIds.add(call.toolUseId);
844
+ streamEventState.nativeToolCallNames.set(call.toolUseId, call.toolName);
845
+ if (!streamEventState.emittedToolCallIds.has(call.toolUseId)) {
846
+ streamEventState.emittedToolCallIds.add(call.toolUseId);
847
+ emit({
848
+ type: 'tool-call',
849
+ toolCallId: call.toolUseId,
850
+ toolName: toCommonName(call.toolName),
851
+ nativeName: call.toolName,
852
+ input: JSON.stringify(call.toolInput ?? {}),
853
+ providerExecuted: true,
854
+ ...(isExternalMcpTool(call.toolName) ? { dynamic: true } : {}),
855
+ });
856
+ }
857
+ // Register before asking, so an answer that arrives while the deferred
858
+ // query is still being disposed is not missed.
859
+ deferredDecision = turn.requestToolApproval(call.toolUseId);
860
+ emit({
861
+ type: 'tool-approval-request',
862
+ approvalId: call.toolUseId,
863
+ toolCallId: call.toolUseId,
864
+ });
865
+ markWaitingOnHostInput({
866
+ state: streamEventState,
867
+ emit,
868
+ toolUseId: call.toolUseId,
869
+ });
870
+ };
671
871
  let emittedTerminalError = false;
672
872
  let emittedTerminalFinish = false;
673
873
 
@@ -700,8 +900,48 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
700
900
  toCommonName,
701
901
  });
702
902
 
903
+ /*
904
+ * Yields the messages of every query in this turn. When Claude Code defers a
905
+ * call for host approval, wait for the decision and resume the session
906
+ * without a prompt so the same call runs with that decision.
907
+ */
908
+ async function* messages(): AsyncGenerator<ClaudeMessage> {
909
+ for (;;) {
910
+ if (deferredToolUseId != null) {
911
+ const toolUseId = deferredToolUseId;
912
+ const decision = await Promise.race([
913
+ deferredDecision ?? turn.requestToolApproval(toolUseId),
914
+ hostAborted,
915
+ ]).finally(() =>
916
+ clearWaitingOnHostInput({ state: streamEventState, toolUseId }),
917
+ );
918
+ deferredDecision = undefined;
919
+ if (decision == null || abortCtl.signal.aborted) return;
920
+ approvalDecisions.set(toolUseId, decision);
921
+ deferredToolUseId = undefined;
922
+ resumingDeferredCall = true;
923
+ q = claudeSdk.query({
924
+ prompt: withoutInitialMessage(queryInput.input),
925
+ options: {
926
+ ...queryArgs.options,
927
+ ...(lastClaudeSessionId != null
928
+ ? { continue: undefined, resume: lastClaudeSessionId }
929
+ : {}),
930
+ },
931
+ });
932
+ }
933
+ for await (const msg of q as AsyncIterable<ClaudeMessage>) {
934
+ yield msg;
935
+ if (deferredToolUseId != null) break;
936
+ }
937
+ if (deferredToolUseId == null || abortCtl.signal.aborted) return;
938
+ queryInput.retire();
939
+ await disposeQuery(q);
940
+ }
941
+ }
942
+
703
943
  try {
704
- for await (const msg of q as AsyncIterable<ClaudeMessage>) {
944
+ for await (const msg of messages()) {
705
945
  if (abortCtl.signal.aborted) break;
706
946
 
707
947
  const type = msg.type;
@@ -720,6 +960,25 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
720
960
 
721
961
  emitStreamEvent(msg);
722
962
 
963
+ if (
964
+ type === 'result' &&
965
+ msg.terminal_reason === 'tool_deferred' &&
966
+ msg.deferred_tool_use?.id != null
967
+ ) {
968
+ const harnessUsage = mapUsage(msg.usage ?? msg.message?.usage);
969
+ if (harnessUsage) turnUsage = addUsage(turnUsage, harnessUsage);
970
+ if (typeof msg.total_cost_usd === 'number') {
971
+ totalCostUsd = msg.total_cost_usd;
972
+ }
973
+ deferredToolUseId = msg.deferred_tool_use.id;
974
+ requestDeferredApproval({
975
+ toolUseId: msg.deferred_tool_use.id,
976
+ toolName: msg.deferred_tool_use.name,
977
+ toolInput: msg.deferred_tool_use.input,
978
+ });
979
+ continue;
980
+ }
981
+
723
982
  if (type === 'result') {
724
983
  if (msg.subtype === 'success') {
725
984
  // `success` does not mean the turn succeeded: the CLI flags a rejected
@@ -767,7 +1026,9 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
767
1026
  usage: streamEventState.pendingStepUsage ?? harnessUsage,
768
1027
  });
769
1028
  }
770
- if (!queryInput.answersSentMessage(msg)) continue;
1029
+ if (!resumingDeferredCall && !queryInput.answersSentMessage(msg)) {
1030
+ continue;
1031
+ }
771
1032
  queryInput.observeResult();
772
1033
  if (!queryInput.hasActiveUserMessages()) {
773
1034
  queryInput.close();
@@ -815,13 +1076,7 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
815
1076
  // through an interrupt or error path can otherwise leak that process —
816
1077
  // observed as orphaned `claude` processes holding the very conversation
817
1078
  // the next turn continues.
818
- try {
819
- await (q as { return?: (value?: unknown) => Promise<unknown> }).return?.(
820
- undefined,
821
- );
822
- } catch {
823
- // Best effort; the abort controller tears the process down otherwise.
824
- }
1079
+ await disposeQuery(q);
825
1080
  }
826
1081
 
827
1082
  if (emittedTerminalError) return;
@@ -864,6 +1119,8 @@ function createQueryInput({
864
1119
  hasActiveUserMessages(): boolean;
865
1120
  answersSentMessage(result: ClaudeMessage): boolean;
866
1121
  observeResult(): void;
1122
+ /** Stops the current query's input from taking more user messages. */
1123
+ retire(): void;
867
1124
  readonly hasObservedResult: boolean;
868
1125
  } {
869
1126
  let closed = false;
@@ -922,6 +1179,31 @@ function createQueryInput({
922
1179
  });
923
1180
 
924
1181
  const messageIterator = userMessages[Symbol.asyncIterator]();
1182
+ /*
1183
+ * A query that ends at a deferred call can still be waiting for the next
1184
+ * user message. Pull through one shared buffer and only hand a message to
1185
+ * the input of the live query, so the resumed query receives it.
1186
+ */
1187
+ let liveInputId = 0;
1188
+ let bufferedMessage:
1189
+ | IteratorResult<Experimental_BridgeUserMessage>
1190
+ | undefined;
1191
+ let pulling: Promise<void> | undefined;
1192
+ const takeMessage = async (inputId: number) => {
1193
+ for (;;) {
1194
+ if (inputId !== liveInputId) return undefined;
1195
+ if (bufferedMessage != null) {
1196
+ const message = bufferedMessage;
1197
+ bufferedMessage = undefined;
1198
+ return message;
1199
+ }
1200
+ pulling ??= messageIterator.next().then(result => {
1201
+ bufferedMessage = result;
1202
+ pulling = undefined;
1203
+ });
1204
+ await pulling;
1205
+ }
1206
+ };
925
1207
 
926
1208
  return {
927
1209
  close,
@@ -970,11 +1252,15 @@ function createQueryInput({
970
1252
  observeResult: () => {
971
1253
  observedResult = true;
972
1254
  },
1255
+ retire: () => {
1256
+ liveInputId++;
1257
+ },
973
1258
  get hasObservedResult() {
974
1259
  return observedResult;
975
1260
  },
976
1261
  input: {
977
1262
  [Symbol.asyncIterator]() {
1263
+ const inputId = ++liveInputId;
978
1264
  let sentInitial = false;
979
1265
  return {
980
1266
  async next() {
@@ -994,8 +1280,8 @@ function createQueryInput({
994
1280
  done: false,
995
1281
  };
996
1282
  }
997
- const nextMessage = await messageIterator.next();
998
- if (nextMessage.done) {
1283
+ const nextMessage = await takeMessage(inputId);
1284
+ if (nextMessage == null || nextMessage.done) {
999
1285
  return {
1000
1286
  value: undefined,
1001
1287
  done: true,
@@ -1021,6 +1307,40 @@ function createQueryInput({
1021
1307
  };
1022
1308
  }
1023
1309
 
1310
+ /*
1311
+ * Resuming a deferred call must not send a prompt: Claude Code would answer
1312
+ * it and drop the call. The stream stays open for steering messages.
1313
+ */
1314
+ function withoutInitialMessage(
1315
+ input: AsyncIterable<unknown>,
1316
+ ): AsyncIterable<unknown> {
1317
+ return {
1318
+ [Symbol.asyncIterator]() {
1319
+ const iterator = input[Symbol.asyncIterator]();
1320
+ let skipped = false;
1321
+ return {
1322
+ async next() {
1323
+ if (!skipped) {
1324
+ skipped = true;
1325
+ await iterator.next();
1326
+ }
1327
+ return iterator.next();
1328
+ },
1329
+ };
1330
+ },
1331
+ };
1332
+ }
1333
+
1334
+ async function disposeQuery(query: unknown): Promise<void> {
1335
+ try {
1336
+ await (
1337
+ query as { return?: (value?: unknown) => Promise<unknown> } | undefined
1338
+ )?.return?.(undefined);
1339
+ } catch {
1340
+ // Best effort; the abort controller tears the process down otherwise.
1341
+ }
1342
+ }
1343
+
1024
1344
  function addUsage(
1025
1345
  total: Record<string, unknown> | undefined,
1026
1346
  usage: Record<string, unknown>,
@@ -5,6 +5,15 @@ type JsonLiteral = string | number | boolean | null;
5
5
  export type JsonSchemaObject = {
6
6
  type?: string | string[];
7
7
  description?: string;
8
+ minimum?: number;
9
+ maximum?: number;
10
+ exclusiveMinimum?: number;
11
+ exclusiveMaximum?: number;
12
+ minLength?: number;
13
+ maxLength?: number;
14
+ minItems?: number;
15
+ maxItems?: number;
16
+ default?: unknown;
8
17
  properties?: Record<string, JsonSchemaObject>;
9
18
  additionalProperties?: boolean | JsonSchemaObject;
10
19
  required?: string[];
@@ -20,7 +29,7 @@ type ZodShape = Record<string, z.ZodTypeAny>;
20
29
 
21
30
  export function jsonSchemaToZodObject(input: unknown) {
22
31
  const schema = isJsonSchemaObject(input) ? input : {};
23
- return toZodObject(schema);
32
+ return withJsonSchemaMetadata(toZodObject(schema), schema);
24
33
  }
25
34
 
26
35
  function toZodObject(schema: JsonSchemaObject) {
@@ -61,7 +70,28 @@ function toZodType(schema: JsonSchemaObject | undefined): z.ZodTypeAny {
61
70
 
62
71
  if (isNullable(schema)) zType = zType.nullable();
63
72
  if (schema.description) zType = zType.describe(schema.description);
64
- return zType;
73
+ return withJsonSchemaMetadata(zType, schema);
74
+ }
75
+
76
+ function withJsonSchemaMetadata<Schema extends z.ZodTypeAny>(
77
+ zType: Schema,
78
+ schema: JsonSchemaObject,
79
+ ): Schema {
80
+ const metadata: Record<string, unknown> = {};
81
+ for (const key of [
82
+ 'minimum',
83
+ 'maximum',
84
+ 'exclusiveMinimum',
85
+ 'exclusiveMaximum',
86
+ 'minLength',
87
+ 'maxLength',
88
+ 'minItems',
89
+ 'maxItems',
90
+ 'default',
91
+ ] as const) {
92
+ if (schema[key] !== undefined) metadata[key] = schema[key];
93
+ }
94
+ return zType.meta(metadata);
65
95
  }
66
96
 
67
97
  function zodForType(schema: JsonSchemaObject): z.ZodTypeAny {
@@ -46,6 +46,15 @@ export const startMessageSchema = harnessV1BridgeStartBaseSchema.extend({
46
46
  env: z.record(z.string(), z.string()).optional(),
47
47
  skills: z.array(z.string()).optional(),
48
48
  mcpServers: z.record(z.string(), z.unknown()).optional(),
49
+ toolHints: z
50
+ .record(
51
+ z.string(),
52
+ z.object({
53
+ readOnly: z.boolean().optional(),
54
+ alwaysLoad: z.boolean().optional(),
55
+ }),
56
+ )
57
+ .optional(),
49
58
  // Resume signal. When true, the bridge passes `{ continue: true }` to the
50
59
  // Claude SDK so the in-workdir thread state is rehydrated. The host sets this
51
60
  // on the first prompt after a cross-process resume.
@@ -58,6 +67,10 @@ export const startMessageSchema = harnessV1BridgeStartBaseSchema.extend({
58
67
  // a resume that itself started a new thread. Mutually exclusive with
59
68
  // `continue` in the SDK, so the bridge sends one or the other.
60
69
  resumeSessionId: z.string().optional(),
70
+ // A tool call the previous bridge deferred while it waited for host
71
+ // approval. The bridge resumes `resumeSessionId` without a prompt once the
72
+ // host answers, so Claude Code runs that same call.
73
+ deferredToolUseId: z.string().optional(),
61
74
  });
62
75
 
63
76
  export type StartMessage = z.infer<typeof startMessageSchema>;