@ai-sdk/harness-claude-code 1.0.96 → 1.0.98
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 +28 -0
- package/dist/bridge/index.mjs +137 -27
- package/dist/bridge/index.mjs.map +1 -1
- package/dist/bridge/package.json +3 -3
- package/dist/bridge/pnpm-lock.yaml +128 -126
- package/dist/bridge/pnpm-workspace.yaml +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +74 -32
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/bridge/create-emit-stream-event.ts +74 -13
- package/src/bridge/index.ts +134 -20
- package/src/bridge/package.json +3 -3
- package/src/bridge/pnpm-lock.yaml +128 -126
- package/src/bridge/pnpm-workspace.yaml +1 -1
- package/src/claude-code-bridge-protocol.ts +8 -0
- package/src/claude-code-harness.ts +116 -41
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# @ai-sdk/harness-claude-code
|
|
2
2
|
|
|
3
|
+
## 1.0.98
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e79bc7a: fix(harness-claude-code): resume the exact conversation instead of the most recent one in the working directory
|
|
8
|
+
- 8961fde: feat(harness): allow changing `model` between turns via call options
|
|
9
|
+
- eb59f2a: fix(harness): ensure harness adapters can stream tool input deltas before the complete tool call arrives
|
|
10
|
+
- Updated dependencies [8961fde]
|
|
11
|
+
- Updated dependencies [eb59f2a]
|
|
12
|
+
- @ai-sdk/harness@1.0.94
|
|
13
|
+
- @ai-sdk/provider-utils@5.0.34
|
|
14
|
+
|
|
15
|
+
## 1.0.97
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- cc9f6ce: fix(harness): stop diagnosing caller-initiated aborts as bridge errors, and serialize bridge turns so a start racing an aborted turn's teardown no longer overlaps it (bounded by a teardown grace period, after which the start proceeds as before)
|
|
20
|
+
- 7608210: feat(harness): add `model` parameter to `HarnessAgent` instead of having each harness adapter support it on their own constructor functions
|
|
21
|
+
- f7bd978: chore(harness): update underlying adapter SDKs to their latest versions
|
|
22
|
+
- 14d4fc0: feat(harness): allow changing harness settings between turns via `prepareCall()` support on `HarnessAgent`
|
|
23
|
+
- Updated dependencies [cc9f6ce]
|
|
24
|
+
- Updated dependencies [7608210]
|
|
25
|
+
- Updated dependencies [6f8a2d7]
|
|
26
|
+
- Updated dependencies [14d4fc0]
|
|
27
|
+
- Updated dependencies [90192f1]
|
|
28
|
+
- @ai-sdk/harness@1.0.93
|
|
29
|
+
- @ai-sdk/provider-utils@5.0.33
|
|
30
|
+
|
|
3
31
|
## 1.0.96
|
|
4
32
|
|
|
5
33
|
### Patch Changes
|
package/dist/bridge/index.mjs
CHANGED
|
@@ -141,6 +141,7 @@ var ENV_TRUTHY = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
|
|
|
141
141
|
var WS_OPEN = 1;
|
|
142
142
|
async function runBridge(options) {
|
|
143
143
|
const { bridgeType, bridgeStateDir: bridgeStateDir2, onStart, onStop, onDestroy } = options;
|
|
144
|
+
const teardownGraceMs = options.turnTeardownGraceMs ?? 1e4;
|
|
144
145
|
const expectedToken = options.token ?? procEnv.BRIDGE_CHANNEL_TOKEN ?? "";
|
|
145
146
|
const bridgeWsPort = options.port ?? parseInt(procEnv.BRIDGE_WS_PORT ?? "0", 10);
|
|
146
147
|
const bridgeMetaPath = `${bridgeStateDir2}/bridge-meta.json`;
|
|
@@ -157,6 +158,7 @@ async function runBridge(options) {
|
|
|
157
158
|
let isFirstTurn = true;
|
|
158
159
|
let turnAbort;
|
|
159
160
|
let currentUserMessages;
|
|
161
|
+
let activeTurn;
|
|
160
162
|
let debugConfig;
|
|
161
163
|
let consoleCaptureInstalled = false;
|
|
162
164
|
const envDebugEnabled = ENV_TRUTHY.has(
|
|
@@ -347,10 +349,28 @@ async function runBridge(options) {
|
|
|
347
349
|
const handleInbound = async (msg, ws) => {
|
|
348
350
|
switch (msg.type) {
|
|
349
351
|
case "start": {
|
|
352
|
+
for (; ; ) {
|
|
353
|
+
const pendingTurn = activeTurn;
|
|
354
|
+
if (pendingTurn == null) break;
|
|
355
|
+
turnAbort?.abort();
|
|
356
|
+
currentUserMessages?.close(
|
|
357
|
+
new Error("A new bridge turn replaced the active turn.")
|
|
358
|
+
);
|
|
359
|
+
let graceTimer;
|
|
360
|
+
const settled = await Promise.race([
|
|
361
|
+
pendingTurn.then(() => true),
|
|
362
|
+
new Promise((resolve) => {
|
|
363
|
+
graceTimer = setTimeout(() => resolve(false), teardownGraceMs);
|
|
364
|
+
graceTimer.unref?.();
|
|
365
|
+
})
|
|
366
|
+
]);
|
|
367
|
+
clearTimeout(graceTimer);
|
|
368
|
+
if (!settled) break;
|
|
369
|
+
}
|
|
370
|
+
let turnFinished;
|
|
371
|
+
const thisTurn = new Promise((resolve) => turnFinished = resolve);
|
|
372
|
+
activeTurn = thisTurn;
|
|
350
373
|
activeSocket = ws;
|
|
351
|
-
currentUserMessages?.close(
|
|
352
|
-
new Error("A new bridge turn replaced the active turn.")
|
|
353
|
-
);
|
|
354
374
|
const firstTurn = isFirstTurn;
|
|
355
375
|
isFirstTurn = false;
|
|
356
376
|
eventLog = [];
|
|
@@ -407,8 +427,12 @@ async function runBridge(options) {
|
|
|
407
427
|
if (currentUserMessages === userMessages) {
|
|
408
428
|
currentUserMessages = void 0;
|
|
409
429
|
}
|
|
410
|
-
|
|
411
|
-
|
|
430
|
+
if (activeTurn === thisTurn) {
|
|
431
|
+
activeTurn = void 0;
|
|
432
|
+
currentTurnState = "waiting";
|
|
433
|
+
void writeBridgeMeta("waiting");
|
|
434
|
+
}
|
|
435
|
+
turnFinished();
|
|
412
436
|
}
|
|
413
437
|
return;
|
|
414
438
|
}
|
|
@@ -711,7 +735,12 @@ function createEmitStreamEvent({
|
|
|
711
735
|
return;
|
|
712
736
|
}
|
|
713
737
|
if (type === "stream_event") {
|
|
714
|
-
handleStreamEvent(
|
|
738
|
+
handleStreamEvent({
|
|
739
|
+
event: msg.event,
|
|
740
|
+
state,
|
|
741
|
+
send: emit,
|
|
742
|
+
toCommonName: toCommonName2
|
|
743
|
+
});
|
|
715
744
|
return;
|
|
716
745
|
}
|
|
717
746
|
if (type === "assistant" && msg.message?.content) {
|
|
@@ -839,9 +868,16 @@ function formatApiRetryWarning(msg) {
|
|
|
839
868
|
if (msg.error) details.push(msg.error);
|
|
840
869
|
return details.length > 0 ? `Claude Code API retry: ${details.join("; ")}` : "Claude Code API retry";
|
|
841
870
|
}
|
|
842
|
-
|
|
871
|
+
var HOST_TOOL_PREFIX = "mcp__harness-tools__";
|
|
872
|
+
function handleStreamEvent({
|
|
873
|
+
event,
|
|
874
|
+
state,
|
|
875
|
+
send,
|
|
876
|
+
toCommonName: toCommonName2
|
|
877
|
+
}) {
|
|
843
878
|
if (!event || typeof event.index !== "number") return;
|
|
844
879
|
const index = event.index;
|
|
880
|
+
const partialBlocks = state.partialBlocks;
|
|
845
881
|
if (event.type === "content_block_start") {
|
|
846
882
|
const blockType = event.content_block?.type;
|
|
847
883
|
if (blockType === "text") {
|
|
@@ -852,6 +888,22 @@ function handleStreamEvent(event, partialBlocks, send) {
|
|
|
852
888
|
const id = randomUUID2();
|
|
853
889
|
partialBlocks.set(index, { id, kind: "thinking" });
|
|
854
890
|
send({ type: "reasoning-start", id });
|
|
891
|
+
} else if (blockType === "tool_use" && typeof event.content_block?.id === "string" && typeof event.content_block.name === "string") {
|
|
892
|
+
const id = event.content_block.id;
|
|
893
|
+
const nativeName = event.content_block.name;
|
|
894
|
+
if (nativeName === "StructuredOutput") {
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const hostToolName = nativeName.startsWith(HOST_TOOL_PREFIX) ? nativeName.slice(HOST_TOOL_PREFIX.length) : void 0;
|
|
898
|
+
const dynamic = hostToolName === void 0 && nativeName.startsWith("mcp__");
|
|
899
|
+
partialBlocks.set(index, { id, kind: "tool-input" });
|
|
900
|
+
send({
|
|
901
|
+
type: "tool-input-start",
|
|
902
|
+
id,
|
|
903
|
+
toolName: hostToolName ?? toCommonName2(nativeName),
|
|
904
|
+
providerExecuted: hostToolName === void 0,
|
|
905
|
+
...dynamic ? { dynamic: true } : {}
|
|
906
|
+
});
|
|
855
907
|
}
|
|
856
908
|
return;
|
|
857
909
|
}
|
|
@@ -866,6 +918,12 @@ function handleStreamEvent(event, partialBlocks, send) {
|
|
|
866
918
|
id: block.id,
|
|
867
919
|
delta: event.delta.thinking
|
|
868
920
|
});
|
|
921
|
+
} else if (block.kind === "tool-input" && event.delta?.type === "input_json_delta" && typeof event.delta.partial_json === "string") {
|
|
922
|
+
send({
|
|
923
|
+
type: "tool-input-delta",
|
|
924
|
+
id: block.id,
|
|
925
|
+
delta: event.delta.partial_json
|
|
926
|
+
});
|
|
869
927
|
}
|
|
870
928
|
return;
|
|
871
929
|
}
|
|
@@ -875,8 +933,10 @@ function handleStreamEvent(event, partialBlocks, send) {
|
|
|
875
933
|
partialBlocks.delete(index);
|
|
876
934
|
if (block.kind === "text") {
|
|
877
935
|
send({ type: "text-end", id: block.id });
|
|
878
|
-
} else {
|
|
936
|
+
} else if (block.kind === "thinking") {
|
|
879
937
|
send({ type: "reasoning-end", id: block.id });
|
|
938
|
+
} else {
|
|
939
|
+
send({ type: "tool-input-end", id: block.id });
|
|
880
940
|
}
|
|
881
941
|
}
|
|
882
942
|
}
|
|
@@ -1102,13 +1162,15 @@ if (!bridgeStateDir) {
|
|
|
1102
1162
|
}
|
|
1103
1163
|
var claudeSdk = claudeAgentSdk;
|
|
1104
1164
|
var mcpModule = mcpServerModule;
|
|
1165
|
+
var lastClaudeSessionId;
|
|
1105
1166
|
await runBridge({
|
|
1106
1167
|
bridgeType: "claude-code",
|
|
1107
1168
|
bridgeStateDir,
|
|
1108
1169
|
onStart: runTurn,
|
|
1109
|
-
// Claude Code's
|
|
1110
|
-
//
|
|
1111
|
-
|
|
1170
|
+
// Claude Code's conversation state lives in the runtime's own store, keyed
|
|
1171
|
+
// by working directory. The resume payload names the exact conversation so a
|
|
1172
|
+
// later resume does not have to fall back to "most recent in this workdir".
|
|
1173
|
+
onStop: () => lastClaudeSessionId == null ? {} : { claudeSessionId: lastClaudeSessionId }
|
|
1112
1174
|
});
|
|
1113
1175
|
function createPermissionOptions(input) {
|
|
1114
1176
|
const permissionMode = input.start.permissionMode ?? "allow-all";
|
|
@@ -1185,12 +1247,19 @@ function nativeToolRequiresApproval(input) {
|
|
|
1185
1247
|
async function runTurn(start, turn) {
|
|
1186
1248
|
const emit = (msg) => turn.emit(msg);
|
|
1187
1249
|
const abortCtl = new AbortController();
|
|
1250
|
+
let gracefulAbort;
|
|
1251
|
+
let hardAbortTimer;
|
|
1252
|
+
const onHostAbort = () => {
|
|
1253
|
+
if (gracefulAbort) {
|
|
1254
|
+
gracefulAbort();
|
|
1255
|
+
} else {
|
|
1256
|
+
abortCtl.abort();
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1188
1259
|
if (turn.abortSignal.aborted) {
|
|
1189
1260
|
abortCtl.abort();
|
|
1190
1261
|
} else {
|
|
1191
|
-
turn.abortSignal.addEventListener("abort",
|
|
1192
|
-
once: true
|
|
1193
|
-
});
|
|
1262
|
+
turn.abortSignal.addEventListener("abort", onHostAbort, { once: true });
|
|
1194
1263
|
}
|
|
1195
1264
|
const streamEventState = createClaudeStreamEventState();
|
|
1196
1265
|
const mcpServers = { ...start.mcpServers ?? {} };
|
|
@@ -1205,8 +1274,10 @@ async function runTurn(start, turn) {
|
|
|
1205
1274
|
tool.name,
|
|
1206
1275
|
tool.description ?? "",
|
|
1207
1276
|
shape,
|
|
1208
|
-
async (
|
|
1209
|
-
const
|
|
1277
|
+
async (...handlerArgs) => {
|
|
1278
|
+
const [input, extra] = handlerArgs;
|
|
1279
|
+
const metadataToolCallId = extra._meta?.["claudecode/toolUseId"];
|
|
1280
|
+
const toolCallId = typeof metadataToolCallId === "string" ? metadataToolCallId : randomUUID3();
|
|
1210
1281
|
emit({
|
|
1211
1282
|
type: "tool-call",
|
|
1212
1283
|
toolCallId,
|
|
@@ -1293,17 +1364,31 @@ async function runTurn(start, turn) {
|
|
|
1293
1364
|
}
|
|
1294
1365
|
]
|
|
1295
1366
|
},
|
|
1296
|
-
// Continuation rule
|
|
1297
|
-
//
|
|
1298
|
-
//
|
|
1299
|
-
//
|
|
1300
|
-
|
|
1367
|
+
// Continuation rule, most specific first.
|
|
1368
|
+
//
|
|
1369
|
+
// `resumeSessionId` names the exact conversation and is what a
|
|
1370
|
+
// cross-process resume should use: `continue` means "most recent thread
|
|
1371
|
+
// in this workdir", which silently picks the wrong one once anything
|
|
1372
|
+
// else has run there. The bridge also retains the id observed during its
|
|
1373
|
+
// previous query, so every later query stays pinned to that conversation
|
|
1374
|
+
// even when the host detached and reattached between turns. `resume` and
|
|
1375
|
+
// `continue` are mutually exclusive in the SDK.
|
|
1376
|
+
//
|
|
1377
|
+
// Otherwise the host can force-continue by setting `start.continue`,
|
|
1378
|
+
// and turns after the first fall back to the legacy cwd-based behavior
|
|
1379
|
+
// when no exact id was observed.
|
|
1380
|
+
...start.resumeSessionId ?? lastClaudeSessionId ? { resume: start.resumeSessionId ?? lastClaudeSessionId } : start.continue === true || !turn.firstTurn ? { continue: true } : {},
|
|
1301
1381
|
...permissionOptions,
|
|
1302
1382
|
mcpServers,
|
|
1303
1383
|
cwd: workdir,
|
|
1304
1384
|
abortSignal: abortCtl.signal
|
|
1305
1385
|
}
|
|
1306
1386
|
});
|
|
1387
|
+
gracefulAbort = () => {
|
|
1388
|
+
hardAbortTimer = setTimeout(() => abortCtl.abort(), 5e3);
|
|
1389
|
+
hardAbortTimer.unref?.();
|
|
1390
|
+
void Promise.resolve().then(() => q.interrupt()).catch(() => abortCtl.abort());
|
|
1391
|
+
};
|
|
1307
1392
|
let turnUsage;
|
|
1308
1393
|
let totalCostUsd;
|
|
1309
1394
|
let emittedTerminalError = false;
|
|
@@ -1313,10 +1398,12 @@ async function runTurn(start, turn) {
|
|
|
1313
1398
|
if (!normalized || emittedTerminalError || emittedTerminalFinish) return;
|
|
1314
1399
|
streamEventState.observedTerminalError = normalized;
|
|
1315
1400
|
emittedTerminalError = true;
|
|
1316
|
-
turn.
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1401
|
+
if (!turn.abortSignal.aborted) {
|
|
1402
|
+
turn.emitError({
|
|
1403
|
+
error: normalized,
|
|
1404
|
+
message: "claude-code terminal error"
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1320
1407
|
queryInput.close();
|
|
1321
1408
|
abortCtl.abort();
|
|
1322
1409
|
};
|
|
@@ -1335,6 +1422,10 @@ async function runTurn(start, turn) {
|
|
|
1335
1422
|
if (type === "command_lifecycle") {
|
|
1336
1423
|
queryInput.handleLifecycle(msg);
|
|
1337
1424
|
}
|
|
1425
|
+
const sessionId = msg.session_id;
|
|
1426
|
+
if (typeof sessionId === "string" && sessionId.length > 0) {
|
|
1427
|
+
lastClaudeSessionId = sessionId;
|
|
1428
|
+
}
|
|
1338
1429
|
emitStreamEvent(msg);
|
|
1339
1430
|
if (type === "result") {
|
|
1340
1431
|
if (msg.subtype === "success") {
|
|
@@ -1385,12 +1476,21 @@ async function runTurn(start, turn) {
|
|
|
1385
1476
|
}
|
|
1386
1477
|
}
|
|
1387
1478
|
} catch (err) {
|
|
1388
|
-
if (!(abortCtl.signal.aborted && emittedTerminalError)) {
|
|
1479
|
+
if (!turn.abortSignal.aborted && !(abortCtl.signal.aborted && emittedTerminalError)) {
|
|
1389
1480
|
turn.emitError({ error: err, message: "claude-code turn failed" });
|
|
1390
1481
|
}
|
|
1391
1482
|
return;
|
|
1392
1483
|
} finally {
|
|
1484
|
+
gracefulAbort = void 0;
|
|
1485
|
+
if (hardAbortTimer != null) clearTimeout(hardAbortTimer);
|
|
1486
|
+
turn.abortSignal.removeEventListener("abort", onHostAbort);
|
|
1393
1487
|
queryInput.close();
|
|
1488
|
+
try {
|
|
1489
|
+
await q.return?.(
|
|
1490
|
+
void 0
|
|
1491
|
+
);
|
|
1492
|
+
} catch {
|
|
1493
|
+
}
|
|
1394
1494
|
}
|
|
1395
1495
|
if (emittedTerminalError) return;
|
|
1396
1496
|
emittedTerminalFinish = true;
|
|
@@ -1399,7 +1499,17 @@ async function runTurn(start, turn) {
|
|
|
1399
1499
|
type: "finish",
|
|
1400
1500
|
finishReason: { unified: "stop", raw: "stop" },
|
|
1401
1501
|
totalUsage: turnUsage ?? streamEventState.stepUsage ?? defaultUsage(),
|
|
1402
|
-
...totalCostUsd !== void 0
|
|
1502
|
+
...totalCostUsd !== void 0 || lastClaudeSessionId !== void 0 ? {
|
|
1503
|
+
harnessMetadata: {
|
|
1504
|
+
"claude-code": {
|
|
1505
|
+
...totalCostUsd !== void 0 ? { costUsd: totalCostUsd } : {},
|
|
1506
|
+
// The conversation this turn belongs to, resumable outside the
|
|
1507
|
+
// SDK with `claude --resume <sessionId>` and captured by the
|
|
1508
|
+
// adapter for exact cross-process resume.
|
|
1509
|
+
...lastClaudeSessionId !== void 0 ? { sessionId: lastClaudeSessionId } : {}
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
} : {}
|
|
1403
1513
|
});
|
|
1404
1514
|
}
|
|
1405
1515
|
function createQueryInput({
|