@koda-sl/baker-bridge 0.152.0-dev.1a19b1876 → 0.153.0-dev.ded7a9497

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 (61) hide show
  1. package/README.md +33 -7
  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 +5 -8
  10. package/dist/hono/agent.js.map +1 -1
  11. package/dist/hono/bridge-job.d.ts +9 -1
  12. package/dist/hono/bridge-job.d.ts.map +1 -1
  13. package/dist/hono/bridge-job.js +151 -20
  14. package/dist/hono/bridge-job.js.map +1 -1
  15. package/dist/hono/bridge-job.test.js +233 -2
  16. package/dist/hono/bridge-job.test.js.map +1 -1
  17. package/dist/hono/convex.d.ts +2 -1
  18. package/dist/hono/convex.d.ts.map +1 -1
  19. package/dist/hono/convex.js +4 -0
  20. package/dist/hono/convex.js.map +1 -1
  21. package/dist/hono/main-sync.d.ts.map +1 -1
  22. package/dist/hono/main-sync.js +18 -5
  23. package/dist/hono/main-sync.js.map +1 -1
  24. package/dist/hono/main-sync.test.js +12 -0
  25. package/dist/hono/main-sync.test.js.map +1 -1
  26. package/dist/hono/relay.d.ts +8 -1
  27. package/dist/hono/relay.d.ts.map +1 -1
  28. package/dist/hono/relay.js +3 -0
  29. package/dist/hono/relay.js.map +1 -1
  30. package/dist/hono/served-model.d.ts +23 -0
  31. package/dist/hono/served-model.d.ts.map +1 -0
  32. package/dist/hono/served-model.js +82 -0
  33. package/dist/hono/served-model.js.map +1 -0
  34. package/dist/hono/served-model.test.d.ts +2 -0
  35. package/dist/hono/served-model.test.d.ts.map +1 -0
  36. package/dist/hono/served-model.test.js +29 -0
  37. package/dist/hono/served-model.test.js.map +1 -0
  38. package/dist/hono/session-continuity.d.ts +30 -0
  39. package/dist/hono/session-continuity.d.ts.map +1 -1
  40. package/dist/hono/session-continuity.integration.test.js +201 -5
  41. package/dist/hono/session-continuity.integration.test.js.map +1 -1
  42. package/dist/hono/session-continuity.js +99 -8
  43. package/dist/hono/session-continuity.js.map +1 -1
  44. package/dist/hono/session-store.d.ts +2 -0
  45. package/dist/hono/session-store.d.ts.map +1 -1
  46. package/dist/hono/session-store.js +6 -0
  47. package/dist/hono/session-store.js.map +1 -1
  48. package/dist/hono/session-usage-baseline.d.ts +1 -0
  49. package/dist/hono/session-usage-baseline.d.ts.map +1 -1
  50. package/dist/hono/session-usage-baseline.js +17 -4
  51. package/dist/hono/session-usage-baseline.js.map +1 -1
  52. package/dist/hono/turn-events.d.ts +1 -1
  53. package/dist/hono/turn-events.d.ts.map +1 -1
  54. package/dist/hono/turn-events.js.map +1 -1
  55. package/dist/review/digest.d.ts +35 -0
  56. package/dist/review/digest.d.ts.map +1 -1
  57. package/dist/review/digest.js +171 -27
  58. package/dist/review/digest.js.map +1 -1
  59. package/dist/review/digest.test.js +130 -1
  60. package/dist/review/digest.test.js.map +1 -1
  61. package/package.json +1 -1
