@koda-sl/baker-bridge 0.147.0-dev.ad499429a → 0.148.0-dev.5b9952021

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.
Files changed (42) hide show
  1. package/README.md +21 -2
  2. package/dist/hono/agent-session.d.ts +0 -1
  3. package/dist/hono/agent-session.d.ts.map +1 -1
  4. package/dist/hono/agent-session.js +0 -7
  5. package/dist/hono/agent-session.js.map +1 -1
  6. package/dist/hono/agent-session.test.js +1 -7
  7. package/dist/hono/agent-session.test.js.map +1 -1
  8. package/dist/hono/agent.d.ts.map +1 -1
  9. package/dist/hono/agent.js +1 -7
  10. package/dist/hono/agent.js.map +1 -1
  11. package/dist/hono/bridge-job.d.ts +7 -1
  12. package/dist/hono/bridge-job.d.ts.map +1 -1
  13. package/dist/hono/bridge-job.js +111 -12
  14. package/dist/hono/bridge-job.js.map +1 -1
  15. package/dist/hono/bridge-job.test.js +153 -2
  16. package/dist/hono/bridge-job.test.js.map +1 -1
  17. package/dist/hono/served-model.d.ts +23 -0
  18. package/dist/hono/served-model.d.ts.map +1 -0
  19. package/dist/hono/served-model.js +81 -0
  20. package/dist/hono/served-model.js.map +1 -0
  21. package/dist/hono/served-model.test.d.ts +2 -0
  22. package/dist/hono/served-model.test.d.ts.map +1 -0
  23. package/dist/hono/served-model.test.js +29 -0
  24. package/dist/hono/served-model.test.js.map +1 -0
  25. package/dist/hono/session-continuity.d.ts +24 -0
  26. package/dist/hono/session-continuity.d.ts.map +1 -1
  27. package/dist/hono/session-continuity.integration.test.js +200 -5
  28. package/dist/hono/session-continuity.integration.test.js.map +1 -1
  29. package/dist/hono/session-continuity.js +94 -7
  30. package/dist/hono/session-continuity.js.map +1 -1
  31. package/dist/hono/session-store.d.ts +2 -0
  32. package/dist/hono/session-store.d.ts.map +1 -1
  33. package/dist/hono/session-store.js +6 -0
  34. package/dist/hono/session-store.js.map +1 -1
  35. package/dist/hono/session-usage-baseline.d.ts +1 -0
  36. package/dist/hono/session-usage-baseline.d.ts.map +1 -1
  37. package/dist/hono/session-usage-baseline.js +17 -4
  38. package/dist/hono/session-usage-baseline.js.map +1 -1
  39. package/dist/hono/turn-events.d.ts +1 -1
  40. package/dist/hono/turn-events.d.ts.map +1 -1
  41. package/dist/hono/turn-events.js.map +1 -1
  42. package/package.json +1 -1
