@oar-sh/oar 0.9.2 → 0.9.4

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 (138) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +336 -0
  4. package/README.md +455 -423
  5. package/bin/oar.js +25 -10
  6. package/package.json +11 -5
  7. package/server/README.md +44 -4
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  10. package/server/claude-worker/claude-session-process.mjs +1073 -69
  11. package/server/claude-worker/claude-session-worker.mjs +45 -4
  12. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  13. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  15. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  16. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  17. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  18. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  19. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
  21. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  22. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  23. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  24. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  25. package/server/db-schema.mjs +28 -0
  26. package/server/grok-worker/acp-client.mjs +92 -6
  27. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  28. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  29. package/server/mcp/oar-mcp-server.mjs +473 -0
  30. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  31. package/server/migrations/0005-remote-relays.mjs +90 -0
  32. package/server/public/app/action-confirmations.js +141 -13
  33. package/server/public/app/api-client.js +125 -2
  34. package/server/public/app/ask-user-view.js +102 -13
  35. package/server/public/app/attachments-view.js +37 -2
  36. package/server/public/app/background-task-timeout-options.mjs +9 -0
  37. package/server/public/app/background-tasks-view.mjs +33 -18
  38. package/server/public/app/bootstrap.js +55 -14
  39. package/server/public/app/composer-control-state.mjs +52 -34
  40. package/server/public/app/conversation-deep-link.mjs +42 -0
  41. package/server/public/app/conversation-draft-sync.mjs +187 -0
  42. package/server/public/app/conversation-list-filter.mjs +86 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  44. package/server/public/app/conversation-view.js +1016 -200
  45. package/server/public/app/emoji-view.js +2 -0
  46. package/server/public/app/font-scaling.js +4 -37
  47. package/server/public/app/host-suspend-ui.js +272 -0
  48. package/server/public/app/journal-view.js +149 -6
  49. package/server/public/app/mention-autocomplete.mjs +298 -0
  50. package/server/public/app/model-selector-options.mjs +1 -1
  51. package/server/public/app/push-settings.js +1 -0
  52. package/server/public/app/pwa-install.js +8 -3
  53. package/server/public/app/question-multi-select.mjs +52 -0
  54. package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
  55. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  56. package/server/public/app/remote-relay-shared.mjs +85 -0
  57. package/server/public/app/remote-relays-settings.js +618 -0
  58. package/server/public/app/remote-relays-store.mjs +126 -0
  59. package/server/public/app/settings-modal.js +12 -8
  60. package/server/public/app/settings-tabs.js +1 -1
  61. package/server/public/app/socket-handlers.js +37 -1
  62. package/server/public/app/steered-turn-merge.mjs +100 -0
  63. package/server/public/app/store.js +126 -15
  64. package/server/public/app/sync-outbox.mjs +16 -8
  65. package/server/public/app/thinking-follow.js +260 -0
  66. package/server/public/app/tool-activity-emoji.mjs +40 -0
  67. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  68. package/server/public/index.html +463 -17
  69. package/server/public/sw.js +7 -0
  70. package/server/relay-tools.md +7 -1
  71. package/server/repositories/message-repository.mjs +68 -18
  72. package/server/repositories/question-repository.mjs +18 -0
  73. package/server/repositories/remote-relay-repository.mjs +75 -0
  74. package/server/repositories/session-repository.mjs +13 -32
  75. package/server/routes/ask-user-routes.mjs +42 -2
  76. package/server/routes/messages-routes.mjs +572 -56
  77. package/server/routes/remote-relay-routes.mjs +133 -0
  78. package/server/routes/sessions-routes.mjs +378 -84
  79. package/server/server-runtime.mjs +303 -25
  80. package/server/services/github-ci-activity-service.mjs +199 -0
  81. package/server/services/host-suspend-command.mjs +38 -0
  82. package/server/services/host-suspend-service.mjs +254 -0
  83. package/server/services/live-turn-picker.mjs +47 -0
  84. package/server/services/push-dispatch-service.mjs +37 -4
  85. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  86. package/server/services/relay-question-context.mjs +46 -0
  87. package/server/services/remote-relay-caller-context.mjs +341 -0
  88. package/server/services/remote-relay-client.mjs +332 -0
  89. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  90. package/server/services/remote-relay-inbound.mjs +187 -0
  91. package/server/services/remote-relay-pairing.mjs +189 -0
  92. package/server/services/remote-relay-registry-service.mjs +581 -0
  93. package/server/services/sdk-session-import-service.mjs +49 -7
  94. package/server/services/session-worker-launch-service.mjs +131 -12
  95. package/server/services/session-worker-registry-service.mjs +75 -0
  96. package/server/services/session-worker-supervisor-service.mjs +7 -1
  97. package/server/services/session-worker-websocket-service.mjs +23 -1
  98. package/server/services/steer-resend.mjs +45 -0
  99. package/shared/background-task-timeout.mjs +12 -4
  100. package/shared/control-poller.mjs +4 -1
  101. package/shared/question-multi-select.mjs +16 -0
  102. package/shared/relay-stop-failure.mjs +15 -0
  103. package/shared/remote-relay-contract.mjs +539 -0
  104. package/shared/remote-relay-mentions.mjs +129 -0
  105. package/shared/remote-relay-tool-core.mjs +314 -0
  106. package/shared/slash-command-guard.mjs +76 -0
  107. package/shared/steer-note.mjs +66 -0
  108. package/shared/steer-settle-failure.mjs +89 -0
  109. package/shared/steer-settle-markers.mjs +18 -0
  110. package/shared/worker-crash-guard.mjs +40 -25
  111. package/shared/worker-runtime/api-client.mjs +112 -22
  112. package/shared/worker-runtime/heartbeat.mjs +55 -1
  113. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  114. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  115. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  116. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  117. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  118. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  119. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  120. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  121. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  122. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  123. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  124. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  125. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  126. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  127. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  128. package/server/routes/messages-routes-test-harness.mjs +0 -111
  129. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  130. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  131. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  132. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  133. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  134. package/server/services/fixtures/cli-install-stub.sh +0 -4
  135. package/server/services/fixtures/grok-stub.cmd +0 -1
  136. package/server/services/fixtures/grok-stub.mjs +0 -89
  137. package/server/services/fixtures/grok-stub.sh +0 -4
  138. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -6,8 +6,10 @@ import { buildClaudeUserContent } from './claude-attachments.mjs';
6
6
  import {
7
7
  compactBoundaryActivityAction,
8
8
  createSdkMessageNormalizer,
9
+ displayToolName,
9
10
  formatApiRetryNotice,
10
11
  isSubagentToolName,
12
+ summarizeToolInput,
11
13
  } from './sdk-message-normalizer.mjs';
12
14
  import { digestFromRunRecord, digestFromJournal } from './workflow-progress-digest.mjs';
13
15
  import { createClaudeSessionRootResolver } from '../services/claude-session-root-service.mjs';
@@ -26,8 +28,22 @@ import {
26
28
  } from './claude-sdk-adapter.mjs';
27
29
  import { parseThinkingDisplay, parseThinkingEnabled } from '../../shared/claude-thinking.mjs';
28
30
  import { relocateClaudeTranscriptForCwd } from './claude-transcript-relocator.mjs';
29
- import { attemptFields, createClaudeTurnPublisher, isStaleAttemptError } from './claude-turn-publisher.mjs';
31
+ import {
32
+ attemptFields,
33
+ buildSteerSettleFailure,
34
+ createClaudeTurnPublisher,
35
+ isStaleAttemptError,
36
+ } from './claude-turn-publisher.mjs';
30
37
  import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
38
+ import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
39
+ import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
40
+ import { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT } from '../../shared/steer-settle-markers.mjs';
41
+ import { stripSteerNote, withSteerNoteContent } from '../../shared/steer-note.mjs';
42
+ import {
43
+ stripSlashCommandGuard,
44
+ stripSlashCommandGuardContent,
45
+ withSlashCommandGuardContent,
46
+ } from '../../shared/slash-command-guard.mjs';
31
47
 
32
48
  /**
33
49
  * Which system-prompt append a relay mode gets (claude-sdk-adapter's
@@ -42,8 +58,11 @@ function modeAppendClass(relayMode) {
42
58
  return mode === 'ask' || mode === 'autopilot' ? mode : 'none';
43
59
  }
44
60
 
61
+ export { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT };
62
+
45
63
  /** Task types that run a model of their own (vs. bash/monitor/workflow). */
46
64
  const AGENT_TASK_TYPES = new Set(['local_agent', 'agent', 'subagent']);
65
+ const MAX_TASK_TOOL_CALL_CHARS = 300;
47
66
 
48
67
  // ---------------------------------------------------------------------------
49
68
  // Workflow progress sources (CLI 2.1.226 on-disk formats)
