@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/CHANGELOG.md +27 -0
- package/dist/bridge/index.mjs +297 -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 +4 -4
- package/src/bridge/create-emit-stream-event.ts +6 -0
- package/src/bridge/index.ts +341 -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.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.
|
|
30
|
-
"@ai-sdk/provider": "4.0.
|
|
31
|
-
"@ai-sdk/provider-utils": "5.0.
|
|
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 = {
|
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,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
|
|
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
|
|
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
|
|
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))
|
|
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
|
-
|
|
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
|
|
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>;
|