@@ -25,7 +25,8 @@ import { BEDROCK_SERVABLE_CAPABILITIES, buildAgentExtraBody, EXTRA_BODY_ENV, MOD
25
25
  import { credentialReminder, currentReferenceReminder, kickoffBriefReminder } from "./prompt-context.js";
26
26
  import { publishLanguageReminder, publishRunReminder } from "./publish-handoff.js";
27
27
  import { bridgeRuntimeFiles } from "./runtime-files.js";
28
- import { applyCurrentSenderAttribution, buildCleanBoundary, buildRehydratedPrompt, compactionRetryAttempt, emptyInProgressSession, interruptedResumeAttempt, isContextOverflowError, isResumeSessionError, observeInProgressSession, prepareSessionAttempt, stallContinuationAttempt, } from "./session-continuity.js";
28
+ import { alignServedModels } from "./served-model.js";
29
+ import { applyCurrentSenderAttribution, buildCancelBoundary, buildCleanBoundary, buildRehydratedPrompt, compactionRetryAttempt, emptyInProgressSession, interruptedResumeAttempt, isCliResumeRefusal, isContextOverflowError, isResumeSessionError, observeInProgressSession, prepareSessionAttempt, stallContinuationAttempt, } from "./session-continuity.js";
29
30
  import { readSessionSkills } from "./session-skills.js";
30
31
  import { ownQueryUsage, readUsageBaseline } from "./session-usage-baseline.js";
31
32
  import { BridgeSlashCommands, leadingSkillCommand, verbatimSlashCommand } from "./slash-commands.js";
@@ -423,10 +424,20 @@ const CONTEXT_OVERFLOW_MESSAGE = "This chat has grown too long to continue in on
423
424
  // so the turn ends on this instead. The refused exchange is not saved to the
424
425
  // session, so a rephrased message starts from the conversation before it.
425
426
  const MODEL_REFUSAL_MESSAGE = "I couldn't help with that request. Try rephrasing what you need.";
426
- function userFacingTurnError(message) {
427
+ // What the user reads when the model provider failed the request itself — out
428
+ // of credit, rate-limited, down. The CLI's own text ("API Error: 402 Insufficient
429
+ // credits. Add more using https://openrouter.ai/…") is the provider talking to
430
+ // Baker: a vendor, a status code and a billing page the marketer cannot open.
431
+ // The raw text stays in the bridge log and the turn events.
432
+ const PROVIDER_FAILURE_MESSAGE = "Baker's AI service had a problem on our side, so this step stopped. Nothing you did caused it — send your message again to pick up where it stopped.";
433
+ function userFacingTurnError(error) {
434
+ const message = errorMessage(error);
427
435
  if (isContextOverflowError(message)) {
428
436
  return CONTEXT_OVERFLOW_MESSAGE;
429
437
  }
438
+ if (error instanceof SdkResultError && error.providerFailure) {
439
+ return PROVIDER_FAILURE_MESSAGE;
440
+ }
430
441
  return enrichImageError(message);
431
442
  }
432
443
  function enrichImageError(message) {
@@ -467,6 +478,9 @@ function subagentModelEnv() {
467
478
  const override = agentModelOverride();
468
479
  return override ? { CLAUDE_CODE_SUBAGENT_MODEL: override, CLAUDE_CODE_SUBAGENT_MODEL_FORCE: "1" } : {};
469
480
  }
481
+ function claudeConfigDir() {
482
+ return process.env.CLAUDE_CONFIG_DIR || resolve(process.env.HOME || homedir(), ".claude");
483
+ }
470
484
  function claudePlanDir() {
471
485
  return resolve(process.env.HOME || homedir(), ".claude", "plans");
472
486
  }
@@ -564,14 +578,35 @@ function sdkResultErrorMessage(msg) {
564
578
  function turnResultError(state, msg) {
565
579
  return state.refusedWithoutFallback ? undefined : sdkResultError(msg);
566
580
  }
581
+ class SdkResultError extends Error {
582
+ providerFailure;
583
+ name = "SdkResultError";
584
+ constructor(message,
585
+ /** The model provider refused or failed the request, rather than the turn's own work failing. */
586
+ providerFailure) {
587
+ super(`Claude Code returned an error result: ${message}`);
588
+ this.providerFailure = providerFailure;
589
+ }
590
+ }
591
+ // What the request's own content caused (an unreadable image, a malformed or
592
+ // oversized request): sending the same message again would fail the same way, so
593
+ // it keeps its own remediation instead of the provider-failure message.
594
+ const REQUEST_CONTENT_STATUSES = [400, 413, 422];
595
+ function isProviderFailureResult(msg) {
596
+ if (msg.subtype !== "success") {
597
+ return false;
598
+ }
599
+ const status = msg.api_error_status ?? Number(/^API Error: (\d{3})\b/.exec(msg.result)?.[1]);
600
+ if (REQUEST_CONTENT_STATUSES.includes(status)) {
601
+ return false;
602
+ }
603
+ return typeof msg.api_error_status === "number" || msg.result.startsWith("API Error");
604
+ }
567
605
  function sdkResultError(msg) {
568
606
  if (msg.type !== "result" || !msg.is_error) {
569
607
  return undefined;
570
608
  }
571
- const message = sdkResultErrorMessage(msg);
572
- const error = new Error(`Claude Code returned an error result: ${message}`);
573
- error.name = "SdkResultError";
574
- return error;
609
+ return new SdkResultError(sdkResultErrorMessage(msg), isProviderFailureResult(msg));
575
610
  }
576
611
  // When the bridge closes the sandbox CLI's stdin (its permission control
577
612
  // channel) — on abort/replace/timeout — while the CLI is still alive (a long
@@ -685,6 +720,15 @@ export function isContextOverflowRefusal(msg) {
685
720
  }
686
721
  return OVERFLOW_REFUSAL_LEADS.some((lead) => text.startsWith(lead));
687
722
  }
723
+ /**
724
+ * True when an assistant message is the CLI's own report of a failed model
725
+ * request ("API Error: 402 Insufficient credits…", "API Error: Server error
726
+ * mid-response…"), not anything the model wrote. The CLI writes it for a
727
+ * terminal user: a `<synthetic>` model and the request's `error` class.
728
+ */
729
+ function isApiErrorMessage(msg) {
730
+ return msg.type === "assistant" && msg.error !== undefined && msg.message.model === "<synthetic>";
731
+ }
688
732
  /** Content blocks of an assistant/user SDK message; [] for other shapes. */
689
733
  function messageContentBlocks(msg) {
690
734
  const content = msg.message?.content;
@@ -1060,6 +1104,7 @@ export class BridgeJobExecutor {
1060
1104
  turnEndExitGuardMs;
1061
1105
  now;
1062
1106
  readUsageBaseline;
1107
+ alignServedModels;
1063
1108
  stalledCliExitMs;
1064
1109
  readFlowTree;
1065
1110
  settleWorkspace;
@@ -1081,8 +1126,8 @@ export class BridgeJobExecutor {
1081
1126
  this.turnEndExitGuardMs = dependencies.turnEndExitGuardMs ?? DEFAULT_TURN_END_EXIT_GUARD_MS;
1082
1127
  this.now = dependencies.now ?? Date.now;
1083
1128
  this.readUsageBaseline =
1084
- dependencies.readUsageBaseline ??
1085
- ((sessionId) => readUsageBaseline(sessionId, process.env.CLAUDE_CONFIG_DIR || resolve(process.env.HOME || homedir(), ".claude")));
1129
+ dependencies.readUsageBaseline ?? ((sessionId) => readUsageBaseline(sessionId, claudeConfigDir()));
1130
+ this.alignServedModels = dependencies.alignServedModels;
1086
1131
  this.stalledCliExitMs = dependencies.stalledCliExitMs ?? STALLED_CLI_EXIT_MS;
1087
1132
  this.settleWorkspace = dependencies.settleWorkspace ?? (() => settleWorkspaceAfterTasks());
1088
1133
  this.readFlowTree =
@@ -1123,6 +1168,7 @@ export class BridgeJobExecutor {
1123
1168
  });
1124
1169
  attempt = (await this.interruptedTurnAttempt(job, replayAttempt)) ?? replayAttempt;
1125
1170
  state.usedBoundary = attempt.usedBoundary;
1171
+ state.requestPrompt = request.prompt;
1126
1172
  state.detachedSession = attempt.detachedSession === true;
1127
1173
  result.attemptedRehydration = attempt.attemptedRehydration;
1128
1174
  this.recordAttemptContextSource(job, attempt);
@@ -1133,6 +1179,7 @@ export class BridgeJobExecutor {
1133
1179
  const repaired = await this.fallbackAttempt(job, controls, { request, attempt, replayAttempt }, state, error);
1134
1180
  if (repaired && controls.isActive()) {
1135
1181
  const repairState = createBridgeJobState();
1182
+ repairState.detachedSession = repaired.detachedSession === true;
1136
1183
  let repairResult = { costUsd: 0, attemptedRehydration: repaired.attemptedRehydration };
1137
1184
  try {
1138
1185
  repairResult = await this.runAttempt(job, controls, repairState, repaired);
@@ -1173,7 +1220,7 @@ export class BridgeJobExecutor {
1173
1220
  async interruptedResumeFallbackAttempt(job, replay, state, error) {
1174
1221
  if (!replay ||
1175
1222
  state.sawUserVisibleSdkMessage ||
1176
- (error instanceof Error && error.name === "SdkResultError") ||
1223
+ (error instanceof Error && error.name === "SdkResultError" && !isCliResumeRefusal(error)) ||
1177
1224
  !isResumeSessionError(error)) {
1178
1225
  return null;
1179
1226
  }
@@ -1368,18 +1415,29 @@ export class BridgeJobExecutor {
1368
1415
  if (!controls.isActive()) {
1369
1416
  return { kind: "cancelled", costUsd: result.costUsd, model, modelUsage };
1370
1417
  }
1418
+ const shown = userFacingTurnError(error);
1419
+ if (shown !== errorMessage(error)) {
1420
+ console.error(`[BridgeJob] turn failed for thread ${job.threadId}: ${errorMessage(error)}`);
1421
+ }
1371
1422
  return {
1372
1423
  kind: "errored",
1373
1424
  costUsd: result.costUsd,
1374
1425
  model,
1375
1426
  modelUsage,
1376
- errors: [userFacingTurnError(errorMessage(error))],
1427
+ errors: [shown],
1377
1428
  };
1378
1429
  }
1379
1430
  usageBaselineFor(attempt) {
1380
1431
  const resumed = attempt.resumeOptions.resume;
1381
1432
  return resumed ? this.readUsageBaseline(resumed) : Promise.resolve(undefined);
1382
1433
  }
1434
+ alignResumedTranscript(attempt) {
1435
+ const resumed = attempt.resumeOptions.resume;
1436
+ if (!resumed) {
1437
+ return Promise.resolve();
1438
+ }
1439
+ return this.alignServedModels ? this.alignServedModels(resumed) : alignServedModels(resumed, claudeConfigDir());
1440
+ }
1383
1441
  async awaitReplacedCliExit(next) {
1384
1442
  if (next.resumeOptions.resume && this.stalledCliExitMs > 0) {
1385
1443
  await new Promise((resolve) => setTimeout(resolve, this.stalledCliExitMs));
@@ -1445,7 +1503,7 @@ export class BridgeJobExecutor {
1445
1503
  async invalidResumeFallbackAttempt(job, controls, request, attempt, state, error) {
1446
1504
  if (!attempt.usedBoundary ||
1447
1505
  state.sawUserVisibleSdkMessage ||
1448
- (error instanceof Error && error.name === "SdkResultError") ||
1506
+ (error instanceof Error && error.name === "SdkResultError" && !isCliResumeRefusal(error)) ||
1449
1507
  !isResumeSessionError(error)) {
1450
1508
  return null;
1451
1509
  }
@@ -1636,6 +1694,7 @@ export class BridgeJobExecutor {
1636
1694
  // so a stall that lands after a clean result still contributes its snapshot.
1637
1695
  state.queryModelUsage = new Map();
1638
1696
  state.queryUsageBaseline = await this.usageBaselineFor(attempt);
1697
+ await this.alignResumedTranscript(attempt);
1639
1698
  const mcpServers = await this.resolveMcpServers(job);
1640
1699
  const skills = await readSessionSkills(bridgeRuntimeFiles.projectRoot());
1641
1700
  const autoCompactWindow = autoCompactWindowForModel(model);
@@ -2066,7 +2125,8 @@ export class BridgeJobExecutor {
2066
2125
  }
2067
2126
  /**
2068
2127
  * A message consumed here and never relayed or counted as the agent's work:
2069
- * a provider refusal for size, or part of a model refusal.
2128
+ * a provider refusal for size, the CLI's report of a failed model request, or
2129
+ * part of a model refusal.
2070
2130
  */
2071
2131
  droppedBeforeRelay(job, state, msg) {
2072
2132
  // Drop the size refusal outright. A recovered turn must not leave a raw
@@ -2088,6 +2148,22 @@ export class BridgeJobExecutor {
2088
2148
  state.turnText = "";
2089
2149
  return true;
2090
2150
  }
2151
+ if (isApiErrorMessage(msg)) {
2152
+ // The provider's words, never the product's: a turn that recovers shows
2153
+ // nothing, and one that fails says so once through the error outcome.
2154
+ const text = messageContentBlocks(msg)
2155
+ .map((block) => (typeof block.text === "string" ? block.text : ""))
2156
+ .join(" ");
2157
+ console.error(`[BridgeJob] model request failed for thread ${job.threadId}: ${text}`);
2158
+ this.recordTurnEvent({
2159
+ threadId: job.threadId,
2160
+ turnId: job.turnId,
2161
+ event: "api_error",
2162
+ detail: { error: msg.error, text },
2163
+ });
2164
+ state.turnText = "";
2165
+ return true;
2166
+ }
2091
2167
  return this.observeModelRefusal(job, state, msg);
2092
2168
  }
2093
2169
  /**
@@ -2201,6 +2277,10 @@ export class BridgeJobExecutor {
2201
2277
  if (state.detachedSession) {
2202
2278
  return;
2203
2279
  }
2280
+ if (reason === "abort") {
2281
+ await this.keepCancelledTurn(job, controls, state);
2282
+ return;
2283
+ }
2204
2284
  if (!state.usedBoundary && !state.inProgressSession.sawSessionId) {
2205
2285
  return;
2206
2286
  }
@@ -2212,6 +2292,25 @@ export class BridgeJobExecutor {
2212
2292
  });
2213
2293
  await this.sessionStore.clear(job.threadId, { runToken: controls.runToken, reason });
2214
2294
  }
2295
+ // A cancel (Stop, a replacing message, an answered card) is not a broken
2296
+ // session: the next turn resumes what this one completed (`buildCancelBoundary`).
2297
+ async keepCancelledTurn(job, controls, state) {
2298
+ const boundary = buildCancelBoundary({
2299
+ inProgress: state.inProgressSession,
2300
+ resumed: state.usedBoundary,
2301
+ request: state.requestPrompt,
2302
+ nowMs: this.now(),
2303
+ });
2304
+ this.recordTurnEvent({
2305
+ threadId: job.threadId,
2306
+ turnId: job.turnId,
2307
+ event: "cancel_resume_point",
2308
+ detail: { resumeFrom: state.inProgressSession.cancelPoint ? "cancelled_turn" : "previous_turn" },
2309
+ });
2310
+ if (boundary) {
2311
+ await this.sessionStore.save(job.threadId, boundary, { runToken: controls.runToken });
2312
+ }
2313
+ }
2215
2314
  // Parks the turn on a blocking dashboard prompt. An unanswered prompt must
2216
2315
  // NOT error the turn: the wait window ending resolves to `unanswered` so the
2217
2316
  // caller can deny the tool gracefully — the agent ends its turn cleanly and