@navels/neal 0.1.0 → 0.2.0

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.
@@ -125,9 +125,44 @@ async function appendClaudeText(label, state, text, events, sessionHandle, role
125
125
  }
126
126
  return emittedTexts;
127
127
  }
128
- async function logClaudeMessage(label, message, events, state, sessionHandle, role = roleForClaudeLabel(label)) {
128
+ function getClaudeBashCommand(toolInput) {
129
+ if (toolInput === null || typeof toolInput !== 'object') {
130
+ return null;
131
+ }
132
+ const command = toolInput.command;
133
+ return typeof command === 'string' && command.trim() ? command : null;
134
+ }
135
+ // Extracts the human-readable text of a tool_result block: string content is
136
+ // used as-is; block-array content contributes its text blocks. Non-text
137
+ // content (images, documents) yields null.
138
+ function getClaudeToolResultText(content) {
139
+ if (typeof content === 'string') {
140
+ return content;
141
+ }
142
+ if (!Array.isArray(content)) {
143
+ return null;
144
+ }
145
+ const texts = [];
146
+ for (const block of content) {
147
+ if (block !== null &&
148
+ typeof block === 'object' &&
149
+ block.type === 'text' &&
150
+ typeof block.text === 'string') {
151
+ texts.push(block.text);
152
+ }
153
+ }
154
+ return texts.length > 0 ? texts.join('\n') : null;
155
+ }
156
+ async function logClaudeMessage(label, message, events, state, sessionHandle, role = roleForClaudeLabel(label), cwd) {
129
157
  switch (message.type) {
130
158
  case 'assistant': {
159
+ if (state) {
160
+ for (const block of message.message.content) {
161
+ if (block.type === 'tool_use') {
162
+ state.pendingToolUses.set(block.id, { name: block.name, input: block.input });
163
+ }
164
+ }
165
+ }
131
166
  if (state?.sawTextDelta) {
132
167
  return [];
133
168
  }
@@ -150,6 +185,72 @@ async function logClaudeMessage(label, message, events, state, sessionHandle, ro
150
185
  }
151
186
  break;
152
187
  }
188
+ case 'user': {
189
+ // SDK tool executions surface as user messages carrying tool_result
190
+ // blocks. Matching them against the turn's pending tool_use blocks maps
191
+ // Bash runs to `command_completed` and successful write-tool calls to
192
+ // `file_changed`. Read-class tools (Read/Grep/Glob) intentionally emit
193
+ // nothing here — they are not verification evidence. The mapping is
194
+ // role-agnostic: advisor rounds run a read-only toolset, so they never
195
+ // produce Bash or write tool_uses.
196
+ if (!state) {
197
+ break;
198
+ }
199
+ const content = message.message.content;
200
+ if (!Array.isArray(content)) {
201
+ break;
202
+ }
203
+ for (const block of content) {
204
+ if (block.type !== 'tool_result') {
205
+ continue;
206
+ }
207
+ const pending = state.pendingToolUses.get(block.tool_use_id);
208
+ if (!pending) {
209
+ continue;
210
+ }
211
+ state.pendingToolUses.delete(block.tool_use_id);
212
+ if (pending.name === 'Bash') {
213
+ const command = getClaudeBashCommand(pending.input);
214
+ if (!command) {
215
+ continue;
216
+ }
217
+ // The SDK's tool_result exposes no exit code, so `exitCode` is
218
+ // omitted; `status` carries the is_error signal instead.
219
+ const output = getClaudeToolResultText(block.content);
220
+ await emitProviderEvent(events, {
221
+ type: 'command_completed',
222
+ provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
223
+ role,
224
+ label,
225
+ sessionHandle,
226
+ itemId: block.tool_use_id,
227
+ command,
228
+ status: block.is_error === true ? 'failed' : 'completed',
229
+ ...(output !== null ? { output } : {}),
230
+ ...(cwd ? { cwd } : {}),
231
+ providerData: { sdkMessageType: message.type, toolName: pending.name, toolUseId: block.tool_use_id },
232
+ });
233
+ }
234
+ else if (CLAUDE_WRITE_TOOL_NAMES.has(pending.name) && block.is_error !== true) {
235
+ // An is_error result means the write did not happen (e.g. a
236
+ // write-path guard deny), so it must not claim a file change.
237
+ const filePath = getClaudeWriteToolPath(pending.input);
238
+ if (!filePath) {
239
+ continue;
240
+ }
241
+ await emitProviderEvent(events, {
242
+ type: 'file_changed',
243
+ provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
244
+ role,
245
+ label,
246
+ sessionHandle,
247
+ files: [filePath],
248
+ providerData: { sdkMessageType: message.type, toolName: pending.name, toolUseId: block.tool_use_id },
249
+ });
250
+ }
251
+ }
252
+ break;
253
+ }
153
254
  case 'stream_event':
154
255
  if (message.event.type === 'content_block_delta') {
155
256
  if (message.event.delta.type === 'text_delta' && state) {
@@ -274,6 +375,9 @@ async function logClaudeMessage(label, message, events, state, sessionHandle, ro
274
375
  }
275
376
  break;
276
377
  case 'result':
378
+ // Turn boundary: a tool_use whose result never arrived must not match a
379
+ // tool_result from a later turn in the same stream.
380
+ state?.pendingToolUses.clear();
277
381
  await emitProviderEvent(events, {
278
382
  type: 'turn_completed',
279
383
  provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
@@ -281,6 +385,10 @@ async function logClaudeMessage(label, message, events, state, sessionHandle, ro
281
385
  label,
282
386
  sessionHandle,
283
387
  usage: message.usage,
388
+ // One `result` message is one query invocation = one turn, so
389
+ // `total_cost_usd` is a per-turn figure that sums cleanly across turns.
390
+ costUsd: message.total_cost_usd,
391
+ costSource: 'provider',
284
392
  providerData: { sdkMessageType: message.type, subtype: message.subtype },
285
393
  });
286
394
  if (message.usage !== undefined) {
@@ -291,6 +399,8 @@ async function logClaudeMessage(label, message, events, state, sessionHandle, ro
291
399
  label,
292
400
  sessionHandle,
293
401
  usage: message.usage,
402
+ costUsd: message.total_cost_usd,
403
+ costSource: 'provider',
294
404
  providerData: { sdkMessageType: message.type, subtype: message.subtype },
295
405
  });
296
406
  }
@@ -401,7 +511,12 @@ async function collectClaudeResult(stream, cwd, label, inactivityTimeoutMs, even
401
511
  let sessionHandle = null;
402
512
  let lastResult = null;
403
513
  let firstStructuredResult = null;
404
- const logState = { textBuffer: '', sawTextDelta: false, lastThinkingEmitMs: 0 };
514
+ const logState = {
515
+ textBuffer: '',
516
+ sawTextDelta: false,
517
+ lastThinkingEmitMs: 0,
518
+ pendingToolUses: new Map(),
519
+ };
405
520
  const assistantTexts = [];
406
521
  const iterator = stream[Symbol.asyncIterator]();
407
522
  while (true) {
@@ -435,7 +550,7 @@ async function collectClaudeResult(stream, cwd, label, inactivityTimeoutMs, even
435
550
  providerData: { sdkMessageType: message.type },
436
551
  });
437
552
  }
438
- const emittedAssistantTexts = await logClaudeMessage(label, message, events, logState, sessionHandle, role);
553
+ const emittedAssistantTexts = await logClaudeMessage(label, message, events, logState, sessionHandle, role, cwd);
439
554
  if (emittedAssistantTexts.length > 0) {
440
555
  assistantTexts.push(emittedAssistantTexts.join('\n\n'));
441
556
  }
@@ -484,36 +599,57 @@ function createClaudeTurnAbortController(signal) {
484
599
  function deriveClaudeAbortController(signal) {
485
600
  return signal ? createClaudeTurnAbortController(signal) : undefined;
486
601
  }
487
- function buildClaudeQueryOptions(args, defaultModel, claudeExecutablePath = getClaudeCodeExecutablePath(), defaultEffort, abortController = deriveClaudeAbortController(args.signal)) {
602
+ function buildClaudeCoreQueryOptions(spec) {
488
603
  return {
489
- cwd: args.cwd,
490
- ...(abortController ? { abortController } : {}),
491
- ...((args.model ?? defaultModel) ? { model: args.model ?? defaultModel ?? undefined } : {}),
492
- ...(defaultEffort ? { effort: defaultEffort } : {}),
493
- tools: ['Read', 'Grep', 'Glob'],
604
+ cwd: spec.cwd,
605
+ ...(spec.abortController ? { abortController: spec.abortController } : {}),
606
+ ...(spec.model ? { model: spec.model } : {}),
607
+ ...(spec.effort ? { effort: spec.effort } : {}),
608
+ tools: spec.tools,
609
+ ...(spec.hooks ? { hooks: spec.hooks } : {}),
494
610
  permissionMode: 'bypassPermissions',
495
611
  allowDangerouslySkipPermissions: true,
496
- ...(args.resumeHandle ? { resume: args.resumeHandle } : {}),
497
- ...(claudeExecutablePath ? { pathToClaudeCodeExecutable: claudeExecutablePath } : {}),
612
+ ...(spec.resumeHandle ? { resume: spec.resumeHandle } : {}),
613
+ ...(spec.claudeExecutablePath ? { pathToClaudeCodeExecutable: spec.claudeExecutablePath } : {}),
614
+ ...(spec.outputSchema
615
+ ? {
616
+ outputFormat: {
617
+ type: 'json_schema',
618
+ schema: spec.outputSchema,
619
+ },
620
+ }
621
+ : {}),
498
622
  stderr: (data) => {
499
- void args.events?.({
623
+ void spec.events?.({
500
624
  type: 'tool_progress',
501
625
  provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
502
- role: 'structured-advisor',
503
- label: args.label,
504
- sessionHandle: args.resumeHandle ?? null,
626
+ role: spec.stderrRole,
627
+ label: spec.stderrLabel,
628
+ sessionHandle: spec.stderrSessionHandle,
505
629
  toolName: 'stderr',
506
630
  message: data,
507
631
  isError: true,
508
632
  providerData: { stream: 'stderr' },
509
633
  });
510
634
  },
511
- outputFormat: {
512
- type: 'json_schema',
513
- schema: args.schema,
514
- },
515
635
  };
516
636
  }
637
+ function buildClaudeQueryOptions(args, defaultModel, claudeExecutablePath = getClaudeCodeExecutablePath(), defaultEffort, abortController = deriveClaudeAbortController(args.signal)) {
638
+ return buildClaudeCoreQueryOptions({
639
+ cwd: args.cwd,
640
+ abortController,
641
+ model: args.model ?? defaultModel,
642
+ effort: defaultEffort,
643
+ tools: ['Read', 'Grep', 'Glob'],
644
+ resumeHandle: args.resumeHandle,
645
+ claudeExecutablePath,
646
+ outputSchema: args.schema,
647
+ events: args.events,
648
+ stderrRole: 'structured-advisor',
649
+ stderrLabel: args.label,
650
+ stderrSessionHandle: args.resumeHandle ?? null,
651
+ });
652
+ }
517
653
  function buildClaudeQueryStream(args, defaultModel, defaultEffort, abortController) {
518
654
  return query({
519
655
  prompt: args.prompt,
@@ -527,30 +663,19 @@ function buildClaudeJsonBlockReviewPrompt(args) {
527
663
  return args.prompt;
528
664
  }
529
665
  function buildClaudeJsonBlockQueryOptions(args, defaultModel, claudeExecutablePath = getClaudeCodeExecutablePath(), defaultEffort, abortController = deriveClaudeAbortController(args.signal)) {
530
- return {
666
+ return buildClaudeCoreQueryOptions({
531
667
  cwd: args.cwd,
532
- ...(abortController ? { abortController } : {}),
533
- ...((args.model ?? defaultModel) ? { model: args.model ?? defaultModel ?? undefined } : {}),
534
- ...(defaultEffort ? { effort: defaultEffort } : {}),
668
+ abortController,
669
+ model: args.model ?? defaultModel,
670
+ effort: defaultEffort,
535
671
  tools: ['Read', 'Grep', 'Glob'],
536
- permissionMode: 'bypassPermissions',
537
- allowDangerouslySkipPermissions: true,
538
- ...(args.resumeHandle ? { resume: args.resumeHandle } : {}),
539
- ...(claudeExecutablePath ? { pathToClaudeCodeExecutable: claudeExecutablePath } : {}),
540
- stderr: (data) => {
541
- void args.events?.({
542
- type: 'tool_progress',
543
- provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
544
- role: 'structured-advisor',
545
- label: args.label,
546
- sessionHandle: args.resumeHandle ?? null,
547
- toolName: 'stderr',
548
- message: data,
549
- isError: true,
550
- providerData: { stream: 'stderr' },
551
- });
552
- },
553
- };
672
+ resumeHandle: args.resumeHandle,
673
+ claudeExecutablePath,
674
+ events: args.events,
675
+ stderrRole: 'structured-advisor',
676
+ stderrLabel: args.label,
677
+ stderrSessionHandle: args.resumeHandle ?? null,
678
+ });
554
679
  }
555
680
  function buildClaudeJsonBlockQueryStream(args, defaultModel, defaultEffort, abortController) {
556
681
  return query({
@@ -559,29 +684,18 @@ function buildClaudeJsonBlockQueryStream(args, defaultModel, defaultEffort, abor
559
684
  });
560
685
  }
561
686
  function buildClaudeJsonBlockRepairQueryOptions(args, defaultModel, claudeExecutablePath = getClaudeCodeExecutablePath(), defaultEffort, abortController) {
562
- return {
687
+ return buildClaudeCoreQueryOptions({
563
688
  cwd: args.cwd,
564
- ...(abortController ? { abortController } : {}),
565
- ...((args.model ?? defaultModel) ? { model: args.model ?? defaultModel ?? undefined } : {}),
566
- ...(defaultEffort ? { effort: defaultEffort } : {}),
689
+ abortController,
690
+ model: args.model ?? defaultModel,
691
+ effort: defaultEffort,
567
692
  tools: [],
568
- permissionMode: 'bypassPermissions',
569
- allowDangerouslySkipPermissions: true,
570
- ...(claudeExecutablePath ? { pathToClaudeCodeExecutable: claudeExecutablePath } : {}),
571
- stderr: (data) => {
572
- void args.events?.({
573
- type: 'tool_progress',
574
- provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
575
- role: 'structured-advisor',
576
- label: `${args.label}:structured-json-repair`,
577
- sessionHandle: null,
578
- toolName: 'stderr',
579
- message: data,
580
- isError: true,
581
- providerData: { stream: 'stderr' },
582
- });
583
- },
584
- };
693
+ claudeExecutablePath,
694
+ events: args.events,
695
+ stderrRole: 'structured-advisor',
696
+ stderrLabel: `${args.label}:structured-json-repair`,
697
+ stderrSessionHandle: null,
698
+ });
585
699
  }
586
700
  function buildClaudeJsonBlockRepairQueryStream(args, repairPrompt, defaultModel, defaultEffort, abortController) {
587
701
  return query({
@@ -959,7 +1073,33 @@ async function validateOrRepairClaudeJsonBlockResponse(args) {
959
1073
  throw providerError;
960
1074
  }
961
1075
  }
962
- async function runClaudeJsonBlockStructuredAdvisorRound(args, defaultModel, createStream = buildClaudeJsonBlockQueryStream, createRepairStream = buildClaudeJsonBlockRepairQueryStream, defaultEffort, sleep = defaultSleep) {
1076
+ // Both advisor protocols give up on an unsuccessful Claude result with the
1077
+ // same normalized provider error; only the loop position at which each
1078
+ // protocol reaches this differs (see runClaudeStructuredAdvisorRoundLoop).
1079
+ async function createClaudeAdvisorUnsuccessfulResultError(details) {
1080
+ const providerError = createClaudeProviderError({
1081
+ message: details.resultErrorMessage
1082
+ ? `Claude ${details.label} did not return a successful result${details.subtype ? ` (${details.subtype})` : ''}: ${details.resultErrorMessage}`
1083
+ : `Claude ${details.label} did not return a successful result${details.subtype ? ` (${details.subtype})` : ''}`,
1084
+ role: 'structured-advisor',
1085
+ sessionHandle: details.sessionHandle,
1086
+ kind: getClaudeStructuredFailureKind(details.subtype, details.resultErrorMessage),
1087
+ subtype: details.subtype,
1088
+ });
1089
+ await emitClaudeProviderError(details.events, providerError, details.label);
1090
+ return providerError;
1091
+ }
1092
+ // One advisor round loop serves both structured-output protocols. The shared
1093
+ // skeleton — per-turn abort controller, assistant-text mirroring, transient
1094
+ // api_error retries with exponential backoff, transient error-result
1095
+ // retries, and the final unsuccessful-result error — is identical for both;
1096
+ // the `run.protocol` discriminator selects the json-block behavior (local
1097
+ // extraction/validation with the repair sub-loop, plus the mirrored-prose
1098
+ // recovery when the stream fails after emitting assistant text) or the
1099
+ // native behavior (SDK `json_schema` structured output with stream-error and
1100
+ // retry-exhaustion telemetry).
1101
+ async function runClaudeStructuredAdvisorRoundLoop(run, defaultModel, defaultEffort, sleep = defaultSleep) {
1102
+ const args = run.args;
963
1103
  let sessionHandle = args.resumeHandle ?? null;
964
1104
  let apiRetryCount = 0;
965
1105
  const apiRetryLimit = args.apiRetryLimit;
@@ -974,56 +1114,9 @@ async function runClaudeJsonBlockStructuredAdvisorRound(args, defaultModel, crea
974
1114
  await args.events?.(event);
975
1115
  }
976
1116
  : undefined;
977
- let result;
978
- try {
979
- const turnAbortController = createClaudeTurnAbortController(args.signal);
980
- result = await collectClaudeResult(createStream(args, defaultModel, defaultEffort, turnAbortController), args.cwd, args.label, args.inactivityTimeoutMs, primaryEvents, 'structured-advisor', turnAbortController);
981
- }
982
- catch (error) {
983
- const providerError = normalizeClaudeProviderError(error, {
984
- role: 'structured-advisor',
985
- sessionHandle,
986
- });
987
- if (providerError.kind === 'api_error' && apiRetryCount < apiRetryLimit) {
988
- apiRetryCount += 1;
989
- await emitProviderEvent(args.events, {
990
- type: 'tool_progress',
991
- provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
992
- role: 'structured-advisor',
993
- label: args.label,
994
- sessionHandle,
995
- toolName: 'api_retry',
996
- message: `transient API failure; retrying review (${apiRetryCount}/${apiRetryLimit})`,
997
- isError: true,
998
- providerData: {
999
- retryCount: apiRetryCount,
1000
- retryLimit: apiRetryLimit,
1001
- message: providerError.message,
1002
- },
1003
- });
1004
- await sleep(getApiRetryDelayMs(apiRetryCount));
1005
- continue;
1006
- }
1007
- const mirroredAssistantText = mirroredAssistantTexts.join('\n\n').trim();
1008
- if (mirroredAssistantText &&
1009
- (providerError.kind === 'structured_output_invalid' || providerError.kind === 'structured_output_missing')) {
1010
- return await validateOrRepairClaudeJsonBlockResponse({
1011
- roundArgs: args,
1012
- assistantText: mirroredAssistantText,
1013
- defaultModel,
1014
- defaultEffort,
1015
- sessionHandle: providerError.sessionHandle ?? sessionHandle,
1016
- createRepairStream,
1017
- });
1018
- }
1019
- await emitClaudeProviderError(args.events, providerError, args.label);
1020
- throw providerError;
1021
- }
1022
- sessionHandle = result.sessionHandle ?? sessionHandle;
1023
- const subtype = result.lastResult?.subtype ?? null;
1024
- const resultErrorMessage = getClaudeResultErrorMessage(result.lastResult);
1025
- const isErrorResult = result.lastResult?.is_error === true;
1026
- if (isErrorResult && isTransientClaudeFailure(subtype, resultErrorMessage) && apiRetryCount < apiRetryLimit) {
1117
+ // Reads `sessionHandle` at call time, so post-collect callers observe the
1118
+ // handle updated from the just-collected result.
1119
+ const retryAfterTransientErrorResult = async (subtype, message) => {
1027
1120
  apiRetryCount += 1;
1028
1121
  await emitProviderEvent(args.events, {
1029
1122
  type: 'tool_progress',
@@ -1038,60 +1131,15 @@ async function runClaudeJsonBlockStructuredAdvisorRound(args, defaultModel, crea
1038
1131
  retryCount: apiRetryCount,
1039
1132
  retryLimit: apiRetryLimit,
1040
1133
  subtype,
1041
- message: resultErrorMessage,
1134
+ message,
1042
1135
  },
1043
1136
  });
1044
1137
  await sleep(getApiRetryDelayMs(apiRetryCount));
1045
- continue;
1046
- }
1047
- const structuredRetryExhausted = isStructuredOutputRetryExhaustion(subtype, resultErrorMessage);
1048
- if (subtype && subtype !== 'success' && !structuredRetryExhausted) {
1049
- const providerError = createClaudeProviderError({
1050
- message: resultErrorMessage
1051
- ? `Claude ${args.label} did not return a successful result${subtype ? ` (${subtype})` : ''}: ${resultErrorMessage}`
1052
- : `Claude ${args.label} did not return a successful result${subtype ? ` (${subtype})` : ''}`,
1053
- role: 'structured-advisor',
1054
- sessionHandle,
1055
- kind: getClaudeStructuredFailureKind(subtype, resultErrorMessage),
1056
- subtype,
1057
- });
1058
- await emitClaudeProviderError(args.events, providerError, args.label);
1059
- throw providerError;
1060
- }
1061
- const assistantText = getClaudeAssistantTextForLocalJson(result, mirroredAssistantTexts.join('\n\n').trim());
1062
- return await validateOrRepairClaudeJsonBlockResponse({
1063
- roundArgs: args,
1064
- assistantText,
1065
- defaultModel,
1066
- defaultEffort,
1067
- sessionHandle,
1068
- createRepairStream,
1069
- });
1070
- }
1071
- }
1072
- async function runClaudeStructuredAdvisorRound(args, defaultModel, createStream = buildClaudeQueryStream, defaultEffort, sleep = defaultSleep) {
1073
- if (isClaudeJsonBlockStructuredAdvisorRound(args)) {
1074
- return await runClaudeJsonBlockStructuredAdvisorRound(args, defaultModel, undefined, undefined, defaultEffort, sleep);
1075
- }
1076
- let sessionHandle = args.resumeHandle ?? null;
1077
- let apiRetryCount = 0;
1078
- const apiRetryLimit = args.apiRetryLimit;
1079
- while (true) {
1138
+ };
1080
1139
  let result;
1081
- const mirroredAssistantTexts = [];
1082
- const primaryEvents = args.events
1083
- ? async (event) => {
1084
- const text = getMirroredAssistantText(event, 'structured-advisor', args.label);
1085
- if (text) {
1086
- mirroredAssistantTexts.push(text);
1087
- }
1088
- await args.events?.(event);
1089
- }
1090
- : undefined;
1091
1140
  try {
1092
1141
  const turnAbortController = createClaudeTurnAbortController(args.signal);
1093
- const stream = createStream(args, defaultModel, defaultEffort, turnAbortController);
1094
- result = await collectClaudeResult(stream, args.cwd, args.label, args.inactivityTimeoutMs, primaryEvents, 'structured-advisor', turnAbortController);
1142
+ result = await collectClaudeResult(run.createStream(run.args, defaultModel, defaultEffort, turnAbortController), args.cwd, args.label, args.inactivityTimeoutMs, primaryEvents, 'structured-advisor', turnAbortController);
1095
1143
  }
1096
1144
  catch (error) {
1097
1145
  const providerError = normalizeClaudeProviderError(error, {
@@ -1099,7 +1147,7 @@ async function runClaudeStructuredAdvisorRound(args, defaultModel, createStream
1099
1147
  sessionHandle,
1100
1148
  });
1101
1149
  const mirroredAssistantText = mirroredAssistantTexts.join('\n\n').trim();
1102
- if (providerError.kind === 'structured_output_invalid') {
1150
+ if (run.protocol === 'native' && providerError.kind === 'structured_output_invalid') {
1103
1151
  await emitProviderEvent(args.events, {
1104
1152
  type: 'tool_progress',
1105
1153
  provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
@@ -1137,11 +1185,51 @@ async function runClaudeStructuredAdvisorRound(args, defaultModel, createStream
1137
1185
  await sleep(getApiRetryDelayMs(apiRetryCount));
1138
1186
  continue;
1139
1187
  }
1188
+ if (run.protocol === 'json-block' &&
1189
+ mirroredAssistantText &&
1190
+ (providerError.kind === 'structured_output_invalid' || providerError.kind === 'structured_output_missing')) {
1191
+ return await validateOrRepairClaudeJsonBlockResponse({
1192
+ roundArgs: run.args,
1193
+ assistantText: mirroredAssistantText,
1194
+ defaultModel,
1195
+ defaultEffort,
1196
+ sessionHandle: providerError.sessionHandle ?? sessionHandle,
1197
+ createRepairStream: run.createRepairStream,
1198
+ });
1199
+ }
1140
1200
  await emitClaudeProviderError(args.events, providerError, args.label);
1141
1201
  throw providerError;
1142
1202
  }
1143
1203
  sessionHandle = result.sessionHandle ?? sessionHandle;
1144
1204
  const lastResult = result.lastResult;
1205
+ const subtype = lastResult?.subtype ?? null;
1206
+ const resultErrorMessage = getClaudeResultErrorMessage(lastResult);
1207
+ const structuredRetryExhausted = isStructuredOutputRetryExhaustion(subtype, resultErrorMessage);
1208
+ const isTransientErrorResult = lastResult?.is_error === true && isTransientClaudeFailure(subtype, resultErrorMessage);
1209
+ if (run.protocol === 'json-block') {
1210
+ if (isTransientErrorResult && apiRetryCount < apiRetryLimit) {
1211
+ await retryAfterTransientErrorResult(subtype, resultErrorMessage);
1212
+ continue;
1213
+ }
1214
+ if (subtype && subtype !== 'success' && !structuredRetryExhausted) {
1215
+ throw await createClaudeAdvisorUnsuccessfulResultError({
1216
+ events: args.events,
1217
+ label: args.label,
1218
+ subtype,
1219
+ resultErrorMessage,
1220
+ sessionHandle,
1221
+ });
1222
+ }
1223
+ const assistantText = getClaudeAssistantTextForLocalJson(result, mirroredAssistantTexts.join('\n\n').trim());
1224
+ return await validateOrRepairClaudeJsonBlockResponse({
1225
+ roundArgs: run.args,
1226
+ assistantText,
1227
+ defaultModel,
1228
+ defaultEffort,
1229
+ sessionHandle,
1230
+ createRepairStream: run.createRepairStream,
1231
+ });
1232
+ }
1145
1233
  const structured = getPreferredStructuredOutput(result);
1146
1234
  if (structured !== undefined) {
1147
1235
  await emitProviderEvent(args.events, {
@@ -1156,9 +1244,6 @@ async function runClaudeStructuredAdvisorRound(args, defaultModel, createStream
1156
1244
  structured,
1157
1245
  };
1158
1246
  }
1159
- const subtype = lastResult?.subtype ?? null;
1160
- const resultErrorMessage = getClaudeResultErrorMessage(lastResult);
1161
- const structuredRetryExhausted = isStructuredOutputRetryExhaustion(subtype, resultErrorMessage);
1162
1247
  if (structuredRetryExhausted) {
1163
1248
  await emitProviderEvent(args.events, {
1164
1249
  type: 'tool_progress',
@@ -1177,40 +1262,28 @@ async function runClaudeStructuredAdvisorRound(args, defaultModel, createStream
1177
1262
  },
1178
1263
  });
1179
1264
  }
1180
- if (lastResult?.is_error === true && isTransientClaudeFailure(subtype, resultErrorMessage) && apiRetryCount < apiRetryLimit) {
1181
- apiRetryCount += 1;
1182
- await emitProviderEvent(args.events, {
1183
- type: 'tool_progress',
1184
- provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
1185
- role: 'structured-advisor',
1186
- label: args.label,
1187
- sessionHandle,
1188
- toolName: 'api_retry',
1189
- message: `transient Claude error result; retrying review (${apiRetryCount}/${apiRetryLimit})`,
1190
- isError: true,
1191
- providerData: {
1192
- retryCount: apiRetryCount,
1193
- retryLimit: apiRetryLimit,
1194
- subtype,
1195
- message: resultErrorMessage,
1196
- },
1197
- });
1198
- await sleep(getApiRetryDelayMs(apiRetryCount));
1265
+ if (isTransientErrorResult && apiRetryCount < apiRetryLimit) {
1266
+ await retryAfterTransientErrorResult(subtype, resultErrorMessage);
1199
1267
  continue;
1200
1268
  }
1201
- const providerError = createClaudeProviderError({
1202
- message: resultErrorMessage
1203
- ? `Claude ${args.label} did not return a successful result${subtype ? ` (${subtype})` : ''}: ${resultErrorMessage}`
1204
- : `Claude ${args.label} did not return a successful result${subtype ? ` (${subtype})` : ''}`,
1205
- role: 'structured-advisor',
1206
- sessionHandle,
1207
- kind: getClaudeStructuredFailureKind(subtype, resultErrorMessage),
1269
+ throw await createClaudeAdvisorUnsuccessfulResultError({
1270
+ events: args.events,
1271
+ label: args.label,
1208
1272
  subtype,
1273
+ resultErrorMessage,
1274
+ sessionHandle,
1209
1275
  });
1210
- await emitClaudeProviderError(args.events, providerError, args.label);
1211
- throw providerError;
1212
1276
  }
1213
1277
  }
1278
+ async function runClaudeJsonBlockStructuredAdvisorRound(args, defaultModel, createStream = buildClaudeJsonBlockQueryStream, createRepairStream = buildClaudeJsonBlockRepairQueryStream, defaultEffort, sleep = defaultSleep) {
1279
+ return runClaudeStructuredAdvisorRoundLoop({ protocol: 'json-block', args, createStream, createRepairStream }, defaultModel, defaultEffort, sleep);
1280
+ }
1281
+ async function runClaudeStructuredAdvisorRound(args, defaultModel, createStream = buildClaudeQueryStream, defaultEffort, sleep = defaultSleep) {
1282
+ if (isClaudeJsonBlockStructuredAdvisorRound(args)) {
1283
+ return await runClaudeJsonBlockStructuredAdvisorRound(args, defaultModel, undefined, undefined, defaultEffort, sleep);
1284
+ }
1285
+ return runClaudeStructuredAdvisorRoundLoop({ protocol: 'native', args, createStream }, defaultModel, defaultEffort, sleep);
1286
+ }
1214
1287
  class AnthropicClaudeStructuredAdvisorAdapter {
1215
1288
  options;
1216
1289
  constructor(options = {}) {
@@ -1264,45 +1337,27 @@ function buildClaudeWritePathGuardHooks(cwd, allowedWritePaths) {
1264
1337
  return { PreToolUse: [{ hooks: [guard] }] };
1265
1338
  }
1266
1339
  function buildClaudeCoderQueryOptions(args, defaultModel, claudeExecutablePath = getClaudeCodeExecutablePath(), defaultEffort, abortController = deriveClaudeAbortController(args.signal)) {
1267
- return {
1340
+ return buildClaudeCoreQueryOptions({
1268
1341
  cwd: args.cwd,
1269
- ...(abortController ? { abortController } : {}),
1270
- ...(defaultModel ? { model: defaultModel } : {}),
1271
- ...(defaultEffort ? { effort: defaultEffort } : {}),
1342
+ abortController,
1343
+ model: defaultModel,
1344
+ effort: defaultEffort,
1272
1345
  // `toolPolicy.allowRun: false` (plan-authoring turns) removes Bash so the
1273
1346
  // "no shell" jail is enforced by tool absence, not prompt text.
1274
1347
  tools: args.toolPolicy?.allowRun === false
1275
1348
  ? ['Read', 'Grep', 'Glob', 'Edit', 'Write']
1276
1349
  : ['Read', 'Grep', 'Glob', 'Bash', 'Edit', 'Write'],
1277
- ...(args.toolPolicy?.allowedWritePaths
1278
- ? { hooks: buildClaudeWritePathGuardHooks(args.cwd, args.toolPolicy.allowedWritePaths) }
1279
- : {}),
1280
- permissionMode: 'bypassPermissions',
1281
- allowDangerouslySkipPermissions: true,
1282
- ...(args.resumeHandle ? { resume: args.resumeHandle } : {}),
1283
- ...(claudeExecutablePath ? { pathToClaudeCodeExecutable: claudeExecutablePath } : {}),
1284
- ...(args.outputSchema
1285
- ? {
1286
- outputFormat: {
1287
- type: 'json_schema',
1288
- schema: args.outputSchema,
1289
- },
1290
- }
1291
- : {}),
1292
- stderr: (data) => {
1293
- void args.events?.({
1294
- type: 'tool_progress',
1295
- provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
1296
- role: 'coder',
1297
- label: 'coder',
1298
- sessionHandle: args.resumeHandle ?? null,
1299
- toolName: 'stderr',
1300
- message: data,
1301
- isError: true,
1302
- providerData: { stream: 'stderr' },
1303
- });
1304
- },
1305
- };
1350
+ hooks: args.toolPolicy?.allowedWritePaths
1351
+ ? buildClaudeWritePathGuardHooks(args.cwd, args.toolPolicy.allowedWritePaths)
1352
+ : undefined,
1353
+ resumeHandle: args.resumeHandle,
1354
+ claudeExecutablePath,
1355
+ outputSchema: args.outputSchema,
1356
+ events: args.events,
1357
+ stderrRole: 'coder',
1358
+ stderrLabel: 'coder',
1359
+ stderrSessionHandle: args.resumeHandle ?? null,
1360
+ });
1306
1361
  }
1307
1362
  function buildClaudeCoderQueryStream(args, defaultModel, defaultEffort, abortController) {
1308
1363
  return query({
@@ -1311,29 +1366,18 @@ function buildClaudeCoderQueryStream(args, defaultModel, defaultEffort, abortCon
1311
1366
  });
1312
1367
  }
1313
1368
  function buildClaudeCoderRepairQueryOptions(args, defaultModel, claudeExecutablePath = getClaudeCodeExecutablePath(), defaultEffort, abortController) {
1314
- return {
1369
+ return buildClaudeCoreQueryOptions({
1315
1370
  cwd: args.cwd,
1316
- ...(abortController ? { abortController } : {}),
1317
- ...(defaultModel ? { model: defaultModel } : {}),
1318
- ...(defaultEffort ? { effort: defaultEffort } : {}),
1371
+ abortController,
1372
+ model: defaultModel,
1373
+ effort: defaultEffort,
1319
1374
  tools: [],
1320
- permissionMode: 'bypassPermissions',
1321
- allowDangerouslySkipPermissions: true,
1322
- ...(claudeExecutablePath ? { pathToClaudeCodeExecutable: claudeExecutablePath } : {}),
1323
- stderr: (data) => {
1324
- void args.events?.({
1325
- type: 'tool_progress',
1326
- provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
1327
- role: 'coder',
1328
- label: `${args.label}:structured-json-repair`,
1329
- sessionHandle: null,
1330
- toolName: 'stderr',
1331
- message: data,
1332
- isError: true,
1333
- providerData: { stream: 'stderr' },
1334
- });
1335
- },
1336
- };
1375
+ claudeExecutablePath,
1376
+ events: args.events,
1377
+ stderrRole: 'coder',
1378
+ stderrLabel: `${args.label}:structured-json-repair`,
1379
+ stderrSessionHandle: null,
1380
+ });
1337
1381
  }
1338
1382
  function buildClaudeCoderRepairQueryStream(args, repairPrompt, defaultModel, defaultEffort, abortController) {
1339
1383
  return query({