@ai-sdk/harness-claude-code 1.0.97 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/harness-claude-code",
3
- "version": "1.0.97",
3
+ "version": "1.0.98",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -26,8 +26,8 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@ai-sdk/harness": "1.0.93",
30
- "@ai-sdk/provider-utils": "5.0.33",
29
+ "@ai-sdk/harness": "1.0.94",
30
+ "@ai-sdk/provider-utils": "5.0.34",
31
31
  "ws": "^8.21.0"
32
32
  },
33
33
  "peerDependencies": {
@@ -21,8 +21,17 @@ export type ClaudeMessage = {
21
21
  event?: {
22
22
  type?: string;
23
23
  index?: number;
24
- content_block?: { type?: string };
25
- delta?: { type?: string; text?: string; thinking?: string };
24
+ content_block?: {
25
+ type?: string;
26
+ id?: string;
27
+ name?: string;
28
+ };
29
+ delta?: {
30
+ type?: string;
31
+ text?: string;
32
+ thinking?: string;
33
+ partial_json?: string;
34
+ };
26
35
  };
27
36
  message?: {
28
37
  content?: ReadonlyArray<MessageBlock>;
@@ -56,7 +65,7 @@ export type ClaudeStreamEventState = {
56
65
  */
57
66
  nativeToolCallNames: Map<string, string>;
58
67
  approvalRequestedToolUseIds: Set<string>;
59
- partialBlocks: Map<number, { id: string; kind: 'text' | 'thinking' }>;
68
+ partialBlocks: Map<number, PartialBlock>;
60
69
  stepUsage: Record<string, unknown> | undefined;
61
70
  pendingStepToolUseIds: Set<string>;
62
71
  pendingStepUsage: Record<string, unknown> | undefined;
@@ -64,9 +73,8 @@ export type ClaudeStreamEventState = {
64
73
  /*
65
74
  * Tool-use ids that originated from the MCP server hosting user-supplied
66
75
  * tools. The MCP handler emits its own `tool-call`/`tool-result` pair with
67
- * the user-facing tool name and a synthetic id, so the duplicate
68
- * `tool_result` block Claude reports for the underlying native id must be
69
- * suppressed.
76
+ * the user-facing tool name, so the duplicate `tool_result` block Claude
77
+ * reports for the underlying native id must be suppressed.
70
78
  */
71
79
  mcpToolUseIds: Set<string>;
72
80
  externalMcpToolUseIds: Set<string>;
@@ -74,6 +82,10 @@ export type ClaudeStreamEventState = {
74
82
  observedTerminalError: string | undefined;
75
83
  };
76
84
 
85
+ type PartialBlock =
86
+ | { id: string; kind: 'text' | 'thinking' }
87
+ | { id: string; kind: 'tool-input' };
88
+
77
89
  export function createClaudeStreamEventState(): ClaudeStreamEventState {
78
90
  return {
79
91
  nativeToolCallNames: new Map(),
@@ -195,7 +207,12 @@ export function createEmitStreamEvent({
195
207
  }
196
208
 
197
209
  if (type === 'stream_event') {
198
- handleStreamEvent(msg.event, state.partialBlocks, emit);
210
+ handleStreamEvent({
211
+ event: msg.event,
212
+ state,
213
+ send: emit,
214
+ toCommonName,
215
+ });
199
216
  return;
200
217
  }
201
218
 
@@ -371,13 +388,22 @@ function formatApiRetryWarning(msg: ClaudeMessage): string {
371
388
  : 'Claude Code API retry';
372
389
  }
373
390
 
374
- function handleStreamEvent(
375
- event: ClaudeMessage['event'] | undefined,
376
- partialBlocks: Map<number, { id: string; kind: 'text' | 'thinking' }>,
377
- send: Emit,
378
- ): void {
391
+ const HOST_TOOL_PREFIX = 'mcp__harness-tools__';
392
+
393
+ function handleStreamEvent({
394
+ event,
395
+ state,
396
+ send,
397
+ toCommonName,
398
+ }: {
399
+ event: ClaudeMessage['event'] | undefined;
400
+ state: ClaudeStreamEventState;
401
+ send: Emit;
402
+ toCommonName: (nativeName: string) => string;
403
+ }): void {
379
404
  if (!event || typeof event.index !== 'number') return;
380
405
  const index = event.index;
406
+ const partialBlocks = state.partialBlocks;
381
407
 
382
408
  if (event.type === 'content_block_start') {
383
409
  const blockType = event.content_block?.type;
@@ -389,6 +415,29 @@ function handleStreamEvent(
389
415
  const id = randomUUID();
390
416
  partialBlocks.set(index, { id, kind: 'thinking' });
391
417
  send({ type: 'reasoning-start', id });
418
+ } else if (
419
+ blockType === 'tool_use' &&
420
+ typeof event.content_block?.id === 'string' &&
421
+ typeof event.content_block.name === 'string'
422
+ ) {
423
+ const id = event.content_block.id;
424
+ const nativeName = event.content_block.name;
425
+ if (nativeName === 'StructuredOutput') {
426
+ return;
427
+ }
428
+ const hostToolName = nativeName.startsWith(HOST_TOOL_PREFIX)
429
+ ? nativeName.slice(HOST_TOOL_PREFIX.length)
430
+ : undefined;
431
+ const dynamic =
432
+ hostToolName === undefined && nativeName.startsWith('mcp__');
433
+ partialBlocks.set(index, { id, kind: 'tool-input' });
434
+ send({
435
+ type: 'tool-input-start',
436
+ id,
437
+ toolName: hostToolName ?? toCommonName(nativeName),
438
+ providerExecuted: hostToolName === undefined,
439
+ ...(dynamic ? { dynamic: true } : {}),
440
+ });
392
441
  }
393
442
  return;
394
443
  }
@@ -412,6 +461,16 @@ function handleStreamEvent(
412
461
  id: block.id,
413
462
  delta: event.delta.thinking,
414
463
  });
464
+ } else if (
465
+ block.kind === 'tool-input' &&
466
+ event.delta?.type === 'input_json_delta' &&
467
+ typeof event.delta.partial_json === 'string'
468
+ ) {
469
+ send({
470
+ type: 'tool-input-delta',
471
+ id: block.id,
472
+ delta: event.delta.partial_json,
473
+ });
415
474
  }
416
475
  return;
417
476
  }
@@ -422,8 +481,10 @@ function handleStreamEvent(
422
481
  partialBlocks.delete(index);
423
482
  if (block.kind === 'text') {
424
483
  send({ type: 'text-end', id: block.id });
425
- } else {
484
+ } else if (block.kind === 'thinking') {
426
485
  send({ type: 'reasoning-end', id: block.id });
486
+ } else {
487
+ send({ type: 'tool-input-end', id: block.id });
427
488
  }
428
489
  }
429
490
  }
@@ -125,13 +125,23 @@ const claudeSdk = claudeAgentSdk as any;
125
125
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
126
126
  const mcpModule = mcpServerModule as any;
127
127
 
128
+ /**
129
+ * The Claude session id most recently reported by the SDK, captured from the
130
+ * message stream. Every turn in this bridge process may fork a new id (the
131
+ * SDK's `continue`/`resume` create a new session linked to the previous one),
132
+ * so the latest observation is the one a later resume must name.
133
+ */
134
+ let lastClaudeSessionId: string | undefined;
135
+
128
136
  await runBridge<StartMessage>({
129
137
  bridgeType: 'claude-code',
130
138
  bridgeStateDir,
131
139
  onStart: runTurn,
132
- // Claude Code's session state lives in the workdir on the sandbox filesystem
133
- // (captured by the sandbox snapshot on stop); the resume payload is empty.
134
- onStop: () => ({}),
140
+ // Claude Code's conversation state lives in the runtime's own store, keyed
141
+ // by working directory. The resume payload names the exact conversation so a
142
+ // later resume does not have to fall back to "most recent in this workdir".
143
+ onStop: () =>
144
+ lastClaudeSessionId == null ? {} : { claudeSessionId: lastClaudeSessionId },
135
145
  });
136
146
 
137
147
  type Emit = (msg: Record<string, unknown>) => void;
@@ -290,8 +300,18 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
290
300
  tool.name,
291
301
  tool.description ?? '',
292
302
  shape,
293
- async (input: Record<string, unknown>) => {
294
- const toolCallId = randomUUID();
303
+ async (
304
+ ...handlerArgs: [
305
+ Record<string, unknown>,
306
+ { requestId: string | number; _meta?: Record<string, unknown> },
307
+ ]
308
+ ) => {
309
+ const [input, extra] = handlerArgs;
310
+ const metadataToolCallId = extra._meta?.['claudecode/toolUseId'];
311
+ const toolCallId =
312
+ typeof metadataToolCallId === 'string'
313
+ ? metadataToolCallId
314
+ : randomUUID();
295
315
  emit({
296
316
  type: 'tool-call',
297
317
  toolCallId,
@@ -392,11 +412,24 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
392
412
  },
393
413
  ],
394
414
  },
395
- // Continuation rule: the host can force-continue (resume after a
396
- // cross-process detach) by setting `start.continue: true`; otherwise
397
- // we continue every subsequent turn after the first one in this
398
- // bridge process.
399
- ...(start.continue === true || !turn.firstTurn ? { continue: true } : {}),
415
+ // Continuation rule, most specific first.
416
+ //
417
+ // `resumeSessionId` names the exact conversation and is what a
418
+ // cross-process resume should use: `continue` means "most recent thread
419
+ // in this workdir", which silently picks the wrong one once anything
420
+ // else has run there. The bridge also retains the id observed during its
421
+ // previous query, so every later query stays pinned to that conversation
422
+ // even when the host detached and reattached between turns. `resume` and
423
+ // `continue` are mutually exclusive in the SDK.
424
+ //
425
+ // Otherwise the host can force-continue by setting `start.continue`,
426
+ // and turns after the first fall back to the legacy cwd-based behavior
427
+ // when no exact id was observed.
428
+ ...((start.resumeSessionId ?? lastClaudeSessionId)
429
+ ? { resume: start.resumeSessionId ?? lastClaudeSessionId }
430
+ : start.continue === true || !turn.firstTurn
431
+ ? { continue: true }
432
+ : {}),
400
433
  ...permissionOptions,
401
434
  mcpServers,
402
435
  cwd: workdir,
@@ -460,6 +493,14 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
460
493
  queryInput.handleLifecycle(msg);
461
494
  }
462
495
 
496
+ // Every SDK message carries the session id of the conversation it
497
+ // belongs to. Track the latest so the stop payload and the terminal
498
+ // finish metadata name the exact conversation.
499
+ const sessionId = (msg as { session_id?: unknown }).session_id;
500
+ if (typeof sessionId === 'string' && sessionId.length > 0) {
501
+ lastClaudeSessionId = sessionId;
502
+ }
503
+
463
504
  emitStreamEvent(msg);
464
505
 
465
506
  if (type === 'result') {
@@ -559,8 +600,20 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
559
600
  type: 'finish',
560
601
  finishReason: { unified: 'stop', raw: 'stop' },
561
602
  totalUsage: turnUsage ?? streamEventState.stepUsage ?? defaultUsage(),
562
- ...(totalCostUsd !== undefined
563
- ? { harnessMetadata: { 'claude-code': { costUsd: totalCostUsd } } }
603
+ ...(totalCostUsd !== undefined || lastClaudeSessionId !== undefined
604
+ ? {
605
+ harnessMetadata: {
606
+ 'claude-code': {
607
+ ...(totalCostUsd !== undefined ? { costUsd: totalCostUsd } : {}),
608
+ // The conversation this turn belongs to, resumable outside the
609
+ // SDK with `claude --resume <sessionId>` and captured by the
610
+ // adapter for exact cross-process resume.
611
+ ...(lastClaudeSessionId !== undefined
612
+ ? { sessionId: lastClaudeSessionId }
613
+ : {}),
614
+ },
615
+ },
616
+ }
564
617
  : {}),
565
618
  });
566
619
  }
@@ -42,6 +42,14 @@ export const startMessageSchema = harnessV1BridgeStartBaseSchema.extend({
42
42
  // Claude SDK so the in-workdir thread state is rehydrated. The host sets this
43
43
  // on the first prompt after a cross-process resume.
44
44
  continue: z.boolean().optional(),
45
+ // Exact conversation to resume, as the Claude SDK's `resume` option.
46
+ //
47
+ // Preferred over `continue` whenever the host knows which conversation it
48
+ // means: `continue` resolves to the most recent thread in the workdir, which
49
+ // is the wrong one as soon as anything else has run there since — including
50
+ // a resume that itself started a new thread. Mutually exclusive with
51
+ // `continue` in the SDK, so the bridge sends one or the other.
52
+ resumeSessionId: z.string().optional(),
45
53
  });
46
54
 
47
55
  export type StartMessage = z.infer<typeof startMessageSchema>;
@@ -802,6 +802,17 @@ const claudeCodeBridgeCoordsSchema = z.object({
802
802
  const claudeCodeResumeStateSchema = z.looseObject({
803
803
  bridge: claudeCodeBridgeCoordsSchema.optional(),
804
804
  sandboxCredentialEnvironment: z.record(z.string(), z.string()).optional(),
805
+ /**
806
+ * The exact Claude conversation to rehydrate on resume. Written by the
807
+ * adapter on `doStop()`/`doDetach()`/`doSuspendTurn()` from the session id
808
+ * the bridge observed, so a resume names the conversation instead of
809
+ * relying on the SDK's `continue` flag — "most recent thread in this
810
+ * workdir" — which silently picks the wrong one once a second thread
811
+ * exists there. Hosts that captured the id themselves (it is surfaced as
812
+ * `harnessMetadata['claude-code'].sessionId` on `finish` parts) may also
813
+ * set it explicitly.
814
+ */
815
+ claudeSessionId: z.string().optional(),
805
816
  });
806
817
 
807
818
  type ClaudeCodeBridgeCoords = z.infer<typeof claudeCodeBridgeCoordsSchema>;
@@ -831,7 +842,6 @@ export function createClaudeCode(
831
842
  lifecycleStateSchema: claudeCodeResumeStateSchema,
832
843
  getBootstrap: getClaudeCodeBootstrap,
833
844
  doStart: async startOpts => {
834
- const model = startOpts.model ?? settings.model;
835
845
  const sandboxSession = startOpts.sandboxSession;
836
846
  const toolSafeSandboxSession =
837
847
  getRestrictedSandboxSession(sandboxSession);
@@ -860,6 +870,7 @@ export function createClaudeCode(
860
870
  ? (lifecycleState?.data as {
861
871
  bridge?: ClaudeCodeBridgeCoords;
862
872
  sandboxCredentialEnvironment?: Record<string, string>;
873
+ claudeSessionId?: string;
863
874
  })
864
875
  : undefined;
865
876
  const coords = resumeData?.bridge;
@@ -921,6 +932,10 @@ export function createClaudeCode(
921
932
  defaultWorkingDirectory,
922
933
  BOOTSTRAP_DIR,
923
934
  );
935
+ // The conversation the host wants back, when it is known. Absent on
936
+ // state written before this field existed; those resumes fall back to
937
+ // the `continue` flag as before.
938
+ const resumeSessionId = resumeData?.claudeSessionId;
924
939
 
925
940
  const workDir = startOpts.sessionWorkDir;
926
941
  const sandboxHomeDir = await resolveSandboxHomeDir({
@@ -994,11 +1009,12 @@ export function createClaudeCode(
994
1009
  return createSession({
995
1010
  sessionId: startOpts.sessionId,
996
1011
  channel: attachChannel,
1012
+ ...(resumeSessionId ? { resumeSessionId } : {}),
997
1013
  // The live bridge was spawned by another process; this one owns no
998
1014
  // process handle. The session lifecycle method decides whether the
999
1015
  // sandbox is left running, stopped, or destroyed.
1000
1016
  proc: undefined,
1001
- model,
1017
+ model: settings.model,
1002
1018
  maxTurns: settings.maxTurns,
1003
1019
  env: sandboxClaudeEnvironment,
1004
1020
  thinking,
@@ -1149,13 +1165,14 @@ export function createClaudeCode(
1149
1165
  sessionId: startOpts.sessionId,
1150
1166
  channel,
1151
1167
  proc,
1152
- model,
1168
+ model: settings.model,
1153
1169
  maxTurns: settings.maxTurns,
1154
1170
  env: sandboxClaudeEnvironment,
1155
1171
  thinking,
1156
1172
  effort: settings.effort,
1157
1173
  isResume: respawnStrategy !== undefined,
1158
1174
  continueOnFirstPrompt: respawnStrategy !== undefined,
1175
+ ...(resumeSessionId ? { resumeSessionId } : {}),
1159
1176
  rerunContinue: respawnStrategy === 'rerun',
1160
1177
  bridgePort: boundPort,
1161
1178
  bridgeToken: token,
@@ -1486,6 +1503,7 @@ function createSession({
1486
1503
  effort,
1487
1504
  isResume,
1488
1505
  continueOnFirstPrompt,
1506
+ resumeSessionId,
1489
1507
  rerunContinue,
1490
1508
  bridgePort,
1491
1509
  bridgeToken,
@@ -1510,6 +1528,12 @@ function createSession({
1510
1528
  effort: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | undefined;
1511
1529
  isResume: boolean;
1512
1530
  continueOnFirstPrompt: boolean;
1531
+ /**
1532
+ * Exact conversation to rehydrate whenever a fresh SDK query starts. Takes
1533
+ * precedence over the `continue` flag, which can only mean "most recent in
1534
+ * this workdir".
1535
+ */
1536
+ resumeSessionId?: string;
1513
1537
  rerunContinue: boolean;
1514
1538
  bridgePort: number;
1515
1539
  bridgeToken: string;
@@ -1527,12 +1551,21 @@ function createSession({
1527
1551
  let stopPromise: Promise<void> | undefined;
1528
1552
  /*
1529
1553
  * Force the Claude SDK's `continue: true` on the first prompt only when the
1530
- * bridge was respawned (rerun/replay): a fresh bridge process treats its
1531
- * first turn as new, so it must be told to rehydrate the workdir thread. An
1532
- * `attach`ed bridge is already past its first turn and continues on its own.
1554
+ * bridge was respawned without an exact conversation id (rerun/replay): a
1555
+ * fresh bridge process treats its first turn as new, so it must be told to
1556
+ * rehydrate the workdir thread. Exact ids are sent independently of this
1557
+ * fallback on every fresh query.
1533
1558
  */
1534
1559
  let pendingResumeFlag = continueOnFirstPrompt;
1535
1560
 
1561
+ /*
1562
+ * The Claude conversation this session currently embodies. Seeded from the
1563
+ * resume state and updated from every `finish` part's metadata — each turn
1564
+ * may fork a new id, so the latest observation is the one a later
1565
+ * stop/detach must record for exact resume.
1566
+ */
1567
+ let lastClaudeSessionId = resumeSessionId;
1568
+
1536
1569
  /*
1537
1570
  * Wire the channel into one turn's worth of events and return the control
1538
1571
  * surface. Shared by `doPromptTurn` (which sends a `start` afterwards) and
@@ -1588,6 +1621,9 @@ function createSession({
1588
1621
  'reasoning-start',
1589
1622
  'reasoning-delta',
1590
1623
  'reasoning-end',
1624
+ 'tool-input-start',
1625
+ 'tool-input-delta',
1626
+ 'tool-input-end',
1591
1627
  'tool-call',
1592
1628
  'tool-approval-request',
1593
1629
  'tool-result',
@@ -1603,6 +1639,14 @@ function createSession({
1603
1639
  }
1604
1640
  unsubs.push(
1605
1641
  channel.on('finish', msg => {
1642
+ const metadata = (
1643
+ msg as {
1644
+ harnessMetadata?: { 'claude-code'?: { sessionId?: unknown } };
1645
+ }
1646
+ ).harnessMetadata?.['claude-code']?.sessionId;
1647
+ if (typeof metadata === 'string' && metadata.length > 0) {
1648
+ lastClaudeSessionId = metadata;
1649
+ }
1606
1650
  forward(msg);
1607
1651
  settleSuccess();
1608
1652
  }),
@@ -1683,7 +1727,6 @@ function createSession({
1683
1727
  return {
1684
1728
  sessionId,
1685
1729
  isResume,
1686
- modelId: model,
1687
1730
  doPromptTurn: async promptOpts => {
1688
1731
  if (
1689
1732
  promptOpts.responseFormat?.type === 'json' &&
@@ -1730,7 +1773,7 @@ function createSession({
1730
1773
  ...(promptOpts.instructions
1731
1774
  ? { instructions: promptOpts.instructions }
1732
1775
  : {}),
1733
- model,
1776
+ model: promptOpts.model ?? model,
1734
1777
  maxTurns,
1735
1778
  ...(env !== undefined ? { env } : {}),
1736
1779
  thinking,
@@ -1742,7 +1785,11 @@ function createSession({
1742
1785
  ...(permissionMode ? { permissionMode } : {}),
1743
1786
  ...(builtinToolFiltering ? { builtinToolFiltering } : {}),
1744
1787
  ...(debug ? { debug } : {}),
1745
- ...(pendingResumeFlag ? { continue: true } : {}),
1788
+ ...(lastClaudeSessionId
1789
+ ? { resumeSessionId: lastClaudeSessionId }
1790
+ : pendingResumeFlag
1791
+ ? { continue: true }
1792
+ : {}),
1746
1793
  };
1747
1794
  pendingResumeFlag = false;
1748
1795
  channel.send(startMessage);
@@ -1809,7 +1856,7 @@ function createSession({
1809
1856
  ...(continueOpts.instructions
1810
1857
  ? { instructions: continueOpts.instructions }
1811
1858
  : {}),
1812
- model,
1859
+ model: continueOpts.model ?? model,
1813
1860
  maxTurns,
1814
1861
  ...(env !== undefined ? { env } : {}),
1815
1862
  thinking,
@@ -1821,7 +1868,9 @@ function createSession({
1821
1868
  ...(permissionMode ? { permissionMode } : {}),
1822
1869
  ...(builtinToolFiltering ? { builtinToolFiltering } : {}),
1823
1870
  ...(debug ? { debug } : {}),
1824
- continue: true,
1871
+ ...(lastClaudeSessionId
1872
+ ? { resumeSessionId: lastClaudeSessionId }
1873
+ : { continue: true }),
1825
1874
  });
1826
1875
  }
1827
1876
 
@@ -1863,6 +1912,9 @@ function createSession({
1863
1912
  lastSeenEventId,
1864
1913
  ...(sandboxId == null ? {} : { sandboxId }),
1865
1914
  },
1915
+ ...(lastClaudeSessionId
1916
+ ? { claudeSessionId: lastClaudeSessionId }
1917
+ : {}),
1866
1918
  },
1867
1919
  };
1868
1920
  return payload;
@@ -1974,7 +2026,13 @@ function createSession({
1974
2026
  type: 'resume-session',
1975
2027
  harnessId: 'claude-code',
1976
2028
  specificationVersion: 'harness-v1',
2029
+ // The bridge's stop reply carries the session id it observed; the
2030
+ // adapter's own record backfills it when the reply predates the
2031
+ // field or the channel was already closed.
1977
2032
  data: {
2033
+ ...(lastClaudeSessionId
2034
+ ? { claudeSessionId: lastClaudeSessionId }
2035
+ : {}),
1978
2036
  ...lifecycleData,
1979
2037
  ...(sandboxCredentialEnvironment == null
1980
2038
  ? {}
@@ -2013,6 +2071,9 @@ function createSession({
2013
2071
  lastSeenEventId,
2014
2072
  ...(sandboxId == null ? {} : { sandboxId }),
2015
2073
  },
2074
+ ...(lastClaudeSessionId
2075
+ ? { claudeSessionId: lastClaudeSessionId }
2076
+ : {}),
2016
2077
  },
2017
2078
  };
2018
2079
  return payload;