@@ -265,13 +284,16 @@ function contentText(content) {
265
284
  function turnOpeningUserSignature(sdkMessage) {
266
285
  if (sdkMessage?.type !== 'user' || sdkMessage?.parent_tool_use_id) return null;
267
286
  const content = sdkMessage?.message?.content;
268
- if (typeof content === 'string') return content;
287
+ // A steered push carries the hidden steer note, and a "/x" the slash-command
288
+ // guard (shared/slash-command-guard.mjs); every comparison is against the
289
+ // user's own text, so both are stripped here, once, for all of them.
290
+ if (typeof content === 'string') return stripSteerNote(stripSlashCommandGuard(content));
269
291
  if (!Array.isArray(content)) return null;
270
292
  if (content.some((block) => block?.type === 'tool_result')) return null;
271
293
  // Unlike turnOpeningUserText this keeps '' — an attachment-only message
272
294
  // replays with an empty text block, and the absorbed-steering match must
273
295
  // still recognize it (expectedText is '' for those too).
274
- return contentText(content);
296
+ return stripSteerNote(contentText(stripSlashCommandGuardContent(content)));
275
297
  }
276
298
 
277
299
  function turnOpeningUserText(sdkMessage) {
@@ -315,6 +337,34 @@ function isTaskNotificationReplay(sdkMessage) {
315
337
  return text !== null && text.trimStart().startsWith(TASK_NOTIFICATION_TAG_PREFIX);
316
338
  }
317
339
 
340
+ /**
341
+ * The CLI's own record of a command that ran locally, replayed on the stream
342
+ * as a user-role message. The one the relay causes is the model switch:
343
+ * `set_model` on a conversation another model already answered writes the
344
+ * "/model" breadcrumbs into the transcript and replays the
345
+ * `<local-command-stdout>Set model to …` one (`isReplay: true`, string
346
+ * content, no `origin`) BEFORE it answers the control request — read in the
347
+ * CLI binaries 2.1.261 to 2.1.283. A session that was never answered gets no
348
+ * breadcrumb, which is why a probe on a fresh session only ever shows the
349
+ * re-init. Only the two output tags count, and only on a replay the CLI wrote
350
+ * itself (no origin, not inside a subagent): output is never a prompt, so it
351
+ * never opens a turn. A `<command-name>` record is left alone on purpose: it
352
+ * heads prompt-type slash commands and skills, which do open one.
353
+ */
354
+ const LOCAL_COMMAND_OUTPUT_TAG_PREFIXES = Object.freeze([
355
+ '<local-command-stdout>',
356
+ '<local-command-stderr>',
357
+ ]);
358
+
359
+ function isLocalCommandReplay(sdkMessage) {
360
+ if (sdkMessage?.isReplay !== true) return false;
361
+ if (sdkMessage.origin || sdkMessage.parent_tool_use_id) return false;
362
+ const text = firstTextBlock(sdkMessage);
363
+ if (text === null) return false;
364
+ const trimmed = text.trimStart();
365
+ return LOCAL_COMMAND_OUTPUT_TAG_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
366
+ }
367
+
318
368
  /**
319
369
  * Whether a turn the CLI opened by itself may be the compaction re-opening a
320
370
  * delivered turn, rather than something else the CLI decided to say.
@@ -405,6 +455,15 @@ export function createClaudeSessionRunner({
405
455
  // the slowest cold start observed; the primary absorbed-steering fix in
406
456
  // resolveContext should make this fire only for unknown variants.
407
457
  pendingDeliveredTimeoutMs = 5 * 60_000,
458
+ // How long a STEERED entry (pushed while a turn was already running) may sit
459
+ // unattached once the process goes idle before it is settled as merged into
460
+ // the turn it was folded into. The CLI folds a mid-turn push into the running
461
+ // turn and answers with one result on that turn, without replaying the
462
+ // steered text — so a short grace after idle (long enough to rule out the CLI
463
+ // opening its own turn for it, which it does immediately if at all) is all it
464
+ // takes to distinguish a fold from a queued run. Far below the watchdog so a
465
+ // folded row settles as answered, not failed with a "resend" error.
466
+ steeredFoldGraceMs = 2_000,
408
467
  // Backstop for the compaction hold. A compaction is announced ONCE by
409
468
  // `status: 'compacting'` (the CLI's periodic re-emit is gated on the
410
469
  // remote-control client's activity callback, which the plain SDK `query()`
@@ -422,9 +481,41 @@ export function createClaudeSessionRunner({
422
481
  // by the first turn to open regardless.
423
482
  compactReplayAdoptionMs = 60_000,
424
483
  continuationRetryDelayMs = 500,
484
+ // Backoff between attempts to save a consumed steer's settle marker (~31 s
485
+ // in all); after the last one the row fails terminally instead.
486
+ settleRetryDelaysMs = [1_000, 2_000, 4_000, 8_000, 8_000, 8_000],
487
+ // How many times the terminal failure itself is posted before the worker
488
+ // stops trying and hands the row to the relay via the heartbeat's
489
+ // `settleFailed` (a persistent HTTP error — say a 401 after a token change —
490
+ // would otherwise spin forever).
491
+ maxTerminalSettleAttempts = 5,
492
+ // After a Stop that cut off pushed steers, the CLI immediately opens a
493
+ // follow-up turn for them (a bare init, no user replay) that re-runs the
494
+ // interrupted tool call before answering (SDK probe 2026-09-24, CLI
495
+ // 2.1.281). A turn starting within this window of the Stop — or of the
496
+ // previous suppressed turn's end — is interrupted before it can do anything.
497
+ stopFollowUpWindowMs = 3_000,
498
+ // How long a stopped turn's row stays reported to the heartbeat after its
499
+ // interrupt and final publishes resolved (the abort ack is still landing),
500
+ // and the hard cap on that linger.
501
+ stoppedRowLingerMs = 5_000,
502
+ stoppedRowLingerMaxMs = 60_000,
503
+ // False while the connected relay does not understand the `steering-held`
504
+ // hand-back (a worker updated on disk before the relay restarted): a held
505
+ // delivery is then pushed the legacy way instead of being handed back into
506
+ // a retry-and-backoff requeue.
507
+ canHandBackHeldDelivery = () => true,
425
508
  workflowPollMs = 2_000,
426
509
  resolveWorkflowSessionDirImpl = null,
427
510
  askUserBridgeOptions = {},
511
+ // Called with `true`/`false` whenever the runner flips between accepting
512
+ // deliveries and holding them (see isDeliveryHeld). The worker wires it to
513
+ // the socket link: a hold withdraws the relay's readiness, and its end
514
+ // re-arms it at once so a held message steers into the resumed turn.
515
+ onDeliveryReadinessChange = () => {},
516
+ // Whether CLI processes carry the relay's `remote_relay` tool
517
+ // (shared/remote-relay-tool-core.mjs). Default: ask the relay.
518
+ remoteRelayToolGate = null,
428
519
  dbg = () => {},
429
520
  } = {}) {
430
521
  const publisher = createClaudeTurnPublisher({
@@ -432,9 +523,45 @@ export function createClaudeSessionRunner({
432
523
  dbg,
433
524
  takeWorkflowRuns: () => drainSettledWorkflowRuns(),
434
525
  });
526
+ // `remote_relay` is registered only while this relay has paired remotes.
527
+ // Asked once when the worker starts, and again whenever a CLI process winds
528
+ // down, so a remote paired later reaches the next process. A cold spawn
529
+ // waits for the pending answer (handlePendingPayload): the tool list is
530
+ // fixed per process, and the first delivery can beat the startup GET.
531
+ const remoteRelayGate = remoteRelayToolGate || createRemoteRelayToolGate({ api });
532
+ void remoteRelayGate.refresh();
435
533
  const resolveWorkflowSessionDir = resolveWorkflowSessionDirImpl || createDefaultWorkflowSessionDirResolver();
436
534
  let claudeNativeSessionId = '';
437
535
  let proc = null;
536
+ // Turn-opening deliveries between entry and push (adaptProcess and a cold
537
+ // spawn await in between); counted so a second delivery arriving in that
538
+ // gap is held instead of racing the first into the CLI.
539
+ let admittingDeliveries = 0;
540
+ // Settings re-inits (see the init handler). The window opens at
541
+ // adaptProcess's first control request — never when nothing changes — and
542
+ // closes when the delivery it serves is registered in pendingDelivered; the
543
+ // count is how many bare inits inside it are our own. A count, not a flag:
544
+ // a continuation the CLI opens inside the same window inits the same way,
545
+ // and ignoring every init there swallowed its boundary and swapped its
546
+ // answer with the delivered one. Over-counting (a request that never
547
+ // re-inits) only degrades to that. Some CLI paths merely mark the init
548
+ // stale and re-init when the pushed input is dequeued; that init lands
549
+ // after the window, in the delivered-row branch. Known gap: when that
550
+ // happens AND a continuation inits inside the window, the continuation's
551
+ // init is taken for ours and its answer attaches to the delivered row —
552
+ // the same misattribution as a continuation whose init follows the push.
553
+ let settingsReinitsOwed = 0;
554
+ let settingsWindowOpen = false;
555
+ let lastReportedDeliveryReady = true;
556
+ // Rows whose prompt the CLI consumed and whose settle marker is being saved
557
+ // (id → relay message). Runner-level, not per process: a process dying
558
+ // mid-settle must not drop them from the heartbeat's owned set.
559
+ const settlingMessages = new Map();
560
+ // id → terminalError for settling rows the worker gave up posting; reported
561
+ // in the heartbeat's `settleFailed` until the relay confirms it failed them.
562
+ const settleFailedTerminals = new Map();
563
+ // Row id → the latest in-flight /api/queue-consumed POST touching it.
564
+ const consumedMarkChains = new Map();
438
565
 
439
566
  async function persistNativeSessionId(conversationId, sessionId) {
440
567
  const normalized = String(sessionId || '').trim();
@@ -471,22 +598,103 @@ export function createClaudeSessionRunner({
471
598
  }
472
599
 
473
600
  // Every queue row this worker currently owes work for, as { id, attemptId }
474
- // entries: the running turn (delivered or continuation) plus any delivered
475
- // message queued behind it. The heartbeat reports all of them (id-only — its
476
- // call site unwraps) so the server's owner-recovery never replays a row the
477
- // process still holds; the crash guard takes the entries whole so its
478
- // requeues stay fenced to this attempt.
601
+ // entries: the running turn (delivered or continuation), any delivered
602
+ // message queued behind it, and every consumed steer whose settle marker is
603
+ // still being saved. The heartbeat reports all of them (id-only — its call
604
+ // site unwraps) so the server's owner-recovery never replays a row the
605
+ // worker still holds; the crash guard takes the entries whole so its
606
+ // requeues stay fenced to this attempt — and a settling row carries the
607
+ // terminal failure the guard must send instead of a requeue, because its
608
+ // prompt must never run twice.
479
609
  function getActiveQueueMessageIds() {
480
- if (!proc) return [];
481
- return [
482
- proc.activeCtx?.message,
483
- ...proc.pendingDelivered.map((entry) => entry.ctx.message),
484
- ]
485
- .map((message) => ({
610
+ const live = proc
611
+ ? [proc.activeCtx?.message, ...proc.pendingDelivered.map((entry) => entry.ctx.message)]
612
+ : [];
613
+ const entries = live.map((message) => ({
614
+ id: String(message?.id || '').trim(),
615
+ attemptId: message?.attemptId || null,
616
+ }));
617
+ // A stopped row whose abort ack has not landed yet: the crash guard (and
618
+ // SIGTERM) must fail it the way the ack would, never requeue it — that
619
+ // would re-run a prompt the user explicitly stopped.
620
+ for (const { message } of lingeringStopped.values()) {
621
+ entries.push({
486
622
  id: String(message?.id || '').trim(),
487
623
  attemptId: message?.attemptId || null,
488
- }))
624
+ terminalError: buildRelayStopFailure(),
625
+ });
626
+ }
627
+ for (const { message, variant } of settlingMessages.values()) {
628
+ entries.push({
629
+ id: String(message?.id || '').trim(),
630
+ attemptId: message?.attemptId || null,
631
+ terminalError: buildSteerSettleFailure(message, { variant, agentLabel: 'Claude' }),
632
+ });
633
+ }
634
+ return entries.filter((entry) => entry.id);
635
+ }
636
+
637
+ /**
638
+ * Claim a consumed row as settling — synchronously, BEFORE whatever detaches
639
+ * it from pendingDelivered/activeCtx, so no heartbeat can see it unowned.
640
+ * `variant` picks the terminal wording should its settle never land.
641
+ */
642
+ function claimSettling(message, variant = 'folded') {
643
+ const id = String(message?.id || '').trim();
644
+ if (id && !settlingMessages.has(id)) settlingMessages.set(id, { message, variant });
645
+ }
646
+
647
+ /**
648
+ * Mark rows whose prompt the CLI has taken as consumed on the relay, for the
649
+ * paths where no response carries the mark (a stopped turn, a handoff).
650
+ * Best-effort: the worker's own settle still owns these rows while it lives;
651
+ * the mark only protects them when it does not.
652
+ */
653
+ function markConsumed(conversationId, entries = [], { consumed = true } = {}) {
654
+ const rows = entries
655
+ .map((entry) => ({ id: String(entry?.id || '').trim(), attemptId: entry?.attemptId || null }))
489
656
  .filter((entry) => entry.id);
657
+ if (!rows.length) return Promise.resolve();
658
+ // Per-row ordering: a stopped turn's mark and the un-mark of a steer that
659
+ // then runs its own turn are separate fire-and-forget POSTs, and must
660
+ // reach the relay in the order they were decided.
661
+ const previous = Promise.allSettled(rows.map((row) => consumedMarkChains.get(row.id)).filter(Boolean));
662
+ const post = previous.then(() => api('POST', '/api/queue-consumed', {
663
+ conversationId: String(conversationId || sdkSessionId || ''),
664
+ entries: rows,
665
+ ...(consumed ? {} : { consumed: false }),
666
+ })).catch((error) => {
667
+ dbg('consumed mark failed', rows.map((entry) => entry.id).join(','), error?.message || String(error));
668
+ });
669
+ for (const row of rows) consumedMarkChains.set(row.id, post);
670
+ post.then(() => {
671
+ for (const row of rows) if (consumedMarkChains.get(row.id) === post) consumedMarkChains.delete(row.id);
672
+ });
673
+ return post;
674
+ }
675
+
676
+
677
+ /** Release a settled row — unless it was handed to the relay to fail. */
678
+ function releaseSettling(id) {
679
+ const key = String(id || '').trim();
680
+ if (!settleFailedTerminals.has(key)) settlingMessages.delete(key);
681
+ }
682
+
683
+ /** Heartbeat payload: rows whose terminal failure the relay must post. */
684
+ function getSettleFailed() {
685
+ return [...settleFailedTerminals.entries()].map(([id, terminalError]) => ({
686
+ id,
687
+ attemptId: settlingMessages.get(id)?.message?.attemptId || null,
688
+ terminalError,
689
+ }));
690
+ }
691
+
692
+ function acknowledgeSettleFailed(ids = []) {
693
+ for (const id of ids) {
694
+ const key = String(id || '').trim();
695
+ if (!settleFailedTerminals.delete(key)) continue;
696
+ settlingMessages.delete(key);
697
+ }
490
698
  }
491
699
 
492
700
  function isTurnActive() {
@@ -524,6 +732,7 @@ export function createClaudeSessionRunner({
524
732
  discarded: false,
525
733
  registered: kind === 'delivered',
526
734
  bufferedActions: [],
735
+ resultBuffered: false,
527
736
  controlState: null,
528
737
  finalized: false,
529
738
  resolveDone: null,
@@ -547,6 +756,7 @@ export function createClaudeSessionRunner({
547
756
  proc.continuationInitPending = false;
548
757
  proc.notificationPendingAt = 0;
549
758
  proc.taskSettledAt = 0;
759
+ proc.lastApiRetryAt = 0;
550
760
  if (proc.initModel && !ctx.state.responseModel) ctx.state.responseModel = proc.initModel;
551
761
  ctx.controlState = controlPoller?.start?.({
552
762
  queueMessageId: ctx.message?.id || '',
@@ -580,13 +790,33 @@ export function createClaudeSessionRunner({
580
790
  * back to back can replay out of order, and pairing by position alone would
581
791
  * answer each with the other's turn.
582
792
  */
583
- function attachDeliveredContext(index = 0) {
793
+ function attachDeliveredContext(index = 0, { provisional = false } = {}) {
584
794
  const [entry] = proc.pendingDelivered.splice(index, 1);
585
795
  entry.ctx.deliveredEntry = entry;
796
+ // A provisional (post-compaction) attach may still be unwound back onto
797
+ // the queue; its un-mark waits for the adoption to commit.
798
+ if (!provisional) unmarkConsumedOnOwnTurn(entry);
586
799
  activateContext(entry.ctx);
587
800
  return entry.ctx;
588
801
  }
589
802
 
803
+ /**
804
+ * A steer marked consumed by the turn it was pushed into, now running as a
805
+ * turn of its own: from here it is an ordinary in-flight row, and a worker
806
+ * death mid-turn must not fail it with "check the reply above".
807
+ */
808
+ function unmarkConsumedOnOwnTurn(entry) {
809
+ if (!entry?.consumedMarked) return;
810
+ entry.consumedMarked = false;
811
+ markConsumed(entry.ctx.message?.conversationId, [entry.ctx.message], { consumed: false });
812
+ }
813
+
814
+ /** A provisional post-compaction adoption became final. */
815
+ function commitAdoption(ctx) {
816
+ ctx.adoptedFromCompaction = false;
817
+ unmarkConsumedOnOwnTurn(ctx.deliveredEntry);
818
+ }
819
+
590
820
  /**
591
821
  * Undo a post-compaction adoption: this turn was never the delivered
592
822
  * message's, and nothing has published on it (the provisional flag is
@@ -619,6 +849,9 @@ export function createClaudeSessionRunner({
619
849
  // not to the row: left set, `finalizeContext` would take its interrupted
620
850
  // branch when the row finally runs for real and publish no response at all.
621
851
  ctx.interrupted = false;
852
+ // Nothing will close this context as a stopped turn now, so a Stop guard
853
+ // armed by that abort opens its window here instead.
854
+ anchorStopFollowUpWindow();
622
855
  controlPoller?.stop?.(ctx.controlState);
623
856
  ctx.controlState = null;
624
857
  const entry = ctx.deliveredEntry;
@@ -765,6 +998,22 @@ export function createClaudeSessionRunner({
765
998
  if (absorbed >= 0) {
766
999
  const outgoing = proc.activeCtx;
767
1000
  const restored = outgoing.adoptedFromCompaction;
1001
+ // A continuation whose turn already ENDED — its result is buffered,
1002
+ // only waiting on its row's registration — was not interrupted by
1003
+ // this replay: the replay opens the delivered message's own turn.
1004
+ // Handing off would discard the continuation and its buffered
1005
+ // answer with it. Let the registration publish that answer on the
1006
+ // continuation row first; the delivered message gets a fresh turn.
1007
+ const finishedContinuation = !restored
1008
+ && outgoing.kind === 'continuation'
1009
+ && !outgoing.registered
1010
+ && !outgoing.discarded
1011
+ && outgoing.resultBuffered === true;
1012
+ if (finishedContinuation) {
1013
+ detachActiveContext(outgoing);
1014
+ proc.handoffSettling = waitForContextRegistration(outgoing);
1015
+ return attachDeliveredContext(absorbed);
1016
+ }
768
1017
  if (restored) {
769
1018
  // The outgoing context was only PROVISIONALLY adopted and has
770
1019
  // published nothing. Settling it as an absorbed turn would tell
@@ -807,7 +1056,7 @@ export function createClaudeSessionRunner({
807
1056
  // this prompt" — after this replay that premise is false, and putting
808
1057
  // the row back would fail or re-run a message the CLI already took.
809
1058
  if (signature !== null && signature === proc.activeCtx.deliveredEntry?.expectedText) {
810
- proc.activeCtx.adoptedFromCompaction = false;
1059
+ commitAdoption(proc.activeCtx);
811
1060
  return proc.activeCtx;
812
1061
  }
813
1062
  if (signature !== null && isTaskNotificationReplay(sdkMessage)) {
@@ -839,7 +1088,7 @@ export function createClaudeSessionRunner({
839
1088
  // lifecycle timer a window to reap a context that is already settling.
840
1089
  if (!sdkMessage?.parent_tool_use_id
841
1090
  && (type === 'assistant' || type === 'stream_event' || type === 'result')) {
842
- proc.activeCtx.adoptedFromCompaction = false;
1091
+ commitAdoption(proc.activeCtx);
843
1092
  }
844
1093
  }
845
1094
  return proc.activeCtx;
@@ -850,6 +1099,18 @@ export function createClaudeSessionRunner({
850
1099
  if (openingText !== null) {
851
1100
  const matched = proc.pendingDelivered.findIndex((entry) => entry.expectedText === openingText);
852
1101
  if (matched >= 0) return attachDeliveredContext(matched);
1102
+ // The CLI's record of a local command is not a turn, and no traffic
1103
+ // follows it. Read as a self-opened boundary, the "/model" breadcrumb a
1104
+ // setModel leaves stayed armed through the push that followed: the
1105
+ // delivered message's answer opened a continuation row, and its own row
1106
+ // sat unattached, holding every later message out until the watchdog
1107
+ // failed it or the session was killed (seen live 2026-09-27; the
1108
+ // re-init handling alone cannot cover it). Checked after the match, so
1109
+ // a user who sends such a text still gets their turn.
1110
+ if (isLocalCommandReplay(sdkMessage)) {
1111
+ dbg('local command replay ignored as a turn boundary');
1112
+ return null;
1113
+ }
853
1114
  // A compaction just replayed the conversation: the CLI re-opens the turn
854
1115
  // with its own summary message ("This session is being continued…"),
855
1116
  // whose text matches no delivered entry, and then answers the delivered
@@ -879,7 +1140,7 @@ export function createClaudeSessionRunner({
879
1140
  && Date.now() < proc.compactReplayUntil
880
1141
  && isCompactionReplayCandidate(sdkMessage)
881
1142
  ) {
882
- const ctx = attachDeliveredContext();
1143
+ const ctx = attachDeliveredContext(0, { provisional: true });
883
1144
  // Provisional: the summary row alone cannot rule out a continuation
884
1145
  // whose notification replays after it (see the hand-back above).
885
1146
  ctx.adoptedFromCompaction = true;
@@ -894,7 +1155,12 @@ export function createClaudeSessionRunner({
894
1155
 
895
1156
  if (type === 'assistant' || type === 'stream_event' || type === 'user') {
896
1157
  if (proc.lastBoundary !== 'self-opened') {
897
- if (proc.pendingDelivered.length) return attachDeliveredContext();
1158
+ // A background subagent's frames arrive at top level carrying
1159
+ // `parent_tool_use_id` (same marker the adoption commit checks): they
1160
+ // are a LIVE task's chatter, not the delivered message's turn, and
1161
+ // attaching on them would both publish the wrong output and splice the
1162
+ // entry out of the fold reaper's reach.
1163
+ if (proc.pendingDelivered.length && !sdkMessage?.parent_tool_use_id) return attachDeliveredContext();
898
1164
  // Between-turn chatter — a background subagent's stream, a stray
899
1165
  // top-level tool_result — is not a turn of its own. Only traffic that
900
1166
  // follows a self-opened boundary (the CLI's task-notification replay)
@@ -911,7 +1177,9 @@ export function createClaudeSessionRunner({
911
1177
  proc.lastBoundary = null;
912
1178
  return null;
913
1179
  }
914
- if (proc.lastBoundary !== 'self-opened' && proc.pendingDelivered.length) return attachDeliveredContext();
1180
+ if (proc.lastBoundary !== 'self-opened' && proc.pendingDelivered.length && !sdkMessage?.parent_tool_use_id) {
1181
+ return attachDeliveredContext();
1182
+ }
915
1183
  if (proc.lastBoundary === 'self-opened') return openContinuationContext();
916
1184
  return null;
917
1185
  }
@@ -932,6 +1200,9 @@ export function createClaudeSessionRunner({
932
1200
  }
933
1201
  if (!ctx.registered) {
934
1202
  ctx.bufferedActions.push(action);
1203
+ // Kept as a flag, not read off the buffer: the registration drain
1204
+ // splices batches out of it while they publish.
1205
+ if (action.channel === 'result') ctx.resultBuffered = true;
935
1206
  return;
936
1207
  }
937
1208
  await publisher.dispatchAction(ctx.message, action, ctx.state);
@@ -946,6 +1217,44 @@ export function createClaudeSessionRunner({
946
1217
  // and model fallbacks must survive that.
947
1218
  const turnRef = proc?.turn || null;
948
1219
  const procModel = proc?.model || null;
1220
+ // Steers pushed into this turn and still unsettled: the CLI has their
1221
+ // prompts, so the relay marks them consumed with this turn's commit and
1222
+ // never re-runs them even if this worker dies before settling them.
1223
+ // Only while this turn is still the active one: a continuation finalizing
1224
+ // late from its registration drain (see the finished-continuation path in
1225
+ // resolveContext) no longer owns the steers pending behind a newer turn.
1226
+ const consumedEntries = (proc?.activeCtx === ctx ? proc.pendingDelivered : []).filter((entry) => entry.steered);
1227
+ for (const entry of consumedEntries) entry.consumedMarked = true;
1228
+ const consumedSteerIds = consumedEntries
1229
+ .map((entry) => ({ id: String(entry.ctx.message?.id || '').trim(), attemptId: entry.ctx.message?.attemptId || null }))
1230
+ .filter((entry) => entry.id);
1231
+
1232
+ if (ctx.interrupted || proc?.aborted) {
1233
+ // A stopped turn closes FIRST and publishes after, off the stream loop:
1234
+ // the CLI opens its follow-up turn for any pushed steers right after
1235
+ // the interrupt, and the Stop guard can only kill that turn at its init
1236
+ // if the loop reaches the init promptly — the usage reads (an
1237
+ // experimental control request with a 10 s timeout) and relay POSTs
1238
+ // would otherwise sit in front of it. Same shape as the per-turn
1239
+ // runner's abort path otherwise: surface what streamed, let the
1240
+ // server-side abort control own the queue row's fate.
1241
+ markConsumed(ctx.message?.conversationId, consumedSteerIds);
1242
+ const publishChain = (async () => {
1243
+ await publisher.publishFinalStream(ctx.message, ctx.state.lastStreamedText);
1244
+ const [contextUsage, planUsage] = await Promise.all([
1245
+ readContextUsageImpl(turnRef, dbg),
1246
+ readPlanUsageImpl(turnRef, dbg),
1247
+ ]);
1248
+ ctx.state.contextUsage = contextUsage;
1249
+ ctx.state.planUsage = planUsage;
1250
+ await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model: procModel, sdkSessionId });
1251
+ await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId });
1252
+ })().catch((error) => dbg('stopped-turn publish failed', error?.message || String(error)));
1253
+ lingerStoppedRow(ctx, publishChain);
1254
+ closeContext(ctx, true);
1255
+ return;
1256
+ }
1257
+
949
1258
  // The control transport is alive for the process's whole life now, but
950
1259
  // the snapshot still belongs to this turn's finalize so the composer
951
1260
  // indicator updates with each reply.
@@ -959,18 +1268,15 @@ export function createClaudeSessionRunner({
959
1268
  await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model: procModel, sdkSessionId });
960
1269
  await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId });
961
1270
 
962
- if (ctx.interrupted || proc?.aborted) {
963
- // Same shape as the per-turn runner's abort path: surface what streamed,
964
- // let the server-side abort control own the queue row's fate.
965
- await publisher.publishFinalStream(ctx.message, ctx.state.lastStreamedText);
966
- } else if (ctx.state.result?.isError) {
967
- await publisher.publishErrorResult({ message: ctx.message, state: ctx.state, responseModel });
1271
+ if (ctx.state.result?.isError) {
1272
+ await publisher.publishErrorResult({ message: ctx.message, state: ctx.state, responseModel, consumedSteerIds });
968
1273
  } else {
969
1274
  await publisher.publishCompletedTurn({
970
1275
  message: ctx.message,
971
1276
  state: ctx.state,
972
1277
  responseModel,
973
1278
  planBoardPosted: ctx.planBoardPosted,
1279
+ consumedSteerIds,
974
1280
  });
975
1281
  }
976
1282
  closeContext(ctx, true);
@@ -1019,13 +1325,19 @@ export function createClaudeSessionRunner({
1019
1325
  if (proc && proc.activeCtx === ctx) {
1020
1326
  proc.activeCtx = null;
1021
1327
  proc.lastBoundary = null;
1328
+ proc.settleSeqAtTurnEnd = proc.taskSettleSeq;
1022
1329
  }
1023
1330
  }
1024
1331
 
1025
1332
  function closeContext(ctx, handled) {
1333
+ // The Stop guard's window runs from the stopped turn's END: the CLI opens
1334
+ // its follow-up right after the interrupted result, and anything slow in
1335
+ // between must not eat the window.
1336
+ if (ctx.interrupted) anchorStopFollowUpWindow();
1026
1337
  detachActiveContext(ctx);
1027
1338
  ctx.resolveDone?.(handled);
1028
1339
  evaluateLifecycle();
1340
+ syncDeliveryReadiness();
1029
1341
  }
1030
1342
 
1031
1343
  /**
@@ -1033,9 +1345,21 @@ export function createClaudeSessionRunner({
1033
1345
  * active context NOW. The caller attaches the delivered context
1034
1346
  * synchronously; onSdkMessage awaits the returned promise before
1035
1347
  * dispatching the new context's actions so the two rows' publishes stay
1036
- * ordered.
1348
+ * ordered. That promise resolves after the settle's FIRST publish attempt,
1349
+ * not the whole retry ladder: onSdkMessage runs inside the SDK stream loop,
1350
+ * and waiting out ~30 s of retries there froze the new turn's stream. A
1351
+ * failed first attempt keeps retrying in the background with the row still
1352
+ * owned (settling).
1037
1353
  */
1038
1354
  function handoffAbsorbedTurn(ctx) {
1355
+ // Claimed as settling BEFORE the detach: from the detach on nothing else
1356
+ // reports this row, and a heartbeat landing in between would recover it
1357
+ // and re-run a prompt the CLI already consumed.
1358
+ const delivered = ctx.kind === 'delivered' && Boolean(ctx.message?.id);
1359
+ if (delivered) {
1360
+ claimSettling(ctx.message, 'handoff');
1361
+ markConsumed(ctx.message.conversationId, [ctx.message]);
1362
+ }
1039
1363
  detachActiveContext(ctx);
1040
1364
  if (ctx.kind === 'continuation' && !ctx.registered) {
1041
1365
  // Registration (or its buffered-action drain) is still in flight:
@@ -1049,12 +1373,18 @@ export function createClaudeSessionRunner({
1049
1373
  // so reading it later would see the absorbed turn's text too.
1050
1374
  const fallbackText = String(ctx.state.lastStreamedText || ctx.normalizer.finalStreamText() || '').trim();
1051
1375
  dbg('active turn absorbed a delivered message; handing off', ctx.message?.id || '(unregistered continuation)');
1052
- return settleHandedOffContext(ctx, fallbackText).catch((error) => {
1376
+ let releaseOrder = () => {};
1377
+ const firstAttempt = new Promise((resolve) => { releaseOrder = resolve; });
1378
+ settleHandedOffContext(ctx, fallbackText, { onFirstAttempt: () => releaseOrder() }).catch((error) => {
1053
1379
  dbg('absorbed-turn handoff settle failed', error?.message || String(error));
1380
+ }).finally(() => {
1381
+ releaseOrder();
1382
+ if (delivered) releaseSettling(ctx.message.id);
1054
1383
  });
1384
+ return firstAttempt;
1055
1385
  }
1056
1386
 
1057
- async function settleHandedOffContext(ctx, fallbackText) {
1387
+ async function settleHandedOffContext(ctx, fallbackText, { onFirstAttempt = () => {} } = {}) {
1058
1388
  if (ctx.finalized) return;
1059
1389
  ctx.finalized = true;
1060
1390
  controlPoller?.stop?.(ctx.controlState);
@@ -1075,11 +1405,14 @@ export function createClaudeSessionRunner({
1075
1405
  // NEVER requeue an absorbed delivered row: the CLI already consumed
1076
1406
  // the prompt into the running turn — re-delivery would execute it a
1077
1407
  // second time (and the requeue route marks the healthy worker errored).
1078
- const text = fallbackText
1079
- ? `${fallbackText}\n\n_(This turn was merged into the next message — the reply continues there.)_`
1080
- : '_(This turn was merged into the next message — see the following reply.)_';
1408
+ // `absorbed: true` becomes `kind='absorbed'` on the assistant message,
1409
+ // and the transcript renders the reply as continuing through the next
1410
+ // (steered) user message — so the text carries no prose note when
1411
+ // something streamed; the empty case keeps one so an exported or
1412
+ // marker-blind view still explains the stub.
1413
+ const text = fallbackText || '_(Answered together with the next message.)_';
1081
1414
  await publisher.publishFinalStream(ctx.message, text);
1082
- await publisher.publishResponse(ctx.message, { text, model });
1415
+ await publishSettleMarker(ctx.message, { text, model, kind: 'absorbed', variant: 'handoff', onFirstAttempt });
1083
1416
  } else if (fallbackText) {
1084
1417
  await publisher.publishFinalStream(ctx.message, fallbackText);
1085
1418
  await publisher.publishResponse(ctx.message, { text: fallbackText, model });
@@ -1092,8 +1425,83 @@ export function createClaudeSessionRunner({
1092
1425
  closeContext(ctx, true);
1093
1426
  }
1094
1427
 
1095
- async function interruptActiveTurn(ctx) {
1428
+ function anchorStopFollowUpWindow() {
1429
+ if (!proc?.stopFollowUp || proc.stopFollowUp.anchored) return;
1430
+ proc.stopFollowUp.anchored = true;
1431
+ proc.stopFollowUp.until = Date.now() + stopFollowUpWindowMs;
1432
+ }
1433
+
1434
+ /**
1435
+ * The stopped turn's row until the Stop has fully landed: the context closes
1436
+ * on the interrupted result, but the row stays `processing` until the
1437
+ * control poller's abort ack reaches the relay — sent only after the
1438
+ * interrupt call resolves, which races that result. Reported to the
1439
+ * heartbeat meanwhile, or an owner-heartbeat recovery in the gap fails a
1440
+ * streamed row with the wrong wording, or requeues (re-runs) one that
1441
+ * produced nothing yet.
1442
+ */
1443
+ const lingeringStopped = new Map();
1444
+
1445
+ function lingerStoppedRow(ctx, publishChain) {
1446
+ const id = String(ctx.message?.id || '').trim();
1447
+ if (!id || ctx.kind !== 'delivered') return;
1448
+ const token = {};
1449
+ lingeringStopped.set(id, { message: ctx.message, token });
1450
+ const release = () => {
1451
+ if (lingeringStopped.get(id)?.token === token) lingeringStopped.delete(id);
1452
+ };
1453
+ const cap = setTimeout(release, stoppedRowLingerMaxMs);
1454
+ cap.unref?.();
1455
+ Promise.allSettled([publishChain, ctx.interruptDone || Promise.resolve()])
1456
+ // The ack POST starts right after the interrupt resolves; give it time.
1457
+ .then(() => new Promise((resolve) => {
1458
+ const timer = setTimeout(resolve, stoppedRowLingerMs);
1459
+ timer.unref?.();
1460
+ }))
1461
+ .then(() => {
1462
+ clearTimeout(cap);
1463
+ release();
1464
+ });
1465
+ }
1466
+
1467
+ function interruptActiveTurn(ctx) {
1468
+ ctx.interruptDone = runInterrupt(ctx);
1469
+ return ctx.interruptDone;
1470
+ }
1471
+
1472
+ async function runInterrupt(ctx) {
1096
1473
  ctx.interrupted = true;
1474
+ // Steers already pushed into the turn being stopped went with it: if the
1475
+ // CLI does not open a turn of its own for one, it settles as 'stopped',
1476
+ // not as handled by the reply the Stop cut short. Still never requeued —
1477
+ // the CLI saw the prompt (2026-09-20 decision).
1478
+ let stoppedSteers = 0;
1479
+ for (const entry of proc?.pendingDelivered || []) {
1480
+ if (!entry.steered) continue;
1481
+ entry.stoppedWithTurn = true;
1482
+ stoppedSteers += 1;
1483
+ }
1484
+ if (proc && stoppedSteers) {
1485
+ // The CLI re-opens a turn for the queued steers right after the
1486
+ // interrupt — re-running the stopped tool call — so the Stop would not
1487
+ // stick. Arm the guard that interrupts those follow-up turns (see
1488
+ // maybeSuppressStopFollowUp). The window opens when the stopped turn
1489
+ // closes (closeContext anchors it). A task that settled before the Stop
1490
+ // may have its continuation queued in the CLI already: then the next
1491
+ // turn is ambiguous and the guard only stands down (it is never killed).
1492
+ proc.stopFollowUp = {
1493
+ settleSeq: proc.taskSettleSeq,
1494
+ until: Number.POSITIVE_INFINITY,
1495
+ anchored: false,
1496
+ remaining: stoppedSteers,
1497
+ standDown: isContinuationImminent(),
1498
+ };
1499
+ }
1500
+ // A late abort (the control poller does not re-check after its GET) can
1501
+ // land on a context that has already closed: nothing will anchor the
1502
+ // window at close, so it opens now.
1503
+ if (ctx.finalized || ctx !== proc?.activeCtx) anchorStopFollowUpWindow();
1504
+ syncDeliveryReadiness();
1097
1505
  try {
1098
1506
  await proc.turn.interrupt();
1099
1507
  } catch (error) {
@@ -1133,7 +1541,8 @@ export function createClaudeSessionRunner({
1133
1541
  // the next publish. Bash & co. get no model at all.
1134
1542
  const agentLike = AGENT_TASK_TYPES.has(String(task.taskType || ''));
1135
1543
  const pinnedModel = (task.toolUseId && processRef.subagentSpawns.get(task.toolUseId)) || '';
1136
- const model = agentLike ? (pinnedModel || processRef.initModel || null) : null;
1544
+ const activity = (task.toolUseId && processRef.subagentActivity.get(task.toolUseId)) || {};
1545
+ const model = agentLike ? (activity.model || pinnedModel || processRef.initModel || null) : null;
1137
1546
  return {
1138
1547
  taskId,
1139
1548
  taskType: task.taskType,
@@ -1141,11 +1550,12 @@ export function createClaudeSessionRunner({
1141
1550
  startedAt: task.startedAt || null,
1142
1551
  summary: task.summary || null,
1143
1552
  lastToolName: task.lastToolName || null,
1553
+ lastToolCall: (agentLike && activity.lastToolCall) || null,
1144
1554
  totalTokens: task.totalTokens ?? null,
1145
1555
  toolUseId: task.toolUseId || null,
1146
1556
  subagentType: task.subagentType || null,
1147
1557
  model,
1148
- modelInherited: Boolean(model && !pinnedModel),
1558
+ modelInherited: Boolean(model && !pinnedModel && !activity.model),
1149
1559
  // The workflow digest (poller below) rides the same row; the relay
1150
1560
  // sanitizer whitelists and re-clamps it before storing.
1151
1561
  ...(task.workflowProgress ? { workflowProgress: task.workflowProgress } : {}),
@@ -1450,6 +1860,43 @@ export function createClaudeSessionRunner({
1450
1860
  }
1451
1861
  }
1452
1862
 
1863
+ // A subagent's own assistant frames (parent_tool_use_id = the spawning
1864
+ // tool_use id, the same key as task.toolUseId) carry what the task events
1865
+ // never do: the API model that actually answers (a spawn pin is only an
1866
+ // alias like "opus") and the full input of the tool it is calling now.
1867
+ // Single-lined and capped: the panel crops it to its width.
1868
+ function recordSubagentActivity(sdkMessage) {
1869
+ const parentId = String(sdkMessage?.parent_tool_use_id || '').trim();
1870
+ if (!parentId) return;
1871
+ const entry = proc.subagentActivity.get(parentId) || {};
1872
+ const before = `${entry.model || ''}\n${entry.lastToolCall || ''}`;
1873
+ const model = String(sdkMessage?.message?.model || '').trim();
1874
+ if (model && model !== '<synthetic>') entry.model = model;
1875
+ const blocks = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
1876
+ for (const block of blocks) {
1877
+ if (block?.type === 'tool_use') {
1878
+ const toolName = displayToolName(block.name) || 'tool';
1879
+ const summary = summarizeToolInput(toolName, block.input).replace(/\s+/g, ' ').trim();
1880
+ entry.lastToolCall = (summary ? `Tool (${toolName}): ${summary}` : `Tool (${toolName})`)
1881
+ .slice(0, MAX_TASK_TOOL_CALL_CHARS);
1882
+ } else if (block?.type === 'text' && String(block.text || '').trim()) {
1883
+ // Writing prose means the last tool call is over; the panel falls
1884
+ // back to the SDK's progress summary instead of a stale command.
1885
+ entry.lastToolCall = null;
1886
+ }
1887
+ }
1888
+ proc.subagentActivity.delete(parentId);
1889
+ proc.subagentActivity.set(parentId, entry);
1890
+ while (proc.subagentActivity.size > 100) {
1891
+ proc.subagentActivity.delete(proc.subagentActivity.keys().next().value);
1892
+ }
1893
+ // Foreground subagents stream the same frames; only a change on a live
1894
+ // background task is worth a publish (and a panel re-render).
1895
+ if (before === `${entry.model || ''}\n${entry.lastToolCall || ''}`) return;
1896
+ const ownsLiveTask = [...proc.liveTasks.values()].some((task) => task.toolUseId === parentId);
1897
+ if (ownsLiveTask) publishBackgroundTasks({ throttled: true });
1898
+ }
1899
+
1453
1900
  // Between-turn notices carried into the next activated context, as either a
1454
1901
  // plain line or a prepared publisher action (a compaction boundary has to
1455
1902
  // keep the structured metadata the transcript's break row is built from —
@@ -1518,6 +1965,7 @@ export function createClaudeSessionRunner({
1518
1965
  const type = String(sdkMessage?.type || '');
1519
1966
  if (type === 'assistant') {
1520
1967
  recordSubagentSpawns(sdkMessage);
1968
+ recordSubagentActivity(sdkMessage);
1521
1969
  return;
1522
1970
  }
1523
1971
  if (type !== 'system') return;
@@ -1550,7 +1998,20 @@ export function createClaudeSessionRunner({
1550
1998
  // of the async stream consumer), so it is correctly excluded here.
1551
1999
  //
1552
2000
  if (!proc.activeCtx && !proc.pendingDelivered.length) {
1553
- proc.lastBoundary = 'self-opened';
2001
+ // Not a re-init a settings change caused: setModel /
2002
+ // setPermissionMode / applyFlagSettings make the CLI re-init (live
2003
+ // probe, CLI 2.1.281), and adaptProcess issues them BEFORE the
2004
+ // delivery they belong to is registered and pushed. Reading that
2005
+ // init as self-opened routed the whole answer to a continuation row
2006
+ // and stranded the delivered message (conv dad758cd, 2026-09-25,
2007
+ // right after a /model switch). Inside that window an init beyond the
2008
+ // ones owed is a continuation only if a task settled since the last
2009
+ // turn ended — a stray extra re-init must not strand the delivery the
2010
+ // same way.
2011
+ if (settingsReinitsOwed > 0) settingsReinitsOwed -= 1;
2012
+ else if (!settingsWindowOpen || proc.taskSettleSeq !== proc.settleSeqAtTurnEnd) {
2013
+ proc.lastBoundary = 'self-opened';
2014
+ }
1554
2015
  } else if (!proc.activeCtx && !firstInit) {
1555
2016
  // A LATER init, with a delivered row waiting, is the CLI opening a turn
1556
2017
  // of its own — the live-verified continuation shape is a bare init with
@@ -1667,6 +2128,17 @@ export function createClaudeSessionRunner({
1667
2128
  if (typeof last === 'string' && last.startsWith('Anthropic API')) proc.pendingActivities.pop();
1668
2129
  carryPendingActivity(notice);
1669
2130
  }
2131
+ // A retry with no active turn means the CLI is mid-request for a turn
2132
+ // it is about to open — likely for a pending delivered entry. Hold the
2133
+ // fold clock so the entry is not settled as "merged" out from under
2134
+ // the turn that will answer it. The hold must be a freshness window,
2135
+ // not just a clock reset: a 529 backoff routinely exceeds the 2s fold
2136
+ // grace, so a reset alone would re-arm and fire between notices.
2137
+ proc.lastApiRetryAt = Date.now();
2138
+ for (const entry of proc.pendingDelivered) {
2139
+ entry.foldSince = 0;
2140
+ entry.unattachedSince = 0;
2141
+ }
1670
2142
  }
1671
2143
  return;
1672
2144
  }
@@ -1705,6 +2177,7 @@ export function createClaudeSessionRunner({
1705
2177
  for (const taskId of previous.keys()) {
1706
2178
  if (!proc.liveTasks.has(taskId)) {
1707
2179
  proc.taskSettledAt = Date.now();
2180
+ proc.taskSettleSeq += 1;
1708
2181
  break;
1709
2182
  }
1710
2183
  }
@@ -1765,6 +2238,7 @@ export function createClaudeSessionRunner({
1765
2238
  // continuation is coming, so nothing should pin the process for one.
1766
2239
  if (taskId && proc.knownTasks.has(taskId) && sdkMessage.skip_transcript !== true) {
1767
2240
  proc.notificationPendingAt = Date.now();
2241
+ proc.taskSettleSeq += 1;
1768
2242
  }
1769
2243
  if (!activeContextPublishes()) {
1770
2244
  const status = String(sdkMessage.status || '').trim() || 'unknown';
@@ -1778,6 +2252,82 @@ export function createClaudeSessionRunner({
1778
2252
  }
1779
2253
  }
1780
2254
 
2255
+ /**
2256
+ * A turn the CLI opens by itself right now would follow the Stop: the
2257
+ * guard is live, no turn is active, and everything still pending was
2258
+ * stopped with the turn (a message delivered after the Stop is held, never
2259
+ * pushed — and pushing one disarms the guard).
2260
+ */
2261
+ function isStopFollowUpPosition(now = Date.now()) {
2262
+ const guard = proc?.stopFollowUp;
2263
+ if (!guard || guard.remaining <= 0 || now >= guard.until) return false;
2264
+ if (proc.activeCtx) return false;
2265
+ return proc.pendingDelivered.length > 0 && proc.pendingDelivered.every((entry) => entry.stoppedWithTurn);
2266
+ }
2267
+
2268
+ /**
2269
+ * ...and that turn can only be the follow-up for the stopped steers: no
2270
+ * background task has settled since the Stop, and none was about to
2271
+ * continue when it happened (either one's continuation opens the same
2272
+ * bare-init way, and must run).
2273
+ */
2274
+ function isStopFollowUpArmed(now = Date.now()) {
2275
+ if (!isStopFollowUpPosition(now)) return false;
2276
+ const guard = proc.stopFollowUp;
2277
+ // A counter, not the settle timestamps: a settle in the Stop's own
2278
+ // millisecond must still count as after it.
2279
+ return !guard.standDown && proc.taskSettleSeq === guard.settleSeq;
2280
+ }
2281
+
2282
+ function isStopFollowUpPending(now = Date.now()) {
2283
+ return Boolean(proc?.suppressingTurn) || isStopFollowUpArmed(now);
2284
+ }
2285
+
2286
+ /**
2287
+ * Kill the CLI's follow-up turn for steers the user stopped. Without this
2288
+ * the Stop does not stick: the follow-up re-runs the interrupted tool call
2289
+ * and answers the steers on a phantom continuation. Interrupting it at its
2290
+ * init ends it with an error result and no model output (SDK probe
2291
+ * 2026-09-24). Everything that turn emits is swallowed — its error result
2292
+ * must not settle a stopped steer as a failed turn — except process-level
2293
+ * frames (task bookkeeping, background subagents' streams). Returns
2294
+ * 'swallow', 'observe-only', 'continuation' (the guard stood down: the turn
2295
+ * is a task's continuation and must open as one rather than attach to a
2296
+ * stopped steer's row), or null for a message to handle normally.
2297
+ */
2298
+ function maybeSuppressStopFollowUp(sdkMessage) {
2299
+ const type = String(sdkMessage?.type || '');
2300
+ const subtype = String(sdkMessage?.subtype || '');
2301
+ if (proc.suppressingTurn) {
2302
+ // A background subagent's frames belong to a live task, not the turn.
2303
+ if (sdkMessage?.parent_tool_use_id) return 'observe-only';
2304
+ if (type === 'result') {
2305
+ proc.suppressingTurn = false;
2306
+ // Several stopped steers may come back as several turns: the next
2307
+ // one gets its own window from this one's end.
2308
+ if (proc.stopFollowUp) proc.stopFollowUp.until = Date.now() + stopFollowUpWindowMs;
2309
+ dbg('stopped-steer follow-up turn ended');
2310
+ return 'swallow';
2311
+ }
2312
+ return type === 'system' && subtype !== 'init' ? 'observe-only' : 'swallow';
2313
+ }
2314
+ if (type !== 'system' || subtype !== 'init' || !isStopFollowUpPosition()) return null;
2315
+ if (!isStopFollowUpArmed()) {
2316
+ // Stood down for a task continuation: spent, and the turn is the task's.
2317
+ proc.stopFollowUp = null;
2318
+ dbg('stop guard stood down for a task continuation');
2319
+ return 'continuation';
2320
+ }
2321
+ proc.suppressingTurn = true;
2322
+ proc.stopFollowUp.remaining -= 1;
2323
+ dbg('interrupting the CLI follow-up turn for steers the user stopped');
2324
+ const turnRef = proc.turn;
2325
+ Promise.resolve()
2326
+ .then(() => turnRef?.interrupt?.())
2327
+ .catch((error) => dbg('follow-up interrupt failed', error?.message || String(error)));
2328
+ return 'swallow';
2329
+ }
2330
+
1781
2331
  async function onSdkMessage(processRef, sdkMessage) {
1782
2332
  // A superseded process (push-race respawn, mode-recycle timeout) may still
1783
2333
  // be draining its stream; its late messages must not mutate the state of
@@ -1785,7 +2335,13 @@ export function createClaudeSessionRunner({
1785
2335
  // cleanupProcess when the old stream ends.
1786
2336
  if (!proc || proc !== processRef) return;
1787
2337
  proc.lastEventAt = Date.now();
2338
+ const suppressed = maybeSuppressStopFollowUp(sdkMessage);
2339
+ if (suppressed === 'swallow') return;
1788
2340
  observeProcessLevel(sdkMessage);
2341
+ if (suppressed === 'observe-only') return;
2342
+ // Its traffic opens a continuation of its own instead of attaching to the
2343
+ // stopped steer waiting in pendingDelivered (which still settles stopped).
2344
+ if (suppressed === 'continuation') proc.lastBoundary = 'self-opened';
1789
2345
  const ctx = resolveContext(sdkMessage);
1790
2346
  if (proc.handoffSettling) {
1791
2347
  // An absorbed-steering handoff just settled the previous context;
@@ -1816,6 +2372,17 @@ export function createClaudeSessionRunner({
1816
2372
  return fresh(proc.notificationPendingAt) || fresh(proc.taskSettledAt);
1817
2373
  }
1818
2374
 
2375
+ /**
2376
+ * The CLI recently reported an API retry with no turn open: it is
2377
+ * mid-request for a turn it has not opened yet (retry backoffs run tens of
2378
+ * seconds), so the fold/stale reapers must not settle a pending delivered
2379
+ * entry out from under it. Cleared when a turn opens (activateContext).
2380
+ */
2381
+ function isApiRetryPending() {
2382
+ if (!proc) return false;
2383
+ return Boolean(proc.lastApiRetryAt) && Date.now() - proc.lastApiRetryAt < notificationGraceMs;
2384
+ }
2385
+
1819
2386
  function hasLiveWork() {
1820
2387
  if (!proc) return false;
1821
2388
  return Boolean(
@@ -1867,14 +2434,188 @@ export function createClaudeSessionRunner({
1867
2434
  restoreAdoptedContext(ctx);
1868
2435
  }
1869
2436
 
2437
+ /**
2438
+ * Settle steered entries the CLI folded into a turn without replaying them.
2439
+ * A mid-turn push is folded into the running turn and answered by that turn's
2440
+ * single result — the steered entry never gets its own replay, so
2441
+ * resolveContext's absorbed-steering handoff never fires and the entry would
2442
+ * otherwise sit until the 5-minute watchdog fails it with a bogus "resend".
2443
+ * Once nothing turn-shaped could still attach the entry (no active turn, no
2444
+ * control round-trip, no compaction, no imminent task continuation — but
2445
+ * deliberately NOT "no live background tasks", see the gate comment) for the
2446
+ * short fold grace, settle it as merged into the reply it was folded into.
2447
+ */
2448
+ function reapFoldedSteeredEntries(now) {
2449
+ if (!proc || !proc.pendingDelivered.length) return;
2450
+ if (!(steeredFoldGraceMs > 0)) return;
2451
+ if (!proc.sawInit) return;
2452
+ // Live background tasks deliberately do NOT hold the fold clock: a task
2453
+ // can run for an hour and the CLI opens turns for pushed messages
2454
+ // regardless, so a steered entry folded into an already-finished turn
2455
+ // would otherwise sit `processing` — steer slot occupied, worker.ready
2456
+ // suppressed, every later message in the conversation stuck behind it —
2457
+ // until the task exits (live 2026-09-20: a hung 35-minute Bash task
2458
+ // wedged exactly this way). If the CLI is instead going to open a real
2459
+ // turn for the push, it does so within the grace, tasks or no tasks.
2460
+ // A task that just SETTLED is different: its continuation turn is about
2461
+ // to dequeue and may replay the steered text, so that window holds the
2462
+ // clock (isContinuationImminent). So does a fresh API retry notice: the
2463
+ // CLI is mid-request for a turn it has not opened yet.
2464
+ if (proc.activeCtx || proc.pendingControlRequests > 0 || isCompacting() || isContinuationImminent()
2465
+ || isApiRetryPending() || isStopFollowUpPending(now)) {
2466
+ // Not idle: the CLI may still open a turn for it (the ordinary
2467
+ // delivered-attach path), so the fold clock has not started.
2468
+ for (const entry of proc.pendingDelivered) entry.foldSince = 0;
2469
+ return;
2470
+ }
2471
+ const folded = [];
2472
+ proc.pendingDelivered = proc.pendingDelivered.filter((entry) => {
2473
+ // A non-steered entry (pushed with no turn active) is a genuine queued
2474
+ // turn or a real drop — that stays the watchdog's to resolve.
2475
+ if (!entry.steered) return true;
2476
+ if (!entry.foldSince) { entry.foldSince = now; return true; }
2477
+ if (now - entry.foldSince < steeredFoldGraceMs) return true;
2478
+ folded.push(entry);
2479
+ // Moved, not dropped: the row stays in the heartbeat's owned set until
2480
+ // its marker is committed (see publishSettleMarker).
2481
+ claimSettling(entry.ctx.message, entry.stoppedWithTurn ? 'stopped' : 'folded');
2482
+ return false;
2483
+ });
2484
+ if (!folded.length) return;
2485
+ // Settle sequentially, in push order: concurrent publishes could commit a
2486
+ // later steer's merge stub before an earlier one's, and the transcript
2487
+ // order is part of the multi-steer contract.
2488
+ void (async () => {
2489
+ for (const entry of folded) {
2490
+ dbg('steered message folded into the finished turn; settling as merged', entry.ctx.message?.id || '(no id)');
2491
+ try {
2492
+ await settleFoldedSteeredEntry(entry);
2493
+ } catch (error) {
2494
+ dbg('folded-steer settle failed', error?.message || String(error));
2495
+ }
2496
+ }
2497
+ })();
2498
+ }
2499
+
2500
+ async function settleFoldedSteeredEntry(entry) {
2501
+ const ctx = entry.ctx;
2502
+ if (ctx.finalized) {
2503
+ releaseSettling(ctx.message?.id);
2504
+ return;
2505
+ }
2506
+ ctx.finalized = true;
2507
+ controlPoller?.stop?.(ctx.controlState);
2508
+ const model = ctx.state.responseModel || proc?.model || null;
2509
+ // No streamed text of its own — the answer lives on the turn it was folded
2510
+ // into, and kind='folded' renders it as a compact marker on its own
2511
+ // message (never 'absorbed': that says the reply continues through the
2512
+ // NEXT message, and mis-merged the next ordinary turn). A steer pushed
2513
+ // into a turn the user then stopped was never answered: kind='stopped'
2514
+ // says so (and the client offers to resend it) instead of claiming the
2515
+ // stopped reply handled it.
2516
+ const stopped = entry.stoppedWithTurn === true;
2517
+ const text = stopped ? STEER_STOPPED_TEXT : STEER_FOLDED_TEXT;
2518
+ try {
2519
+ await publishSettleMarker(ctx.message, {
2520
+ text,
2521
+ model,
2522
+ kind: stopped ? 'stopped' : 'folded',
2523
+ variant: stopped ? 'stopped' : 'folded',
2524
+ });
2525
+ } finally {
2526
+ // The entry is already spliced out of pendingDelivered, so nothing else
2527
+ // can resolve this delivery — resolveDone must run even if the publish
2528
+ // threw, or handlePendingPayload's `await ctx.done` would hang forever.
2529
+ ctx.resolveDone?.(true);
2530
+ }
2531
+ }
2532
+
2533
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2534
+
2535
+ /**
2536
+ * Save the settle marker of a steered message the CLI already consumed —
2537
+ * at most once. The row stays in `settlingMessages`, and so in every
2538
+ * heartbeat, until the relay commits the marker: reporting it gone first
2539
+ * let owner-heartbeat recovery put the row back to pending (and a heartbeat
2540
+ * between splice and publish nulled its attempt, 409-ing the publish), and
2541
+ * the CLI ran the prompt twice. A marker that cannot be saved within the
2542
+ * retry budget ends as a terminal failure — never a requeue — and if even
2543
+ * that cannot be posted, the heartbeat hands the row to the relay to fail.
2544
+ * `onFirstAttempt` fires once the first publish attempt has returned, for
2545
+ * callers that must order other publishes behind it without waiting out
2546
+ * the retries.
2547
+ */
2548
+ async function publishSettleMarker(message, { text, model, kind, variant = 'folded', onFirstAttempt = () => {} }) {
2549
+ const id = String(message?.id || '').trim();
2550
+ if (!id) {
2551
+ onFirstAttempt();
2552
+ return;
2553
+ }
2554
+ claimSettling(message, variant);
2555
+ let firstAttemptReported = false;
2556
+ const reportFirstAttempt = () => {
2557
+ if (firstAttemptReported) return;
2558
+ firstAttemptReported = true;
2559
+ onFirstAttempt();
2560
+ };
2561
+ // Drained once and reused by every attempt: a digest drained by a failed
2562
+ // attempt would otherwise be lost.
2563
+ const workflowRuns = publisher.drainWorkflowRuns();
2564
+ try {
2565
+ for (let attempt = 0; ; attempt += 1) {
2566
+ const outcome = await publisher.publishResponse(message, { text, model, kind, workflowRuns });
2567
+ reportFirstAttempt();
2568
+ if (outcome !== 'failed') return;
2569
+ if (attempt >= settleRetryDelaysMs.length) break;
2570
+ await sleep(settleRetryDelaysMs[attempt]);
2571
+ }
2572
+ dbg('settle marker could not be saved; failing the row terminally', id);
2573
+ const terminalError = buildSteerSettleFailure(message, { variant, agentLabel: 'Claude' });
2574
+ const retryMs = Math.max(100, Number(settleRetryDelaysMs.at(-1)) || 0);
2575
+ for (let attempt = 0; attempt < Math.max(1, maxTerminalSettleAttempts); attempt += 1) {
2576
+ if (attempt) await sleep(retryMs);
2577
+ const outcome = await publisher.publishResponse(message, {
2578
+ text: terminalError.message,
2579
+ model,
2580
+ terminalError,
2581
+ requeueOnFailure: false,
2582
+ workflowRuns: null,
2583
+ });
2584
+ if (outcome !== 'failed') return;
2585
+ // Second channel: the requeue route fails a row outright when handed
2586
+ // a terminal error.
2587
+ const failedViaRequeue = await api('POST', '/api/requeue', {
2588
+ messageId: id,
2589
+ terminalError,
2590
+ ...attemptFields(message),
2591
+ }).then(() => true, (error) => isStaleAttemptError(error));
2592
+ if (failedViaRequeue) return;
2593
+ }
2594
+ // Still owned (the lease keeps renewing) and reported as settleFailed:
2595
+ // the relay fails it from the next heartbeat that gets through.
2596
+ dbg('terminal settle could not be posted; handing it to the heartbeat', id);
2597
+ settleFailedTerminals.set(id, terminalError);
2598
+ } finally {
2599
+ reportFirstAttempt();
2600
+ releaseSettling(id);
2601
+ }
2602
+ }
2603
+
1870
2604
  function reapStalePendingDelivered(now) {
1871
2605
  if (!proc || !proc.pendingDelivered.length) return;
1872
2606
  if (!(pendingDeliveredTimeoutMs > 0)) return; // 0 = watchdog disabled
1873
2607
  // A silent cold boot (no init yet) is slow, not wedged: a CLI that never
1874
2608
  // inits dies on its own and cleanupProcess rejects the contexts then.
1875
2609
  if (!proc.sawInit) return;
1876
- if (proc.activeCtx || proc.liveTasks.size || proc.pendingControlRequests > 0 || isCompacting()) {
1877
- // A turn, task, control round-trip, or compaction may still attach these
2610
+ // Same gate as the fold reaper, for the same reason: a live background
2611
+ // task does not stop the CLI opening turns for pushed messages, so it
2612
+ // must not pin this clock either — a dropped entry under an hour-long
2613
+ // task would otherwise block the conversation for the hour instead of
2614
+ // failing over after the timeout. Just-settled tasks (continuation
2615
+ // imminent) and in-flight API retries still hold it.
2616
+ if (proc.activeCtx || proc.pendingControlRequests > 0 || isCompacting() || isContinuationImminent()
2617
+ || isApiRetryPending() || isStopFollowUpPending(now)) {
2618
+ // A turn, control round-trip, or compaction may still attach these
1878
2619
  // entries — the unattached clock starts over once the process actually
1879
2620
  // goes quiet. A compaction is silent work, not idleness: the CLI replays
1880
2621
  // the turn these entries belong to once it finishes.
@@ -1901,9 +2642,23 @@ export function createClaudeSessionRunner({
1901
2642
  }
1902
2643
 
1903
2644
  function evaluateLifecycle() {
2645
+ runLifecycle();
2646
+ // The reapers and the compaction staleness cap change what a delivery
2647
+ // would meet, and this tick is the backstop for every such change.
2648
+ syncDeliveryReadiness();
2649
+ }
2650
+
2651
+ function runLifecycle() {
1904
2652
  if (!proc || proc.closing) return;
1905
2653
  const now = Date.now();
2654
+ // The Stop guard lives exactly as long as the steers it protects.
2655
+ if (proc.stopFollowUp && !proc.suppressingTurn && !proc.pendingDelivered.some((entry) => entry.stoppedWithTurn)) {
2656
+ proc.stopFollowUp = null;
2657
+ }
1906
2658
  reapSilentProvisionalAdoption(now);
2659
+ // Before the 5-minute watchdog: a folded steered entry settles as merged
2660
+ // within the short fold grace instead of failing over.
2661
+ reapFoldedSteeredEntries(now);
1907
2662
  reapStalePendingDelivered(now);
1908
2663
  if (hasLiveWork()) {
1909
2664
  // Track how long background tasks alone have held the process, for the
@@ -1968,6 +2723,8 @@ export function createClaudeSessionRunner({
1968
2723
 
1969
2724
  async function cleanupProcess(processRef, streamError) {
1970
2725
  const wasCurrent = proc === processRef;
2726
+ // The next spawn reads the registration decision; refresh it now.
2727
+ if (wasCurrent) void remoteRelayGate.refresh();
1971
2728
  stopLifecycleTimer(processRef);
1972
2729
  if (processRef.taskPublishTimer) {
1973
2730
  clearTimeout(processRef.taskPublishTimer);
@@ -1983,11 +2740,16 @@ export function createClaudeSessionRunner({
1983
2740
  api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks: [] }).catch(() => {});
1984
2741
  }
1985
2742
  }
2743
+ // Steers pushed into a turn the user stopped: if the Stop fell back to
2744
+ // killing the process (no interrupt support), they settle as 'stopped'
2745
+ // here rather than being left processing for recovery to re-run.
2746
+ const stoppedSteers = processRef.pendingDelivered.filter((entry) => entry.stoppedWithTurn && !entry.ctx.finalized);
1986
2747
  const openContexts = [
1987
2748
  ...(processRef.activeCtx ? [processRef.activeCtx] : []),
1988
- ...processRef.pendingDelivered.map((entry) => entry.ctx),
2749
+ ...processRef.pendingDelivered.filter((entry) => !stoppedSteers.includes(entry)).map((entry) => entry.ctx),
1989
2750
  ];
1990
2751
  processRef.pendingDelivered = [];
2752
+ for (const entry of stoppedSteers) claimSettling(entry.ctx.message, 'stopped');
1991
2753
  processRef.activeCtx = null;
1992
2754
  if (proc === processRef) proc = null;
1993
2755
  for (const ctx of openContexts) {
@@ -2012,6 +2774,12 @@ export function createClaudeSessionRunner({
2012
2774
  model: processRef.model,
2013
2775
  }).catch(() => {});
2014
2776
  }
2777
+ for (const entry of stoppedSteers) {
2778
+ await settleFoldedSteeredEntry(entry).catch((error) => {
2779
+ dbg('stopped-steer settle failed', error?.message || String(error));
2780
+ });
2781
+ }
2782
+ syncDeliveryReadiness();
2015
2783
  }
2016
2784
 
2017
2785
  // ---------------------------------------------------------------------------
@@ -2062,6 +2830,7 @@ export function createClaudeSessionRunner({
2062
2830
  // tool_use id → explicitly pinned model of a subagent spawn block; the
2063
2831
  // task panel's model column resolves through this at publish time.
2064
2832
  subagentSpawns: new Map(),
2833
+ subagentActivity: new Map(),
2065
2834
  initModel: '',
2066
2835
  // Set when the CLI reported a session-scoped refusal fallback; the next
2067
2836
  // delivered turn repins the composer's model instead of trusting
@@ -2079,6 +2848,10 @@ export function createClaudeSessionRunner({
2079
2848
  pendingActivities: [],
2080
2849
  sawInit: false,
2081
2850
  handoffSettling: null,
2851
+ // Stop guard (see maybeSuppressStopFollowUp): armed by a Stop that cut
2852
+ // off pushed steers; suppressingTurn while a follow-up turn is killed.
2853
+ stopFollowUp: null,
2854
+ suppressingTurn: false,
2082
2855
  activeCtx: null,
2083
2856
  pendingDelivered: [],
2084
2857
  lastBoundary: null,
@@ -2086,6 +2859,12 @@ export function createClaudeSessionRunner({
2086
2859
  lastEventAt: Date.now(),
2087
2860
  heldForTasksSince: 0,
2088
2861
  taskSettledAt: 0,
2862
+ // Bumped by every task-settle signal; the Stop guard compares it.
2863
+ taskSettleSeq: 0,
2864
+ // taskSettleSeq when the last turn ended: a settle since then is what
2865
+ // makes an unowed init inside a settings window a continuation.
2866
+ settleSeqAtTurnEnd: 0,
2867
+ lastApiRetryAt: 0,
2089
2868
  tasksExpired: false,
2090
2869
  tasksExpiredAt: 0,
2091
2870
  aborted: false,
@@ -2120,6 +2899,7 @@ export function createClaudeSessionRunner({
2120
2899
  // the human thinks, and an idle shutdown under it would reject the
2121
2900
  // request with "Tool permission stream closed".
2122
2901
  processRef.pendingControlRequests += 1;
2902
+ syncDeliveryReadiness();
2123
2903
  try {
2124
2904
  // A question from a background agent between turns has no active turn
2125
2905
  // to attach to, and /api/relay-question requires a processing queue
@@ -2140,6 +2920,9 @@ export function createClaudeSessionRunner({
2140
2920
  } finally {
2141
2921
  processRef.pendingControlRequests -= 1;
2142
2922
  processRef.lastEventAt = Date.now();
2923
+ // The card was answered (or the prompt resolved): a message held
2924
+ // behind it steers into the resumed turn within one round-trip.
2925
+ syncDeliveryReadiness();
2143
2926
  }
2144
2927
  };
2145
2928
 
@@ -2173,6 +2956,7 @@ export function createClaudeSessionRunner({
2173
2956
  || sdkSessionId
2174
2957
  || '',
2175
2958
  ),
2959
+ remoteRelayTool: remoteRelayGate.isEnabled(),
2176
2960
  dbg,
2177
2961
  });
2178
2962
  processRef.lifecycleTimer = setInterval(() => evaluateLifecycle(), lifecyclePollMs);
@@ -2182,6 +2966,7 @@ export function createClaudeSessionRunner({
2182
2966
  try {
2183
2967
  for await (const sdkMessage of processRef.turn) {
2184
2968
  await onSdkMessage(processRef, sdkMessage);
2969
+ syncDeliveryReadiness();
2185
2970
  }
2186
2971
  } catch (error) {
2187
2972
  if (!processRef.aborted && !abortController.signal.aborted) streamError = error;
@@ -2196,6 +2981,11 @@ export function createClaudeSessionRunner({
2196
2981
  return processRef;
2197
2982
  }
2198
2983
 
2984
+ function expectSettingsReinit() {
2985
+ settingsReinitsOwed += 1;
2986
+ settingsWindowOpen = true;
2987
+ }
2988
+
2199
2989
  async function adaptProcess(message) {
2200
2990
  const relayMode = message.relayMode || 'agent';
2201
2991
  const model = resolvePerTurnModel(message);
@@ -2204,23 +2994,16 @@ export function createClaudeSessionRunner({
2204
2994
  // A mode change that would alter the spawn-time system prompt append gets
2205
2995
  // a fresh process — but only when nothing lives in the old one.
2206
2996
  if (modeAppendClass(relayMode) !== proc.appendClass && !hasLiveWork() && !proc.liveTasks.size) {
2207
- const previous = proc;
2208
2997
  gracefulShutdown('mode-change');
2209
- await Promise.race([
2210
- previous.consumer,
2211
- new Promise((resolve) => setTimeout(resolve, 10_000)),
2212
- ]);
2213
- if (proc === previous) {
2214
- // The drain timed out: force the old CLI down so it cannot keep
2215
- // streaming next to the replacement the caller is about to spawn.
2216
- try { previous.turn.close?.(); } catch {}
2217
- proc = null;
2218
- }
2998
+ // A drain that times out forces the old CLI down so it cannot keep
2999
+ // streaming next to the replacement the caller is about to spawn.
3000
+ await drainClosingProcess(10_000);
2219
3001
  return null;
2220
3002
  }
2221
3003
 
2222
3004
  const permissionMode = permissionModeForRelayMode(relayMode);
2223
3005
  if (permissionMode !== proc.permissionMode) {
3006
+ expectSettingsReinit();
2224
3007
  await Promise.resolve(proc.turn.setPermissionMode?.(permissionMode)).catch((error) => {
2225
3008
  dbg('setPermissionMode failed', error?.message || String(error));
2226
3009
  });
@@ -2239,6 +3022,7 @@ export function createClaudeSessionRunner({
2239
3022
  // (model '') keeps whatever the CLI chose.
2240
3023
  const repinAfterFallback = Boolean(proc.modelFallback) && Boolean(model);
2241
3024
  if (model !== proc.model || repinAfterFallback) {
3025
+ expectSettingsReinit();
2242
3026
  await Promise.resolve(proc.turn.setModel?.(model || undefined)).catch((error) => {
2243
3027
  dbg('setModel failed', error?.message || String(error));
2244
3028
  });
@@ -2250,6 +3034,7 @@ export function createClaudeSessionRunner({
2250
3034
  // session-scoped ultracode/enableWorkflows flags (with the effort pinned
2251
3035
  // to the xhigh the flag implies); every other change clears those flags
2252
3036
  // alongside the new effortLevel.
3037
+ expectSettingsReinit();
2253
3038
  await Promise.resolve(proc.turn.applyFlagSettings?.(claudeUltracodeFlagSettings(effort))).catch((error) => {
2254
3039
  dbg('applyFlagSettings effort failed', error?.message || String(error));
2255
3040
  });
@@ -2260,6 +3045,7 @@ export function createClaudeSessionRunner({
2260
3045
  // be pushed too or clearing the slider would never take effect.
2261
3046
  const autoCompactWindow = normalizeAutoCompactWindow(getAutoCompactWindow());
2262
3047
  if (autoCompactWindow !== proc.autoCompactWindow) {
3048
+ expectSettingsReinit();
2263
3049
  await Promise.resolve(proc.turn.applyFlagSettings?.(claudeAutoCompactFlagSettings(autoCompactWindow))).catch((error) => {
2264
3050
  dbg('applyFlagSettings autoCompactWindow failed', error?.message || String(error));
2265
3051
  });
@@ -2277,6 +3063,7 @@ export function createClaudeSessionRunner({
2277
3063
  // Enabling applies live. The display MUST be re-sent afterwards, in
2278
3064
  // this order: a session spawned with thinking off carries no display
2279
3065
  // mode, so enabling without it yields visible-but-empty thinking.
3066
+ expectSettingsReinit();
2280
3067
  await Promise.resolve(proc.turn.applyFlagSettings?.(claudeThinkingFlagSettings(true))).catch((error) => {
2281
3068
  dbg('applyFlagSettings thinkingEnabled failed', error?.message || String(error));
2282
3069
  });
@@ -2294,6 +3081,7 @@ export function createClaudeSessionRunner({
2294
3081
  }
2295
3082
  }
2296
3083
  if (thinkingDisplay !== proc.thinkingDisplay || reassertDisplay) {
3084
+ expectSettingsReinit();
2297
3085
  await applyThinkingDisplay(proc.turn, thinkingDisplay, { budget: proc.thinkingBudget, dbg });
2298
3086
  proc.thinkingDisplay = thinkingDisplay;
2299
3087
  }
@@ -2304,28 +3092,199 @@ export function createClaudeSessionRunner({
2304
3092
  // ---------------------------------------------------------------------------
2305
3093
  // Relay contract
2306
3094
 
3095
+ /**
3096
+ * Whether a new delivered message may be pushed into the live turn
3097
+ * (mid-turn steering) instead of waiting in the relay queue for the
3098
+ * result. The CLI dequeues a mid-turn push into the running turn; the
3099
+ * absorbed-steering handoff in resolveContext is the receiving end.
3100
+ * Deliberately narrow — every exclusion here guards an invariant that
3101
+ * already exists:
3102
+ * - a pending canUseTool round-trip (AskUserQuestion, plan approval)
3103
+ * holds steering entirely, so the bridge reply and a steered push can
3104
+ * never race in the CLI (a deliberate product decision, 2026-09-19);
3105
+ * - a compaction replays the turn when it finishes, and a push into that
3106
+ * window would land inside the replay;
3107
+ * - a provisional post-compaction adoption may still be unwound on the
3108
+ * premise that the CLI never consumed the row's prompt — steering
3109
+ * another message in would spend that premise for it;
3110
+ * - a NON-steered pending entry means a turn-opening delivery is still
3111
+ * mid-flight (pushed with no turn active, replay not yet arrived) — a
3112
+ * steer would race the very turn that entry is about to open.
3113
+ * The number of steered entries in flight is deliberately unbounded
3114
+ * (Claude Code parity — the CLI queues any number of mid-turn messages and
3115
+ * folds or replays them in push order; attach matches by expectedText,
3116
+ * oldest first, and the fold reaper settles every leftover as merged).
3117
+ */
3118
+ function canAcceptSteering() {
3119
+ if (!proc || proc.closing || proc.aborted) return false;
3120
+ const ctx = proc.activeCtx;
3121
+ if (!ctx || ctx.finalized || ctx.discarded || ctx.interrupted) return false;
3122
+ if (ctx.adoptedFromCompaction) return false;
3123
+ if (proc.pendingDelivered.some((entry) => !entry.steered)) return false;
3124
+ if (proc.pendingControlRequests > 0) return false;
3125
+ if (isCompacting()) return false;
3126
+ return true;
3127
+ }
3128
+
3129
+ /**
3130
+ * The one steering gate. A delivery arriving now is held — handed back to
3131
+ * the queue instead of pushed — when a turn is live (a context, anything
3132
+ * pushed and not yet settled, or an open control round-trip) and that turn
3133
+ * cannot accept a steer. The socket link consults this for its readiness
3134
+ * too, idle or mid-delivery: a continuation holding a question card is not a
3135
+ * delivery, so the link's own in-flight bookkeeping cannot see it.
3136
+ */
3137
+ function isDeliveryHeld() {
3138
+ if (admittingDeliveries > 0) return true;
3139
+ if (!proc || proc.closing || proc.aborted) return false;
3140
+ if (!isTurnActive() && !(proc.pendingControlRequests > 0)) return false;
3141
+ return !canAcceptSteering();
3142
+ }
3143
+
3144
+ function syncDeliveryReadiness() {
3145
+ const ready = !isDeliveryHeld();
3146
+ if (ready === lastReportedDeliveryReady) return;
3147
+ lastReportedDeliveryReady = ready;
3148
+ try {
3149
+ onDeliveryReadinessChange(ready);
3150
+ } catch (error) {
3151
+ dbg('delivery readiness listener failed', error?.message || String(error));
3152
+ }
3153
+ }
3154
+
3155
+ /**
3156
+ * Give a held delivery back to the queue with no retry penalty. The relay
3157
+ * re-delivers it once this worker signals ready again, i.e. when the hold
3158
+ * ends — the message then steers into the resumed turn. Pushing it into the
3159
+ * hold instead would bypass the question card or land inside a compaction's
3160
+ * replay.
3161
+ */
3162
+ async function handBackHeldDelivery(message) {
3163
+ dbg('steering held; handing the delivery back to the queue', message.id || '(no id)');
3164
+ if (!message.id) return false;
3165
+ await api('POST', '/api/requeue', {
3166
+ messageId: message.id,
3167
+ class: 'steering-held',
3168
+ ...attemptFields(message),
3169
+ }).catch((error) => {
3170
+ if (isStaleAttemptError(error)) {
3171
+ dbg('steering-held hand-back refused as stale_attempt', message.id);
3172
+ return;
3173
+ }
3174
+ dbg('steering-held hand-back failed', message.id, error?.message || String(error));
3175
+ });
3176
+ return false;
3177
+ }
3178
+
3179
+ /**
3180
+ * Composer-facing steering snapshot: whether a turn is live, whether a
3181
+ * message typed now would steer into it, and — when it would not — why.
3182
+ * Rides the worker heartbeat to the relay's worker registry, so the client
3183
+ * can disable the Steer button during holds instead of silently queueing.
3184
+ * holdReason is a coarse enum, not a diagnostic: 'question' (a canUseTool
3185
+ * round-trip — question card / plan approval — is open), 'compaction',
3186
+ * 'adoption' (provisional post-compaction adoption), 'delivery' (a
3187
+ * turn-opening delivery is still mid-flight).
3188
+ */
3189
+ function steeringState() {
3190
+ // A closing/aborted process is about to die — nothing about its last turn
3191
+ // should hold the composer of the worker that replaces it.
3192
+ if (!proc || proc.closing || proc.aborted) {
3193
+ return { turnActive: false, canSteer: false, holdReason: null, messageId: null, supported: true };
3194
+ }
3195
+ const ctx = proc.activeCtx || null;
3196
+ const ctxLive = Boolean(ctx && !ctx.finalized && !ctx.discarded && !ctx.interrupted);
3197
+ // A turn-opening delivery in flight (pushed with no active ctx, replay not
3198
+ // yet arrived) reads as an active turn to the user — the row is already
3199
+ // `processing` — so it must report as one here, with a 'delivery' hold.
3200
+ const openingDelivery = proc.pendingDelivered.find((entry) => !entry.steered) || null;
3201
+ const turnActive = ctxLive || Boolean(openingDelivery);
3202
+ const canSteer = canAcceptSteering();
3203
+ let holdReason = null;
3204
+ if (turnActive && !canSteer) {
3205
+ if (proc.pendingControlRequests > 0) holdReason = 'question';
3206
+ else if (isCompacting()) holdReason = 'compaction';
3207
+ else if (ctxLive && ctx.adoptedFromCompaction) holdReason = 'adoption';
3208
+ else if (openingDelivery) holdReason = 'delivery';
3209
+ else holdReason = 'other';
3210
+ }
3211
+ // The turn this snapshot describes, so a client can discard a stale hold
3212
+ // that belongs to a previous turn.
3213
+ const messageId = String(
3214
+ (ctxLive ? ctx.message?.id : '') || openingDelivery?.ctx?.message?.id || '',
3215
+ ).trim() || null;
3216
+ // `supported` is the worker-advertised opt-in the client's composer reads
3217
+ // (the same flag the Copilot SDK worker publishes) — its only steering
3218
+ // gate, so every snapshot carries it, idle ones included.
3219
+ return { turnActive, canSteer, holdReason, messageId, supported: true };
3220
+ }
3221
+
3222
+ /**
3223
+ * A closing process drains on its own; wait (bounded) so two CLIs never
3224
+ * share the native session transcript, then make sure it is gone.
3225
+ */
3226
+ async function drainClosingProcess(timeoutMs = 15_000) {
3227
+ const previous = proc;
3228
+ if (!previous) return;
3229
+ let timer = null;
3230
+ await Promise.race([
3231
+ previous.consumer,
3232
+ new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); }),
3233
+ ]);
3234
+ clearTimeout(timer);
3235
+ if (proc === previous) {
3236
+ try { previous.turn.close?.(); } catch {}
3237
+ proc = null;
3238
+ }
3239
+ }
3240
+
2307
3241
  async function handlePendingPayload(pending) {
2308
3242
  const message = pending?.message || null;
2309
3243
  if (!message) return false;
3244
+ // An older relay (the worker updated on disk before the relay restarted)
3245
+ // treats the hand-back as a failed delivery — retry, backoff, worker
3246
+ // marked errored — so against one the message is pushed the legacy way.
3247
+ if (isDeliveryHeld() && canHandBackHeldDelivery() !== false) return handBackHeldDelivery(message);
3248
+ // A turn-opening delivery awaits (adaptProcess, a draining process, a
3249
+ // spawn) before it pushes; until then it counts as admitting, so nothing
3250
+ // else is let in behind it. A steer pushes synchronously and needs none.
3251
+ let admitting = !(proc && !proc.closing && !proc.aborted && proc.activeCtx);
3252
+ if (admitting) admittingDeliveries += 1;
3253
+ const endAdmission = () => {
3254
+ if (!admitting) return;
3255
+ admitting = false;
3256
+ admittingDeliveries -= 1;
3257
+ syncDeliveryReadiness();
3258
+ };
3259
+ syncDeliveryReadiness();
2310
3260
  try {
2311
- if (proc && !proc.closing && !proc.aborted) {
3261
+ // A steered delivery (a turn is live) must not run the settings diffs:
3262
+ // setModel/effort/mode against a mid-flight turn would disturb the very
3263
+ // turn the message is steering into, and the mode-change recycle is
3264
+ // refused under live work anyway. The next between-turns delivery
3265
+ // re-runs adaptProcess with the then-current settings, so nothing is
3266
+ // lost — the steered message's own model/mode intent applies from the
3267
+ // next turn on, same as Claude Code.
3268
+ if (proc && !proc.closing && !proc.aborted && !proc.activeCtx) {
2312
3269
  await adaptProcess(message);
2313
3270
  }
2314
3271
  if (!proc || proc.closing || proc.aborted) {
2315
- // A closing process drains on its own; wait so two CLIs never share
2316
- // the native session transcript.
2317
- const previous = proc;
2318
- if (previous) {
2319
- await Promise.race([
2320
- previous.consumer,
2321
- new Promise((resolve) => setTimeout(resolve, 15_000)),
2322
- ]);
2323
- if (proc === previous) {
2324
- try { previous.turn.close?.(); } catch {}
2325
- proc = null;
2326
- }
2327
- }
2328
- spawnProcess(message);
3272
+ // Awaited only when there is one: a cold spawn with the decision
3273
+ // already in pushes synchronously.
3274
+ if (proc) await drainClosingProcess();
3275
+ // The tool set is fixed per CLI process: let the remote_relay
3276
+ // decision settle first. The worker's first delivery can arrive
3277
+ // before its startup GET has answered (seen live: 180 ms after the
3278
+ // spawn). ready() never rejects and is bounded by the gate's timeout.
3279
+ if (!remoteRelayGate.isSettled?.()) await remoteRelayGate.ready();
3280
+ // Another delivery spawned while this one waited: join that process
3281
+ // like any delivery (between turns: adapt; mid-turn: steer below).
3282
+ if (proc && !proc.closing && !proc.aborted && !proc.activeCtx) await adaptProcess(message);
3283
+ // What it joined may be gone by now: that adapt can recycle the
3284
+ // process (a mode change), and a process can wind down under any of
3285
+ // these waits. Only a live one takes the push below.
3286
+ if (proc && (proc.closing || proc.aborted)) await drainClosingProcess();
3287
+ if (!proc) spawnProcess(message);
2329
3288
  // A cold start is the one moment a turn can sit silent for many
2330
3289
  // seconds (transcript load + first request); say so instead of
2331
3290
  // showing bare dots. Posted once here — not in spawnProcess — so a
@@ -2338,9 +3297,38 @@ export function createClaudeSessionRunner({
2338
3297
  for (let attempt = 0; attempt < 2; attempt += 1) {
2339
3298
  const procRef = proc;
2340
3299
  const ctx = createDeliveredContext(message);
2341
- procRef.pendingDelivered.push({ ctx, expectedText: contentText(content), pushedAt: Date.now() });
3300
+ // A message pushed while a turn is already active is a steered
3301
+ // delivery. The CLI folds it into that running turn and answers
3302
+ // everything with ONE result on the turn it was already running —
3303
+ // it does NOT necessarily replay the steered text as a turn-opening
3304
+ // user message, so the absorbed-steering handoff in resolveContext
3305
+ // (which needs that replay) may never fire. Mark the entry so the
3306
+ // fold reaper can settle it as merged once the turn it joined ends,
3307
+ // instead of leaving its queue row stuck `processing` until the
3308
+ // 5-minute watchdog fails it with a bogus "resend" error.
3309
+ const steered = Boolean(procRef.activeCtx);
3310
+ // A steer carries a hidden note that it adds to the running request
3311
+ // rather than replacing it (shared/steer-note.mjs). expectedText stays
3312
+ // the user's own text: the replay signatures strip the note before
3313
+ // any comparison, so matching works whether or not the CLI echoes it.
3314
+ // Not into a continuation: there the running work is the CLI's own
3315
+ // follow-up to a background task, not a request of the user's.
3316
+ // Whatever is left starting with "/" — a "/x" delivered between turns,
3317
+ // on a first turn, or into a continuation — gets the slash-command
3318
+ // guard, or the CLI would run it as a command instead of handing the
3319
+ // model the text the user sent (shared/slash-command-guard.mjs). A
3320
+ // noted steer no longer starts with "/" and is pushed as noted. The
3321
+ // replay signatures strip the guard too, so expectedText stays the
3322
+ // user's own text here as well.
3323
+ const pushedContent = withSlashCommandGuardContent(steered && procRef.activeCtx?.kind !== 'continuation'
3324
+ ? withSteerNoteContent(content)
3325
+ : content);
3326
+ procRef.pendingDelivered.push({ ctx, expectedText: contentText(content), pushedAt: Date.now(), steered });
3327
+ // Registered: from here a later init takes the delivered-row branch.
3328
+ settingsReinitsOwed = 0;
3329
+ settingsWindowOpen = false;
2342
3330
  try {
2343
- procRef.turn.pushUserMessage(content);
3331
+ procRef.turn.pushUserMessage(pushedContent);
2344
3332
  } catch (pushError) {
2345
3333
  // The stream ended under us (process wound down between the
2346
3334
  // liveness check and the push): retire this context and respawn.
@@ -2353,14 +3341,25 @@ export function createClaudeSessionRunner({
2353
3341
  spawnProcess(message);
2354
3342
  continue;
2355
3343
  }
3344
+ // Pushed: the pending entry now holds the gate on its own.
3345
+ endAdmission();
3346
+ // A genuinely new message reached the CLI (only possible against an
3347
+ // older relay): the next turn may be its, so the Stop guard must not
3348
+ // kill it.
3349
+ if (procRef.stopFollowUp) procRef.stopFollowUp = null;
2356
3350
  return await ctx.done;
2357
3351
  }
2358
3352
  throw new Error('claude session process closed while accepting the message');
2359
3353
  } catch (error) {
3354
+ endAdmission();
3355
+ settingsReinitsOwed = 0;
3356
+ settingsWindowOpen = false;
2360
3357
  const errorText = String(error?.message || error || 'unknown error');
2361
3358
  dbg('claude turn failed', message.id, errorText);
2362
3359
  await publisher.publishTurnException({ message, errorText });
2363
3360
  return true;
3361
+ } finally {
3362
+ endAdmission();
2364
3363
  }
2365
3364
  }
2366
3365
 
@@ -2410,8 +3409,13 @@ export function createClaudeSessionRunner({
2410
3409
 
2411
3410
  return {
2412
3411
  handlePendingPayload,
3412
+ canAcceptSteering,
3413
+ isDeliveryHeld,
3414
+ steeringState,
2413
3415
  getActiveQueueMessageId,
2414
3416
  getActiveQueueMessageIds,
3417
+ getSettleFailed,
3418
+ acknowledgeSettleFailed,
2415
3419
  isTurnActive,
2416
3420
  stopBackgroundTask,
2417
3421
  stopBackgroundTaskByToolUseId,