@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/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.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.
|
|
30
|
-
"@ai-sdk/provider-utils": "5.0.
|
|
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.
|
|
38
|
-
"@modelcontextprotocol/sdk": "1.
|
|
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?: {
|
|
25
|
-
|
|
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,
|
|
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
|
|
68
|
-
*
|
|
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(
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
}
|
package/src/bridge/index.ts
CHANGED
|
@@ -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
|
|
133
|
-
//
|
|
134
|
-
|
|
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',
|
|
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 (
|
|
279
|
-
|
|
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
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
-
? {
|
|
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
|
}
|
package/src/bridge/package.json
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.
|
|
8
|
-
"@anthropic-ai/claude-code": "2.1.
|
|
9
|
-
"@modelcontextprotocol/sdk": "1.
|
|
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
|
}
|