@oar-sh/oar 0.9.1 → 0.9.3

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 (146) hide show
  1. package/.github/extensions/web-relay/extension.mjs +0 -3
  2. package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
  3. package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
  4. package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
  5. package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
  6. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
  7. package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
  8. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
  9. package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
  10. package/CHANGELOG.md +269 -0
  11. package/README.md +407 -424
  12. package/bin/oar.js +142 -11
  13. package/package.json +8 -4
  14. package/server/README.md +59 -22
  15. package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
  16. package/server/claude-worker/claude-session-process.mjs +1013 -45
  17. package/server/claude-worker/claude-session-worker.mjs +54 -9
  18. package/server/claude-worker/claude-turn-publisher.mjs +110 -19
  19. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  20. package/server/copilot-sdk-runtime.mjs +12 -3
  21. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  22. package/server/copilot-worker/copilot-attachments.mjs +35 -2
  23. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  24. package/server/copilot-worker/copilot-model-switch.mjs +425 -0
  25. package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
  26. package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
  27. package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
  28. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
  29. package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
  30. package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
  31. package/server/cursor-worker/cursor-session-worker.mjs +5 -5
  32. package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
  33. package/server/db-schema.mjs +61 -1
  34. package/server/features.mjs +155 -41
  35. package/server/grok-worker/grok-session-worker.mjs +5 -5
  36. package/server/grok-worker/grok-turn-runner.mjs +45 -4
  37. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  38. package/server/public/app/annotate-core.mjs +181 -0
  39. package/server/public/app/annotate-editor.js +617 -0
  40. package/server/public/app/api-client.js +86 -5
  41. package/server/public/app/ask-user-view.js +102 -13
  42. package/server/public/app/attachments-view.js +172 -3
  43. package/server/public/app/background-task-timeout-options.mjs +9 -0
  44. package/server/public/app/background-tasks-view.mjs +34 -19
  45. package/server/public/app/bootstrap.js +153 -69
  46. package/server/public/app/composer-control-state.mjs +52 -34
  47. package/server/public/app/context-tier-options.mjs +24 -4
  48. package/server/public/app/conversation-draft-sync.mjs +187 -0
  49. package/server/public/app/conversation-list-filter.mjs +86 -0
  50. package/server/public/app/conversation-preferences.mjs +47 -10
  51. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  52. package/server/public/app/conversation-view.js +1032 -218
  53. package/server/public/app/emoji-view.js +2 -0
  54. package/server/public/app/font-scaling.js +4 -37
  55. package/server/public/app/journal-view.js +190 -24
  56. package/server/public/app/model-selector-options.mjs +185 -1
  57. package/server/public/app/new-conversation-model-choice.mjs +29 -9
  58. package/server/public/app/pwa-install.js +64 -136
  59. package/server/public/app/question-multi-select.mjs +52 -0
  60. package/server/public/app/reasoning-effort-labels.mjs +13 -3
  61. package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
  62. package/server/public/app/router.js +120 -11
  63. package/server/public/app/settings-modal.js +225 -20
  64. package/server/public/app/settings-tabs.js +1 -1
  65. package/server/public/app/socket-handlers.js +47 -4
  66. package/server/public/app/steered-turn-merge.mjs +100 -0
  67. package/server/public/app/store.js +159 -16
  68. package/server/public/app/sync-outbox.mjs +16 -8
  69. package/server/public/app/tool-activity-emoji.mjs +36 -0
  70. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  71. package/server/public/app/update-ui.js +194 -0
  72. package/server/public/index.html +483 -24
  73. package/server/public/sw.js +7 -0
  74. package/server/relay-tools.md +2 -0
  75. package/server/relay.mjs +23 -21
  76. package/server/repositories/message-repository.mjs +78 -21
  77. package/server/repositories/question-repository.mjs +19 -1
  78. package/server/repositories/session-repository.mjs +7 -31
  79. package/server/routes/ask-user-routes.mjs +10 -0
  80. package/server/routes/messages-routes.mjs +689 -159
  81. package/server/routes/sessions-routes.mjs +574 -80
  82. package/server/server-runtime.mjs +393 -925
  83. package/server/services/claude-auth-service.mjs +7 -1
  84. package/server/services/cli-install-service.mjs +8 -27
  85. package/server/services/cli-process-runner.mjs +30 -0
  86. package/server/services/copilot-model-discovery-service.mjs +202 -0
  87. package/server/services/data-dir-resolution.mjs +47 -0
  88. package/server/services/grok-auth-service.mjs +9 -1
  89. package/server/services/live-turn-picker.mjs +47 -0
  90. package/server/services/model-variant-catalog-service.mjs +1183 -0
  91. package/server/services/oar-cli-helpers.mjs +6 -2
  92. package/server/services/relay-prompt-sanitizer.mjs +30 -3
  93. package/server/services/relay-question-context.mjs +46 -0
  94. package/server/services/sdk-session-import-service.mjs +144 -16
  95. package/server/services/sdk-session-sync-service.mjs +84 -27
  96. package/server/services/session-worker-registry-service.mjs +116 -3
  97. package/server/services/session-worker-supervisor-service.mjs +7 -1
  98. package/server/services/session-worker-websocket-service.mjs +119 -24
  99. package/server/services/status-event-service.mjs +8 -2
  100. package/server/services/steer-resend.mjs +45 -0
  101. package/server/services/update-check-service.mjs +220 -0
  102. package/server/services/update-install-service.mjs +279 -0
  103. package/server/services/windows-autostart-service.mjs +42 -6
  104. package/server/services/windows-boot-autostart-service.mjs +487 -0
  105. package/shared/ask-user-bridge.mjs +37 -5
  106. package/shared/background-task-timeout.mjs +12 -4
  107. package/shared/claude-model-catalog.mjs +49 -0
  108. package/shared/context-window-fallbacks.mjs +1 -0
  109. package/shared/control-poller.mjs +4 -1
  110. package/shared/copilot-model-order.mjs +182 -0
  111. package/shared/media-embed-instructions.mjs +35 -0
  112. package/shared/model-descriptors.mjs +181 -30
  113. package/shared/model-id.mjs +6 -0
  114. package/shared/pwa-app-name.mjs +44 -0
  115. package/shared/question-multi-select.mjs +16 -0
  116. package/shared/relay-stop-failure.mjs +15 -0
  117. package/shared/slash-command-guard.mjs +76 -0
  118. package/shared/steer-note.mjs +66 -0
  119. package/shared/steer-settle-failure.mjs +89 -0
  120. package/shared/steer-settle-markers.mjs +18 -0
  121. package/shared/update-semver.mjs +50 -0
  122. package/shared/worker-crash-guard.mjs +40 -14
  123. package/shared/worker-runtime/api-client.mjs +34 -0
  124. package/shared/worker-runtime/config-loader.mjs +104 -0
  125. package/shared/worker-runtime/heartbeat.mjs +109 -0
  126. package/shared/worker-runtime/prompt-context.mjs +160 -0
  127. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  128. package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
  129. package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
  130. package/server/claude-worker/claude-session-test-harness.mjs +0 -236
  131. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  132. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
  133. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  134. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  135. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  136. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  137. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  138. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  139. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  140. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  141. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  142. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  143. package/server/routes/messages-routes-test-harness.mjs +0 -111
  144. package/server/services/fixtures/claude-auth-stub.sh +0 -62
  145. package/server/services/fixtures/cli-install-stub.sh +0 -75
  146. package/server/services/fixtures/grok-stub.sh +0 -86
@@ -1,12 +1,15 @@
1
1
  import nodeFs from 'node:fs';
2
2
  import nodePath from 'node:path';
3
+ import { randomUUID } from 'crypto';
3
4
 
4
5
  import { buildClaudeUserContent } from './claude-attachments.mjs';
5
6
  import {
6
7
  compactBoundaryActivityAction,
7
8
  createSdkMessageNormalizer,
9
+ displayToolName,
8
10
  formatApiRetryNotice,
9
11
  isSubagentToolName,
12
+ summarizeToolInput,
10
13
  } from './sdk-message-normalizer.mjs';
11
14
  import { digestFromRunRecord, digestFromJournal } from './workflow-progress-digest.mjs';
12
15
  import { createClaudeSessionRootResolver } from '../services/claude-session-root-service.mjs';
@@ -25,8 +28,21 @@ import {
25
28
  } from './claude-sdk-adapter.mjs';
26
29
  import { parseThinkingDisplay, parseThinkingEnabled } from '../../shared/claude-thinking.mjs';
27
30
  import { relocateClaudeTranscriptForCwd } from './claude-transcript-relocator.mjs';
28
- import { createClaudeTurnPublisher } from './claude-turn-publisher.mjs';
31
+ import {
32
+ attemptFields,
33
+ buildSteerSettleFailure,
34
+ createClaudeTurnPublisher,
35
+ isStaleAttemptError,
36
+ } from './claude-turn-publisher.mjs';
29
37
  import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
38
+ import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
39
+ import { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT } from '../../shared/steer-settle-markers.mjs';
40
+ import { stripSteerNote, withSteerNoteContent } from '../../shared/steer-note.mjs';
41
+ import {
42
+ stripSlashCommandGuard,
43
+ stripSlashCommandGuardContent,
44
+ withSlashCommandGuardContent,
45
+ } from '../../shared/slash-command-guard.mjs';
30
46
 
31
47
  /**
32
48
  * Which system-prompt append a relay mode gets (claude-sdk-adapter's
@@ -41,8 +57,11 @@ function modeAppendClass(relayMode) {
41
57
  return mode === 'ask' || mode === 'autopilot' ? mode : 'none';
42
58
  }
43
59
 
60
+ export { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT };
61
+
44
62
  /** Task types that run a model of their own (vs. bash/monitor/workflow). */
45
63
  const AGENT_TASK_TYPES = new Set(['local_agent', 'agent', 'subagent']);
64
+ const MAX_TASK_TOOL_CALL_CHARS = 300;
46
65
 
47
66
  // ---------------------------------------------------------------------------
48
67
  // Workflow progress sources (CLI 2.1.226 on-disk formats)
