@ai-sdk/harness-claude-code 1.0.170 → 1.0.172

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.170",
3
+ "version": "1.0.172",
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.166",
29
+ "@ai-sdk/harness": "1.0.168",
30
30
  "@ai-sdk/provider": "4.0.34",
31
- "@ai-sdk/provider-utils": "5.0.70",
31
+ "@ai-sdk/provider-utils": "5.0.71",
32
32
  "ws": "^8.21.0"
33
33
  },
34
34
  "peerDependencies": {
@@ -70,7 +70,7 @@
70
70
  "build": "pnpm clean && tsdown && pnpm copy-bridge-assets",
71
71
  "build:watch": "pnpm clean && tsdown --watch",
72
72
  "clean": "del-cli *.tsbuildinfo",
73
- "copy-bridge-assets": "node -e \"import('node:fs/promises').then(async fs => { await fs.copyFile('src/bridge/package.json', 'dist/bridge/package.json'); await fs.copyFile('src/bridge/pnpm-lock.yaml', 'dist/bridge/pnpm-lock.yaml'); await fs.copyFile('src/bridge/pnpm-workspace.yaml', 'dist/bridge/pnpm-workspace.yaml'); })\"",
73
+ "copy-bridge-assets": "node -e \"import('node:fs/promises').then(async fs => { await fs.copyFile('src/bridge/package.json', 'dist/bridge/package.json'); await fs.copyFile('src/bridge/pnpm-lock.yaml', 'dist/bridge/pnpm-lockfile.yaml'); await fs.copyFile('src/bridge/pnpm-workspace.yaml', 'dist/bridge/pnpm-workspace.yaml'); })\"",
74
74
  "type-check": "tsc --build",
75
75
  "test": "pnpm test:node",
76
76
  "test:watch": "vitest --config vitest.node.config.js",
@@ -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,81 @@ function createPermissionSettings(input: {
366
373
  };
367
374
  }
368
375
 
376
+ function needsHostApproval(input: {
377
+ toolName: string;
378
+ permissionMode: 'allow-reads' | 'allow-edits' | 'allow-all';
379
+ inactiveNativeTools: ReadonlySet<string>;
380
+ }): boolean {
381
+ if (input.toolName.startsWith('mcp__harness-tools__')) return false;
382
+ return (
383
+ input.inactiveNativeTools.has(input.toolName) ||
384
+ nativeToolRequiresApproval({
385
+ nativeName: input.toolName,
386
+ permissionMode: input.permissionMode,
387
+ })
388
+ );
389
+ }
390
+
391
+ /*
392
+ * Defers a call that needs host approval, so Claude Code ends the turn and
393
+ * keeps the call in the transcript instead of holding it in this process.
394
+ * Resuming the session without a prompt fires the hook again for the same
395
+ * call, and the host's decision is applied then. This survives a bridge
396
+ * restart because nothing waits in memory.
397
+ */
398
+ function createApprovalPreToolUseHook(input: {
399
+ permissionMode: 'allow-reads' | 'allow-edits' | 'allow-all';
400
+ inactiveNativeTools: ReadonlySet<string>;
401
+ decisions: Map<string, { approved: boolean; reason?: string }>;
402
+ deferredToolUseIds: Set<string>;
403
+ nativeToolCallNames: Map<string, string>;
404
+ }): HookCallback {
405
+ return async (hookInput, toolUseID): Promise<HookJSONOutput> => {
406
+ const toolName = (hookInput as { tool_name?: unknown }).tool_name;
407
+ if (
408
+ toolUseID == null ||
409
+ typeof toolName !== 'string' ||
410
+ toolName === 'AskUserQuestion'
411
+ ) {
412
+ return {};
413
+ }
414
+ const decision = input.decisions.get(toolUseID);
415
+ if (decision != null) {
416
+ // A bridge that resumes another bridge's deferred call never saw its
417
+ // tool_use, so record the name for the tool result that follows.
418
+ input.nativeToolCallNames.set(toolUseID, toolName);
419
+ if (!decision.approved) input.decisions.delete(toolUseID);
420
+ return {
421
+ hookSpecificOutput: {
422
+ hookEventName: 'PreToolUse',
423
+ ...(decision.approved
424
+ ? { permissionDecision: 'allow' }
425
+ : {
426
+ permissionDecision: 'deny',
427
+ permissionDecisionReason: decision.reason ?? 'Denied',
428
+ }),
429
+ },
430
+ };
431
+ }
432
+ if (
433
+ !needsHostApproval({
434
+ toolName,
435
+ permissionMode: input.permissionMode,
436
+ inactiveNativeTools: input.inactiveNativeTools,
437
+ })
438
+ ) {
439
+ return {};
440
+ }
441
+ input.deferredToolUseIds.add(toolUseID);
442
+ return {
443
+ hookSpecificOutput: {
444
+ hookEventName: 'PreToolUse',
445
+ permissionDecision: 'defer',
446
+ },
447
+ };
448
+ };
449
+ }
450
+
369
451
  function nativeToolRequiresApproval(input: {
370
452
  nativeName: string;
371
453
  permissionMode: 'allow-reads' | 'allow-edits' | 'allow-all';
@@ -414,11 +496,18 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
414
496
  version: '1.0.0',
415
497
  });
416
498
  for (const tool of start.tools) {
499
+ const hints = start.toolHints?.[tool.name];
417
500
  server.registerTool(
418
501
  tool.name,
419
502
  {
420
503
  description: tool.description ?? '',
421
504
  inputSchema: jsonSchemaToZodObject(tool.inputSchema),
505
+ ...(hints?.readOnly == null
506
+ ? {}
507
+ : { annotations: { readOnlyHint: hints.readOnly } }),
508
+ ...(hints?.alwaysLoad == null
509
+ ? {}
510
+ : { _meta: { 'anthropic/alwaysLoad': hints.alwaysLoad } }),
422
511
  },
423
512
  async (
424
513
  ...handlerArgs: [
@@ -542,7 +631,13 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
542
631
  const inactiveNativeTools = resolveInactiveNativeTools(
543
632
  start.builtinToolFiltering,
544
633
  );
634
+ const permissionMode = start.permissionMode ?? 'allow-all';
635
+ const approvalDecisions = new Map<
636
+ string,
637
+ { approved: boolean; reason?: string }
638
+ >();
545
639
  const permissionOptions = createPermissionOptions({
640
+ approvalDecisions,
546
641
  start,
547
642
  inactiveNativeTools,
548
643
  turn,
@@ -555,6 +650,14 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
555
650
  approvalRequestedToolUseIds: streamEventState.approvalRequestedToolUseIds,
556
651
  emittedToolCallIds: streamEventState.emittedToolCallIds,
557
652
  });
653
+ const deferredToolUseIds = new Set<string>();
654
+ const approvalPreToolUseHook = createApprovalPreToolUseHook({
655
+ permissionMode,
656
+ inactiveNativeTools: new Set(inactiveNativeTools),
657
+ decisions: approvalDecisions,
658
+ deferredToolUseIds,
659
+ nativeToolCallNames: streamEventState.nativeToolCallNames,
660
+ });
558
661
  const questionPreToolUseHook = createQuestionPreToolUseHook({
559
662
  turn,
560
663
  emit,
@@ -565,7 +668,7 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
565
668
  clearWaitingOnHostInput({ state: streamEventState, toolUseId }),
566
669
  });
567
670
 
568
- const q = claudeSdk.query({
671
+ const queryArgs = {
569
672
  prompt: queryInput.input,
570
673
  options: {
571
674
  ...(start.model ? { model: start.model } : {}),
@@ -608,6 +711,9 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
608
711
  // `compaction` event; return an empty output so compaction proceeds.
609
712
  hooks: {
610
713
  PreToolUse: [
714
+ ...(permissionMode === 'allow-all'
715
+ ? []
716
+ : [{ hooks: [approvalPreToolUseHook] }]),
611
717
  {
612
718
  matcher: 'AskUserQuestion',
613
719
  hooks: [questionPreToolUseHook],
@@ -652,6 +758,15 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
652
758
  cwd: workdir,
653
759
  abortSignal: abortCtl.signal,
654
760
  },
761
+ };
762
+ let deferredToolUseId = start.deferredToolUseId;
763
+ let resumingDeferredCall = false;
764
+ let q = deferredToolUseId == null ? claudeSdk.query(queryArgs) : undefined;
765
+ const hostAborted = new Promise<undefined>(resolve => {
766
+ if (abortCtl.signal.aborted) resolve(undefined);
767
+ abortCtl.signal.addEventListener('abort', () => resolve(undefined), {
768
+ once: true,
769
+ });
655
770
  });
656
771
 
657
772
  gracefulAbort = () => {
@@ -663,11 +778,68 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
663
778
  hardAbortTimer = setTimeout(() => abortCtl.abort(), 5000);
664
779
  hardAbortTimer.unref?.();
665
780
  void Promise.resolve()
666
- .then(() => q.interrupt())
781
+ .then(() => q?.interrupt())
667
782
  .catch(() => abortCtl.abort());
668
783
  };
669
784
  let turnUsage: Record<string, unknown> | undefined;
670
785
  let totalCostUsd: number | undefined;
786
+
787
+ /*
788
+ * Claude Code ended the turn at a call that needs host approval. Every other
789
+ * call the hook deferred in the same message was dropped by Claude Code,
790
+ * which can only resume one deferred call, so report those as not run.
791
+ */
792
+ let deferredDecision:
793
+ | Promise<{ approved: boolean; reason?: string }>
794
+ | undefined;
795
+ const requestDeferredApproval = (call: {
796
+ toolUseId: string;
797
+ toolName: string;
798
+ toolInput: Record<string, unknown>;
799
+ }) => {
800
+ for (const droppedToolUseId of deferredToolUseIds) {
801
+ if (droppedToolUseId === call.toolUseId) continue;
802
+ const nativeName =
803
+ streamEventState.nativeToolCallNames.get(droppedToolUseId) ?? 'unknown';
804
+ emit({
805
+ type: 'tool-result',
806
+ toolCallId: droppedToolUseId,
807
+ toolName: toCommonName(nativeName),
808
+ result:
809
+ 'Claude Code can wait for only one approval at a time, so this call did not run.',
810
+ isError: true,
811
+ });
812
+ streamEventState.pendingStepToolUseIds.delete(droppedToolUseId);
813
+ }
814
+ deferredToolUseIds.clear();
815
+ streamEventState.approvalRequestedToolUseIds.add(call.toolUseId);
816
+ streamEventState.nativeToolCallNames.set(call.toolUseId, call.toolName);
817
+ if (!streamEventState.emittedToolCallIds.has(call.toolUseId)) {
818
+ streamEventState.emittedToolCallIds.add(call.toolUseId);
819
+ emit({
820
+ type: 'tool-call',
821
+ toolCallId: call.toolUseId,
822
+ toolName: toCommonName(call.toolName),
823
+ nativeName: call.toolName,
824
+ input: JSON.stringify(call.toolInput ?? {}),
825
+ providerExecuted: true,
826
+ ...(isExternalMcpTool(call.toolName) ? { dynamic: true } : {}),
827
+ });
828
+ }
829
+ // Register before asking, so an answer that arrives while the deferred
830
+ // query is still being disposed is not missed.
831
+ deferredDecision = turn.requestToolApproval(call.toolUseId);
832
+ emit({
833
+ type: 'tool-approval-request',
834
+ approvalId: call.toolUseId,
835
+ toolCallId: call.toolUseId,
836
+ });
837
+ markWaitingOnHostInput({
838
+ state: streamEventState,
839
+ emit,
840
+ toolUseId: call.toolUseId,
841
+ });
842
+ };
671
843
  let emittedTerminalError = false;
672
844
  let emittedTerminalFinish = false;
673
845
 
@@ -700,8 +872,48 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
700
872
  toCommonName,
701
873
  });
702
874
 
875
+ /*
876
+ * Yields the messages of every query in this turn. When Claude Code defers a
877
+ * call for host approval, wait for the decision and resume the session
878
+ * without a prompt so the same call runs with that decision.
879
+ */
880
+ async function* messages(): AsyncGenerator<ClaudeMessage> {
881
+ for (;;) {
882
+ if (deferredToolUseId != null) {
883
+ const toolUseId = deferredToolUseId;
884
+ const decision = await Promise.race([
885
+ deferredDecision ?? turn.requestToolApproval(toolUseId),
886
+ hostAborted,
887
+ ]).finally(() =>
888
+ clearWaitingOnHostInput({ state: streamEventState, toolUseId }),
889
+ );
890
+ deferredDecision = undefined;
891
+ if (decision == null || abortCtl.signal.aborted) return;
892
+ approvalDecisions.set(toolUseId, decision);
893
+ deferredToolUseId = undefined;
894
+ resumingDeferredCall = true;
895
+ q = claudeSdk.query({
896
+ prompt: withoutInitialMessage(queryInput.input),
897
+ options: {
898
+ ...queryArgs.options,
899
+ ...(lastClaudeSessionId != null
900
+ ? { continue: undefined, resume: lastClaudeSessionId }
901
+ : {}),
902
+ },
903
+ });
904
+ }
905
+ for await (const msg of q as AsyncIterable<ClaudeMessage>) {
906
+ yield msg;
907
+ if (deferredToolUseId != null) break;
908
+ }
909
+ if (deferredToolUseId == null || abortCtl.signal.aborted) return;
910
+ queryInput.retire();
911
+ await disposeQuery(q);
912
+ }
913
+ }
914
+
703
915
  try {
704
- for await (const msg of q as AsyncIterable<ClaudeMessage>) {
916
+ for await (const msg of messages()) {
705
917
  if (abortCtl.signal.aborted) break;
706
918
 
707
919
  const type = msg.type;
@@ -720,6 +932,25 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
720
932
 
721
933
  emitStreamEvent(msg);
722
934
 
935
+ if (
936
+ type === 'result' &&
937
+ msg.terminal_reason === 'tool_deferred' &&
938
+ msg.deferred_tool_use?.id != null
939
+ ) {
940
+ const harnessUsage = mapUsage(msg.usage ?? msg.message?.usage);
941
+ if (harnessUsage) turnUsage = addUsage(turnUsage, harnessUsage);
942
+ if (typeof msg.total_cost_usd === 'number') {
943
+ totalCostUsd = msg.total_cost_usd;
944
+ }
945
+ deferredToolUseId = msg.deferred_tool_use.id;
946
+ requestDeferredApproval({
947
+ toolUseId: msg.deferred_tool_use.id,
948
+ toolName: msg.deferred_tool_use.name,
949
+ toolInput: msg.deferred_tool_use.input,
950
+ });
951
+ continue;
952
+ }
953
+
723
954
  if (type === 'result') {
724
955
  if (msg.subtype === 'success') {
725
956
  // `success` does not mean the turn succeeded: the CLI flags a rejected
@@ -767,7 +998,9 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
767
998
  usage: streamEventState.pendingStepUsage ?? harnessUsage,
768
999
  });
769
1000
  }
770
- if (!queryInput.answersSentMessage(msg)) continue;
1001
+ if (!resumingDeferredCall && !queryInput.answersSentMessage(msg)) {
1002
+ continue;
1003
+ }
771
1004
  queryInput.observeResult();
772
1005
  if (!queryInput.hasActiveUserMessages()) {
773
1006
  queryInput.close();
@@ -815,13 +1048,7 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
815
1048
  // through an interrupt or error path can otherwise leak that process —
816
1049
  // observed as orphaned `claude` processes holding the very conversation
817
1050
  // 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
- }
1051
+ await disposeQuery(q);
825
1052
  }
826
1053
 
827
1054
  if (emittedTerminalError) return;
@@ -864,6 +1091,8 @@ function createQueryInput({
864
1091
  hasActiveUserMessages(): boolean;
865
1092
  answersSentMessage(result: ClaudeMessage): boolean;
866
1093
  observeResult(): void;
1094
+ /** Stops the current query's input from taking more user messages. */
1095
+ retire(): void;
867
1096
  readonly hasObservedResult: boolean;
868
1097
  } {
869
1098
  let closed = false;
@@ -922,6 +1151,31 @@ function createQueryInput({
922
1151
  });
923
1152
 
924
1153
  const messageIterator = userMessages[Symbol.asyncIterator]();
1154
+ /*
1155
+ * A query that ends at a deferred call can still be waiting for the next
1156
+ * user message. Pull through one shared buffer and only hand a message to
1157
+ * the input of the live query, so the resumed query receives it.
1158
+ */
1159
+ let liveInputId = 0;
1160
+ let bufferedMessage:
1161
+ | IteratorResult<Experimental_BridgeUserMessage>
1162
+ | undefined;
1163
+ let pulling: Promise<void> | undefined;
1164
+ const takeMessage = async (inputId: number) => {
1165
+ for (;;) {
1166
+ if (inputId !== liveInputId) return undefined;
1167
+ if (bufferedMessage != null) {
1168
+ const message = bufferedMessage;
1169
+ bufferedMessage = undefined;
1170
+ return message;
1171
+ }
1172
+ pulling ??= messageIterator.next().then(result => {
1173
+ bufferedMessage = result;
1174
+ pulling = undefined;
1175
+ });
1176
+ await pulling;
1177
+ }
1178
+ };
925
1179
 
926
1180
  return {
927
1181
  close,
@@ -970,11 +1224,15 @@ function createQueryInput({
970
1224
  observeResult: () => {
971
1225
  observedResult = true;
972
1226
  },
1227
+ retire: () => {
1228
+ liveInputId++;
1229
+ },
973
1230
  get hasObservedResult() {
974
1231
  return observedResult;
975
1232
  },
976
1233
  input: {
977
1234
  [Symbol.asyncIterator]() {
1235
+ const inputId = ++liveInputId;
978
1236
  let sentInitial = false;
979
1237
  return {
980
1238
  async next() {
@@ -994,8 +1252,8 @@ function createQueryInput({
994
1252
  done: false,
995
1253
  };
996
1254
  }
997
- const nextMessage = await messageIterator.next();
998
- if (nextMessage.done) {
1255
+ const nextMessage = await takeMessage(inputId);
1256
+ if (nextMessage == null || nextMessage.done) {
999
1257
  return {
1000
1258
  value: undefined,
1001
1259
  done: true,
@@ -1021,6 +1279,40 @@ function createQueryInput({
1021
1279
  };
1022
1280
  }
1023
1281
 
1282
+ /*
1283
+ * Resuming a deferred call must not send a prompt: Claude Code would answer
1284
+ * it and drop the call. The stream stays open for steering messages.
1285
+ */
1286
+ function withoutInitialMessage(
1287
+ input: AsyncIterable<unknown>,
1288
+ ): AsyncIterable<unknown> {
1289
+ return {
1290
+ [Symbol.asyncIterator]() {
1291
+ const iterator = input[Symbol.asyncIterator]();
1292
+ let skipped = false;
1293
+ return {
1294
+ async next() {
1295
+ if (!skipped) {
1296
+ skipped = true;
1297
+ await iterator.next();
1298
+ }
1299
+ return iterator.next();
1300
+ },
1301
+ };
1302
+ },
1303
+ };
1304
+ }
1305
+
1306
+ async function disposeQuery(query: unknown): Promise<void> {
1307
+ try {
1308
+ await (
1309
+ query as { return?: (value?: unknown) => Promise<unknown> } | undefined
1310
+ )?.return?.(undefined);
1311
+ } catch {
1312
+ // Best effort; the abort controller tears the process down otherwise.
1313
+ }
1314
+ }
1315
+
1024
1316
  function addUsage(
1025
1317
  total: Record<string, unknown> | undefined,
1026
1318
  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 {
@@ -4,10 +4,12 @@ import { createReadBridgeAsset } from '@ai-sdk/harness/utils';
4
4
  /*
5
5
  * Keep every asset URL literal so bundlers can emit each file separately.
6
6
  * Dynamic new URL() paths can collapse multiple assets into one resolution.
7
+ * The lockfile is copied as `pnpm-lockfile.yaml` in `dist/` because `pnpm pack`
8
+ * unconditionally excludes files named `pnpm-lock.yaml` at any directory depth.
7
9
  */
8
10
  const readBridgeAsset = createReadBridgeAsset({
9
11
  'package.json': new URL('./bridge/package.json', import.meta.url),
10
- 'pnpm-lock.yaml': new URL('./bridge/pnpm-lock.yaml', import.meta.url),
12
+ 'pnpm-lock.yaml': new URL('./bridge/pnpm-lockfile.yaml', import.meta.url),
11
13
  'pnpm-workspace.yaml': new URL(
12
14
  './bridge/pnpm-workspace.yaml',
13
15
  import.meta.url,
@@ -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>;