@ai-sdk/harness-claude-code 1.0.171 → 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/CHANGELOG.md +15 -0
- package/dist/bridge/index.mjs +280 -77
- package/dist/bridge/index.mjs.map +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -4
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/bridge/create-emit-stream-event.ts +6 -0
- package/src/bridge/index.ts +313 -21
- package/src/bridge/json-schema-to-zod.ts +32 -2
- package/src/claude-code-bridge-protocol.ts +13 -0
- package/src/claude-code-harness.ts +40 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-sdk/harness-claude-code",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
29
|
+
"@ai-sdk/harness": "1.0.168",
|
|
30
30
|
"@ai-sdk/provider": "4.0.34",
|
|
31
|
-
"@ai-sdk/provider-utils": "5.0.
|
|
31
|
+
"@ai-sdk/provider-utils": "5.0.71",
|
|
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 = {
|
package/src/bridge/index.ts
CHANGED
|
@@ -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
|
-
!
|
|
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
|
|
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
|
|
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
|
|
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))
|
|
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
|
-
|
|
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
|
|
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 {
|
|
@@ -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>;
|