@@ -264,13 +283,16 @@ function contentText(content) {
264
283
  function turnOpeningUserSignature(sdkMessage) {
265
284
  if (sdkMessage?.type !== 'user' || sdkMessage?.parent_tool_use_id) return null;
266
285
  const content = sdkMessage?.message?.content;
267
- if (typeof content === 'string') return content;
286
+ // A steered push carries the hidden steer note, and a "/x" the slash-command
287
+ // guard (shared/slash-command-guard.mjs); every comparison is against the
288
+ // user's own text, so both are stripped here, once, for all of them.
289
+ if (typeof content === 'string') return stripSteerNote(stripSlashCommandGuard(content));
268
290
  if (!Array.isArray(content)) return null;
269
291
  if (content.some((block) => block?.type === 'tool_result')) return null;
270
292
  // Unlike turnOpeningUserText this keeps '' — an attachment-only message
271
293
  // replays with an empty text block, and the absorbed-steering match must
272
294
  // still recognize it (expectedText is '' for those too).
273
- return contentText(content);
295
+ return stripSteerNote(contentText(stripSlashCommandGuardContent(content)));
274
296
  }
275
297
 
276
298
  function turnOpeningUserText(sdkMessage) {
@@ -404,6 +426,15 @@ export function createClaudeSessionRunner({
404
426
  // the slowest cold start observed; the primary absorbed-steering fix in
405
427
  // resolveContext should make this fire only for unknown variants.
406
428
  pendingDeliveredTimeoutMs = 5 * 60_000,
429
+ // How long a STEERED entry (pushed while a turn was already running) may sit
430
+ // unattached once the process goes idle before it is settled as merged into
431
+ // the turn it was folded into. The CLI folds a mid-turn push into the running
432
+ // turn and answers with one result on that turn, without replaying the
433
+ // steered text — so a short grace after idle (long enough to rule out the CLI
434
+ // opening its own turn for it, which it does immediately if at all) is all it
435
+ // takes to distinguish a fold from a queued run. Far below the watchdog so a
436
+ // folded row settles as answered, not failed with a "resend" error.
437
+ steeredFoldGraceMs = 2_000,
407
438
  // Backstop for the compaction hold. A compaction is announced ONCE by
408
439
  // `status: 'compacting'` (the CLI's periodic re-emit is gated on the
409
440
  // remote-control client's activity callback, which the plain SDK `query()`
@@ -421,9 +452,38 @@ export function createClaudeSessionRunner({
421
452
  // by the first turn to open regardless.
422
453
  compactReplayAdoptionMs = 60_000,
423
454
  continuationRetryDelayMs = 500,
455
+ // Backoff between attempts to save a consumed steer's settle marker (~31 s
456
+ // in all); after the last one the row fails terminally instead.
457
+ settleRetryDelaysMs = [1_000, 2_000, 4_000, 8_000, 8_000, 8_000],
458
+ // How many times the terminal failure itself is posted before the worker
459
+ // stops trying and hands the row to the relay via the heartbeat's
460
+ // `settleFailed` (a persistent HTTP error — say a 401 after a token change —
461
+ // would otherwise spin forever).
462
+ maxTerminalSettleAttempts = 5,
463
+ // After a Stop that cut off pushed steers, the CLI immediately opens a
464
+ // follow-up turn for them (a bare init, no user replay) that re-runs the
465
+ // interrupted tool call before answering (SDK probe 2026-09-24, CLI
466
+ // 2.1.281). A turn starting within this window of the Stop — or of the
467
+ // previous suppressed turn's end — is interrupted before it can do anything.
468
+ stopFollowUpWindowMs = 3_000,
469
+ // How long a stopped turn's row stays reported to the heartbeat after its
470
+ // interrupt and final publishes resolved (the abort ack is still landing),
471
+ // and the hard cap on that linger.
472
+ stoppedRowLingerMs = 5_000,
473
+ stoppedRowLingerMaxMs = 60_000,
474
+ // False while the connected relay does not understand the `steering-held`
475
+ // hand-back (a worker updated on disk before the relay restarted): a held
476
+ // delivery is then pushed the legacy way instead of being handed back into
477
+ // a retry-and-backoff requeue.
478
+ canHandBackHeldDelivery = () => true,
424
479
  workflowPollMs = 2_000,
425
480
  resolveWorkflowSessionDirImpl = null,
426
481
  askUserBridgeOptions = {},
482
+ // Called with `true`/`false` whenever the runner flips between accepting
483
+ // deliveries and holding them (see isDeliveryHeld). The worker wires it to
484
+ // the socket link: a hold withdraws the relay's readiness, and its end
485
+ // re-arms it at once so a held message steers into the resumed turn.
486
+ onDeliveryReadinessChange = () => {},
427
487
  dbg = () => {},
428
488
  } = {}) {
429
489
  const publisher = createClaudeTurnPublisher({
@@ -434,6 +494,35 @@ export function createClaudeSessionRunner({
434
494
  const resolveWorkflowSessionDir = resolveWorkflowSessionDirImpl || createDefaultWorkflowSessionDirResolver();
435
495
  let claudeNativeSessionId = '';
436
496
  let proc = null;
497
+ // Turn-opening deliveries between entry and push (adaptProcess and a cold
498
+ // spawn await in between); counted so a second delivery arriving in that
499
+ // gap is held instead of racing the first into the CLI.
500
+ let admittingDeliveries = 0;
501
+ // Settings re-inits (see the init handler). The window opens at
502
+ // adaptProcess's first control request — never when nothing changes — and
503
+ // closes when the delivery it serves is registered in pendingDelivered; the
504
+ // count is how many bare inits inside it are our own. A count, not a flag:
505
+ // a continuation the CLI opens inside the same window inits the same way,
506
+ // and ignoring every init there swallowed its boundary and swapped its
507
+ // answer with the delivered one. Over-counting (a request that never
508
+ // re-inits) only degrades to that. Some CLI paths merely mark the init
509
+ // stale and re-init when the pushed input is dequeued; that init lands
510
+ // after the window, in the delivered-row branch. Known gap: when that
511
+ // happens AND a continuation inits inside the window, the continuation's
512
+ // init is taken for ours and its answer attaches to the delivered row —
513
+ // the same misattribution as a continuation whose init follows the push.
514
+ let settingsReinitsOwed = 0;
515
+ let settingsWindowOpen = false;
516
+ let lastReportedDeliveryReady = true;
517
+ // Rows whose prompt the CLI consumed and whose settle marker is being saved
518
+ // (id → relay message). Runner-level, not per process: a process dying
519
+ // mid-settle must not drop them from the heartbeat's owned set.
520
+ const settlingMessages = new Map();
521
+ // id → terminalError for settling rows the worker gave up posting; reported
522
+ // in the heartbeat's `settleFailed` until the relay confirms it failed them.
523
+ const settleFailedTerminals = new Map();
524
+ // Row id → the latest in-flight /api/queue-consumed POST touching it.
525
+ const consumedMarkChains = new Map();
437
526
 
438
527
  async function persistNativeSessionId(conversationId, sessionId) {
439
528
  const normalized = String(sessionId || '').trim();
@@ -469,16 +558,104 @@ export function createClaudeSessionRunner({
469
558
  return String(proc.activeCtx?.message?.id || proc.pendingDelivered[0]?.ctx.message?.id || '');
470
559
  }
471
560
 
472
- // Every queue row this worker currently owes work for: the running turn
473
- // (delivered or continuation) plus any delivered message queued behind it.
474
- // The heartbeat reports all of them so the server's owner-recovery never
475
- // replays a row the process still holds.
561
+ // Every queue row this worker currently owes work for, as { id, attemptId }
562
+ // entries: the running turn (delivered or continuation), any delivered
563
+ // message queued behind it, and every consumed steer whose settle marker is
564
+ // still being saved. The heartbeat reports all of them (id-only — its call
565
+ // site unwraps) so the server's owner-recovery never replays a row the
566
+ // worker still holds; the crash guard takes the entries whole so its
567
+ // requeues stay fenced to this attempt — and a settling row carries the
568
+ // terminal failure the guard must send instead of a requeue, because its
569
+ // prompt must never run twice.
476
570
  function getActiveQueueMessageIds() {
477
- if (!proc) return [];
478
- return [
479
- proc.activeCtx?.message?.id,
480
- ...proc.pendingDelivered.map((entry) => entry.ctx.message?.id),
481
- ].map((id) => String(id || '').trim()).filter(Boolean);
571
+ const live = proc
572
+ ? [proc.activeCtx?.message, ...proc.pendingDelivered.map((entry) => entry.ctx.message)]
573
+ : [];
574
+ const entries = live.map((message) => ({
575
+ id: String(message?.id || '').trim(),
576
+ attemptId: message?.attemptId || null,
577
+ }));
578
+ // A stopped row whose abort ack has not landed yet: the crash guard (and
579
+ // SIGTERM) must fail it the way the ack would, never requeue it — that
580
+ // would re-run a prompt the user explicitly stopped.
581
+ for (const { message } of lingeringStopped.values()) {
582
+ entries.push({
583
+ id: String(message?.id || '').trim(),
584
+ attemptId: message?.attemptId || null,
585
+ terminalError: buildRelayStopFailure(),
586
+ });
587
+ }
588
+ for (const { message, variant } of settlingMessages.values()) {
589
+ entries.push({
590
+ id: String(message?.id || '').trim(),
591
+ attemptId: message?.attemptId || null,
592
+ terminalError: buildSteerSettleFailure(message, { variant, agentLabel: 'Claude' }),
593
+ });
594
+ }
595
+ return entries.filter((entry) => entry.id);
596
+ }
597
+
598
+ /**
599
+ * Claim a consumed row as settling — synchronously, BEFORE whatever detaches
600
+ * it from pendingDelivered/activeCtx, so no heartbeat can see it unowned.
601
+ * `variant` picks the terminal wording should its settle never land.
602
+ */
603
+ function claimSettling(message, variant = 'folded') {
604
+ const id = String(message?.id || '').trim();
605
+ if (id && !settlingMessages.has(id)) settlingMessages.set(id, { message, variant });
606
+ }
607
+
608
+ /**
609
+ * Mark rows whose prompt the CLI has taken as consumed on the relay, for the
610
+ * paths where no response carries the mark (a stopped turn, a handoff).
611
+ * Best-effort: the worker's own settle still owns these rows while it lives;
612
+ * the mark only protects them when it does not.
613
+ */
614
+ function markConsumed(conversationId, entries = [], { consumed = true } = {}) {
615
+ const rows = entries
616
+ .map((entry) => ({ id: String(entry?.id || '').trim(), attemptId: entry?.attemptId || null }))
617
+ .filter((entry) => entry.id);
618
+ if (!rows.length) return Promise.resolve();
619
+ // Per-row ordering: a stopped turn's mark and the un-mark of a steer that
620
+ // then runs its own turn are separate fire-and-forget POSTs, and must
621
+ // reach the relay in the order they were decided.
622
+ const previous = Promise.allSettled(rows.map((row) => consumedMarkChains.get(row.id)).filter(Boolean));
623
+ const post = previous.then(() => api('POST', '/api/queue-consumed', {
624
+ conversationId: String(conversationId || sdkSessionId || ''),
625
+ entries: rows,
626
+ ...(consumed ? {} : { consumed: false }),
627
+ })).catch((error) => {
628
+ dbg('consumed mark failed', rows.map((entry) => entry.id).join(','), error?.message || String(error));
629
+ });
630
+ for (const row of rows) consumedMarkChains.set(row.id, post);
631
+ post.then(() => {
632
+ for (const row of rows) if (consumedMarkChains.get(row.id) === post) consumedMarkChains.delete(row.id);
633
+ });
634
+ return post;
635
+ }
636
+
637
+
638
+ /** Release a settled row — unless it was handed to the relay to fail. */
639
+ function releaseSettling(id) {
640
+ const key = String(id || '').trim();
641
+ if (!settleFailedTerminals.has(key)) settlingMessages.delete(key);
642
+ }
643
+
644
+ /** Heartbeat payload: rows whose terminal failure the relay must post. */
645
+ function getSettleFailed() {
646
+ return [...settleFailedTerminals.entries()].map(([id, terminalError]) => ({
647
+ id,
648
+ attemptId: settlingMessages.get(id)?.message?.attemptId || null,
649
+ terminalError,
650
+ }));
651
+ }
652
+
653
+ function acknowledgeSettleFailed(ids = []) {
654
+ for (const id of ids) {
655
+ const key = String(id || '').trim();
656
+ if (!settleFailedTerminals.delete(key)) continue;
657
+ settlingMessages.delete(key);
658
+ }
482
659
  }
483
660
 
484
661
  function isTurnActive() {
@@ -516,6 +693,7 @@ export function createClaudeSessionRunner({
516
693
  discarded: false,
517
694
  registered: kind === 'delivered',
518
695
  bufferedActions: [],
696
+ resultBuffered: false,
519
697
  controlState: null,
520
698
  finalized: false,
521
699
  resolveDone: null,
@@ -539,6 +717,7 @@ export function createClaudeSessionRunner({
539
717
  proc.continuationInitPending = false;
540
718
  proc.notificationPendingAt = 0;
541
719
  proc.taskSettledAt = 0;
720
+ proc.lastApiRetryAt = 0;
542
721
  if (proc.initModel && !ctx.state.responseModel) ctx.state.responseModel = proc.initModel;
543
722
  ctx.controlState = controlPoller?.start?.({
544
723
  queueMessageId: ctx.message?.id || '',
@@ -572,13 +751,33 @@ export function createClaudeSessionRunner({
572
751
  * back to back can replay out of order, and pairing by position alone would
573
752
  * answer each with the other's turn.
574
753
  */
575
- function attachDeliveredContext(index = 0) {
754
+ function attachDeliveredContext(index = 0, { provisional = false } = {}) {
576
755
  const [entry] = proc.pendingDelivered.splice(index, 1);
577
756
  entry.ctx.deliveredEntry = entry;
757
+ // A provisional (post-compaction) attach may still be unwound back onto
758
+ // the queue; its un-mark waits for the adoption to commit.
759
+ if (!provisional) unmarkConsumedOnOwnTurn(entry);
578
760
  activateContext(entry.ctx);
579
761
  return entry.ctx;
580
762
  }
581
763
 
764
+ /**
765
+ * A steer marked consumed by the turn it was pushed into, now running as a
766
+ * turn of its own: from here it is an ordinary in-flight row, and a worker
767
+ * death mid-turn must not fail it with "check the reply above".
768
+ */
769
+ function unmarkConsumedOnOwnTurn(entry) {
770
+ if (!entry?.consumedMarked) return;
771
+ entry.consumedMarked = false;
772
+ markConsumed(entry.ctx.message?.conversationId, [entry.ctx.message], { consumed: false });
773
+ }
774
+
775
+ /** A provisional post-compaction adoption became final. */
776
+ function commitAdoption(ctx) {
777
+ ctx.adoptedFromCompaction = false;
778
+ unmarkConsumedOnOwnTurn(ctx.deliveredEntry);
779
+ }
780
+
582
781
  /**
583
782
  * Undo a post-compaction adoption: this turn was never the delivered
584
783
  * message's, and nothing has published on it (the provisional flag is
@@ -611,6 +810,9 @@ export function createClaudeSessionRunner({
611
810
  // not to the row: left set, `finalizeContext` would take its interrupted
612
811
  // branch when the row finally runs for real and publish no response at all.
613
812
  ctx.interrupted = false;
813
+ // Nothing will close this context as a stopped turn now, so a Stop guard
814
+ // armed by that abort opens its window here instead.
815
+ anchorStopFollowUpWindow();
614
816
  controlPoller?.stop?.(ctx.controlState);
615
817
  ctx.controlState = null;
616
818
  const entry = ctx.deliveredEntry;
@@ -648,6 +850,10 @@ export function createClaudeSessionRunner({
648
850
  ctx.registered = false;
649
851
  activateContext(ctx);
650
852
  (async () => {
853
+ // One idempotency key for the whole loop: a retry whose predecessor was
854
+ // created server-side but whose response was lost must get the SAME row
855
+ // back, not mint a sibling nobody will ever settle.
856
+ const operationId = randomUUID();
651
857
  let response = null;
652
858
  // Retry on any registration that produced no message id — a truthy but
653
859
  // empty response body must not end the loop early.
@@ -659,6 +865,7 @@ export function createClaudeSessionRunner({
659
865
  sdkSessionId,
660
866
  relayMode: ctx.message.relayMode,
661
867
  trigger: 'background_task',
868
+ operationId,
662
869
  }).catch((error) => {
663
870
  dbg('continuation turn registration failed', error?.message || String(error));
664
871
  return null;
@@ -673,17 +880,24 @@ export function createClaudeSessionRunner({
673
880
  dbg('continuation turn discarded (no relay message id)');
674
881
  return;
675
882
  }
883
+ const attemptId = String(response.attemptId || '') || null;
676
884
  if (ctx.discarded || ctx.finalized) {
677
885
  // The context was handed off or settled while registration was in
678
886
  // flight; drop the just-created server row (requeue fails a
679
887
  // processing continuation quietly) instead of leaving it orphaned.
680
- await api('POST', '/api/requeue', { messageId: String(response.messageId) }).catch(() => {});
888
+ await api('POST', '/api/requeue', {
889
+ messageId: String(response.messageId),
890
+ ...(attemptId ? { attemptId } : {}),
891
+ }).catch(() => {});
681
892
  controlPoller?.stop?.(ctx.controlState);
682
893
  ctx.controlState = null;
683
894
  dbg('continuation turn dropped (handed off during registration)');
684
895
  return;
685
896
  }
686
897
  ctx.message.id = String(response.messageId);
898
+ // The attempt the row was minted under; every publish echoes it, exactly
899
+ // as a delivered row echoes the attempt id its delivery carried.
900
+ ctx.message.attemptId = attemptId;
687
901
  // The route reports which conversation the synthetic row landed on;
688
902
  // trusting it beats assuming worker session id === conversation id.
689
903
  if (String(response.conversationId || '').trim()) {
@@ -745,6 +959,22 @@ export function createClaudeSessionRunner({
745
959
  if (absorbed >= 0) {
746
960
  const outgoing = proc.activeCtx;
747
961
  const restored = outgoing.adoptedFromCompaction;
962
+ // A continuation whose turn already ENDED — its result is buffered,
963
+ // only waiting on its row's registration — was not interrupted by
964
+ // this replay: the replay opens the delivered message's own turn.
965
+ // Handing off would discard the continuation and its buffered
966
+ // answer with it. Let the registration publish that answer on the
967
+ // continuation row first; the delivered message gets a fresh turn.
968
+ const finishedContinuation = !restored
969
+ && outgoing.kind === 'continuation'
970
+ && !outgoing.registered
971
+ && !outgoing.discarded
972
+ && outgoing.resultBuffered === true;
973
+ if (finishedContinuation) {
974
+ detachActiveContext(outgoing);
975
+ proc.handoffSettling = waitForContextRegistration(outgoing);
976
+ return attachDeliveredContext(absorbed);
977
+ }
748
978
  if (restored) {
749
979
  // The outgoing context was only PROVISIONALLY adopted and has
750
980
  // published nothing. Settling it as an absorbed turn would tell
@@ -787,7 +1017,7 @@ export function createClaudeSessionRunner({
787
1017
  // this prompt" — after this replay that premise is false, and putting
788
1018
  // the row back would fail or re-run a message the CLI already took.
789
1019
  if (signature !== null && signature === proc.activeCtx.deliveredEntry?.expectedText) {
790
- proc.activeCtx.adoptedFromCompaction = false;
1020
+ commitAdoption(proc.activeCtx);
791
1021
  return proc.activeCtx;
792
1022
  }
793
1023
  if (signature !== null && isTaskNotificationReplay(sdkMessage)) {
@@ -819,7 +1049,7 @@ export function createClaudeSessionRunner({
819
1049
  // lifecycle timer a window to reap a context that is already settling.
820
1050
  if (!sdkMessage?.parent_tool_use_id
821
1051
  && (type === 'assistant' || type === 'stream_event' || type === 'result')) {
822
- proc.activeCtx.adoptedFromCompaction = false;
1052
+ commitAdoption(proc.activeCtx);
823
1053
  }
824
1054
  }
825
1055
  return proc.activeCtx;
@@ -859,7 +1089,7 @@ export function createClaudeSessionRunner({
859
1089
  && Date.now() < proc.compactReplayUntil
860
1090
  && isCompactionReplayCandidate(sdkMessage)
861
1091
  ) {
862
- const ctx = attachDeliveredContext();
1092
+ const ctx = attachDeliveredContext(0, { provisional: true });
863
1093
  // Provisional: the summary row alone cannot rule out a continuation
864
1094
  // whose notification replays after it (see the hand-back above).
865
1095
  ctx.adoptedFromCompaction = true;
@@ -874,7 +1104,12 @@ export function createClaudeSessionRunner({
874
1104
 
875
1105
  if (type === 'assistant' || type === 'stream_event' || type === 'user') {
876
1106
  if (proc.lastBoundary !== 'self-opened') {
877
- if (proc.pendingDelivered.length) return attachDeliveredContext();
1107
+ // A background subagent's frames arrive at top level carrying
1108
+ // `parent_tool_use_id` (same marker the adoption commit checks): they
1109
+ // are a LIVE task's chatter, not the delivered message's turn, and
1110
+ // attaching on them would both publish the wrong output and splice the
1111
+ // entry out of the fold reaper's reach.
1112
+ if (proc.pendingDelivered.length && !sdkMessage?.parent_tool_use_id) return attachDeliveredContext();
878
1113
  // Between-turn chatter — a background subagent's stream, a stray
879
1114
  // top-level tool_result — is not a turn of its own. Only traffic that
880
1115
  // follows a self-opened boundary (the CLI's task-notification replay)
@@ -891,7 +1126,9 @@ export function createClaudeSessionRunner({
891
1126
  proc.lastBoundary = null;
892
1127
  return null;
893
1128
  }
894
- if (proc.lastBoundary !== 'self-opened' && proc.pendingDelivered.length) return attachDeliveredContext();
1129
+ if (proc.lastBoundary !== 'self-opened' && proc.pendingDelivered.length && !sdkMessage?.parent_tool_use_id) {
1130
+ return attachDeliveredContext();
1131
+ }
895
1132
  if (proc.lastBoundary === 'self-opened') return openContinuationContext();
896
1133
  return null;
897
1134
  }
@@ -912,6 +1149,9 @@ export function createClaudeSessionRunner({
912
1149
  }
913
1150
  if (!ctx.registered) {
914
1151
  ctx.bufferedActions.push(action);
1152
+ // Kept as a flag, not read off the buffer: the registration drain
1153
+ // splices batches out of it while they publish.
1154
+ if (action.channel === 'result') ctx.resultBuffered = true;
915
1155
  return;
916
1156
  }
917
1157
  await publisher.dispatchAction(ctx.message, action, ctx.state);
@@ -926,6 +1166,44 @@ export function createClaudeSessionRunner({
926
1166
  // and model fallbacks must survive that.
927
1167
  const turnRef = proc?.turn || null;
928
1168
  const procModel = proc?.model || null;
1169
+ // Steers pushed into this turn and still unsettled: the CLI has their
1170
+ // prompts, so the relay marks them consumed with this turn's commit and
1171
+ // never re-runs them even if this worker dies before settling them.
1172
+ // Only while this turn is still the active one: a continuation finalizing
1173
+ // late from its registration drain (see the finished-continuation path in
1174
+ // resolveContext) no longer owns the steers pending behind a newer turn.
1175
+ const consumedEntries = (proc?.activeCtx === ctx ? proc.pendingDelivered : []).filter((entry) => entry.steered);
1176
+ for (const entry of consumedEntries) entry.consumedMarked = true;
1177
+ const consumedSteerIds = consumedEntries
1178
+ .map((entry) => ({ id: String(entry.ctx.message?.id || '').trim(), attemptId: entry.ctx.message?.attemptId || null }))
1179
+ .filter((entry) => entry.id);
1180
+
1181
+ if (ctx.interrupted || proc?.aborted) {
1182
+ // A stopped turn closes FIRST and publishes after, off the stream loop:
1183
+ // the CLI opens its follow-up turn for any pushed steers right after
1184
+ // the interrupt, and the Stop guard can only kill that turn at its init
1185
+ // if the loop reaches the init promptly — the usage reads (an
1186
+ // experimental control request with a 10 s timeout) and relay POSTs
1187
+ // would otherwise sit in front of it. Same shape as the per-turn
1188
+ // runner's abort path otherwise: surface what streamed, let the
1189
+ // server-side abort control own the queue row's fate.
1190
+ markConsumed(ctx.message?.conversationId, consumedSteerIds);
1191
+ const publishChain = (async () => {
1192
+ await publisher.publishFinalStream(ctx.message, ctx.state.lastStreamedText);
1193
+ const [contextUsage, planUsage] = await Promise.all([
1194
+ readContextUsageImpl(turnRef, dbg),
1195
+ readPlanUsageImpl(turnRef, dbg),
1196
+ ]);
1197
+ ctx.state.contextUsage = contextUsage;
1198
+ ctx.state.planUsage = planUsage;
1199
+ await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model: procModel, sdkSessionId });
1200
+ await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId });
1201
+ })().catch((error) => dbg('stopped-turn publish failed', error?.message || String(error)));
1202
+ lingerStoppedRow(ctx, publishChain);
1203
+ closeContext(ctx, true);
1204
+ return;
1205
+ }
1206
+
929
1207
  // The control transport is alive for the process's whole life now, but
930
1208
  // the snapshot still belongs to this turn's finalize so the composer
931
1209
  // indicator updates with each reply.
@@ -939,18 +1217,15 @@ export function createClaudeSessionRunner({
939
1217
  await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model: procModel, sdkSessionId });
940
1218
  await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId });
941
1219
 
942
- if (ctx.interrupted || proc?.aborted) {
943
- // Same shape as the per-turn runner's abort path: surface what streamed,
944
- // let the server-side abort control own the queue row's fate.
945
- await publisher.publishFinalStream(ctx.message, ctx.state.lastStreamedText);
946
- } else if (ctx.state.result?.isError) {
947
- await publisher.publishErrorResult({ message: ctx.message, state: ctx.state, responseModel });
1220
+ if (ctx.state.result?.isError) {
1221
+ await publisher.publishErrorResult({ message: ctx.message, state: ctx.state, responseModel, consumedSteerIds });
948
1222
  } else {
949
1223
  await publisher.publishCompletedTurn({
950
1224
  message: ctx.message,
951
1225
  state: ctx.state,
952
1226
  responseModel,
953
1227
  planBoardPosted: ctx.planBoardPosted,
1228
+ consumedSteerIds,
954
1229
  });
955
1230
  }
956
1231
  closeContext(ctx, true);
@@ -985,7 +1260,12 @@ export function createClaudeSessionRunner({
985
1260
  model: ctx.state.responseModel || model || null,
986
1261
  });
987
1262
  } else if (ctx.message?.id) {
988
- await api('POST', '/api/requeue', { messageId: ctx.message.id }).catch(() => {});
1263
+ await api('POST', '/api/requeue', { messageId: ctx.message.id, ...attemptFields(ctx.message) })
1264
+ .catch((error) => {
1265
+ // Superseded attempt: the row already belongs to a newer delivery,
1266
+ // so there is nothing left for this one to put back.
1267
+ if (isStaleAttemptError(error)) dbg('requeue refused as stale_attempt', ctx.message.id);
1268
+ });
989
1269
  }
990
1270
  closeContext(ctx, true);
991
1271
  }
@@ -994,13 +1274,19 @@ export function createClaudeSessionRunner({
994
1274
  if (proc && proc.activeCtx === ctx) {
995
1275
  proc.activeCtx = null;
996
1276
  proc.lastBoundary = null;
1277
+ proc.settleSeqAtTurnEnd = proc.taskSettleSeq;
997
1278
  }
998
1279
  }
999
1280
 
1000
1281
  function closeContext(ctx, handled) {
1282
+ // The Stop guard's window runs from the stopped turn's END: the CLI opens
1283
+ // its follow-up right after the interrupted result, and anything slow in
1284
+ // between must not eat the window.
1285
+ if (ctx.interrupted) anchorStopFollowUpWindow();
1001
1286
  detachActiveContext(ctx);
1002
1287
  ctx.resolveDone?.(handled);
1003
1288
  evaluateLifecycle();
1289
+ syncDeliveryReadiness();
1004
1290
  }
1005
1291
 
1006
1292
  /**
@@ -1008,9 +1294,21 @@ export function createClaudeSessionRunner({
1008
1294
  * active context NOW. The caller attaches the delivered context
1009
1295
  * synchronously; onSdkMessage awaits the returned promise before
1010
1296
  * dispatching the new context's actions so the two rows' publishes stay
1011
- * ordered.
1297
+ * ordered. That promise resolves after the settle's FIRST publish attempt,
1298
+ * not the whole retry ladder: onSdkMessage runs inside the SDK stream loop,
1299
+ * and waiting out ~30 s of retries there froze the new turn's stream. A
1300
+ * failed first attempt keeps retrying in the background with the row still
1301
+ * owned (settling).
1012
1302
  */
1013
1303
  function handoffAbsorbedTurn(ctx) {
1304
+ // Claimed as settling BEFORE the detach: from the detach on nothing else
1305
+ // reports this row, and a heartbeat landing in between would recover it
1306
+ // and re-run a prompt the CLI already consumed.
1307
+ const delivered = ctx.kind === 'delivered' && Boolean(ctx.message?.id);
1308
+ if (delivered) {
1309
+ claimSettling(ctx.message, 'handoff');
1310
+ markConsumed(ctx.message.conversationId, [ctx.message]);
1311
+ }
1014
1312
  detachActiveContext(ctx);
1015
1313
  if (ctx.kind === 'continuation' && !ctx.registered) {
1016
1314
  // Registration (or its buffered-action drain) is still in flight:
@@ -1024,12 +1322,18 @@ export function createClaudeSessionRunner({
1024
1322
  // so reading it later would see the absorbed turn's text too.
1025
1323
  const fallbackText = String(ctx.state.lastStreamedText || ctx.normalizer.finalStreamText() || '').trim();
1026
1324
  dbg('active turn absorbed a delivered message; handing off', ctx.message?.id || '(unregistered continuation)');
1027
- return settleHandedOffContext(ctx, fallbackText).catch((error) => {
1325
+ let releaseOrder = () => {};
1326
+ const firstAttempt = new Promise((resolve) => { releaseOrder = resolve; });
1327
+ settleHandedOffContext(ctx, fallbackText, { onFirstAttempt: () => releaseOrder() }).catch((error) => {
1028
1328
  dbg('absorbed-turn handoff settle failed', error?.message || String(error));
1329
+ }).finally(() => {
1330
+ releaseOrder();
1331
+ if (delivered) releaseSettling(ctx.message.id);
1029
1332
  });
1333
+ return firstAttempt;
1030
1334
  }
1031
1335
 
1032
- async function settleHandedOffContext(ctx, fallbackText) {
1336
+ async function settleHandedOffContext(ctx, fallbackText, { onFirstAttempt = () => {} } = {}) {
1033
1337
  if (ctx.finalized) return;
1034
1338
  ctx.finalized = true;
1035
1339
  controlPoller?.stop?.(ctx.controlState);
@@ -1037,7 +1341,10 @@ export function createClaudeSessionRunner({
1037
1341
  // A discarded continuation that already has its server row must still
1038
1342
  // drop it (requeue fails a processing continuation quietly).
1039
1343
  if (ctx.message?.id) {
1040
- await api('POST', '/api/requeue', { messageId: ctx.message.id }).catch(() => {});
1344
+ await api('POST', '/api/requeue', { messageId: ctx.message.id, ...attemptFields(ctx.message) })
1345
+ .catch((error) => {
1346
+ if (isStaleAttemptError(error)) dbg('requeue refused as stale_attempt', ctx.message.id);
1347
+ });
1041
1348
  }
1042
1349
  closeContext(ctx, true);
1043
1350
  return;
@@ -1047,22 +1354,103 @@ export function createClaudeSessionRunner({
1047
1354
  // NEVER requeue an absorbed delivered row: the CLI already consumed
1048
1355
  // the prompt into the running turn — re-delivery would execute it a
1049
1356
  // second time (and the requeue route marks the healthy worker errored).
1050
- const text = fallbackText
1051
- ? `${fallbackText}\n\n_(This turn was merged into the next message — the reply continues there.)_`
1052
- : '_(This turn was merged into the next message — see the following reply.)_';
1357
+ // `absorbed: true` becomes `kind='absorbed'` on the assistant message,
1358
+ // and the transcript renders the reply as continuing through the next
1359
+ // (steered) user message — so the text carries no prose note when
1360
+ // something streamed; the empty case keeps one so an exported or
1361
+ // marker-blind view still explains the stub.
1362
+ const text = fallbackText || '_(Answered together with the next message.)_';
1053
1363
  await publisher.publishFinalStream(ctx.message, text);
1054
- await publisher.publishResponse(ctx.message, { text, model });
1364
+ await publishSettleMarker(ctx.message, { text, model, kind: 'absorbed', variant: 'handoff', onFirstAttempt });
1055
1365
  } else if (fallbackText) {
1056
1366
  await publisher.publishFinalStream(ctx.message, fallbackText);
1057
1367
  await publisher.publishResponse(ctx.message, { text: fallbackText, model });
1058
1368
  } else if (ctx.message?.id) {
1059
- await api('POST', '/api/requeue', { messageId: ctx.message.id }).catch(() => {});
1369
+ await api('POST', '/api/requeue', { messageId: ctx.message.id, ...attemptFields(ctx.message) })
1370
+ .catch((error) => {
1371
+ if (isStaleAttemptError(error)) dbg('requeue refused as stale_attempt', ctx.message.id);
1372
+ });
1060
1373
  }
1061
1374
  closeContext(ctx, true);
1062
1375
  }
1063
1376
 
1064
- async function interruptActiveTurn(ctx) {
1377
+ function anchorStopFollowUpWindow() {
1378
+ if (!proc?.stopFollowUp || proc.stopFollowUp.anchored) return;
1379
+ proc.stopFollowUp.anchored = true;
1380
+ proc.stopFollowUp.until = Date.now() + stopFollowUpWindowMs;
1381
+ }
1382
+
1383
+ /**
1384
+ * The stopped turn's row until the Stop has fully landed: the context closes
1385
+ * on the interrupted result, but the row stays `processing` until the
1386
+ * control poller's abort ack reaches the relay — sent only after the
1387
+ * interrupt call resolves, which races that result. Reported to the
1388
+ * heartbeat meanwhile, or an owner-heartbeat recovery in the gap fails a
1389
+ * streamed row with the wrong wording, or requeues (re-runs) one that
1390
+ * produced nothing yet.
1391
+ */
1392
+ const lingeringStopped = new Map();
1393
+
1394
+ function lingerStoppedRow(ctx, publishChain) {
1395
+ const id = String(ctx.message?.id || '').trim();
1396
+ if (!id || ctx.kind !== 'delivered') return;
1397
+ const token = {};
1398
+ lingeringStopped.set(id, { message: ctx.message, token });
1399
+ const release = () => {
1400
+ if (lingeringStopped.get(id)?.token === token) lingeringStopped.delete(id);
1401
+ };
1402
+ const cap = setTimeout(release, stoppedRowLingerMaxMs);
1403
+ cap.unref?.();
1404
+ Promise.allSettled([publishChain, ctx.interruptDone || Promise.resolve()])
1405
+ // The ack POST starts right after the interrupt resolves; give it time.
1406
+ .then(() => new Promise((resolve) => {
1407
+ const timer = setTimeout(resolve, stoppedRowLingerMs);
1408
+ timer.unref?.();
1409
+ }))
1410
+ .then(() => {
1411
+ clearTimeout(cap);
1412
+ release();
1413
+ });
1414
+ }
1415
+
1416
+ function interruptActiveTurn(ctx) {
1417
+ ctx.interruptDone = runInterrupt(ctx);
1418
+ return ctx.interruptDone;
1419
+ }
1420
+
1421
+ async function runInterrupt(ctx) {
1065
1422
  ctx.interrupted = true;
1423
+ // Steers already pushed into the turn being stopped went with it: if the
1424
+ // CLI does not open a turn of its own for one, it settles as 'stopped',
1425
+ // not as handled by the reply the Stop cut short. Still never requeued —
1426
+ // the CLI saw the prompt (2026-09-20 decision).
1427
+ let stoppedSteers = 0;
1428
+ for (const entry of proc?.pendingDelivered || []) {
1429
+ if (!entry.steered) continue;
1430
+ entry.stoppedWithTurn = true;
1431
+ stoppedSteers += 1;
1432
+ }
1433
+ if (proc && stoppedSteers) {
1434
+ // The CLI re-opens a turn for the queued steers right after the
1435
+ // interrupt — re-running the stopped tool call — so the Stop would not
1436
+ // stick. Arm the guard that interrupts those follow-up turns (see
1437
+ // maybeSuppressStopFollowUp). The window opens when the stopped turn
1438
+ // closes (closeContext anchors it). A task that settled before the Stop
1439
+ // may have its continuation queued in the CLI already: then the next
1440
+ // turn is ambiguous and the guard only stands down (it is never killed).
1441
+ proc.stopFollowUp = {
1442
+ settleSeq: proc.taskSettleSeq,
1443
+ until: Number.POSITIVE_INFINITY,
1444
+ anchored: false,
1445
+ remaining: stoppedSteers,
1446
+ standDown: isContinuationImminent(),
1447
+ };
1448
+ }
1449
+ // A late abort (the control poller does not re-check after its GET) can
1450
+ // land on a context that has already closed: nothing will anchor the
1451
+ // window at close, so it opens now.
1452
+ if (ctx.finalized || ctx !== proc?.activeCtx) anchorStopFollowUpWindow();
1453
+ syncDeliveryReadiness();
1066
1454
  try {
1067
1455
  await proc.turn.interrupt();
1068
1456
  } catch (error) {
@@ -1102,7 +1490,8 @@ export function createClaudeSessionRunner({
1102
1490
  // the next publish. Bash & co. get no model at all.
1103
1491
  const agentLike = AGENT_TASK_TYPES.has(String(task.taskType || ''));
1104
1492
  const pinnedModel = (task.toolUseId && processRef.subagentSpawns.get(task.toolUseId)) || '';
1105
- const model = agentLike ? (pinnedModel || processRef.initModel || null) : null;
1493
+ const activity = (task.toolUseId && processRef.subagentActivity.get(task.toolUseId)) || {};
1494
+ const model = agentLike ? (activity.model || pinnedModel || processRef.initModel || null) : null;
1106
1495
  return {
1107
1496
  taskId,
1108
1497
  taskType: task.taskType,
@@ -1110,11 +1499,12 @@ export function createClaudeSessionRunner({
1110
1499
  startedAt: task.startedAt || null,
1111
1500
  summary: task.summary || null,
1112
1501
  lastToolName: task.lastToolName || null,
1502
+ lastToolCall: (agentLike && activity.lastToolCall) || null,
1113
1503
  totalTokens: task.totalTokens ?? null,
1114
1504
  toolUseId: task.toolUseId || null,
1115
1505
  subagentType: task.subagentType || null,
1116
1506
  model,
1117
- modelInherited: Boolean(model && !pinnedModel),
1507
+ modelInherited: Boolean(model && !pinnedModel && !activity.model),
1118
1508
  // The workflow digest (poller below) rides the same row; the relay
1119
1509
  // sanitizer whitelists and re-clamps it before storing.
1120
1510
  ...(task.workflowProgress ? { workflowProgress: task.workflowProgress } : {}),
@@ -1419,6 +1809,43 @@ export function createClaudeSessionRunner({
1419
1809
  }
1420
1810
  }
1421
1811
 
1812
+ // A subagent's own assistant frames (parent_tool_use_id = the spawning
1813
+ // tool_use id, the same key as task.toolUseId) carry what the task events
1814
+ // never do: the API model that actually answers (a spawn pin is only an
1815
+ // alias like "opus") and the full input of the tool it is calling now.
1816
+ // Single-lined and capped: the panel crops it to its width.
1817
+ function recordSubagentActivity(sdkMessage) {
1818
+ const parentId = String(sdkMessage?.parent_tool_use_id || '').trim();
1819
+ if (!parentId) return;
1820
+ const entry = proc.subagentActivity.get(parentId) || {};
1821
+ const before = `${entry.model || ''}\n${entry.lastToolCall || ''}`;
1822
+ const model = String(sdkMessage?.message?.model || '').trim();
1823
+ if (model && model !== '<synthetic>') entry.model = model;
1824
+ const blocks = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
1825
+ for (const block of blocks) {
1826
+ if (block?.type === 'tool_use') {
1827
+ const toolName = displayToolName(block.name) || 'tool';
1828
+ const summary = summarizeToolInput(toolName, block.input).replace(/\s+/g, ' ').trim();
1829
+ entry.lastToolCall = (summary ? `Tool (${toolName}): ${summary}` : `Tool (${toolName})`)
1830
+ .slice(0, MAX_TASK_TOOL_CALL_CHARS);
1831
+ } else if (block?.type === 'text' && String(block.text || '').trim()) {
1832
+ // Writing prose means the last tool call is over; the panel falls
1833
+ // back to the SDK's progress summary instead of a stale command.
1834
+ entry.lastToolCall = null;
1835
+ }
1836
+ }
1837
+ proc.subagentActivity.delete(parentId);
1838
+ proc.subagentActivity.set(parentId, entry);
1839
+ while (proc.subagentActivity.size > 100) {
1840
+ proc.subagentActivity.delete(proc.subagentActivity.keys().next().value);
1841
+ }
1842
+ // Foreground subagents stream the same frames; only a change on a live
1843
+ // background task is worth a publish (and a panel re-render).
1844
+ if (before === `${entry.model || ''}\n${entry.lastToolCall || ''}`) return;
1845
+ const ownsLiveTask = [...proc.liveTasks.values()].some((task) => task.toolUseId === parentId);
1846
+ if (ownsLiveTask) publishBackgroundTasks({ throttled: true });
1847
+ }
1848
+
1422
1849
  // Between-turn notices carried into the next activated context, as either a
1423
1850
  // plain line or a prepared publisher action (a compaction boundary has to
1424
1851
  // keep the structured metadata the transcript's break row is built from —
@@ -1487,6 +1914,7 @@ export function createClaudeSessionRunner({
1487
1914
  const type = String(sdkMessage?.type || '');
1488
1915
  if (type === 'assistant') {
1489
1916
  recordSubagentSpawns(sdkMessage);
1917
+ recordSubagentActivity(sdkMessage);
1490
1918
  return;
1491
1919
  }
1492
1920
  if (type !== 'system') return;
@@ -1519,7 +1947,20 @@ export function createClaudeSessionRunner({
1519
1947
  // of the async stream consumer), so it is correctly excluded here.
1520
1948
  //
1521
1949
  if (!proc.activeCtx && !proc.pendingDelivered.length) {
1522
- proc.lastBoundary = 'self-opened';
1950
+ // Not a re-init a settings change caused: setModel /
1951
+ // setPermissionMode / applyFlagSettings make the CLI re-init (live
1952
+ // probe, CLI 2.1.281), and adaptProcess issues them BEFORE the
1953
+ // delivery they belong to is registered and pushed. Reading that
1954
+ // init as self-opened routed the whole answer to a continuation row
1955
+ // and stranded the delivered message (conv dad758cd, 2026-09-25,
1956
+ // right after a /model switch). Inside that window an init beyond the
1957
+ // ones owed is a continuation only if a task settled since the last
1958
+ // turn ended — a stray extra re-init must not strand the delivery the
1959
+ // same way.
1960
+ if (settingsReinitsOwed > 0) settingsReinitsOwed -= 1;
1961
+ else if (!settingsWindowOpen || proc.taskSettleSeq !== proc.settleSeqAtTurnEnd) {
1962
+ proc.lastBoundary = 'self-opened';
1963
+ }
1523
1964
  } else if (!proc.activeCtx && !firstInit) {
1524
1965
  // A LATER init, with a delivered row waiting, is the CLI opening a turn
1525
1966
  // of its own — the live-verified continuation shape is a bare init with
@@ -1636,6 +2077,17 @@ export function createClaudeSessionRunner({
1636
2077
  if (typeof last === 'string' && last.startsWith('Anthropic API')) proc.pendingActivities.pop();
1637
2078
  carryPendingActivity(notice);
1638
2079
  }
2080
+ // A retry with no active turn means the CLI is mid-request for a turn
2081
+ // it is about to open — likely for a pending delivered entry. Hold the
2082
+ // fold clock so the entry is not settled as "merged" out from under
2083
+ // the turn that will answer it. The hold must be a freshness window,
2084
+ // not just a clock reset: a 529 backoff routinely exceeds the 2s fold
2085
+ // grace, so a reset alone would re-arm and fire between notices.
2086
+ proc.lastApiRetryAt = Date.now();
2087
+ for (const entry of proc.pendingDelivered) {
2088
+ entry.foldSince = 0;
2089
+ entry.unattachedSince = 0;
2090
+ }
1639
2091
  }
1640
2092
  return;
1641
2093
  }
@@ -1674,6 +2126,7 @@ export function createClaudeSessionRunner({
1674
2126
  for (const taskId of previous.keys()) {
1675
2127
  if (!proc.liveTasks.has(taskId)) {
1676
2128
  proc.taskSettledAt = Date.now();
2129
+ proc.taskSettleSeq += 1;
1677
2130
  break;
1678
2131
  }
1679
2132
  }
@@ -1734,6 +2187,7 @@ export function createClaudeSessionRunner({
1734
2187
  // continuation is coming, so nothing should pin the process for one.
1735
2188
  if (taskId && proc.knownTasks.has(taskId) && sdkMessage.skip_transcript !== true) {
1736
2189
  proc.notificationPendingAt = Date.now();
2190
+ proc.taskSettleSeq += 1;
1737
2191
  }
1738
2192
  if (!activeContextPublishes()) {
1739
2193
  const status = String(sdkMessage.status || '').trim() || 'unknown';
@@ -1747,6 +2201,82 @@ export function createClaudeSessionRunner({
1747
2201
  }
1748
2202
  }
1749
2203
 
2204
+ /**
2205
+ * A turn the CLI opens by itself right now would follow the Stop: the
2206
+ * guard is live, no turn is active, and everything still pending was
2207
+ * stopped with the turn (a message delivered after the Stop is held, never
2208
+ * pushed — and pushing one disarms the guard).
2209
+ */
2210
+ function isStopFollowUpPosition(now = Date.now()) {
2211
+ const guard = proc?.stopFollowUp;
2212
+ if (!guard || guard.remaining <= 0 || now >= guard.until) return false;
2213
+ if (proc.activeCtx) return false;
2214
+ return proc.pendingDelivered.length > 0 && proc.pendingDelivered.every((entry) => entry.stoppedWithTurn);
2215
+ }
2216
+
2217
+ /**
2218
+ * ...and that turn can only be the follow-up for the stopped steers: no
2219
+ * background task has settled since the Stop, and none was about to
2220
+ * continue when it happened (either one's continuation opens the same
2221
+ * bare-init way, and must run).
2222
+ */
2223
+ function isStopFollowUpArmed(now = Date.now()) {
2224
+ if (!isStopFollowUpPosition(now)) return false;
2225
+ const guard = proc.stopFollowUp;
2226
+ // A counter, not the settle timestamps: a settle in the Stop's own
2227
+ // millisecond must still count as after it.
2228
+ return !guard.standDown && proc.taskSettleSeq === guard.settleSeq;
2229
+ }
2230
+
2231
+ function isStopFollowUpPending(now = Date.now()) {
2232
+ return Boolean(proc?.suppressingTurn) || isStopFollowUpArmed(now);
2233
+ }
2234
+
2235
+ /**
2236
+ * Kill the CLI's follow-up turn for steers the user stopped. Without this
2237
+ * the Stop does not stick: the follow-up re-runs the interrupted tool call
2238
+ * and answers the steers on a phantom continuation. Interrupting it at its
2239
+ * init ends it with an error result and no model output (SDK probe
2240
+ * 2026-09-24). Everything that turn emits is swallowed — its error result
2241
+ * must not settle a stopped steer as a failed turn — except process-level
2242
+ * frames (task bookkeeping, background subagents' streams). Returns
2243
+ * 'swallow', 'observe-only', 'continuation' (the guard stood down: the turn
2244
+ * is a task's continuation and must open as one rather than attach to a
2245
+ * stopped steer's row), or null for a message to handle normally.
2246
+ */
2247
+ function maybeSuppressStopFollowUp(sdkMessage) {
2248
+ const type = String(sdkMessage?.type || '');
2249
+ const subtype = String(sdkMessage?.subtype || '');
2250
+ if (proc.suppressingTurn) {
2251
+ // A background subagent's frames belong to a live task, not the turn.
2252
+ if (sdkMessage?.parent_tool_use_id) return 'observe-only';
2253
+ if (type === 'result') {
2254
+ proc.suppressingTurn = false;
2255
+ // Several stopped steers may come back as several turns: the next
2256
+ // one gets its own window from this one's end.
2257
+ if (proc.stopFollowUp) proc.stopFollowUp.until = Date.now() + stopFollowUpWindowMs;
2258
+ dbg('stopped-steer follow-up turn ended');
2259
+ return 'swallow';
2260
+ }
2261
+ return type === 'system' && subtype !== 'init' ? 'observe-only' : 'swallow';
2262
+ }
2263
+ if (type !== 'system' || subtype !== 'init' || !isStopFollowUpPosition()) return null;
2264
+ if (!isStopFollowUpArmed()) {
2265
+ // Stood down for a task continuation: spent, and the turn is the task's.
2266
+ proc.stopFollowUp = null;
2267
+ dbg('stop guard stood down for a task continuation');
2268
+ return 'continuation';
2269
+ }
2270
+ proc.suppressingTurn = true;
2271
+ proc.stopFollowUp.remaining -= 1;
2272
+ dbg('interrupting the CLI follow-up turn for steers the user stopped');
2273
+ const turnRef = proc.turn;
2274
+ Promise.resolve()
2275
+ .then(() => turnRef?.interrupt?.())
2276
+ .catch((error) => dbg('follow-up interrupt failed', error?.message || String(error)));
2277
+ return 'swallow';
2278
+ }
2279
+
1750
2280
  async function onSdkMessage(processRef, sdkMessage) {
1751
2281
  // A superseded process (push-race respawn, mode-recycle timeout) may still
1752
2282
  // be draining its stream; its late messages must not mutate the state of
@@ -1754,7 +2284,13 @@ export function createClaudeSessionRunner({
1754
2284
  // cleanupProcess when the old stream ends.
1755
2285
  if (!proc || proc !== processRef) return;
1756
2286
  proc.lastEventAt = Date.now();
2287
+ const suppressed = maybeSuppressStopFollowUp(sdkMessage);
2288
+ if (suppressed === 'swallow') return;
1757
2289
  observeProcessLevel(sdkMessage);
2290
+ if (suppressed === 'observe-only') return;
2291
+ // Its traffic opens a continuation of its own instead of attaching to the
2292
+ // stopped steer waiting in pendingDelivered (which still settles stopped).
2293
+ if (suppressed === 'continuation') proc.lastBoundary = 'self-opened';
1758
2294
  const ctx = resolveContext(sdkMessage);
1759
2295
  if (proc.handoffSettling) {
1760
2296
  // An absorbed-steering handoff just settled the previous context;
@@ -1785,6 +2321,17 @@ export function createClaudeSessionRunner({
1785
2321
  return fresh(proc.notificationPendingAt) || fresh(proc.taskSettledAt);
1786
2322
  }
1787
2323
 
2324
+ /**
2325
+ * The CLI recently reported an API retry with no turn open: it is
2326
+ * mid-request for a turn it has not opened yet (retry backoffs run tens of
2327
+ * seconds), so the fold/stale reapers must not settle a pending delivered
2328
+ * entry out from under it. Cleared when a turn opens (activateContext).
2329
+ */
2330
+ function isApiRetryPending() {
2331
+ if (!proc) return false;
2332
+ return Boolean(proc.lastApiRetryAt) && Date.now() - proc.lastApiRetryAt < notificationGraceMs;
2333
+ }
2334
+
1788
2335
  function hasLiveWork() {
1789
2336
  if (!proc) return false;
1790
2337
  return Boolean(
@@ -1836,14 +2383,188 @@ export function createClaudeSessionRunner({
1836
2383
  restoreAdoptedContext(ctx);
1837
2384
  }
1838
2385
 
2386
+ /**
2387
+ * Settle steered entries the CLI folded into a turn without replaying them.
2388
+ * A mid-turn push is folded into the running turn and answered by that turn's
2389
+ * single result — the steered entry never gets its own replay, so
2390
+ * resolveContext's absorbed-steering handoff never fires and the entry would
2391
+ * otherwise sit until the 5-minute watchdog fails it with a bogus "resend".
2392
+ * Once nothing turn-shaped could still attach the entry (no active turn, no
2393
+ * control round-trip, no compaction, no imminent task continuation — but
2394
+ * deliberately NOT "no live background tasks", see the gate comment) for the
2395
+ * short fold grace, settle it as merged into the reply it was folded into.
2396
+ */
2397
+ function reapFoldedSteeredEntries(now) {
2398
+ if (!proc || !proc.pendingDelivered.length) return;
2399
+ if (!(steeredFoldGraceMs > 0)) return;
2400
+ if (!proc.sawInit) return;
2401
+ // Live background tasks deliberately do NOT hold the fold clock: a task
2402
+ // can run for an hour and the CLI opens turns for pushed messages
2403
+ // regardless, so a steered entry folded into an already-finished turn
2404
+ // would otherwise sit `processing` — steer slot occupied, worker.ready
2405
+ // suppressed, every later message in the conversation stuck behind it —
2406
+ // until the task exits (live 2026-09-20: a hung 35-minute Bash task
2407
+ // wedged exactly this way). If the CLI is instead going to open a real
2408
+ // turn for the push, it does so within the grace, tasks or no tasks.
2409
+ // A task that just SETTLED is different: its continuation turn is about
2410
+ // to dequeue and may replay the steered text, so that window holds the
2411
+ // clock (isContinuationImminent). So does a fresh API retry notice: the
2412
+ // CLI is mid-request for a turn it has not opened yet.
2413
+ if (proc.activeCtx || proc.pendingControlRequests > 0 || isCompacting() || isContinuationImminent()
2414
+ || isApiRetryPending() || isStopFollowUpPending(now)) {
2415
+ // Not idle: the CLI may still open a turn for it (the ordinary
2416
+ // delivered-attach path), so the fold clock has not started.
2417
+ for (const entry of proc.pendingDelivered) entry.foldSince = 0;
2418
+ return;
2419
+ }
2420
+ const folded = [];
2421
+ proc.pendingDelivered = proc.pendingDelivered.filter((entry) => {
2422
+ // A non-steered entry (pushed with no turn active) is a genuine queued
2423
+ // turn or a real drop — that stays the watchdog's to resolve.
2424
+ if (!entry.steered) return true;
2425
+ if (!entry.foldSince) { entry.foldSince = now; return true; }
2426
+ if (now - entry.foldSince < steeredFoldGraceMs) return true;
2427
+ folded.push(entry);
2428
+ // Moved, not dropped: the row stays in the heartbeat's owned set until
2429
+ // its marker is committed (see publishSettleMarker).
2430
+ claimSettling(entry.ctx.message, entry.stoppedWithTurn ? 'stopped' : 'folded');
2431
+ return false;
2432
+ });
2433
+ if (!folded.length) return;
2434
+ // Settle sequentially, in push order: concurrent publishes could commit a
2435
+ // later steer's merge stub before an earlier one's, and the transcript
2436
+ // order is part of the multi-steer contract.
2437
+ void (async () => {
2438
+ for (const entry of folded) {
2439
+ dbg('steered message folded into the finished turn; settling as merged', entry.ctx.message?.id || '(no id)');
2440
+ try {
2441
+ await settleFoldedSteeredEntry(entry);
2442
+ } catch (error) {
2443
+ dbg('folded-steer settle failed', error?.message || String(error));
2444
+ }
2445
+ }
2446
+ })();
2447
+ }
2448
+
2449
+ async function settleFoldedSteeredEntry(entry) {
2450
+ const ctx = entry.ctx;
2451
+ if (ctx.finalized) {
2452
+ releaseSettling(ctx.message?.id);
2453
+ return;
2454
+ }
2455
+ ctx.finalized = true;
2456
+ controlPoller?.stop?.(ctx.controlState);
2457
+ const model = ctx.state.responseModel || proc?.model || null;
2458
+ // No streamed text of its own — the answer lives on the turn it was folded
2459
+ // into, and kind='folded' renders it as a compact marker on its own
2460
+ // message (never 'absorbed': that says the reply continues through the
2461
+ // NEXT message, and mis-merged the next ordinary turn). A steer pushed
2462
+ // into a turn the user then stopped was never answered: kind='stopped'
2463
+ // says so (and the client offers to resend it) instead of claiming the
2464
+ // stopped reply handled it.
2465
+ const stopped = entry.stoppedWithTurn === true;
2466
+ const text = stopped ? STEER_STOPPED_TEXT : STEER_FOLDED_TEXT;
2467
+ try {
2468
+ await publishSettleMarker(ctx.message, {
2469
+ text,
2470
+ model,
2471
+ kind: stopped ? 'stopped' : 'folded',
2472
+ variant: stopped ? 'stopped' : 'folded',
2473
+ });
2474
+ } finally {
2475
+ // The entry is already spliced out of pendingDelivered, so nothing else
2476
+ // can resolve this delivery — resolveDone must run even if the publish
2477
+ // threw, or handlePendingPayload's `await ctx.done` would hang forever.
2478
+ ctx.resolveDone?.(true);
2479
+ }
2480
+ }
2481
+
2482
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2483
+
2484
+ /**
2485
+ * Save the settle marker of a steered message the CLI already consumed —
2486
+ * at most once. The row stays in `settlingMessages`, and so in every
2487
+ * heartbeat, until the relay commits the marker: reporting it gone first
2488
+ * let owner-heartbeat recovery put the row back to pending (and a heartbeat
2489
+ * between splice and publish nulled its attempt, 409-ing the publish), and
2490
+ * the CLI ran the prompt twice. A marker that cannot be saved within the
2491
+ * retry budget ends as a terminal failure — never a requeue — and if even
2492
+ * that cannot be posted, the heartbeat hands the row to the relay to fail.
2493
+ * `onFirstAttempt` fires once the first publish attempt has returned, for
2494
+ * callers that must order other publishes behind it without waiting out
2495
+ * the retries.
2496
+ */
2497
+ async function publishSettleMarker(message, { text, model, kind, variant = 'folded', onFirstAttempt = () => {} }) {
2498
+ const id = String(message?.id || '').trim();
2499
+ if (!id) {
2500
+ onFirstAttempt();
2501
+ return;
2502
+ }
2503
+ claimSettling(message, variant);
2504
+ let firstAttemptReported = false;
2505
+ const reportFirstAttempt = () => {
2506
+ if (firstAttemptReported) return;
2507
+ firstAttemptReported = true;
2508
+ onFirstAttempt();
2509
+ };
2510
+ // Drained once and reused by every attempt: a digest drained by a failed
2511
+ // attempt would otherwise be lost.
2512
+ const workflowRuns = publisher.drainWorkflowRuns();
2513
+ try {
2514
+ for (let attempt = 0; ; attempt += 1) {
2515
+ const outcome = await publisher.publishResponse(message, { text, model, kind, workflowRuns });
2516
+ reportFirstAttempt();
2517
+ if (outcome !== 'failed') return;
2518
+ if (attempt >= settleRetryDelaysMs.length) break;
2519
+ await sleep(settleRetryDelaysMs[attempt]);
2520
+ }
2521
+ dbg('settle marker could not be saved; failing the row terminally', id);
2522
+ const terminalError = buildSteerSettleFailure(message, { variant, agentLabel: 'Claude' });
2523
+ const retryMs = Math.max(100, Number(settleRetryDelaysMs.at(-1)) || 0);
2524
+ for (let attempt = 0; attempt < Math.max(1, maxTerminalSettleAttempts); attempt += 1) {
2525
+ if (attempt) await sleep(retryMs);
2526
+ const outcome = await publisher.publishResponse(message, {
2527
+ text: terminalError.message,
2528
+ model,
2529
+ terminalError,
2530
+ requeueOnFailure: false,
2531
+ workflowRuns: null,
2532
+ });
2533
+ if (outcome !== 'failed') return;
2534
+ // Second channel: the requeue route fails a row outright when handed
2535
+ // a terminal error.
2536
+ const failedViaRequeue = await api('POST', '/api/requeue', {
2537
+ messageId: id,
2538
+ terminalError,
2539
+ ...attemptFields(message),
2540
+ }).then(() => true, (error) => isStaleAttemptError(error));
2541
+ if (failedViaRequeue) return;
2542
+ }
2543
+ // Still owned (the lease keeps renewing) and reported as settleFailed:
2544
+ // the relay fails it from the next heartbeat that gets through.
2545
+ dbg('terminal settle could not be posted; handing it to the heartbeat', id);
2546
+ settleFailedTerminals.set(id, terminalError);
2547
+ } finally {
2548
+ reportFirstAttempt();
2549
+ releaseSettling(id);
2550
+ }
2551
+ }
2552
+
1839
2553
  function reapStalePendingDelivered(now) {
1840
2554
  if (!proc || !proc.pendingDelivered.length) return;
1841
2555
  if (!(pendingDeliveredTimeoutMs > 0)) return; // 0 = watchdog disabled
1842
2556
  // A silent cold boot (no init yet) is slow, not wedged: a CLI that never
1843
2557
  // inits dies on its own and cleanupProcess rejects the contexts then.
1844
2558
  if (!proc.sawInit) return;
1845
- if (proc.activeCtx || proc.liveTasks.size || proc.pendingControlRequests > 0 || isCompacting()) {
1846
- // A turn, task, control round-trip, or compaction may still attach these
2559
+ // Same gate as the fold reaper, for the same reason: a live background
2560
+ // task does not stop the CLI opening turns for pushed messages, so it
2561
+ // must not pin this clock either — a dropped entry under an hour-long
2562
+ // task would otherwise block the conversation for the hour instead of
2563
+ // failing over after the timeout. Just-settled tasks (continuation
2564
+ // imminent) and in-flight API retries still hold it.
2565
+ if (proc.activeCtx || proc.pendingControlRequests > 0 || isCompacting() || isContinuationImminent()
2566
+ || isApiRetryPending() || isStopFollowUpPending(now)) {
2567
+ // A turn, control round-trip, or compaction may still attach these
1847
2568
  // entries — the unattached clock starts over once the process actually
1848
2569
  // goes quiet. A compaction is silent work, not idleness: the CLI replays
1849
2570
  // the turn these entries belong to once it finishes.
@@ -1870,9 +2591,23 @@ export function createClaudeSessionRunner({
1870
2591
  }
1871
2592
 
1872
2593
  function evaluateLifecycle() {
2594
+ runLifecycle();
2595
+ // The reapers and the compaction staleness cap change what a delivery
2596
+ // would meet, and this tick is the backstop for every such change.
2597
+ syncDeliveryReadiness();
2598
+ }
2599
+
2600
+ function runLifecycle() {
1873
2601
  if (!proc || proc.closing) return;
1874
2602
  const now = Date.now();
2603
+ // The Stop guard lives exactly as long as the steers it protects.
2604
+ if (proc.stopFollowUp && !proc.suppressingTurn && !proc.pendingDelivered.some((entry) => entry.stoppedWithTurn)) {
2605
+ proc.stopFollowUp = null;
2606
+ }
1875
2607
  reapSilentProvisionalAdoption(now);
2608
+ // Before the 5-minute watchdog: a folded steered entry settles as merged
2609
+ // within the short fold grace instead of failing over.
2610
+ reapFoldedSteeredEntries(now);
1876
2611
  reapStalePendingDelivered(now);
1877
2612
  if (hasLiveWork()) {
1878
2613
  // Track how long background tasks alone have held the process, for the
@@ -1952,11 +2687,16 @@ export function createClaudeSessionRunner({
1952
2687
  api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks: [] }).catch(() => {});
1953
2688
  }
1954
2689
  }
2690
+ // Steers pushed into a turn the user stopped: if the Stop fell back to
2691
+ // killing the process (no interrupt support), they settle as 'stopped'
2692
+ // here rather than being left processing for recovery to re-run.
2693
+ const stoppedSteers = processRef.pendingDelivered.filter((entry) => entry.stoppedWithTurn && !entry.ctx.finalized);
1955
2694
  const openContexts = [
1956
2695
  ...(processRef.activeCtx ? [processRef.activeCtx] : []),
1957
- ...processRef.pendingDelivered.map((entry) => entry.ctx),
2696
+ ...processRef.pendingDelivered.filter((entry) => !stoppedSteers.includes(entry)).map((entry) => entry.ctx),
1958
2697
  ];
1959
2698
  processRef.pendingDelivered = [];
2699
+ for (const entry of stoppedSteers) claimSettling(entry.ctx.message, 'stopped');
1960
2700
  processRef.activeCtx = null;
1961
2701
  if (proc === processRef) proc = null;
1962
2702
  for (const ctx of openContexts) {
@@ -1981,6 +2721,12 @@ export function createClaudeSessionRunner({
1981
2721
  model: processRef.model,
1982
2722
  }).catch(() => {});
1983
2723
  }
2724
+ for (const entry of stoppedSteers) {
2725
+ await settleFoldedSteeredEntry(entry).catch((error) => {
2726
+ dbg('stopped-steer settle failed', error?.message || String(error));
2727
+ });
2728
+ }
2729
+ syncDeliveryReadiness();
1984
2730
  }
1985
2731
 
1986
2732
  // ---------------------------------------------------------------------------
@@ -2031,6 +2777,7 @@ export function createClaudeSessionRunner({
2031
2777
  // tool_use id → explicitly pinned model of a subagent spawn block; the
2032
2778
  // task panel's model column resolves through this at publish time.
2033
2779
  subagentSpawns: new Map(),
2780
+ subagentActivity: new Map(),
2034
2781
  initModel: '',
2035
2782
  // Set when the CLI reported a session-scoped refusal fallback; the next
2036
2783
  // delivered turn repins the composer's model instead of trusting
@@ -2048,6 +2795,10 @@ export function createClaudeSessionRunner({
2048
2795
  pendingActivities: [],
2049
2796
  sawInit: false,
2050
2797
  handoffSettling: null,
2798
+ // Stop guard (see maybeSuppressStopFollowUp): armed by a Stop that cut
2799
+ // off pushed steers; suppressingTurn while a follow-up turn is killed.
2800
+ stopFollowUp: null,
2801
+ suppressingTurn: false,
2051
2802
  activeCtx: null,
2052
2803
  pendingDelivered: [],
2053
2804
  lastBoundary: null,
@@ -2055,6 +2806,12 @@ export function createClaudeSessionRunner({
2055
2806
  lastEventAt: Date.now(),
2056
2807
  heldForTasksSince: 0,
2057
2808
  taskSettledAt: 0,
2809
+ // Bumped by every task-settle signal; the Stop guard compares it.
2810
+ taskSettleSeq: 0,
2811
+ // taskSettleSeq when the last turn ended: a settle since then is what
2812
+ // makes an unowed init inside a settings window a continuation.
2813
+ settleSeqAtTurnEnd: 0,
2814
+ lastApiRetryAt: 0,
2058
2815
  tasksExpired: false,
2059
2816
  tasksExpiredAt: 0,
2060
2817
  aborted: false,
@@ -2089,6 +2846,7 @@ export function createClaudeSessionRunner({
2089
2846
  // the human thinks, and an idle shutdown under it would reject the
2090
2847
  // request with "Tool permission stream closed".
2091
2848
  processRef.pendingControlRequests += 1;
2849
+ syncDeliveryReadiness();
2092
2850
  try {
2093
2851
  // A question from a background agent between turns has no active turn
2094
2852
  // to attach to, and /api/relay-question requires a processing queue
@@ -2109,6 +2867,9 @@ export function createClaudeSessionRunner({
2109
2867
  } finally {
2110
2868
  processRef.pendingControlRequests -= 1;
2111
2869
  processRef.lastEventAt = Date.now();
2870
+ // The card was answered (or the prompt resolved): a message held
2871
+ // behind it steers into the resumed turn within one round-trip.
2872
+ syncDeliveryReadiness();
2112
2873
  }
2113
2874
  };
2114
2875
 
@@ -2151,6 +2912,7 @@ export function createClaudeSessionRunner({
2151
2912
  try {
2152
2913
  for await (const sdkMessage of processRef.turn) {
2153
2914
  await onSdkMessage(processRef, sdkMessage);
2915
+ syncDeliveryReadiness();
2154
2916
  }
2155
2917
  } catch (error) {
2156
2918
  if (!processRef.aborted && !abortController.signal.aborted) streamError = error;
@@ -2165,6 +2927,11 @@ export function createClaudeSessionRunner({
2165
2927
  return processRef;
2166
2928
  }
2167
2929
 
2930
+ function expectSettingsReinit() {
2931
+ settingsReinitsOwed += 1;
2932
+ settingsWindowOpen = true;
2933
+ }
2934
+
2168
2935
  async function adaptProcess(message) {
2169
2936
  const relayMode = message.relayMode || 'agent';
2170
2937
  const model = resolvePerTurnModel(message);
@@ -2190,6 +2957,7 @@ export function createClaudeSessionRunner({
2190
2957
 
2191
2958
  const permissionMode = permissionModeForRelayMode(relayMode);
2192
2959
  if (permissionMode !== proc.permissionMode) {
2960
+ expectSettingsReinit();
2193
2961
  await Promise.resolve(proc.turn.setPermissionMode?.(permissionMode)).catch((error) => {
2194
2962
  dbg('setPermissionMode failed', error?.message || String(error));
2195
2963
  });
@@ -2208,6 +2976,7 @@ export function createClaudeSessionRunner({
2208
2976
  // (model '') keeps whatever the CLI chose.
2209
2977
  const repinAfterFallback = Boolean(proc.modelFallback) && Boolean(model);
2210
2978
  if (model !== proc.model || repinAfterFallback) {
2979
+ expectSettingsReinit();
2211
2980
  await Promise.resolve(proc.turn.setModel?.(model || undefined)).catch((error) => {
2212
2981
  dbg('setModel failed', error?.message || String(error));
2213
2982
  });
@@ -2219,6 +2988,7 @@ export function createClaudeSessionRunner({
2219
2988
  // session-scoped ultracode/enableWorkflows flags (with the effort pinned
2220
2989
  // to the xhigh the flag implies); every other change clears those flags
2221
2990
  // alongside the new effortLevel.
2991
+ expectSettingsReinit();
2222
2992
  await Promise.resolve(proc.turn.applyFlagSettings?.(claudeUltracodeFlagSettings(effort))).catch((error) => {
2223
2993
  dbg('applyFlagSettings effort failed', error?.message || String(error));
2224
2994
  });
@@ -2229,6 +2999,7 @@ export function createClaudeSessionRunner({
2229
2999
  // be pushed too or clearing the slider would never take effect.
2230
3000
  const autoCompactWindow = normalizeAutoCompactWindow(getAutoCompactWindow());
2231
3001
  if (autoCompactWindow !== proc.autoCompactWindow) {
3002
+ expectSettingsReinit();
2232
3003
  await Promise.resolve(proc.turn.applyFlagSettings?.(claudeAutoCompactFlagSettings(autoCompactWindow))).catch((error) => {
2233
3004
  dbg('applyFlagSettings autoCompactWindow failed', error?.message || String(error));
2234
3005
  });
@@ -2246,6 +3017,7 @@ export function createClaudeSessionRunner({
2246
3017
  // Enabling applies live. The display MUST be re-sent afterwards, in
2247
3018
  // this order: a session spawned with thinking off carries no display
2248
3019
  // mode, so enabling without it yields visible-but-empty thinking.
3020
+ expectSettingsReinit();
2249
3021
  await Promise.resolve(proc.turn.applyFlagSettings?.(claudeThinkingFlagSettings(true))).catch((error) => {
2250
3022
  dbg('applyFlagSettings thinkingEnabled failed', error?.message || String(error));
2251
3023
  });
@@ -2263,6 +3035,7 @@ export function createClaudeSessionRunner({
2263
3035
  }
2264
3036
  }
2265
3037
  if (thinkingDisplay !== proc.thinkingDisplay || reassertDisplay) {
3038
+ expectSettingsReinit();
2266
3039
  await applyThinkingDisplay(proc.turn, thinkingDisplay, { budget: proc.thinkingBudget, dbg });
2267
3040
  proc.thinkingDisplay = thinkingDisplay;
2268
3041
  }
@@ -2273,11 +3046,161 @@ export function createClaudeSessionRunner({
2273
3046
  // ---------------------------------------------------------------------------
2274
3047
  // Relay contract
2275
3048
 
3049
+ /**
3050
+ * Whether a new delivered message may be pushed into the live turn
3051
+ * (mid-turn steering) instead of waiting in the relay queue for the
3052
+ * result. The CLI dequeues a mid-turn push into the running turn; the
3053
+ * absorbed-steering handoff in resolveContext is the receiving end.
3054
+ * Deliberately narrow — every exclusion here guards an invariant that
3055
+ * already exists:
3056
+ * - a pending canUseTool round-trip (AskUserQuestion, plan approval)
3057
+ * holds steering entirely, so the bridge reply and a steered push can
3058
+ * never race in the CLI (a deliberate product decision, 2026-09-19);
3059
+ * - a compaction replays the turn when it finishes, and a push into that
3060
+ * window would land inside the replay;
3061
+ * - a provisional post-compaction adoption may still be unwound on the
3062
+ * premise that the CLI never consumed the row's prompt — steering
3063
+ * another message in would spend that premise for it;
3064
+ * - a NON-steered pending entry means a turn-opening delivery is still
3065
+ * mid-flight (pushed with no turn active, replay not yet arrived) — a
3066
+ * steer would race the very turn that entry is about to open.
3067
+ * The number of steered entries in flight is deliberately unbounded
3068
+ * (Claude Code parity — the CLI queues any number of mid-turn messages and
3069
+ * folds or replays them in push order; attach matches by expectedText,
3070
+ * oldest first, and the fold reaper settles every leftover as merged).
3071
+ */
3072
+ function canAcceptSteering() {
3073
+ if (!proc || proc.closing || proc.aborted) return false;
3074
+ const ctx = proc.activeCtx;
3075
+ if (!ctx || ctx.finalized || ctx.discarded || ctx.interrupted) return false;
3076
+ if (ctx.adoptedFromCompaction) return false;
3077
+ if (proc.pendingDelivered.some((entry) => !entry.steered)) return false;
3078
+ if (proc.pendingControlRequests > 0) return false;
3079
+ if (isCompacting()) return false;
3080
+ return true;
3081
+ }
3082
+
3083
+ /**
3084
+ * The one steering gate. A delivery arriving now is held — handed back to
3085
+ * the queue instead of pushed — when a turn is live (a context, anything
3086
+ * pushed and not yet settled, or an open control round-trip) and that turn
3087
+ * cannot accept a steer. The socket link consults this for its readiness
3088
+ * too, idle or mid-delivery: a continuation holding a question card is not a
3089
+ * delivery, so the link's own in-flight bookkeeping cannot see it.
3090
+ */
3091
+ function isDeliveryHeld() {
3092
+ if (admittingDeliveries > 0) return true;
3093
+ if (!proc || proc.closing || proc.aborted) return false;
3094
+ if (!isTurnActive() && !(proc.pendingControlRequests > 0)) return false;
3095
+ return !canAcceptSteering();
3096
+ }
3097
+
3098
+ function syncDeliveryReadiness() {
3099
+ const ready = !isDeliveryHeld();
3100
+ if (ready === lastReportedDeliveryReady) return;
3101
+ lastReportedDeliveryReady = ready;
3102
+ try {
3103
+ onDeliveryReadinessChange(ready);
3104
+ } catch (error) {
3105
+ dbg('delivery readiness listener failed', error?.message || String(error));
3106
+ }
3107
+ }
3108
+
3109
+ /**
3110
+ * Give a held delivery back to the queue with no retry penalty. The relay
3111
+ * re-delivers it once this worker signals ready again, i.e. when the hold
3112
+ * ends — the message then steers into the resumed turn. Pushing it into the
3113
+ * hold instead would bypass the question card or land inside a compaction's
3114
+ * replay.
3115
+ */
3116
+ async function handBackHeldDelivery(message) {
3117
+ dbg('steering held; handing the delivery back to the queue', message.id || '(no id)');
3118
+ if (!message.id) return false;
3119
+ await api('POST', '/api/requeue', {
3120
+ messageId: message.id,
3121
+ class: 'steering-held',
3122
+ ...attemptFields(message),
3123
+ }).catch((error) => {
3124
+ if (isStaleAttemptError(error)) {
3125
+ dbg('steering-held hand-back refused as stale_attempt', message.id);
3126
+ return;
3127
+ }
3128
+ dbg('steering-held hand-back failed', message.id, error?.message || String(error));
3129
+ });
3130
+ return false;
3131
+ }
3132
+
3133
+ /**
3134
+ * Composer-facing steering snapshot: whether a turn is live, whether a
3135
+ * message typed now would steer into it, and — when it would not — why.
3136
+ * Rides the worker heartbeat to the relay's worker registry, so the client
3137
+ * can disable the Steer button during holds instead of silently queueing.
3138
+ * holdReason is a coarse enum, not a diagnostic: 'question' (a canUseTool
3139
+ * round-trip — question card / plan approval — is open), 'compaction',
3140
+ * 'adoption' (provisional post-compaction adoption), 'delivery' (a
3141
+ * turn-opening delivery is still mid-flight).
3142
+ */
3143
+ function steeringState() {
3144
+ // A closing/aborted process is about to die — nothing about its last turn
3145
+ // should hold the composer of the worker that replaces it.
3146
+ if (!proc || proc.closing || proc.aborted) {
3147
+ return { turnActive: false, canSteer: false, holdReason: null, messageId: null, supported: true };
3148
+ }
3149
+ const ctx = proc.activeCtx || null;
3150
+ const ctxLive = Boolean(ctx && !ctx.finalized && !ctx.discarded && !ctx.interrupted);
3151
+ // A turn-opening delivery in flight (pushed with no active ctx, replay not
3152
+ // yet arrived) reads as an active turn to the user — the row is already
3153
+ // `processing` — so it must report as one here, with a 'delivery' hold.
3154
+ const openingDelivery = proc.pendingDelivered.find((entry) => !entry.steered) || null;
3155
+ const turnActive = ctxLive || Boolean(openingDelivery);
3156
+ const canSteer = canAcceptSteering();
3157
+ let holdReason = null;
3158
+ if (turnActive && !canSteer) {
3159
+ if (proc.pendingControlRequests > 0) holdReason = 'question';
3160
+ else if (isCompacting()) holdReason = 'compaction';
3161
+ else if (ctxLive && ctx.adoptedFromCompaction) holdReason = 'adoption';
3162
+ else if (openingDelivery) holdReason = 'delivery';
3163
+ else holdReason = 'other';
3164
+ }
3165
+ // The turn this snapshot describes, so a client can discard a stale hold
3166
+ // that belongs to a previous turn.
3167
+ const messageId = String(
3168
+ (ctxLive ? ctx.message?.id : '') || openingDelivery?.ctx?.message?.id || '',
3169
+ ).trim() || null;
3170
+ // `supported` is the worker-advertised opt-in the client's composer reads
3171
+ // (the same flag the Copilot SDK worker publishes) — its only steering
3172
+ // gate, so every snapshot carries it, idle ones included.
3173
+ return { turnActive, canSteer, holdReason, messageId, supported: true };
3174
+ }
3175
+
2276
3176
  async function handlePendingPayload(pending) {
2277
3177
  const message = pending?.message || null;
2278
3178
  if (!message) return false;
3179
+ // An older relay (the worker updated on disk before the relay restarted)
3180
+ // treats the hand-back as a failed delivery — retry, backoff, worker
3181
+ // marked errored — so against one the message is pushed the legacy way.
3182
+ if (isDeliveryHeld() && canHandBackHeldDelivery() !== false) return handBackHeldDelivery(message);
3183
+ // A turn-opening delivery awaits (adaptProcess, a draining process, a
3184
+ // spawn) before it pushes; until then it counts as admitting, so nothing
3185
+ // else is let in behind it. A steer pushes synchronously and needs none.
3186
+ let admitting = !(proc && !proc.closing && !proc.aborted && proc.activeCtx);
3187
+ if (admitting) admittingDeliveries += 1;
3188
+ const endAdmission = () => {
3189
+ if (!admitting) return;
3190
+ admitting = false;
3191
+ admittingDeliveries -= 1;
3192
+ syncDeliveryReadiness();
3193
+ };
3194
+ syncDeliveryReadiness();
2279
3195
  try {
2280
- if (proc && !proc.closing && !proc.aborted) {
3196
+ // A steered delivery (a turn is live) must not run the settings diffs:
3197
+ // setModel/effort/mode against a mid-flight turn would disturb the very
3198
+ // turn the message is steering into, and the mode-change recycle is
3199
+ // refused under live work anyway. The next between-turns delivery
3200
+ // re-runs adaptProcess with the then-current settings, so nothing is
3201
+ // lost — the steered message's own model/mode intent applies from the
3202
+ // next turn on, same as Claude Code.
3203
+ if (proc && !proc.closing && !proc.aborted && !proc.activeCtx) {
2281
3204
  await adaptProcess(message);
2282
3205
  }
2283
3206
  if (!proc || proc.closing || proc.aborted) {
@@ -2307,9 +3230,38 @@ export function createClaudeSessionRunner({
2307
3230
  for (let attempt = 0; attempt < 2; attempt += 1) {
2308
3231
  const procRef = proc;
2309
3232
  const ctx = createDeliveredContext(message);
2310
- procRef.pendingDelivered.push({ ctx, expectedText: contentText(content), pushedAt: Date.now() });
3233
+ // A message pushed while a turn is already active is a steered
3234
+ // delivery. The CLI folds it into that running turn and answers
3235
+ // everything with ONE result on the turn it was already running —
3236
+ // it does NOT necessarily replay the steered text as a turn-opening
3237
+ // user message, so the absorbed-steering handoff in resolveContext
3238
+ // (which needs that replay) may never fire. Mark the entry so the
3239
+ // fold reaper can settle it as merged once the turn it joined ends,
3240
+ // instead of leaving its queue row stuck `processing` until the
3241
+ // 5-minute watchdog fails it with a bogus "resend" error.
3242
+ const steered = Boolean(procRef.activeCtx);
3243
+ // A steer carries a hidden note that it adds to the running request
3244
+ // rather than replacing it (shared/steer-note.mjs). expectedText stays
3245
+ // the user's own text: the replay signatures strip the note before
3246
+ // any comparison, so matching works whether or not the CLI echoes it.
3247
+ // Not into a continuation: there the running work is the CLI's own
3248
+ // follow-up to a background task, not a request of the user's.
3249
+ // Whatever is left starting with "/" — a "/x" delivered between turns,
3250
+ // on a first turn, or into a continuation — gets the slash-command
3251
+ // guard, or the CLI would run it as a command instead of handing the
3252
+ // model the text the user sent (shared/slash-command-guard.mjs). A
3253
+ // noted steer no longer starts with "/" and is pushed as noted. The
3254
+ // replay signatures strip the guard too, so expectedText stays the
3255
+ // user's own text here as well.
3256
+ const pushedContent = withSlashCommandGuardContent(steered && procRef.activeCtx?.kind !== 'continuation'
3257
+ ? withSteerNoteContent(content)
3258
+ : content);
3259
+ procRef.pendingDelivered.push({ ctx, expectedText: contentText(content), pushedAt: Date.now(), steered });
3260
+ // Registered: from here a later init takes the delivered-row branch.
3261
+ settingsReinitsOwed = 0;
3262
+ settingsWindowOpen = false;
2311
3263
  try {
2312
- procRef.turn.pushUserMessage(content);
3264
+ procRef.turn.pushUserMessage(pushedContent);
2313
3265
  } catch (pushError) {
2314
3266
  // The stream ended under us (process wound down between the
2315
3267
  // liveness check and the push): retire this context and respawn.
@@ -2322,14 +3274,25 @@ export function createClaudeSessionRunner({
2322
3274
  spawnProcess(message);
2323
3275
  continue;
2324
3276
  }
3277
+ // Pushed: the pending entry now holds the gate on its own.
3278
+ endAdmission();
3279
+ // A genuinely new message reached the CLI (only possible against an
3280
+ // older relay): the next turn may be its, so the Stop guard must not
3281
+ // kill it.
3282
+ if (procRef.stopFollowUp) procRef.stopFollowUp = null;
2325
3283
  return await ctx.done;
2326
3284
  }
2327
3285
  throw new Error('claude session process closed while accepting the message');
2328
3286
  } catch (error) {
3287
+ endAdmission();
3288
+ settingsReinitsOwed = 0;
3289
+ settingsWindowOpen = false;
2329
3290
  const errorText = String(error?.message || error || 'unknown error');
2330
3291
  dbg('claude turn failed', message.id, errorText);
2331
3292
  await publisher.publishTurnException({ message, errorText });
2332
3293
  return true;
3294
+ } finally {
3295
+ endAdmission();
2333
3296
  }
2334
3297
  }
2335
3298
 
@@ -2379,8 +3342,13 @@ export function createClaudeSessionRunner({
2379
3342
 
2380
3343
  return {
2381
3344
  handlePendingPayload,
3345
+ canAcceptSteering,
3346
+ isDeliveryHeld,
3347
+ steeringState,
2382
3348
  getActiveQueueMessageId,
2383
3349
  getActiveQueueMessageIds,
3350
+ getSettleFailed,
3351
+ acknowledgeSettleFailed,
2384
3352
  isTurnActive,
2385
3353
  stopBackgroundTask,
2386
3354
  stopBackgroundTaskByToolUseId,