@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/harness-claude-code",
3
- "version": "1.0.96",
3
+ "version": "1.0.98",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -26,16 +26,16 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@ai-sdk/harness": "1.0.92",
30
- "@ai-sdk/provider-utils": "5.0.32",
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": {
34
34
  "zod": "^3.25.76 || ^4.1.8"
35
35
  },
36
36
  "devDependencies": {
37
- "@anthropic-ai/claude-agent-sdk": "0.3.213",
38
- "@modelcontextprotocol/sdk": "1.29.0",
37
+ "@anthropic-ai/claude-agent-sdk": "0.3.245",
38
+ "@modelcontextprotocol/sdk": "1.30.0",
39
39
  "@types/node": "22.19.19",
40
40
  "@types/ws": "^8.5.13",
41
41
  "@vercel/ai-tsconfig": "0.0.0",
@@ -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;
@@ -253,12 +263,27 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
253
263
  // Local controller for the Claude query. Aborted either by the host (via the
254
264
  // shared runtime's `turn.abortSignal`) or by us on a terminal error.
255
265
  const abortCtl = new AbortController();
266
+ // A host abort prefers the SDK's graceful `interrupt()` — Esc semantics: the
267
+ // in-flight turn is persisted to the session transcript and settles with an
268
+ // interrupted result, so a later resume (including the user's own
269
+ // `claude --resume`) still sees the work done before the interrupt. The hard
270
+ // abort kills the CLI process and loses that turn's records, so it is only
271
+ // the fallback — armed unconditionally, because aborting an already-settled
272
+ // query is a no-op — and the immediate path when the abort arrives before
273
+ // the query exists.
274
+ let gracefulAbort: (() => void) | undefined;
275
+ let hardAbortTimer: ReturnType<typeof setTimeout> | undefined;
276
+ const onHostAbort = (): void => {
277
+ if (gracefulAbort) {
278
+ gracefulAbort();
279
+ } else {
280
+ abortCtl.abort();
281
+ }
282
+ };
256
283
  if (turn.abortSignal.aborted) {
257
284
  abortCtl.abort();
258
285
  } else {
259
- turn.abortSignal.addEventListener('abort', () => abortCtl.abort(), {
260
- once: true,
261
- });
286
+ turn.abortSignal.addEventListener('abort', onHostAbort, { once: true });
262
287
  }
263
288
 
264
289
  const streamEventState = createClaudeStreamEventState();
@@ -275,8 +300,18 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
275
300
  tool.name,
276
301
  tool.description ?? '',
277
302
  shape,
278
- async (input: Record<string, unknown>) => {
279
- 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();
280
315
  emit({
281
316
  type: 'tool-call',
282
317
  toolCallId,
@@ -377,17 +412,43 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
377
412
  },
378
413
  ],
379
414
  },
380
- // Continuation rule: the host can force-continue (resume after a
381
- // cross-process detach) by setting `start.continue: true`; otherwise
382
- // we continue every subsequent turn after the first one in this
383
- // bridge process.
384
- ...(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
+ : {}),
385
433
  ...permissionOptions,
386
434
  mcpServers,
387
435
  cwd: workdir,
388
436
  abortSignal: abortCtl.signal,
389
437
  },
390
438
  });
439
+
440
+ gracefulAbort = () => {
441
+ // Backstop for the whole teardown, not just the interrupt call: if the
442
+ // stream has not settled five seconds after a graceful interrupt was
443
+ // requested, fall back to the hard abort. Aborting an already-settled
444
+ // query is a no-op, and `unref` keeps the timer from pinning the bridge
445
+ // process open on its own.
446
+ hardAbortTimer = setTimeout(() => abortCtl.abort(), 5000);
447
+ hardAbortTimer.unref?.();
448
+ void Promise.resolve()
449
+ .then(() => q.interrupt())
450
+ .catch(() => abortCtl.abort());
451
+ };
391
452
  let turnUsage: Record<string, unknown> | undefined;
392
453
  let totalCostUsd: number | undefined;
393
454
  let emittedTerminalError = false;