@@ -26,7 +26,8 @@ import { BEDROCK_SERVABLE_CAPABILITIES, buildAgentExtraBody, EXTRA_BODY_ENV, MOD
26
26
  import { credentialReminder, currentReferenceReminder, kickoffBriefReminder } from "./prompt-context.js";
27
27
  import { publishLanguageReminder, publishRunReminder } from "./publish-handoff.js";
28
28
  import { bridgeRuntimeFiles } from "./runtime-files.js";
29
- import { applyCurrentSenderAttribution, buildCleanBoundary, buildRehydratedPrompt, compactionRetryAttempt, emptyInProgressSession, interruptedResumeAttempt, isContextOverflowError, isResumeSessionError, observeInProgressSession, prepareSessionAttempt, stallContinuationAttempt, } from "./session-continuity.js";
29
+ import { alignServedModels } from "./served-model.js";
30
+ import { applyCurrentSenderAttribution, buildCancelBoundary, buildCleanBoundary, buildRehydratedPrompt, compactionRetryAttempt, emptyInProgressSession, interruptedResumeAttempt, isCliResumeRefusal, isContextOverflowError, isResumeSessionError, observeInProgressSession, prepareSessionAttempt, stallContinuationAttempt, } from "./session-continuity.js";
30
31
  import { readSessionSkills } from "./session-skills.js";
31
32
  import { ownQueryUsage, readUsageBaseline } from "./session-usage-baseline.js";
32
33
  import { BridgeSlashCommands, leadingSkillCommand, verbatimSlashCommand } from "./slash-commands.js";
@@ -424,10 +425,20 @@ const CONTEXT_OVERFLOW_MESSAGE = "This chat has grown too long to continue in on
424
425
  // so the turn ends on this instead. The refused exchange is not saved to the
425
426
  // session, so a rephrased message starts from the conversation before it.
426
427
  const MODEL_REFUSAL_MESSAGE = "I couldn't help with that request. Try rephrasing what you need.";
427
- function userFacingTurnError(message) {
428
+ // What the user reads when the model provider failed the request itself — out
429
+ // of credit, rate-limited, down. The CLI's own text ("API Error: 402 Insufficient
430
+ // credits. Add more using https://openrouter.ai/…") is the provider talking to
431
+ // Baker: a vendor, a status code and a billing page the marketer cannot open.
432
+ // The raw text stays in the bridge log and the turn events.
433
+ 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.";
434
+ function userFacingTurnError(error) {
435
+ const message = errorMessage(error);
428
436
  if (isContextOverflowError(message)) {
429
437
  return CONTEXT_OVERFLOW_MESSAGE;
430
438
  }
439
+ if (error instanceof SdkResultError && error.providerFailure) {
440
+ return PROVIDER_FAILURE_MESSAGE;
441
+ }
431
442
  return enrichImageError(message);
432
443
  }
433
444
  function enrichImageError(message) {
@@ -468,6 +479,9 @@ function subagentModelEnv() {
468
479
  const override = agentModelOverride();
469
480
  return override ? { CLAUDE_CODE_SUBAGENT_MODEL: override, CLAUDE_CODE_SUBAGENT_MODEL_FORCE: "1" } : {};
470
481
  }
482
+ function claudeConfigDir() {
483
+ return process.env.CLAUDE_CONFIG_DIR || resolve(process.env.HOME || homedir(), ".claude");
484
+ }
471
485
  function claudePlanDir() {
472
486
  return resolve(process.env.HOME || homedir(), ".claude", "plans");
473
487
  }
@@ -565,14 +579,35 @@ function sdkResultErrorMessage(msg) {
565
579
  function turnResultError(state, msg) {
566
580
  return state.refusedWithoutFallback ? undefined : sdkResultError(msg);
567
581
  }
582
+ class SdkResultError extends Error {
583
+ providerFailure;
584
+ name = "SdkResultError";
585
+ constructor(message,
586
+ /** The model provider refused or failed the request, rather than the turn's own work failing. */
587
+ providerFailure) {
588
+ super(`Claude Code returned an error result: ${message}`);
589
+ this.providerFailure = providerFailure;
590
+ }
591
+ }
592
+ // What the request's own content caused (an unreadable image, a malformed or
593
+ // oversized request): sending the same message again would fail the same way, so
594
+ // it keeps its own remediation instead of the provider-failure message.
595
+ const REQUEST_CONTENT_STATUSES = [400, 413, 422];
596
+ function isProviderFailureResult(msg) {
597
+ if (msg.subtype !== "success") {
598
+ return false;
599
+ }
600
+ const status = msg.api_error_status ?? Number(/^API Error: (\d{3})\b/.exec(msg.result)?.[1]);
601
+ if (REQUEST_CONTENT_STATUSES.includes(status)) {
602
+ return false;
603
+ }
604
+ return typeof msg.api_error_status === "number" || msg.result.startsWith("API Error");
605
+ }
568
606
  function sdkResultError(msg) {
569
607
  if (msg.type !== "result" || !msg.is_error) {
570
608
  return undefined;
571
609
  }
572
- const message = sdkResultErrorMessage(msg);
573
- const error = new Error(`Claude Code returned an error result: ${message}`);
574
- error.name = "SdkResultError";
575
- return error;
610
+ return new SdkResultError(sdkResultErrorMessage(msg), isProviderFailureResult(msg));
576
611
  }
577
612
  // When the bridge closes the sandbox CLI's stdin (its permission control
578
613
  // channel) — on abort/replace/timeout — while the CLI is still alive (a long
@@ -637,6 +672,22 @@ export function isControlChannelDeny(msg) {
637
672
  }
638
673
  return false;
639
674
  }
675
+ // A tool result's prefix sent for the history replay, which keeps only its head.
676
+ const TOOL_OUTCOME_TEXT_CHARS = 2000;
677
+ /** How each tool call answered in a `user` message came out, bounded to what history replays. */
678
+ export function toolOutcomesInMessage(msg) {
679
+ if (msg.type !== "user")
680
+ return [];
681
+ return messageContentBlocks(msg).flatMap((block) => block.type === "tool_result" && typeof block.tool_use_id === "string"
682
+ ? [
683
+ {
684
+ toolUseId: block.tool_use_id,
685
+ isError: block.is_error === true,
686
+ text: toolResultText(block.content).slice(0, TOOL_OUTCOME_TEXT_CHARS),
687
+ },
688
+ ]
689
+ : []);
690
+ }
640
691
  function controlChannelClosedError() {
641
692
  const error = new Error("The sandbox tool control-channel closed mid-turn — work outlived the turn it was started in " +
642
693
  "(a backgrounded command or a subagent waking the model back up), so further tool calls were " +
@@ -686,6 +737,15 @@ export function isContextOverflowRefusal(msg) {
686
737
  }
687
738
  return OVERFLOW_REFUSAL_LEADS.some((lead) => text.startsWith(lead));
688
739
  }
740
+ /**
741
+ * True when an assistant message is the CLI's own report of a failed model
742
+ * request ("API Error: 402 Insufficient credits…", "API Error: Server error
743
+ * mid-response…"), not anything the model wrote. The CLI writes it for a
744
+ * terminal user: a `<synthetic>` model and the request's `error` class.
745
+ */
746
+ function isApiErrorMessage(msg) {
747
+ return msg.type === "assistant" && msg.error !== undefined && msg.message.model === "<synthetic>";
748
+ }
689
749
  /** Content blocks of an assistant/user SDK message; [] for other shapes. */
690
750
  function messageContentBlocks(msg) {
691
751
  const content = msg.message?.content;
@@ -1084,8 +1144,7 @@ export class BridgeJobExecutor {
1084
1144
  this.turnEndExitGuardMs = dependencies.turnEndExitGuardMs ?? DEFAULT_TURN_END_EXIT_GUARD_MS;
1085
1145
  this.now = dependencies.now ?? Date.now;
1086
1146
  this.readUsageBaseline =
1087
- dependencies.readUsageBaseline ??
1088
- ((sessionId) => readUsageBaseline(sessionId, process.env.CLAUDE_CONFIG_DIR || resolve(process.env.HOME || homedir(), ".claude")));
1147
+ dependencies.readUsageBaseline ?? ((sessionId) => readUsageBaseline(sessionId, claudeConfigDir()));
1089
1148
  this.stalledCliExitMs = dependencies.stalledCliExitMs ?? STALLED_CLI_EXIT_MS;
1090
1149
  this.settleWorkspace = dependencies.settleWorkspace ?? (() => settleWorkspaceAfterTasks());
1091
1150
  this.catchUpWithMain = dependencies.catchUpWithMain;
@@ -1122,12 +1181,14 @@ export class BridgeJobExecutor {
1122
1181
  replayAttempt = prepareSessionAttempt({
1123
1182
  requestPrompt: request.prompt,
1124
1183
  reminder: request.reminder,
1184
+ delivered: request.mainSync?.delivered,
1125
1185
  history: job.history,
1126
1186
  loaded,
1127
1187
  leadingCommand: request.leadingCommand,
1128
1188
  });
1129
- attempt = (await this.interruptedTurnAttempt(job, replayAttempt)) ?? replayAttempt;
1189
+ attempt = (await this.interruptedTurnAttempt(job, replayAttempt, request.mainSync)) ?? replayAttempt;
1130
1190
  state.usedBoundary = attempt.usedBoundary;
1191
+ state.requestPrompt = request.prompt;
1131
1192
  state.detachedSession = attempt.detachedSession === true;
1132
1193
  result.attemptedRehydration = attempt.attemptedRehydration;
1133
1194
  this.recordAttemptContextSource(job, attempt);
@@ -1138,6 +1199,7 @@ export class BridgeJobExecutor {
1138
1199
  const repaired = await this.fallbackAttempt(job, controls, { request, attempt, replayAttempt }, state, error);
1139
1200
  if (repaired && controls.isActive()) {
1140
1201
  const repairState = createBridgeJobState();
1202
+ repairState.detachedSession = repaired.detachedSession === true;
1141
1203
  let repairResult = { costUsd: 0, attemptedRehydration: repaired.attemptedRehydration };
1142
1204
  try {
1143
1205
  repairResult = await this.runAttempt(job, controls, repairState, repaired);
@@ -1163,9 +1225,9 @@ export class BridgeJobExecutor {
1163
1225
  return await this.invalidResumeFallbackAttempt(job, controls, tried.request, tried.attempt, state, error);
1164
1226
  }
1165
1227
  /** The turn's own session, when Convex sent again a turn this bridge was running when it went down. */
1166
- async interruptedTurnAttempt(job, replay) {
1228
+ async interruptedTurnAttempt(job, replay, mainSync) {
1167
1229
  const inFlight = await this.inFlightTurns.load(job.threadId).catch(() => undefined);
1168
- const resumed = interruptedResumeAttempt(replay, { jobTurnId: job.turnId, inFlight });
1230
+ const resumed = interruptedResumeAttempt(replay, { jobTurnId: job.turnId, inFlight, mainSync });
1169
1231
  if (inFlight && !resumed) {
1170
1232
  await this.inFlightTurns.clear(job.threadId, inFlight.turnId).catch(() => undefined);
1171
1233
  }
@@ -1178,7 +1240,7 @@ export class BridgeJobExecutor {
1178
1240
  async interruptedResumeFallbackAttempt(job, replay, state, error) {
1179
1241
  if (!replay ||
1180
1242
  state.sawUserVisibleSdkMessage ||
1181
- (error instanceof Error && error.name === "SdkResultError") ||
1243
+ (error instanceof Error && error.name === "SdkResultError" && !isCliResumeRefusal(error)) ||
1182
1244
  !isResumeSessionError(error)) {
1183
1245
  return null;
1184
1246
  }
@@ -1216,17 +1278,21 @@ export class BridgeJobExecutor {
1216
1278
  leadingSkillCommand(job.prompt, (await readSessionSkills(bridgeRuntimeFiles.projectRoot())) ?? []);
1217
1279
  let requestPrompt = verbatim ?? applyCurrentSenderAttribution(job.prompt, job.currentSender);
1218
1280
  // A command must stay the leading token, so it carries no note: the news waits for the next turn.
1219
- const reminder = turnReminder(job, verbatim, verbatim === null ? mainSyncReminder(mainSync) : undefined);
1281
+ const syncNote = verbatim === null ? mainSyncReminder(mainSync) : undefined;
1282
+ const reminder = turnReminder(job, verbatim, syncNote);
1220
1283
  if (job.attachments && job.attachments.length > 0) {
1221
1284
  requestPrompt += await this.prepareAttachments(job.attachments, "message");
1222
1285
  }
1223
1286
  if (!controls.isActive()) {
1224
1287
  return null;
1225
1288
  }
1226
- if (verbatim === null) {
1227
- await mainSync?.told();
1228
- }
1229
- return { prompt: requestPrompt, ...(reminder ? { reminder } : {}), leadingCommand: verbatim !== null };
1289
+ return {
1290
+ prompt: requestPrompt,
1291
+ ...(reminder ? { reminder } : {}),
1292
+ leadingCommand: verbatim !== null,
1293
+ // Told only when an attempt carrying the note is sent (`runQueryOnce`).
1294
+ ...(mainSync && syncNote ? { mainSync: { reminder: syncNote, delivered: mainSync.told } } : {}),
1295
+ };
1230
1296
  }
1231
1297
  /**
1232
1298
  * The reviewer's prompt: the brief Convex wrote, with the evidence block this
@@ -1383,18 +1449,29 @@ export class BridgeJobExecutor {
1383
1449
  if (!controls.isActive()) {
1384
1450
  return { kind: "cancelled", costUsd: result.costUsd, model, modelUsage };
1385
1451
  }
1452
+ const shown = userFacingTurnError(error);
1453
+ if (shown !== errorMessage(error)) {
1454
+ console.error(`[BridgeJob] turn failed for thread ${job.threadId}: ${errorMessage(error)}`);
1455
+ }
1386
1456
  return {
1387
1457
  kind: "errored",
1388
1458
  costUsd: result.costUsd,
1389
1459
  model,
1390
1460
  modelUsage,
1391
- errors: [userFacingTurnError(errorMessage(error))],
1461
+ errors: [shown],
1392
1462
  };
1393
1463
  }
1394
1464
  usageBaselineFor(attempt) {
1395
1465
  const resumed = attempt.resumeOptions.resume;
1396
1466
  return resumed ? this.readUsageBaseline(resumed) : Promise.resolve(undefined);
1397
1467
  }
1468
+ alignResumedTranscript(attempt) {
1469
+ const resumed = attempt.resumeOptions.resume;
1470
+ if (!resumed) {
1471
+ return Promise.resolve();
1472
+ }
1473
+ return alignServedModels(resumed, claudeConfigDir());
1474
+ }
1398
1475
  async awaitReplacedCliExit(next) {
1399
1476
  if (next.resumeOptions.resume && this.stalledCliExitMs > 0) {
1400
1477
  await new Promise((resolve) => setTimeout(resolve, this.stalledCliExitMs));
@@ -1460,7 +1537,7 @@ export class BridgeJobExecutor {
1460
1537
  async invalidResumeFallbackAttempt(job, controls, request, attempt, state, error) {
1461
1538
  if (!attempt.usedBoundary ||
1462
1539
  state.sawUserVisibleSdkMessage ||
1463
- (error instanceof Error && error.name === "SdkResultError") ||
1540
+ (error instanceof Error && error.name === "SdkResultError" && !isCliResumeRefusal(error)) ||
1464
1541
  !isResumeSessionError(error)) {
1465
1542
  return null;
1466
1543
  }
@@ -1480,6 +1557,7 @@ export class BridgeJobExecutor {
1480
1557
  return {
1481
1558
  prompt: buildRehydratedPrompt(rebuilt.prompt, job.history),
1482
1559
  ...(rebuilt.reminder ? { reminder: rebuilt.reminder } : {}),
1560
+ ...(rebuilt.reminder && rebuilt.mainSync ? { delivered: rebuilt.mainSync.delivered } : {}),
1483
1561
  attemptedRehydration: true,
1484
1562
  resumeOptions: {},
1485
1563
  };
@@ -1651,6 +1729,7 @@ export class BridgeJobExecutor {
1651
1729
  // so a stall that lands after a clean result still contributes its snapshot.
1652
1730
  state.queryModelUsage = new Map();
1653
1731
  state.queryUsageBaseline = await this.usageBaselineFor(attempt);
1732
+ await this.alignResumedTranscript(attempt);
1654
1733
  const mcpServers = await this.resolveMcpServers(job);
1655
1734
  const skills = await readSessionSkills(bridgeRuntimeFiles.projectRoot());
1656
1735
  const autoCompactWindow = autoCompactWindowForModel(model);
@@ -1772,6 +1851,7 @@ export class BridgeJobExecutor {
1772
1851
  },
1773
1852
  ...attempt.resumeOptions,
1774
1853
  };
1854
+ await markDelivered(attempt, controls);
1775
1855
  const query = this.sdk.query({ prompt: attempt.prompt, reminder: attempt.reminder, options });
1776
1856
  controls.reloadMcpServers = () => this.reloadMcpServers(job, query);
1777
1857
  let queryClosed = false;
@@ -2077,11 +2157,17 @@ export class BridgeJobExecutor {
2077
2157
  state.currentTurnTools = [];
2078
2158
  state.postedAssistantEvent = true;
2079
2159
  await this.relay.assistantEvent(target, msg, state.model);
2160
+ return;
2161
+ }
2162
+ const outcomes = toolOutcomesInMessage(msg);
2163
+ if (outcomes.length > 0) {
2164
+ await this.relay.toolOutcomes(target, outcomes);
2080
2165
  }
2081
2166
  }
2082
2167
  /**
2083
2168
  * A message consumed here and never relayed or counted as the agent's work:
2084
- * a provider refusal for size, or part of a model refusal.
2169
+ * a provider refusal for size, the CLI's report of a failed model request, or
2170
+ * part of a model refusal.
2085
2171
  */
2086
2172
  droppedBeforeRelay(job, state, msg) {
2087
2173
  // Drop the size refusal outright. A recovered turn must not leave a raw
@@ -2103,6 +2189,22 @@ export class BridgeJobExecutor {
2103
2189
  state.turnText = "";
2104
2190
  return true;
2105
2191
  }
2192
+ if (isApiErrorMessage(msg)) {
2193
+ // The provider's words, never the product's: a turn that recovers shows
2194
+ // nothing, and one that fails says so once through the error outcome.
2195
+ const text = messageContentBlocks(msg)
2196
+ .map((block) => (typeof block.text === "string" ? block.text : ""))
2197
+ .join(" ");
2198
+ console.error(`[BridgeJob] model request failed for thread ${job.threadId}: ${text}`);
2199
+ this.recordTurnEvent({
2200
+ threadId: job.threadId,
2201
+ turnId: job.turnId,
2202
+ event: "api_error",
2203
+ detail: { error: msg.error, text },
2204
+ });
2205
+ state.turnText = "";
2206
+ return true;
2207
+ }
2106
2208
  return this.observeModelRefusal(job, state, msg);
2107
2209
  }
2108
2210
  /**
@@ -2216,6 +2318,10 @@ export class BridgeJobExecutor {
2216
2318
  if (state.detachedSession) {
2217
2319
  return;
2218
2320
  }
2321
+ if (reason === "abort") {
2322
+ await this.keepCancelledTurn(job, controls, state);
2323
+ return;
2324
+ }
2219
2325
  if (!state.usedBoundary && !state.inProgressSession.sawSessionId) {
2220
2326
  return;
2221
2327
  }
@@ -2227,6 +2333,25 @@ export class BridgeJobExecutor {
2227
2333
  });
2228
2334
  await this.sessionStore.clear(job.threadId, { runToken: controls.runToken, reason });
2229
2335
  }
2336
+ // A cancel (Stop, a replacing message, an answered card) is not a broken
2337
+ // session: the next turn resumes what this one completed (`buildCancelBoundary`).
2338
+ async keepCancelledTurn(job, controls, state) {
2339
+ const boundary = buildCancelBoundary({
2340
+ inProgress: state.inProgressSession,
2341
+ resumed: state.usedBoundary,
2342
+ request: state.requestPrompt,
2343
+ nowMs: this.now(),
2344
+ });
2345
+ this.recordTurnEvent({
2346
+ threadId: job.threadId,
2347
+ turnId: job.turnId,
2348
+ event: "cancel_resume_point",
2349
+ detail: { resumeFrom: state.inProgressSession.cancelPoint ? "cancelled_turn" : "previous_turn" },
2350
+ });
2351
+ if (boundary) {
2352
+ await this.sessionStore.save(job.threadId, boundary, { runToken: controls.runToken });
2353
+ }
2354
+ }
2230
2355
  // Parks the turn on a blocking dashboard prompt. An unanswered prompt must
2231
2356
  // NOT error the turn: the wait window ending resolves to `unanswered` so the
2232
2357
  // caller can deny the tool gracefully — the agent ends its turn cleanly and
@@ -2641,6 +2766,12 @@ function registerOrAutomateCasting(job, controls, toolName, command) {
2641
2766
  controls.avatarAwaitingCasting = created;
2642
2767
  return null;
2643
2768
  }
2769
+ /** The last step before the prompt goes out: a turn stopped before here never carried its note. */
2770
+ async function markDelivered(attempt, controls) {
2771
+ if (attempt.delivered && controls.isActive()) {
2772
+ await attempt.delivered();
2773
+ }
2774
+ }
2644
2775
  /** Baker's notes for the turn, sent as their own block beside the prompt (see `SessionAttempt.reminder`). */
2645
2776
  function turnReminder(job, verbatim, mainSync) {
2646
2777
  const reminders = [