@@ -398,10 +459,17 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
398
459
  if (!normalized || emittedTerminalError || emittedTerminalFinish) return;
399
460
  streamEventState.observedTerminalError = normalized;
400
461
  emittedTerminalError = true;
401
- turn.emitError({
402
- error: normalized,
403
- message: 'claude-code terminal error',
404
- });
462
+ // A turn the host itself stopped ends with an error-shaped result by
463
+ // construction (an interrupted query reports a diagnostic, not success);
464
+ // reporting the host's own stop as a terminal error makes every clean
465
+ // interrupt look like a malfunction. The host has already settled the
466
+ // turn on its side.
467
+ if (!turn.abortSignal.aborted) {
468
+ turn.emitError({
469
+ error: normalized,
470
+ message: 'claude-code terminal error',
471
+ });
472
+ }
405
473
  queryInput.close();
406
474
  abortCtl.abort();
407
475
  };
@@ -425,6 +493,14 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
425
493
  queryInput.handleLifecycle(msg);
426
494
  }
427
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
+
428
504
  emitStreamEvent(msg);
429
505
 
430
506
  if (type === 'result') {
@@ -483,12 +559,38 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
483
559
  }
484
560
  }
485
561
  } catch (err) {
486
- if (!(abortCtl.signal.aborted && emittedTerminalError)) {
562
+ // Same reasoning as `emitTerminalError`: a throw after the host's own
563
+ // abort (e.g. the hard-abort fallback killing the CLI mid-iteration, or
564
+ // a rejected `interrupt()`) is the stop the host asked for, not a
565
+ // malfunction. The host has already settled the turn on its side.
566
+ if (
567
+ !turn.abortSignal.aborted &&
568
+ !(abortCtl.signal.aborted && emittedTerminalError)
569
+ ) {
487
570
  turn.emitError({ error: err, message: 'claude-code turn failed' });
488
571
  }
489
572
  return;
490
573
  } finally {
574
+ // The turn is over; disarm the host-abort path first. An abort of this
575
+ // turn's signal arriving after this point (e.g. an `abort` message racing
576
+ // the next `start`) must not interrupt the disposed query or arm the
577
+ // hard-abort fallback timer for it.
578
+ gracefulAbort = undefined;
579
+ if (hardAbortTimer != null) clearTimeout(hardAbortTimer);
580
+ turn.abortSignal.removeEventListener('abort', onHostAbort);
491
581
  queryInput.close();
582
+ // Dispose the query explicitly: with streaming input the SDK keeps its
583
+ // CLI subprocess alive for more user messages, and a turn that ended
584
+ // through an interrupt or error path can otherwise leak that process —
585
+ // observed as orphaned `claude` processes holding the very conversation
586
+ // the next turn continues.
587
+ try {
588
+ await (q as { return?: (value?: unknown) => Promise<unknown> }).return?.(
589
+ undefined,
590
+ );
591
+ } catch {
592
+ // Best effort; the abort controller tears the process down otherwise.
593
+ }
492
594
  }
493
595
 
494
596
  if (emittedTerminalError) return;
@@ -498,8 +600,20 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
498
600
  type: 'finish',
499
601
  finishReason: { unified: 'stop', raw: 'stop' },
500
602
  totalUsage: turnUsage ?? streamEventState.stepUsage ?? defaultUsage(),
501
- ...(totalCostUsd !== undefined
502
- ? { 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
+ }
503
617
  : {}),
504
618
  });
505
619
  }
@@ -4,9 +4,9 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {
7
- "@anthropic-ai/claude-agent-sdk": "0.3.213",
8
- "@anthropic-ai/claude-code": "2.1.213",
9
- "@modelcontextprotocol/sdk": "1.29.0",
7
+ "@anthropic-ai/claude-agent-sdk": "0.3.245",
8
+ "@anthropic-ai/claude-code": "2.1.245",
9
+ "@modelcontextprotocol/sdk": "1.30.0",
10
10
  "ws": "8.21.0",
11
11
  "zod": "4.4.3"
12
12
  }