@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
@@ -63,10 +63,12 @@ import { resolveCopilotProviderConfig } from './copilot-byok-provider.mjs';
63
63
  import {
64
64
  createBackgroundShellTracker,
65
65
  createReplayGate,
66
+ describeSettledAgent,
66
67
  describeSettledShell,
67
68
  isContinuationOpeningEvent,
69
+ settledAgentFromNotification,
68
70
  } from './copilot-continuation-signals.mjs';
69
- import { createCopilotEventNormalizer } from './copilot-sdk-event-normalizer.mjs';
71
+ import { createCopilotEventNormalizer, formatToolActivityText } from './copilot-sdk-event-normalizer.mjs';
70
72
  import {
71
73
  DEFAULT_MODEL_SWITCH_TIMEOUT_MS,
72
74
  createCopilotModelSwitcher,
@@ -74,6 +76,16 @@ import {
74
76
  normalizeRelayEffort,
75
77
  } from './copilot-model-switch.mjs';
76
78
  import { createCopilotQuestionBridge } from './copilot-question-bridge.mjs';
79
+ import {
80
+ RELAY_ASK_USER_TOOL_DESCRIPTION,
81
+ RELAY_ASK_USER_TOOL_NAME,
82
+ RELAY_ASK_USER_TOOL_PARAMETERS,
83
+ formatAskUserToolResult,
84
+ isToolOverrideRejection,
85
+ parseAskUserToolArguments,
86
+ } from './copilot-ask-user-tool.mjs';
87
+ import { buildCopilotRemoteRelayTool } from './copilot-remote-relay-tool.mjs';
88
+ import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
77
89
  import {
78
90
  EXIT_PLAN_BOARD_POSTED_FEEDBACK,
79
91
  EXIT_PLAN_NO_BOARD_FEEDBACK,
@@ -89,6 +101,11 @@ import {
89
101
  } from './copilot-prompt-context.mjs';
90
102
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
91
103
  import { buildModelSnapshotFields, extractModelDescriptors } from '../../shared/model-descriptors.mjs';
104
+ import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
105
+ import { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT } from '../../shared/steer-settle-markers.mjs';
106
+ import { buildSteerSettleFailure } from '../../shared/steer-settle-failure.mjs';
107
+ import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
108
+ import { withSteerNote } from '../../shared/steer-note.mjs';
92
109
 
93
110
  // How long the runtime may sit with no session activity before the worker
94
111
  // closes it. The worker process itself stays up and reconnects lazily on the
@@ -132,6 +149,45 @@ const DEFAULT_CONTINUATION_RETRY_DELAY_MS = 500;
132
149
  const CONTINUATION_REGISTRATION_TIMEOUT_MS = 10_000;
133
150
  /** Cap on activity lines carried between turns, so a chatty runtime cannot grow them. */
134
151
  const MAX_PENDING_ACTIVITIES = 20;
152
+ /**
153
+ * Backstop for the compaction hold: `session.compaction_start` without a
154
+ * matching `_complete` (a runtime that died mid-compaction and was resumed)
155
+ * must not hold steering forever. Sized far above any plausible compaction,
156
+ * like the Claude worker's `compactionStaleMs`.
157
+ */
158
+ const DEFAULT_COMPACTION_STALE_MS = 10 * 60_000;
159
+ /**
160
+ * Backoff between attempts to save a consumed steer's settle marker (~31 s in
161
+ * all); after the last one the row fails terminally instead. Same ladder as
162
+ * the Claude worker, because it protects the same invariant: a prompt the
163
+ * runtime already consumed is never re-run.
164
+ */
165
+ const DEFAULT_SETTLE_RETRY_DELAYS_MS = Object.freeze([1_000, 2_000, 4_000, 8_000, 8_000, 8_000]);
166
+ const DEFAULT_MAX_TERMINAL_SETTLE_ATTEMPTS = 5;
167
+ /** Debounce for re-reading the runtime's queued lane after `pending_messages.modified`. */
168
+ const DEFAULT_QUEUE_LANE_REFRESH_MS = 150;
169
+ /**
170
+ * How long a Stop's acknowledgement is held back for the reply of the row it
171
+ * named when that row's early settle is still publishing (see interruptTurn).
172
+ * Bounded: an unresponsive relay must not hold the Stop itself.
173
+ */
174
+ const STOPPED_ROW_PUBLISH_WAIT_MS = 5_000;
175
+ /**
176
+ * Debounce for re-reading `rpc.tasks.list()` after `session.background_tasks_changed`
177
+ * (which carries no payload and fires ~20 times per shell call).
178
+ */
179
+ const DEFAULT_TASK_REFRESH_MS = 250;
180
+ /** Trailing throttle for enrichment-only card updates (a live tool call, tokens). */
181
+ const DEFAULT_TASK_PUBLISH_THROTTLE_MS = 2_000;
182
+ /**
183
+ * How long a pushed prompt the runtime had not started when the turn's idle
184
+ * arrived is kept unsettled, waiting for the runtime to open its run. The
185
+ * drain's idle can race a send that resolved a moment earlier; the runtime
186
+ * then starts the prompt right away (`immediate` when idle) and its
187
+ * `user.message` adopts the row as a turn of its own. Same value as the
188
+ * Claude worker's fold grace, for the same reason.
189
+ */
190
+ const DEFAULT_ORPHAN_GRACE_MS = 2_000;
135
191
 
136
192
  /**
137
193
  * Appended to the partial answer when the RUNTIME interrupted the turn on its
@@ -145,15 +201,27 @@ export const RUNTIME_INTERRUPTED_NOTE =
145
201
 
146
202
  /**
147
203
  * Published to a steered queue row whose prompt the runtime accepted but never
148
- * opened work on before the interaction ended. The prompt is still queued
149
- * INSIDE the runtime, so it will be answered at the start of the next turn —
150
- * requeuing the row would run it twice. Mirrors the Claude worker's
151
- * handed-off-context note, which exists for the same reason.
204
+ * opened work on before the interaction ended normally. The prompt is still
205
+ * queued INSIDE the runtime, so it will be answered at the start of the next
206
+ * turn — requeuing the row would run it twice. With `mode: "immediate"` the
207
+ * runtime drains every pushed prompt before it idles, so this is a defensive
208
+ * fallback rather than a routine outcome.
152
209
  */
153
210
  export const STEERED_ROW_MERGED_NOTE =
154
211
  '_(This message was delivered while the previous turn was still running; the reply continues in '
155
212
  + 'the next turn.)_';
156
213
 
214
+ /**
215
+ * The reply of a continuation row opened only so a background task's question
216
+ * had a row to hang its card off (see `ensureInteractiveTurn`). The runtime
217
+ * produced no root work for it, so nothing else would ever answer the row.
218
+ */
219
+ export const BACKGROUND_QUESTION_NOTE =
220
+ 'System note: a background task asked for input here; it carries on in the background.';
221
+
222
+ /** The provider name in the at-most-once settle failure wording. */
223
+ const SETTLE_AGENT_LABEL = 'Copilot';
224
+
157
225
  /**
158
226
  * The `trigger` reported to `POST /api/continuation-turn`, matching the value
159
227
  * the Claude worker sends so the relay's `CONTINUATION … trigger=` log line and
@@ -199,6 +267,22 @@ function isStaleAttemptError(error) {
199
267
  return detail.includes('stale_attempt');
200
268
  }
201
269
 
270
+ /**
271
+ * Whether publishing `action` makes its row worth keeping (`entry.showedWork`).
272
+ * A bookkeeping line only tracks background work the task card already shows,
273
+ * and so does a line carried in from between turns (a shell or agent the card
274
+ * showed finishing) — unless it reports a failure. The card lists running
275
+ * tasks only, so a failed agent's card vanishes exactly like a finished one's,
276
+ * and a `subagent.failed` between turns reaches no row: that line is the
277
+ * failure's only trace. It renders only through its row's response, so
278
+ * dropping the row would drop the failure with it.
279
+ */
280
+ function showsWork(action) {
281
+ if (action.channel === 'init') return false;
282
+ if (action.carried) return action.reportsFailure === true;
283
+ return !action.bookkeeping;
284
+ }
285
+
202
286
  export function createCopilotSdkSessionRunner({
203
287
  api,
204
288
  sdkSessionId,
@@ -220,15 +304,16 @@ export function createCopilotSdkSessionRunner({
220
304
  resolveProviderConfigImpl = resolveCopilotProviderConfig,
221
305
  // Threading seam for `MessageOptions.mode` ("enqueue" | "immediate").
222
306
  //
223
- // A BYOK probe against runtime 1.0.82 ran a mid-turn send three ways —
224
- // "enqueue", "immediate" and unset — and all three behaved IDENTICALLY: the
225
- // send resolves in ~2ms with a message id, the prompt is queued
226
- // (`pending_messages.modified`), the in-flight model call runs to completion
227
- // untouched, and the prompt is picked up at the NEXT model-call boundary with
228
- // its own `user.message`. Neither mode interrupts anything. "enqueue" is the
229
- // documented default and preserves FIFO order, which is what the relay queue
230
- // contract wants, so it is what this sends. See §5 of the plan doc.
231
- resolveSendModeImpl = () => 'enqueue',
307
+ // "immediate", always (fake-provider probe against runtime 1.0.88,
308
+ // 2026-09-25). Mid-turn it is a real steer: injected at the next tool
309
+ // boundary as `user.message{delivery:"steering"}` (or, when the model is
310
+ // mid-stream, moved to the FRONT of the queue and run right after as
311
+ // `delivery:"queued"`). When the main loop is idle both modes start a run at
312
+ // once — except that an "enqueue" message sent while a background agent or
313
+ // an attached shell keeps `session.idle` deferred sits FROZEN until that
314
+ // work ends, while an "immediate" one runs immediately. So immediate is the
315
+ // only mode that never strands a message behind background work.
316
+ resolveSendModeImpl = () => 'immediate',
232
317
  // Interactive surfaces. Tests inject a fake bridge; nothing here reaches the
233
318
  // relay without one.
234
319
  createQuestionBridgeImpl = createCopilotQuestionBridge,
@@ -259,6 +344,35 @@ export function createCopilotSdkSessionRunner({
259
344
  // How long a settled continuation waits for its row before giving up on it.
260
345
  // Must outlast the registration's own retries; a test shortens it.
261
346
  continuationRegistrationTimeoutMs = CONTINUATION_REGISTRATION_TIMEOUT_MS,
347
+ compactionStaleMs = DEFAULT_COMPACTION_STALE_MS,
348
+ settleRetryDelaysMs = DEFAULT_SETTLE_RETRY_DELAYS_MS,
349
+ maxTerminalSettleAttempts = DEFAULT_MAX_TERMINAL_SETTLE_ATTEMPTS,
350
+ queueLaneRefreshMs = DEFAULT_QUEUE_LANE_REFRESH_MS,
351
+ orphanGraceMs = DEFAULT_ORPHAN_GRACE_MS,
352
+ // Register the relay's own `ask_user` (with `multi_select`) over the
353
+ // runtime's built-in one. A runtime that refuses the override falls back
354
+ // to the built-in for the rest of this worker's life.
355
+ relayAskUserTool = true,
356
+ // Whether sessions carry the relay's `remote_relay` tool
357
+ // (shared/remote-relay-tool-core.mjs). Default: ask the relay.
358
+ remoteRelayToolGate = null,
359
+ taskRefreshMs = DEFAULT_TASK_REFRESH_MS,
360
+ taskPublishThrottleMs = DEFAULT_TASK_PUBLISH_THROTTLE_MS,
361
+ // Called with `true`/`false` whenever the runner flips between accepting
362
+ // deliveries and holding them (see isDeliveryHeld). The worker wires it to
363
+ // the socket link: a hold withdraws the relay's readiness, and its end
364
+ // re-arms it at once so a held message steers into the resumed turn.
365
+ onDeliveryReadinessChange = () => {},
366
+ // False while the connected relay does not understand the `steering-held`
367
+ // hand-back (a worker updated on disk before the relay restarted): a held
368
+ // delivery is then pushed the legacy way instead of being handed back into
369
+ // a retry-and-backoff requeue.
370
+ canHandBackHeldDelivery = () => true,
371
+ // Called when the set of recallable (un-steerable) rows changes. The worker
372
+ // wires it to an immediate heartbeat: the client's Cancel appears from the
373
+ // heartbeat snapshot, and a 10 s wait would often outlast the window in
374
+ // which the runtime still holds the message (live check 2026-09-25).
375
+ onCancellableChange = () => {},
262
376
  dbg = () => {},
263
377
  } = {}) {
264
378
  let client = null;
@@ -303,12 +417,105 @@ export function createCopilotSdkSessionRunner({
303
417
  let starting = null;
304
418
  let disposed = false;
305
419
  let detachRuntimeExit = () => {};
306
- // Prompts this worker sent that the runtime accepted but had not started work
307
- // on when the interaction ended. They stay in the runtime's pending queue and
308
- // are picked up FIRST in the next interaction, ahead of that turn's own
309
- // prompt — so the next turn's segments are shifted by this many, and without
310
- // it the primary row would be answered with a leftover prompt's reply.
311
- let carriedPrompts = 0;
420
+ // A compaction in progress (`session.compaction_start` seen, no `_complete`
421
+ // yet). Holds steering: a message pushed now would land inside the
422
+ // compaction's replay. `compactingSince` backs the stale cap.
423
+ let compactingSince = 0;
424
+ // The last readiness value reported to `onDeliveryReadinessChange`, so the
425
+ // link only hears about flips.
426
+ let lastReportedDeliveryReady = null;
427
+ // Rows whose prompt the runtime consumed and whose settle marker is being
428
+ // saved (id → { message, variant }). Reported by the heartbeat WITH a
429
+ // terminal error until the publish lands, so recovery can never re-run them
430
+ // (see publishSettleMarker).
431
+ const settlingMessages = new Map();
432
+ // Settle markers that could not be posted even terminally; the heartbeat
433
+ // hands them to the relay to fail (id → terminalError).
434
+ const settleFailedTerminals = new Map();
435
+ // Per-row chains of `/api/queue-consumed` POSTs, so a mark and a later
436
+ // un-mark for the same row reach the relay in order.
437
+ const consumedMarkChains = new Map();
438
+ // The runtime's queued lane as last read from `rpc.queue.pendingItems`:
439
+ // runtime message id → stable queue item id. What un-steer needs, and what
440
+ // decides which pushed rows the client may still cancel.
441
+ let queuedLane = new Map();
442
+ // The rest of the last queue snapshot: how many immediate messages wait in
443
+ // the runtime's steering lane (entries not yet folded into the running
444
+ // turn), and how many items the queued lane holds. The steering lane has no
445
+ // ids — only its newest entry can be removed (`queue.removeMostRecent`,
446
+ // LIFO across both lanes; probe 2026-09-25) — so these decide whether our
447
+ // newest steer is still recallable.
448
+ let waitingSteers = 0;
449
+ let queuedItemCount = 0;
450
+ // Guards for the LIFO recall (removeMostRecent takes the runtime's NEWEST
451
+ // waiting message, whoever sent it):
452
+ // - `steerSendsInFlight`: steer sends whose `send()` has not resolved yet.
453
+ // The runtime may already count such a message while this worker cannot
454
+ // yet name it, so no "newest" recall is attempted while any is in flight.
455
+ // - `steerSendSeq`: send order across every owned turn, so "our newest
456
+ // unconsumed steer" is decided by when it was sent, not by which turn
457
+ // object happens to hold it.
458
+ // - `unsteerChain`: un-steers run one at a time (each re-reads the queue).
459
+ // Steer sends never wait on an un-steer; the recall check and its remove
460
+ // request run in the same tick, so no send can be issued between them.
461
+ //
462
+ // What else removeMostRecent can take (live probe, runtime 1.0.85,
463
+ // 2026-09-26): only user-facing sends. The runtime's own notifications
464
+ // (agent_idle / agent_completed / shell_completed) never show in either
465
+ // lane — with one pending and nothing of ours queued the remove answered
466
+ // `removed:false`, and the notification was still delivered after a remove
467
+ // that took our steer. A `source:"system"` send is hidden from pendingItems
468
+ // and skipped (the remove took the OLDER user steer; the system one still
469
+ // ran). An `agent-*`-sourced send is listed and removable, but the
470
+ // runtime's inter-agent prompts never passed through the main lanes. So the
471
+ // read-then-remove window cannot drop a runtime message; what it can still
472
+ // hit is a message another client of the session pushed in that window,
473
+ // which the two guards below detect:
474
+ // - `recallWatches`: runtime id → { taken } for a recall in progress. A
475
+ // root `user.message` naming it sets `taken` from the event callback
476
+ // itself — the SDK dispatches a notification before it resolves a later
477
+ // RPC answer, whereas `entry.consumed` is set on the dispatch chain and
478
+ // can lag behind a slow publish.
479
+ // - `recalledRuntimeIds`: runtime id → row id of every prompt pulled back
480
+ // and cancelled. One must never run; if it does, the remove took
481
+ // another message and the row was cancelled wrongly — logged loudly.
482
+ let steerSendsInFlight = 0;
483
+ let lastCancellableSignature = '';
484
+ let steerSendSeq = 0;
485
+ let unsteerChain = Promise.resolve();
486
+ const recallWatches = new Map();
487
+ const recalledRuntimeIds = new Map();
488
+ const MAX_RECALLED_RUNTIME_IDS = 200;
489
+ /** How long an un-steer may wait on the runtime before it gives up. */
490
+ const UNSTEER_TIMEOUT_MS = 5_000;
491
+ let queueLaneTimer = null;
492
+ let queueLaneRefreshChain = Promise.resolve();
493
+ // Whether the session's `rpc.queue` / `rpc.interruptMainTurn` / `rpc.tasks`
494
+ // are present. Probed per session (the runtime auto-updates under the relay
495
+ // and every one of these is @experimental); a missing surface degrades to
496
+ // the previous behaviour, never to an error.
497
+ let sessionRpc = { queue: false, interruptMainTurn: false, tasks: false };
498
+ // Set once a runtime refused the relay's `ask_user` override: every later
499
+ // session is built without it (the built-in + wording heuristic remain).
500
+ let askUserOverrideRejected = false;
501
+ // The runtime's background task registry as last read from
502
+ // `rpc.tasks.list()` (id → TaskInfo), and what the event stream adds per
503
+ // agent that the registry does not carry: the live tool call, a running
504
+ // token count, the model announced at spawn. Both feed the task panel
505
+ // cards; the registry alone decides liveness (the lifecycle pin).
506
+ let backgroundTasks = new Map();
507
+ const agentDetails = new Map();
508
+ let taskRefreshTimer = null;
509
+ let taskRefreshChain = Promise.resolve();
510
+ let lastTaskRefreshAt = 0;
511
+ let taskPublishTimer = null;
512
+ let lastPublishedTasksSignature = '';
513
+ // Cap expiry is bounded: a task whose cancel the runtime refuses (or that
514
+ // keeps failing) this many times is abandoned — dropped from the live set so
515
+ // it stops pinning the runtime — rather than held forever.
516
+ const taskCancelRefusals = new Map();
517
+ const abandonedTasks = new Set();
518
+ const MAX_TASK_CANCEL_REFUSALS = 3;
312
519
  // Blocking handlers currently waiting on a human (`ask_user`, an ask-mode
313
520
  // tool approval). The runtime emits NO events while blocked in one, and the
314
521
  // question timeout is 8 hours against a 120s stall ceiling — so without this
@@ -316,6 +523,17 @@ export function createCopilotSdkSessionRunner({
316
523
  // the human's eventual answer to a runtime whose row is already settled.
317
524
  // Same guard the Cursor worker's `hasPendingClientWork` provides.
318
525
  let pendingHumanRequests = 0;
526
+ // Running `remote_relay` calls. The runtime is just as silent while one
527
+ // runs (the relay may wait up to 600 s for a remote turn, plus an approval
528
+ // card), so they hold the stall watchdog and the idle shutdown too — but
529
+ // not steering: nobody is answering a card here.
530
+ let pendingRelayToolCalls = 0;
531
+ // `remote_relay` is registered only while this relay has paired remotes.
532
+ // Asked once when the worker starts; a session build waits for that first
533
+ // answer (it is async anyway), and every runtime stop asks again so the
534
+ // next session picks up a remote paired in the meantime.
535
+ const remoteRelayGate = remoteRelayToolGate || createRemoteRelayToolGate({ api });
536
+ void remoteRelayGate.refresh();
319
537
  // The `SessionConfig.provider` block this session was BUILT with, or null for
320
538
  // a hosted (`github`) session. Set by `buildSessionConfig` rather than by a
321
539
  // second throwaway resolve here, so "is this BYOK?" and "what did the runtime
@@ -334,10 +552,11 @@ export function createCopilotSdkSessionRunner({
334
552
  // continuation opening, or by the grace expiring on a runtime that decided it
335
553
  // had nothing to say.
336
554
  let continuationDueSince = 0;
337
- // Transcript lines produced between turns (a settled shell's notification).
338
- // They belong to the turn they trigger, so they are carried into it rather
339
- // than dropped — the same trade the Claude worker's `pendingActivities`
340
- // makes.
555
+ // Transcript lines produced between turns (a settled shell's or background
556
+ // agent's notification), as `{ text, reportsFailure }`. They belong to the
557
+ // turn they trigger, so they are carried into it rather than dropped — the
558
+ // same trade the Claude worker's `pendingActivities` makes. A line that
559
+ // reports a failure keeps the row it lands on (see `showsWork`).
341
560
  let pendingActivities = [];
342
561
  // The relay mode of the last delivered turn. A self-initiated turn has no
343
562
  // delivery to read a mode off, and it is a continuation OF that turn's work,
@@ -345,33 +564,73 @@ export function createCopilotSdkSessionRunner({
345
564
  // plan-board gating behaving the same either side of a background wait.
346
565
  let lastRelayMode = 'agent';
347
566
 
567
+ /**
568
+ * The turn a human request belongs to, opened on demand when none is live.
569
+ *
570
+ * A card needs a `processing` row (`/api/relay-question` 409s otherwise),
571
+ * and a background agent can ask long after the turn that spawned it settled
572
+ * on `assistant.idle`. Nothing else opens a row for that question, so it
573
+ * gets a continuation of its own — the Claude worker opens one before an
574
+ * off-turn `AskUserQuestion` for the same reason. `humanOrigin`: unless root
575
+ * work joins it, it closes itself once its last card is done.
576
+ */
577
+ function ensureInteractiveTurn() {
578
+ if (activeTurn || !session || disposed) return activeTurn;
579
+ const turn = openContinuationTurn();
580
+ turn.humanOrigin = true;
581
+ return turn;
582
+ }
583
+
348
584
  async function whileAwaitingHuman(run) {
585
+ // Captured once: the card belongs to this turn's row even when another
586
+ // turn owns the event stream by the time it is created or answered.
587
+ const turn = ensureInteractiveTurn();
349
588
  pendingHumanRequests += 1;
589
+ if (turn) {
590
+ if (turn.humanRequests === 0) turn.humansIdle = new Promise((resolve) => { turn.resolveHumansIdle = resolve; });
591
+ turn.humanRequests += 1;
592
+ turn.askedHuman = true;
593
+ }
594
+ // An open card holds steering: the relay's readiness is withdrawn at once.
595
+ syncDeliveryReadiness();
350
596
  try {
351
- return await run();
597
+ return await run(turn);
352
598
  } finally {
353
599
  pendingHumanRequests -= 1;
600
+ if (turn) {
601
+ turn.humanRequests -= 1;
602
+ if (turn.humanRequests === 0) {
603
+ turn.resolveHumansIdle();
604
+ // A row opened only for questions: no terminator is coming for it.
605
+ if (turn.humanOrigin && !turn.rootWork && !turn.settled) {
606
+ turn.result = { subtype: 'completed', text: BACKGROUND_QUESTION_NOTE };
607
+ turn.settle();
608
+ }
609
+ }
610
+ }
354
611
  // The clock restarts from the answer, not from before the wait.
355
612
  touch();
356
613
  activeTurn?.armStall?.();
614
+ // The answer re-arms the relay immediately, so a message queued while
615
+ // the card was open steers into the resumed turn within one round trip.
616
+ syncDeliveryReadiness();
357
617
  }
358
618
  }
359
619
 
360
620
  /**
361
- * Hold an interactive callback until the active turn's queue row exists.
621
+ * Hold an interactive callback until its turn's queue row exists.
362
622
  *
363
623
  * A continuation becomes the active turn synchronously but gets its row
364
624
  * asynchronously, and the runtime can block on `user_input.requested` (or an
365
625
  * ask-mode approval) in that gap — the question bridge would then see no
366
- * active message and degrade to an unsupported answer / local rejection for
367
- * a card that was milliseconds from having a real row id. Bounded by the
626
+ * row and degrade to an unsupported answer / local rejection for a card
627
+ * that was milliseconds from having a real row id. Bounded by the
368
628
  * registration timeout (the registration itself can hang on a dead relay);
369
629
  * on expiry — or on a registration that gave up (`rowReady` → false) — the
370
630
  * caller proceeds and degrades exactly as before. Delivered turns are
371
631
  * registered from birth and skip straight through.
372
632
  */
373
- async function awaitInteractiveRow() {
374
- const turn = activeTurn;
633
+ async function awaitInteractiveRow(turn) {
375
634
  if (!turn || turn.registered || !turn.rowReady) return;
376
635
  let timer = null;
377
636
  await Promise.race([
@@ -537,19 +796,89 @@ export function createCopilotSdkSessionRunner({
537
796
  }
538
797
 
539
798
  /**
540
- * Publish an action, or hold it if the turn has no queue row yet.
799
+ * Publish an action onto the row that owns the runtime's current run, or
800
+ * hold it if the turn has no queue row yet.
541
801
  *
542
802
  * The single gate every producer goes through. A continuation's row is
543
803
  * created asynchronously, and a POST carrying `messageId: null` is not merely
544
804
  * useless — the relay's activity/stream routes key on it, so it would be
545
805
  * attributed to nothing at all.
806
+ *
807
+ * One turn can answer several rows in sequence (the primary, then a message
808
+ * pushed mid-turn that the runtime ran as its own `delivery:"queued"` run).
809
+ * Root-level traffic — stream, thoughts, activity, subagent lanes — goes to
810
+ * whichever row owns the run in progress (`turn.currentOwner`); the stream
811
+ * text is re-cut to that row's own segments so a second run's prose never
812
+ * appears under the first row.
546
813
  */
547
814
  async function emitAction(turn, action) {
548
815
  if (!turn.registered) {
549
816
  turn.bufferedActions.push(action);
550
817
  return;
551
818
  }
552
- await dispatchAction(turn.message, action, turn.state);
819
+ await dispatchOwned(turn, action);
820
+ }
821
+
822
+ async function dispatchOwned(turn, action) {
823
+ const owner = turn.currentOwner || turn.primaryEntry;
824
+ if (!owner || owner.settled) {
825
+ // Nothing left to publish into (every row this turn owned has settled);
826
+ // the text still lands in the runtime's own transcript.
827
+ return;
828
+ }
829
+ if (showsWork(action)) owner.showedWork = true;
830
+ if (action.channel === 'stream' && !action.payload?.subagentRunId) {
831
+ // Re-cut to the owner's own segments, and gated against what THAT row
832
+ // last received (the normalizer gated against the whole interaction).
833
+ const text = textFor(turn, owner);
834
+ if (!shouldEmitStreamUpdate(text, owner.state.lastStreamedText)) return;
835
+ await dispatchAction(owner.message, { channel: 'stream', payload: { ...action.payload, text } }, owner.state);
836
+ return;
837
+ }
838
+ await dispatchAction(owner.message, action, owner.state);
839
+ }
840
+
841
+ /**
842
+ * Which row a normalizer segment's text belongs to. A segment nobody
843
+ * claimed (text produced before any prompt boundary — the answer to a
844
+ * prompt carried over from a previous interaction, or a turn the runtime
845
+ * answered without echoing a `user.message` at all) belongs to the next
846
+ * claimed segment's row, failing that to the turn's own row.
847
+ */
848
+ function segmentOwner(turn, index) {
849
+ const owners = turn.segmentOwners;
850
+ if (owners[index]) return owners[index];
851
+ for (let j = index + 1; j < owners.length; j += 1) {
852
+ if (owners[j]) return owners[j];
853
+ }
854
+ return turn.primaryEntry;
855
+ }
856
+
857
+ function textFor(turn, entry, segmentTexts = null) {
858
+ const count = Math.max(turn.normalizer.promptCount(), turn.segmentOwners.length);
859
+ const parts = [];
860
+ for (let index = 0; index < count; index += 1) {
861
+ if (segmentOwner(turn, index) !== entry) continue;
862
+ const text = String(segmentTexts?.[index] ?? turn.normalizer.segmentText(index) ?? '');
863
+ if (text.trim()) parts.push(text);
864
+ }
865
+ return parts.join('\n\n');
866
+ }
867
+
868
+ /**
869
+ * The final reply text for `entry`: its segments, with the task-completion
870
+ * summary taking precedence on the last one (the normalizer's
871
+ * `result.segmentTexts` already applies that rule; before this the runner
872
+ * read `segmentText` directly and lost the summary — the "Cha…" fragment
873
+ * case of 2026-09-12). A turn that opened no segment at all falls back to
874
+ * the whole composed text for its own row, which is strictly better than
875
+ * publishing nothing.
876
+ */
877
+ function finalTextFor(turn, entry, result) {
878
+ const segmentTexts = Array.isArray(result?.segmentTexts) ? result.segmentTexts : null;
879
+ const text = textFor(turn, entry, segmentTexts).trim();
880
+ if (text || entry !== turn.primaryEntry) return text;
881
+ return String(result?.text || entry.state.lastStreamedText || '').trim();
553
882
  }
554
883
 
555
884
  async function publishFinalStream(message, text) {
@@ -563,26 +892,54 @@ export function createCopilotSdkSessionRunner({
563
892
  }).catch(() => {});
564
893
  }
565
894
 
566
- async function publishResponse(message, { text, model, terminalError = null, modelOrigin }) {
567
- await api('POST', '/api/response', {
568
- messageId: message.id,
569
- conversationId: message.conversationId,
570
- text: String(text || ''),
571
- model: model || null,
572
- modelOrigin: modelOrigin
573
- || (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
574
- ...(terminalError ? { terminalError } : {}),
575
- ...attemptFields(message),
576
- }).catch(async (error) => {
895
+ /**
896
+ * Publish a row's outcome. Returns 'published' | 'stale' | 'failed' |
897
+ * 'requeued' so the settle paths can tell a landed marker from a lost one.
898
+ *
899
+ * `kind` ('folded' | 'stopped') stamps the settle stubs of a steered row the
900
+ * runtime consumed without answering on its own row; `consumedSteerIds`
901
+ * names the steers folded into the turn this response finishes, so the
902
+ * relay marks them consumed in the same fenced write. A response carrying a
903
+ * kind is never requeued on failure (the prompt was consumed — re-running it
904
+ * is the one thing that must not happen); `requeueOnFailure: false` opts an
905
+ * ordinary response out of the requeue too.
906
+ */
907
+ async function publishResponse(message, {
908
+ text, model, terminalError = null, modelOrigin, kind = null, consumedSteerIds = null,
909
+ requeueOnFailure = true,
910
+ }) {
911
+ const consumed = Array.isArray(consumedSteerIds)
912
+ ? consumedSteerIds
913
+ .map((entry) => ({ id: String(entry?.id || '').trim(), attemptId: entry?.attemptId || null }))
914
+ .filter((entry) => entry.id)
915
+ : [];
916
+ try {
917
+ await api('POST', '/api/response', {
918
+ messageId: message.id,
919
+ conversationId: message.conversationId,
920
+ text: String(text || ''),
921
+ model: model || null,
922
+ modelOrigin: modelOrigin
923
+ || (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
924
+ ...(terminalError ? { terminalError } : {}),
925
+ ...(kind ? { kind } : {}),
926
+ ...(consumed.length ? { consumedSteerIds: consumed } : {}),
927
+ ...attemptFields(message),
928
+ });
929
+ return 'published';
930
+ } catch (error) {
577
931
  // A stale_attempt 409 means the row already moved on to another attempt
578
932
  // (requeued and re-delivered); the requeue would 409 the same way, and
579
933
  // the newer attempt owes the row its answer — drop this one.
580
934
  if (isStaleAttemptError(error)) {
581
935
  dbg('response refused as stale_attempt; dropping', message.id);
582
- return;
936
+ return 'stale';
583
937
  }
584
- await api('POST', '/api/requeue', { messageId: message.id, ...attemptFields(message) }).catch(() => {});
585
- });
938
+ if (kind || !requeueOnFailure) return 'failed';
939
+ const requeued = await api('POST', '/api/requeue', { messageId: message.id, ...attemptFields(message) })
940
+ .then(() => true, () => false);
941
+ return requeued ? 'requeued' : 'failed';
942
+ }
586
943
  }
587
944
 
588
945
  function terminalErrorRecord(message, classified) {
@@ -596,6 +953,117 @@ export function createCopilotSdkSessionRunner({
596
953
  };
597
954
  }
598
955
 
956
+ // ------------------------------------------------- at-most-once settling --
957
+
958
+ /**
959
+ * Claim a consumed row as settling — synchronously, BEFORE anything stops
960
+ * reporting it as live, so no heartbeat can see it unowned. `variant` picks
961
+ * the terminal wording should its settle never land.
962
+ */
963
+ function claimSettling(message, variant = 'folded') {
964
+ const id = String(message?.id || '').trim();
965
+ if (id && !settlingMessages.has(id)) settlingMessages.set(id, { message, variant });
966
+ }
967
+
968
+ /** Release a settled row — unless it was handed to the relay to fail. */
969
+ function releaseSettling(id) {
970
+ const key = String(id || '').trim();
971
+ if (!settleFailedTerminals.has(key)) settlingMessages.delete(key);
972
+ }
973
+
974
+ /**
975
+ * Mark rows whose prompt the runtime has folded into a running turn as
976
+ * consumed on the relay (the moment `user.message{delivery:"steering"}`
977
+ * confirms it). Best-effort: the worker's own settle still owns these rows
978
+ * while it lives; the mark only protects them when it does not — a crash
979
+ * before the settle marker lands must fail the row, never re-run it.
980
+ */
981
+ function markConsumed(conversationId, entries = [], { consumed = true } = {}) {
982
+ const rows = entries
983
+ .map((entry) => ({ id: String(entry?.id || '').trim(), attemptId: entry?.attemptId || null }))
984
+ .filter((entry) => entry.id);
985
+ if (!rows.length) return Promise.resolve();
986
+ const previous = Promise.allSettled(rows.map((row) => consumedMarkChains.get(row.id)).filter(Boolean));
987
+ const post = previous.then(() => api('POST', '/api/queue-consumed', {
988
+ conversationId: String(conversationId || sdkSessionId || ''),
989
+ entries: rows,
990
+ ...(consumed ? {} : { consumed: false }),
991
+ })).catch((error) => {
992
+ dbg('consumed mark failed', rows.map((entry) => entry.id).join(','), error?.message || String(error));
993
+ });
994
+ for (const row of rows) consumedMarkChains.set(row.id, post);
995
+ post.then(() => {
996
+ for (const row of rows) if (consumedMarkChains.get(row.id) === post) consumedMarkChains.delete(row.id);
997
+ });
998
+ return post;
999
+ }
1000
+
1001
+ /** Heartbeat payload: rows whose terminal failure the relay must post. */
1002
+ function getSettleFailed() {
1003
+ return [...settleFailedTerminals.entries()].map(([id, terminalError]) => ({
1004
+ id,
1005
+ attemptId: settlingMessages.get(id)?.message?.attemptId || null,
1006
+ terminalError,
1007
+ }));
1008
+ }
1009
+
1010
+ function acknowledgeSettleFailed(ids = []) {
1011
+ for (const id of ids) {
1012
+ const key = String(id || '').trim();
1013
+ if (!settleFailedTerminals.delete(key)) continue;
1014
+ settlingMessages.delete(key);
1015
+ }
1016
+ }
1017
+
1018
+ const sleep = (ms) => new Promise((resolve) => { const t = setTimeout(resolve, ms); t.unref?.(); });
1019
+
1020
+ /**
1021
+ * Save the settle marker of a steered message the runtime already consumed
1022
+ * — at most once. The row stays in `settlingMessages`, and so in every
1023
+ * heartbeat, until the relay commits the marker. A marker that cannot be
1024
+ * saved within the retry budget ends as a terminal failure — never a
1025
+ * requeue — and if even that cannot be posted, the heartbeat hands the row
1026
+ * to the relay to fail. Ported from the Claude worker; same guarantees.
1027
+ */
1028
+ async function publishSettleMarker(message, { text, model, kind, variant = 'folded' }) {
1029
+ const id = String(message?.id || '').trim();
1030
+ if (!id) return;
1031
+ claimSettling(message, variant);
1032
+ try {
1033
+ for (let attempt = 0; ; attempt += 1) {
1034
+ const outcome = await publishResponse(message, { text, model, kind });
1035
+ if (outcome !== 'failed') return;
1036
+ if (attempt >= settleRetryDelaysMs.length) break;
1037
+ await sleep(settleRetryDelaysMs[attempt]);
1038
+ }
1039
+ dbg('settle marker could not be saved; failing the row terminally', id);
1040
+ const terminalError = buildSteerSettleFailure(message, { variant, agentLabel: SETTLE_AGENT_LABEL });
1041
+ const retryMs = Math.max(100, Number(settleRetryDelaysMs.at(-1)) || 0);
1042
+ for (let attempt = 0; attempt < Math.max(1, maxTerminalSettleAttempts); attempt += 1) {
1043
+ if (attempt) await sleep(retryMs);
1044
+ const outcome = await publishResponse(message, {
1045
+ text: terminalError.message,
1046
+ model,
1047
+ terminalError,
1048
+ requeueOnFailure: false,
1049
+ });
1050
+ if (outcome !== 'failed') return;
1051
+ // Second channel: the requeue route fails a row outright when handed
1052
+ // a terminal error.
1053
+ const failedViaRequeue = await api('POST', '/api/requeue', {
1054
+ messageId: id,
1055
+ terminalError,
1056
+ ...attemptFields(message),
1057
+ }).then(() => true, (error) => isStaleAttemptError(error));
1058
+ if (failedViaRequeue) return;
1059
+ }
1060
+ dbg('terminal settle could not be posted; handing it to the heartbeat', id);
1061
+ settleFailedTerminals.set(id, terminalError);
1062
+ } finally {
1063
+ releaseSettling(id);
1064
+ }
1065
+ }
1066
+
599
1067
  /**
600
1068
  * Per-turn usage capture, recorded on the runner.
601
1069
  *
@@ -731,16 +1199,34 @@ export function createCopilotSdkSessionRunner({
731
1199
  return;
732
1200
  }
733
1201
  touch();
1202
+ observeRecalls(event);
734
1203
  observeBackgroundShells(event);
735
- // Keeps the confirmed-model tracking truthful for switches this worker did
736
- // not ask for, and resolves the bounded wait on a deferred switch's drain.
737
- // Behind the replay gate on purpose: a historical `session.model_change`
738
- // must not confirm a pending switch.
739
- modelSwitch.observeEvent(event);
740
- // A LIVE model change moves the catalog's currentModel; replays cannot get
741
- // here (the gate above), so this cannot re-publish days-old state.
742
- if (event?.type === 'session.model_change') publishModelSnapshot('model-change');
1204
+ observeBackgroundAgents(event);
1205
+ observeCompaction(event);
1206
+ observeQueueLane(event);
1207
+ // A background subagent's spawn emits a SESSION-level
1208
+ // `session.model_change{source:"agent"}` for the subagent's own model
1209
+ // (live-probed on 1.0.88). It is not the main thread's model: feeding it
1210
+ // to the switcher would confirm a pending switch with the wrong model and
1211
+ // move the catalog's currentModel.
1212
+ const agentModelChange = event?.type === 'session.model_change'
1213
+ && String(event?.data?.source || '').trim().toLowerCase() === 'agent';
1214
+ if (!agentModelChange) {
1215
+ // Keeps the confirmed-model tracking truthful for switches this worker
1216
+ // did not ask for, and resolves the bounded wait on a deferred switch's
1217
+ // drain. Behind the replay gate on purpose: a historical
1218
+ // `session.model_change` must not confirm a pending switch.
1219
+ modelSwitch.observeEvent(event);
1220
+ // A LIVE model change moves the catalog's currentModel; replays cannot
1221
+ // get here (the gate above), so this cannot re-publish days-old state.
1222
+ if (event?.type === 'session.model_change') publishModelSnapshot('model-change');
1223
+ }
743
1224
  let turn = activeTurn;
1225
+ if (!turn) {
1226
+ // A prompt this worker pushed whose run the runtime opened after its
1227
+ // turn had already settled is that row's own turn, not a continuation.
1228
+ turn = adoptOrphanedRun(event);
1229
+ }
744
1230
  if (!turn) {
745
1231
  // The runtime started work with no row open. Anything that is not the
746
1232
  // START of work (terminators, connection bookkeeping) stays a no-op:
@@ -777,15 +1263,396 @@ export function createCopilotSdkSessionRunner({
777
1263
  * backgroundWorkHoldsRuntime. Advisory: a failed post never disturbs events.
778
1264
  */
779
1265
  function publishBackgroundShellTasks() {
780
- const tasks = backgroundShells.live().map((shell) => ({
1266
+ publishBackgroundTasks({ immediate: true });
1267
+ }
1268
+
1269
+ /** The panel rows for the shell tracker's set — the path without `rpc.tasks`. */
1270
+ function shellTrackerCards() {
1271
+ return backgroundShells.live().map((shell) => ({
781
1272
  taskId: shell.shellId,
782
1273
  taskType: 'local_bash',
783
1274
  description: shell.description || 'Detached shell',
784
1275
  startedAt: shell.startedAt || null,
785
1276
  stoppable: false,
786
1277
  }));
787
- api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks })
788
- .catch((error) => dbg('background task publish failed', error?.message || String(error)));
1278
+ }
1279
+
1280
+ /**
1281
+ * The panel rows for the runtime's task registry: background agents and
1282
+ * shells still running, each with what the events added. `stoppable` is
1283
+ * true because `rpc.tasks.cancel` is present whenever this path is taken.
1284
+ */
1285
+ function registryCards() {
1286
+ const cards = [];
1287
+ for (const task of backgroundTasks.values()) {
1288
+ if (task.status !== 'running') continue;
1289
+ const startedAt = Date.parse(String(task.startedAt || '')) || null;
1290
+ if (task.type === 'shell') {
1291
+ cards.push({
1292
+ taskId: task.id,
1293
+ taskType: 'local_bash',
1294
+ description: task.description || task.command || 'Background shell',
1295
+ startedAt,
1296
+ stoppable: true,
1297
+ ...(task.progressSummary ? { summary: task.progressSummary } : {}),
1298
+ });
1299
+ continue;
1300
+ }
1301
+ if (task.type !== 'agent') continue;
1302
+ const detail = agentDetails.get(task.id) || {};
1303
+ const model = task.resolvedModel || task.model || detail.model || '';
1304
+ const inherited = !model;
1305
+ cards.push({
1306
+ taskId: task.id,
1307
+ taskType: 'local_agent',
1308
+ description: task.description || task.displayName || detail.displayName || 'Background agent',
1309
+ startedAt: startedAt || detail.startedAt || null,
1310
+ stoppable: true,
1311
+ subagentType: task.agentType || detail.agentType || null,
1312
+ model: model || appliedModel() || defaultModel || null,
1313
+ modelInherited: inherited,
1314
+ ...(detail.lastToolCall ? { lastToolCall: detail.lastToolCall } : {}),
1315
+ ...(Number.isFinite(detail.totalTokens) && detail.totalTokens > 0 ? { totalTokens: detail.totalTokens } : {}),
1316
+ ...(task.progressSummary ? { summary: task.progressSummary } : {}),
1317
+ });
1318
+ }
1319
+ return cards;
1320
+ }
1321
+
1322
+ /**
1323
+ * Mirror the live background work into the relay's task panel (REPLACE
1324
+ * semantics, the same route the Claude worker publishes on). Set changes
1325
+ * publish at once; enrichment-only changes (a tool call, tokens) ride a
1326
+ * trailing throttle. Advisory: a failed post never disturbs events.
1327
+ */
1328
+ function publishBackgroundTasks({ immediate = false } = {}) {
1329
+ const run = () => {
1330
+ if (taskPublishTimer) {
1331
+ clearTimeout(taskPublishTimer);
1332
+ taskPublishTimer = null;
1333
+ }
1334
+ const tasks = sessionRpc.tasks ? registryCards() : shellTrackerCards();
1335
+ const signature = JSON.stringify(tasks);
1336
+ if (signature === lastPublishedTasksSignature) return;
1337
+ lastPublishedTasksSignature = signature;
1338
+ api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks })
1339
+ .catch((error) => dbg('background task publish failed', error?.message || String(error)));
1340
+ };
1341
+ if (immediate || !(taskPublishThrottleMs > 0)) {
1342
+ run();
1343
+ return;
1344
+ }
1345
+ if (taskPublishTimer) return;
1346
+ taskPublishTimer = setTimeout(run, taskPublishThrottleMs);
1347
+ taskPublishTimer.unref?.();
1348
+ }
1349
+
1350
+ // ------------------------------------------------------- background tasks --
1351
+
1352
+ /**
1353
+ * What the event stream knows about background agents that the registry
1354
+ * does not: spawn details, the live tool call, a running token count, and
1355
+ * the completion heralds. `session.background_tasks_changed` itself is
1356
+ * empty and only says "re-read the registry".
1357
+ */
1358
+ function observeBackgroundAgents(event) {
1359
+ const type = String(event?.type || '');
1360
+ const data = event?.data && typeof event.data === 'object' ? event.data : {};
1361
+ const agentId = String(event?.agentId || '').trim();
1362
+ if (type === 'session.background_tasks_changed') {
1363
+ scheduleTaskRefresh();
1364
+ return;
1365
+ }
1366
+ if (type === 'subagent.started' && String(data.executionMode || '').trim().toLowerCase() === 'background' && agentId) {
1367
+ // Kept for the life of the session (a finished agent's late events must
1368
+ // still be recognised as background), bounded so a long session cannot
1369
+ // grow it forever.
1370
+ while (agentDetails.size >= 500) agentDetails.delete(agentDetails.keys().next().value);
1371
+ agentDetails.set(agentId, {
1372
+ displayName: String(data.agentDisplayName || data.agentName || '').trim(),
1373
+ agentType: String(data.agentType || '').trim(),
1374
+ model: String(data.model || '').trim(),
1375
+ lastToolCall: '',
1376
+ totalTokens: 0,
1377
+ startedAt: Date.now(),
1378
+ });
1379
+ scheduleTaskRefresh();
1380
+ return;
1381
+ }
1382
+ const settledAgent = settledAgentFromNotification(event);
1383
+ if (settledAgent) {
1384
+ dbg('background agent settled', settledAgent.agentId, settledAgent.status);
1385
+ // The runtime is about to re-invoke the model off the back of this
1386
+ // (`read_agent` + a reply): pin the runtime for the continuation, and
1387
+ // carry a line into the turn it triggers when no turn is open to take
1388
+ // it. Same shape as a settled shell's herald.
1389
+ continuationDueSince = Date.now();
1390
+ const note = describeSettledAgent(settledAgent);
1391
+ if (note && !activeTurn && pendingActivities.length < MAX_PENDING_ACTIVITIES) {
1392
+ pendingActivities.push({ text: note, reportsFailure: settledAgent.status === 'failed' });
1393
+ }
1394
+ scheduleTaskRefresh();
1395
+ return;
1396
+ }
1397
+ if (agentId && agentDetails.has(agentId)) {
1398
+ const detail = agentDetails.get(agentId);
1399
+ if (type === 'tool.execution_start') {
1400
+ detail.lastToolCall = formatToolActivityText(data.toolName, data.arguments);
1401
+ publishBackgroundTasks();
1402
+ } else if (type === 'assistant.usage') {
1403
+ const used = (Number(data.inputTokens) || 0) + (Number(data.outputTokens) || 0);
1404
+ if (used > 0) {
1405
+ detail.totalTokens += used;
1406
+ publishBackgroundTasks();
1407
+ }
1408
+ } else if (type === 'subagent.completed' || type === 'subagent.failed') {
1409
+ const total = Number(data.totalTokens);
1410
+ if (Number.isFinite(total) && total > 0) detail.totalTokens = total;
1411
+ scheduleTaskRefresh();
1412
+ }
1413
+ }
1414
+ }
1415
+
1416
+ function scheduleTaskRefresh() {
1417
+ if (!sessionRpc.tasks || taskRefreshTimer) return;
1418
+ taskRefreshTimer = setTimeout(() => {
1419
+ taskRefreshTimer = null;
1420
+ taskRefreshChain = taskRefreshChain.then(() => refreshBackgroundTasks()).catch(() => {});
1421
+ }, taskRefreshMs);
1422
+ taskRefreshTimer.unref?.();
1423
+ }
1424
+
1425
+ /**
1426
+ * Re-read the runtime's task registry. Running agents and shells become
1427
+ * cards (with a progress line when the runtime has one); finished entries
1428
+ * are removed from the runtime's tracking so the registry cannot grow for
1429
+ * the life of the session. A refresh that fails leaves the last read in
1430
+ * place — a stale card beats a vanished one.
1431
+ */
1432
+ async function refreshBackgroundTasks() {
1433
+ const target = session;
1434
+ if (!target || !sessionRpc.tasks) return;
1435
+ lastTaskRefreshAt = Date.now();
1436
+ let listed;
1437
+ try {
1438
+ listed = await target.rpc.tasks.list();
1439
+ } catch (error) {
1440
+ dbg('tasks.list failed', error?.message || String(error));
1441
+ return;
1442
+ }
1443
+ if (session !== target) return;
1444
+ const next = new Map();
1445
+ for (const task of Array.isArray(listed?.tasks) ? listed.tasks : []) {
1446
+ const id = String(task?.id || '').trim();
1447
+ if (!id || abandonedTasks.has(id)) continue;
1448
+ const status = String(task?.status || '').trim().toLowerCase();
1449
+ // Only running tasks are live. 'idle' (a multi-turn agent waiting for
1450
+ // messages) holds nothing and shows nowhere until it is messaged
1451
+ // (roadmap: tasks.sendMessage); finished entries are left to the
1452
+ // runtime's own tracking — removing them here could race the root
1453
+ // agent's `read_agent` follow-up that the completion notification
1454
+ // triggers.
1455
+ if (status === 'running') {
1456
+ next.set(id, { ...task, id, status, progressSummary: backgroundTasks.get(id)?.progressSummary || '' });
1457
+ }
1458
+ }
1459
+ // Progress lines for the live set: the agent's latest intent / last
1460
+ // activity, a shell's last output line. One RPC per live task per refresh
1461
+ // (refreshes are debounced), best effort.
1462
+ if (typeof target.rpc.tasks.getProgress === 'function') {
1463
+ for (const task of next.values()) {
1464
+ try {
1465
+ const progress = (await target.rpc.tasks.getProgress({ id: task.id }))?.progress || null;
1466
+ task.progressSummary = summarizeTaskProgress(progress);
1467
+ } catch { /* a card without a progress line */ }
1468
+ }
1469
+ }
1470
+ if (session !== target) return;
1471
+ const setChanged = next.size !== backgroundTasks.size
1472
+ || [...next.keys()].some((id) => !backgroundTasks.has(id));
1473
+ backgroundTasks = next;
1474
+ publishBackgroundTasks({ immediate: setChanged });
1475
+ // The lifecycle poll (every `lifecyclePollMs`) picks the new set up; a
1476
+ // re-evaluation here would re-schedule a refresh and spin.
1477
+ }
1478
+
1479
+ function summarizeTaskProgress(progress) {
1480
+ if (!progress || typeof progress !== 'object') return '';
1481
+ if (progress.type === 'agent') {
1482
+ const intent = String(progress.latestIntent || '').trim();
1483
+ if (intent) return intent.slice(0, 300);
1484
+ const lines = Array.isArray(progress.recentActivity) ? progress.recentActivity : [];
1485
+ const last = lines.length ? String(lines[lines.length - 1]?.message || '').trim() : '';
1486
+ return last.slice(0, 300);
1487
+ }
1488
+ if (progress.type === 'shell') {
1489
+ const output = String(progress.recentOutput || '').trim();
1490
+ if (!output || output === '(no output yet)') return '';
1491
+ const lines = output.split('\n').map((line) => line.trim()).filter(Boolean);
1492
+ return (lines[lines.length - 1] || '').slice(0, 300);
1493
+ }
1494
+ return '';
1495
+ }
1496
+
1497
+ /** The live tasks from the registry (running agents + shells). */
1498
+ function runningBackgroundTasks() {
1499
+ return [...backgroundTasks.values()].filter((task) => task.status === 'running');
1500
+ }
1501
+
1502
+ /**
1503
+ * Stop one background task (the panel's per-card Stop, or a subagent lane's
1504
+ * Stop for a background agent). Returns whether the runtime cancelled it.
1505
+ */
1506
+ async function stopBackgroundTask(taskId) {
1507
+ const id = String(taskId || '').trim();
1508
+ if (!id || !session || !sessionRpc.tasks) return false;
1509
+ try {
1510
+ const outcome = await session.rpc.tasks.cancel({ id });
1511
+ const cancelled = outcome?.cancelled === true;
1512
+ dbg(cancelled ? 'background task cancelled' : 'background task cancel refused', id);
1513
+ if (cancelled) scheduleTaskRefresh();
1514
+ return cancelled;
1515
+ } catch (error) {
1516
+ dbg('tasks.cancel failed', id, error?.message || String(error));
1517
+ return false;
1518
+ }
1519
+ }
1520
+
1521
+
1522
+
1523
+ /**
1524
+ * The compaction hold. A message pushed while the runtime is compacting
1525
+ * would be injected into the compaction's replay, so steering is held from
1526
+ * `session.compaction_start` to `_complete` (the runtime queues messages
1527
+ * sent during a compaction itself, but holding them relay-side keeps the
1528
+ * bubble cancellable and the composer truthful — the Claude rule).
1529
+ */
1530
+ function observeCompaction(event) {
1531
+ const type = String(event?.type || '');
1532
+ if (event?.agentId) return;
1533
+ if (type === 'session.compaction_start') {
1534
+ compactingSince = Date.now();
1535
+ syncDeliveryReadiness();
1536
+ } else if (type === 'session.compaction_complete') {
1537
+ compactingSince = 0;
1538
+ syncDeliveryReadiness();
1539
+ }
1540
+ }
1541
+
1542
+ function isCompacting() {
1543
+ if (!compactingSince) return false;
1544
+ if (Date.now() - compactingSince < compactionStaleMs) return true;
1545
+ dbg('compaction hold went stale; releasing it');
1546
+ compactingSince = 0;
1547
+ return false;
1548
+ }
1549
+
1550
+ /**
1551
+ * Keep `queuedLane` current. `pending_messages.modified` carries no payload
1552
+ * (it only says "the queue changed"), so it schedules a debounced
1553
+ * `rpc.queue.pendingItems()` read. What matters here is which pushed rows
1554
+ * are still in the QUEUED lane: those the runtime has not consumed and the
1555
+ * client may still cancel (`cancellableIds` on the steering snapshot).
1556
+ */
1557
+ function observeQueueLane(event) {
1558
+ if (String(event?.type || '') !== 'pending_messages.modified') return;
1559
+ scheduleQueueLaneRefresh();
1560
+ }
1561
+
1562
+ /** Un-steer's synchronous view of the runtime taking a prompt (see `recallWatches`). */
1563
+ function observeRecalls(event) {
1564
+ if (String(event?.type || '') !== 'user.message' || event?.agentId) return;
1565
+ const runtimeId = String(event?.data?.messageId || '').trim();
1566
+ if (!runtimeId) return;
1567
+ const watch = recallWatches.get(runtimeId);
1568
+ if (watch) watch.taken = true;
1569
+ const recalledRow = recalledRuntimeIds.get(runtimeId);
1570
+ if (recalledRow) {
1571
+ dbg('UN-STEER MISMATCH: a prompt recalled and cancelled is running after all; the remove took another message',
1572
+ recalledRow, runtimeId);
1573
+ }
1574
+ }
1575
+
1576
+ function scheduleQueueLaneRefresh() {
1577
+ if (!sessionRpc.queue || queueLaneTimer) return;
1578
+ queueLaneTimer = setTimeout(() => {
1579
+ queueLaneTimer = null;
1580
+ queueLaneRefreshChain = queueLaneRefreshChain.then(() => refreshQueueLane()).catch(() => {});
1581
+ }, queueLaneRefreshMs);
1582
+ queueLaneTimer.unref?.();
1583
+ }
1584
+
1585
+ async function refreshQueueLane() {
1586
+ const target = session;
1587
+ if (!target || !sessionRpc.queue) return;
1588
+ try {
1589
+ const snapshot = await target.rpc.queue.pendingItems();
1590
+ const next = new Map();
1591
+ for (const item of Array.isArray(snapshot?.items) ? snapshot.items : []) {
1592
+ const runtimeId = String(item?.messageId || '').trim();
1593
+ const itemId = String(item?.id || '').trim();
1594
+ if (runtimeId && itemId) next.set(runtimeId, itemId);
1595
+ }
1596
+ if (session !== target) return;
1597
+ queuedLane = next;
1598
+ queuedItemCount = Array.isArray(snapshot?.items) ? snapshot.items.length : 0;
1599
+ const steering = Array.isArray(snapshot?.steeringMessages) ? snapshot.steeringMessages.length : 0;
1600
+ const inFlight = Number(snapshot?.inFlightSteeringCount) || 0;
1601
+ waitingSteers = Math.max(0, steering - inFlight);
1602
+ } catch (error) {
1603
+ dbg('queue.pendingItems failed', error?.message || String(error));
1604
+ }
1605
+ noteCancellableChange();
1606
+ }
1607
+
1608
+ /** Tell the worker when the recallable set changed (see onCancellableChange). */
1609
+ function noteCancellableChange() {
1610
+ const signature = cancellableIds().join(',');
1611
+ if (signature === lastCancellableSignature) return;
1612
+ lastCancellableSignature = signature;
1613
+ try {
1614
+ onCancellableChange();
1615
+ } catch (error) {
1616
+ dbg('cancellable listener failed', error?.message || String(error));
1617
+ }
1618
+ }
1619
+
1620
+ /** Our pushed steers the runtime has not started on yet, in send order. */
1621
+ function unconsumedSteers() {
1622
+ const list = [];
1623
+ for (const entry of ownedEntries()) {
1624
+ if (entry.role === 'steer' && !entry.consumed && !entry.settled && entry.runtimeId) list.push(entry);
1625
+ }
1626
+ return list.sort((a, b) => (a.sendSeq || 0) - (b.sendSeq || 0));
1627
+ }
1628
+
1629
+ /**
1630
+ * How `entry` can still be pulled back out of the runtime, or null: by its
1631
+ * stable id when it sits in the queued lane, or with removeMostRecent when
1632
+ * it is the runtime's newest waiting message (our newest unconsumed steer,
1633
+ * at least one steer waiting, nothing in the queued lane that a LIFO remove
1634
+ * would take instead).
1635
+ */
1636
+ function recallMethodFor(entry) {
1637
+ if (!entry || entry.role !== 'steer' || entry.consumed || entry.settled || !entry.runtimeId) return null;
1638
+ const itemId = queuedLane.get(entry.runtimeId);
1639
+ if (itemId) return { kind: 'queued', itemId };
1640
+ if (typeof session?.rpc?.queue?.removeMostRecent !== 'function') return null;
1641
+ if (steerSendsInFlight > 0 || queuedItemCount > 0 || waitingSteers < 1) return null;
1642
+ const pending = unconsumedSteers();
1643
+ return pending.length && pending[pending.length - 1] === entry ? { kind: 'newest' } : null;
1644
+ }
1645
+
1646
+ /** Probe the experimental RPC surfaces once per session. */
1647
+ function probeSessionRpc(target) {
1648
+ sessionRpc = {
1649
+ queue: typeof target?.rpc?.queue?.pendingItems === 'function'
1650
+ && typeof target?.rpc?.queue?.removeAt === 'function',
1651
+ interruptMainTurn: typeof target?.rpc?.interruptMainTurn === 'function',
1652
+ tasks: typeof target?.rpc?.tasks?.list === 'function'
1653
+ && typeof target?.rpc?.tasks?.cancel === 'function',
1654
+ };
1655
+ dbg(`copilot session rpc surfaces: queue=${sessionRpc.queue} interruptMainTurn=${sessionRpc.interruptMainTurn} tasks=${sessionRpc.tasks}`);
789
1656
  }
790
1657
 
791
1658
  function observeBackgroundShells(event) {
@@ -809,7 +1676,7 @@ export function createCopilotSdkSessionRunner({
809
1676
  // closed to new lines, so the note belongs to the continuation this
810
1677
  // notification is about to trigger.
811
1678
  if (note && !activeTurn && pendingActivities.length < MAX_PENDING_ACTIVITIES) {
812
- pendingActivities.push(note);
1679
+ pendingActivities.push({ text: note, reportsFailure: false });
813
1680
  }
814
1681
  }
815
1682
  // Only the runtime's own `system.notification` heralds a continuation. A
@@ -835,9 +1702,13 @@ export function createCopilotSdkSessionRunner({
835
1702
  // — but it is guarded anyway: re-routing INTO a settled turn would recurse
836
1703
  // through this function forever, so it is treated as "no owner".
837
1704
  if (!turn || turn.settled) {
838
- // Same rule as `routeEvent`: only the start of work opens a row.
839
- if (!isContinuationOpeningEvent(event)) return;
840
- turn = openContinuationTurn();
1705
+ // Same rules as `routeEvent`: a pushed prompt's late run is its own
1706
+ // turn; otherwise only the start of work opens a row.
1707
+ turn = adoptOrphanedRun(event);
1708
+ if (!turn) {
1709
+ if (!isContinuationOpeningEvent(event)) return;
1710
+ turn = openContinuationTurn();
1711
+ }
841
1712
  }
842
1713
  await handleTurnEvent(turn, event);
843
1714
  }
@@ -848,6 +1719,9 @@ export function createCopilotSdkSessionRunner({
848
1719
  return;
849
1720
  }
850
1721
  turn.armStall?.();
1722
+ // Root work ends on the runtime's own terminator, so a question-only
1723
+ // continuation it joins no longer closes itself (whileAwaitingHuman).
1724
+ if (isContinuationOpeningEvent(event)) turn.rootWork = true;
851
1725
  let actions = [];
852
1726
  try {
853
1727
  actions = turn.normalizer.normalize(event);
@@ -855,6 +1729,12 @@ export function createCopilotSdkSessionRunner({
855
1729
  dbg('normalize failed', String(event?.type || ''), error?.message || String(error));
856
1730
  return;
857
1731
  }
1732
+ // The prompt boundary: the runtime has started work on a prompt. Decides
1733
+ // which row owns the segment the normalizer just opened, and settles the
1734
+ // previous run's row when this one is a new run (see noteUserMessage).
1735
+ if (String(event?.type || '') === 'user.message' && !event?.agentId) {
1736
+ await noteUserMessage(turn, event);
1737
+ }
858
1738
  for (const action of actions) {
859
1739
  if (action.channel === 'result') {
860
1740
  turn.result = action.payload;
@@ -874,27 +1754,150 @@ export function createCopilotSdkSessionRunner({
874
1754
  while (turn.bufferedActions.length) {
875
1755
  const batch = turn.bufferedActions.splice(0);
876
1756
  for (const action of batch) {
877
- await dispatchAction(turn.message, action, turn.state).catch(() => {});
1757
+ await dispatchOwned(turn, action).catch(() => {});
878
1758
  }
879
1759
  }
880
1760
  }
881
1761
 
882
- function buildSessionConfig(model, relayMode, reasoningEffort = null) {
1762
+ /**
1763
+ * The relay's `ask_user` tool: same name and fields as the runtime's
1764
+ * built-in, plus `multi_select`. The handler runs under exactly the same
1765
+ * human-gating as the built-in path (`onUserInputRequest`): the stall
1766
+ * watchdog is held, steering is held (a question card is open), and a card
1767
+ * raised the moment a continuation opens waits for its row. It never
1768
+ * throws: a failed card answers in-band so the model can carry on.
1769
+ */
1770
+ function buildRelayAskUserTool() {
1771
+ return {
1772
+ name: RELAY_ASK_USER_TOOL_NAME,
1773
+ description: RELAY_ASK_USER_TOOL_DESCRIPTION,
1774
+ parameters: RELAY_ASK_USER_TOOL_PARAMETERS,
1775
+ overridesBuiltInTool: true,
1776
+ // The built-in asks no permission either: asking a question is not an action.
1777
+ skipPermission: true,
1778
+ defer: 'never',
1779
+ handler: async (args, invocation) => {
1780
+ const { question, choices, multiSelect } = parseAskUserToolArguments(args);
1781
+ try {
1782
+ const result = await whileAwaitingHuman(async (turn) => {
1783
+ await awaitInteractiveRow(turn);
1784
+ // Cancelled by either side: the turn (Stop, teardown) or the
1785
+ // runtime itself (a subagent cancelled via tasks.cancel, a
1786
+ // disconnect) — a card nobody will read must not hold steering.
1787
+ const signal = combineSignals(turn?.abortController?.signal, invocation?.signal);
1788
+ return questionBridge.askUserInput(
1789
+ { question, choices, allowFreeform: true, multiSelect, source: 'relay-ask-user-tool' },
1790
+ { signal, message: turn?.message },
1791
+ );
1792
+ });
1793
+ return formatAskUserToolResult({
1794
+ answer: result?.answer,
1795
+ wasFreeform: result?.wasFreeform !== false,
1796
+ timedOut: result?.timedOut === true,
1797
+ });
1798
+ } catch (error) {
1799
+ dbg('relay ask_user failed', error?.message || String(error));
1800
+ return USER_INPUT_UNSUPPORTED_ANSWER;
1801
+ }
1802
+ },
1803
+ };
1804
+ }
1805
+
1806
+ /**
1807
+ * The relay's `remote_relay` tool (copilot-remote-relay-tool.mjs). Every call
1808
+ * is counted while it runs: the stall watchdog re-arms instead of failing the
1809
+ * row, and the lifecycle keeps the runtime up. The clock restarts from the
1810
+ * answer, like after a card. A call is cancelled by the runtime (its
1811
+ * invocation signal) or by a Stop of the turn it started under.
1812
+ */
1813
+ function buildRemoteRelayTool() {
1814
+ return buildCopilotRemoteRelayTool({
1815
+ api,
1816
+ getConversationId: () => activeTurn?.message?.conversationId || sdkSessionId || '',
1817
+ getSignal: (invocation) => combineSignals(activeTurn?.remoteRelayStop?.signal, invocation?.signal),
1818
+ runCall: async (fn) => {
1819
+ pendingRelayToolCalls += 1;
1820
+ try {
1821
+ return await fn();
1822
+ } finally {
1823
+ pendingRelayToolCalls -= 1;
1824
+ touch();
1825
+ activeTurn?.armStall?.();
1826
+ }
1827
+ },
1828
+ dbg,
1829
+ });
1830
+ }
1831
+
1832
+ /** One signal that aborts when any of the given ones does (nulls ignored). */
1833
+ function combineSignals(...signals) {
1834
+ const live = signals.filter((signal) => signal && typeof signal.aborted === 'boolean');
1835
+ if (live.length <= 1) return live[0] || null;
1836
+ if (typeof AbortSignal.any === 'function') return AbortSignal.any(live);
1837
+ const controller = new AbortController();
1838
+ for (const signal of live) {
1839
+ if (signal.aborted) { controller.abort(); break; }
1840
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
1841
+ }
1842
+ return controller.signal;
1843
+ }
1844
+
1845
+ /**
1846
+ * Open a session, retrying once without the relay's `ask_user` when the
1847
+ * runtime refuses the override (an older runtime). The refusal is
1848
+ * remembered until the runtime is stopped — the next runtime (possibly
1849
+ * auto-updated under the relay) gets another try. Only the override goes:
1850
+ * the relay's other tools (`remote_relay`) override nothing and stay.
1851
+ */
1852
+ async function openSession(open, config) {
1853
+ try {
1854
+ return await open(config);
1855
+ } catch (error) {
1856
+ const tools = Array.isArray(config?.tools) ? config.tools : [];
1857
+ const isAskUserOverride = (tool) => tool?.name === RELAY_ASK_USER_TOOL_NAME && tool?.overridesBuiltInTool === true;
1858
+ if (!tools.some(isAskUserOverride) || !isToolOverrideRejection(error) || isSessionNotFoundError(error)) throw error;
1859
+ dbg('the runtime refused the relay ask_user override; using its built-in ask_user', error?.message || String(error));
1860
+ askUserOverrideRejected = true;
1861
+ const { tools: _tools, ...withoutTools } = config;
1862
+ const kept = tools.filter((tool) => !isAskUserOverride(tool));
1863
+ return open(kept.length ? { ...withoutTools, tools: kept } : withoutTools);
1864
+ }
1865
+ }
1866
+
1867
+ /** The relay's own session tools; each is added only when it applies. */
1868
+ function relaySessionTools() {
1869
+ return [
1870
+ // The relay's `ask_user` (see copilot-ask-user-tool.mjs): the built-in's
1871
+ // fields plus `multi_select`, so a model can declare a multi-select
1872
+ // question instead of the relay guessing from its wording.
1873
+ ...(relayAskUserTool && !askUserOverrideRejected ? [buildRelayAskUserTool()] : []),
1874
+ ...(remoteRelayGate.isEnabled() ? [buildRemoteRelayTool()] : []),
1875
+ ];
1876
+ }
1877
+
1878
+ function buildSessionConfig(model, reasoningEffort = null) {
883
1879
  // Built once per session, not per request. Reads the mode off the LIVE turn
884
1880
  // rather than closing over the one the session was built with, because one
885
1881
  // session serves every turn and the user can switch modes between them.
1882
+ // With no turn live (a background agent's request) it is the mode of the
1883
+ // last delivered turn, as for a continuation.
886
1884
  const decidePermission = createCopilotPermissionHandler({
887
1885
  // Only the ask-mode branch actually blocks on a human; wrapping it keeps
888
- // the stall watchdog off a turn where someone is deciding.
1886
+ // the stall watchdog off a turn where someone is deciding, and only
1887
+ // there can an approval open a row of its own.
889
1888
  bridge: {
890
- askToolApproval: (permissionRequest, options) => whileAwaitingHuman(async () => {
1889
+ askToolApproval: (permissionRequest, options) => whileAwaitingHuman(async (turn) => {
891
1890
  // An ask-mode approval raised the moment a continuation opens must
892
1891
  // wait for the row its card will hang off — see `awaitInteractiveRow`.
893
- await awaitInteractiveRow();
894
- return questionBridge.askToolApproval(permissionRequest, options);
1892
+ await awaitInteractiveRow(turn);
1893
+ return questionBridge.askToolApproval(permissionRequest, {
1894
+ ...options,
1895
+ signal: turn?.abortController?.signal || options?.signal || null,
1896
+ message: turn?.message,
1897
+ });
895
1898
  }),
896
1899
  },
897
- getRelayMode: () => activeTurn?.message?.relayMode || relayMode,
1900
+ getRelayMode: () => activeTurn?.message?.relayMode || lastRelayMode,
898
1901
  // An aborted turn must not leave the human staring at a card whose answer
899
1902
  // nothing will read; the bridge times the card out instead.
900
1903
  getSignal: () => activeTurn?.abortController?.signal || null,
@@ -911,6 +1914,7 @@ export function createCopilotSdkSessionRunner({
911
1914
  const provider = resolveProviderConfigImpl({ env, model: model || defaultModel, dbg });
912
1915
  byokProvider = provider;
913
1916
  const effort = normalizeRelayEffort(reasoningEffort);
1917
+ const tools = relaySessionTools();
914
1918
  return {
915
1919
  // The relay session id IS the SDK session id, so the runtime's own state
916
1920
  // under ~/.copilot/session-state/<id> is addressable by conversation and
@@ -933,6 +1937,8 @@ export function createCopilotSdkSessionRunner({
933
1937
  // The runtime's own defaults, pinned. See DEFAULT_INFINITE_SESSION_CONFIG.
934
1938
  ...(infiniteSessionConfig ? { infiniteSessions: { ...infiniteSessionConfig } } : {}),
935
1939
  onEvent: routeEvent,
1940
+ // The relay's own tools (`relaySessionTools`); omitted when there are none.
1941
+ ...(tools.length ? { tools } : {}),
936
1942
  // Permission policy follows the conversation's relay mode: agent and
937
1943
  // autopilot auto-approve, plan denies non-read tools with feedback, and
938
1944
  // ask asks the human through a relay question card. The handler reads the
@@ -956,12 +1962,13 @@ export function createCopilotSdkSessionRunner({
956
1962
  // the deserializer is strict, so it is always a real boolean.
957
1963
  onUserInputRequest: async (request) => {
958
1964
  try {
959
- const { answer, wasFreeform } = await whileAwaitingHuman(async () => {
1965
+ const { answer, wasFreeform } = await whileAwaitingHuman(async (turn) => {
960
1966
  // Same gate as the approval path: an `ask_user` fired straight
961
1967
  // after a continuation opened must get the row, not a null id.
962
- await awaitInteractiveRow();
1968
+ await awaitInteractiveRow(turn);
963
1969
  return questionBridge.askUserInput(request, {
964
- signal: activeTurn?.abortController?.signal || null,
1970
+ signal: turn?.abortController?.signal || null,
1971
+ message: turn?.message,
965
1972
  });
966
1973
  });
967
1974
  return { answer, wasFreeform };
@@ -1015,12 +2022,12 @@ export function createCopilotSdkSessionRunner({
1015
2022
  const source = String(request?.elicitationSource || '').trim();
1016
2023
  const message = String(request?.message || '').trim()
1017
2024
  || 'Copilot needs structured input to continue this turn.';
1018
- const result = await whileAwaitingHuman(async () => {
1019
- await awaitInteractiveRow();
2025
+ const result = await whileAwaitingHuman(async (turn) => {
2026
+ await awaitInteractiveRow(turn);
1020
2027
  return questionBridge.askStructured({
1021
2028
  prompt: source ? `${message}\n\n(Requested by ${source}.)` : message,
1022
2029
  requestedSchema: schema,
1023
- }, { signal: activeTurn?.abortController?.signal || null });
2030
+ }, { signal: turn?.abortController?.signal || null, message: turn?.message });
1024
2031
  });
1025
2032
  const content = result?.structuredAnswer;
1026
2033
  if (result?.timedOut || !content || typeof content !== 'object' || Array.isArray(content)) {
@@ -1070,8 +2077,8 @@ export function createCopilotSdkSessionRunner({
1070
2077
  dbg('copilot runtime exited', detail);
1071
2078
  const turn = activeTurn;
1072
2079
  if (turn && !turn.settled) {
1073
- // handlePendingPayload's catch tears the runtime down and publishes the
1074
- // failure record.
2080
+ // driveTurn tears the runtime down and publishes the failure record
2081
+ // onto every row the turn owns.
1075
2082
  turn.fail(new Error(`the Copilot runtime exited before the turn completed (${detail})`));
1076
2083
  return;
1077
2084
  }
@@ -1126,7 +2133,7 @@ export function createCopilotSdkSessionRunner({
1126
2133
  * rebuilt config. Nothing is lost either way: the relay session id IS the
1127
2134
  * SDK session id, so the rebuild takes the ordinary resume path.
1128
2135
  */
1129
- async function applySelection(model, effort, relayMode) {
2136
+ async function applySelection(model, effort) {
1130
2137
  if (!byokProvider) {
1131
2138
  await modelSwitch.apply(session, { model, effort, byok: false });
1132
2139
  return session;
@@ -1140,7 +2147,7 @@ export function createCopilotSdkSessionRunner({
1140
2147
  } else {
1141
2148
  dbg('BYOK ceilings differ for the target model; rebuilding the session', model);
1142
2149
  }
1143
- return rebuildByokSession(model, effort, relayMode);
2150
+ return rebuildByokSession(model, effort);
1144
2151
  }
1145
2152
 
1146
2153
  /**
@@ -1152,7 +2159,7 @@ export function createCopilotSdkSessionRunner({
1152
2159
  * `SessionConfig.model`), so this path never re-enters the RPC attempt —
1153
2160
  * which is also what makes it terminate: RPC refusal → rebuild → done.
1154
2161
  */
1155
- async function rebuildByokSession(model, effort, relayMode) {
2162
+ async function rebuildByokSession(model, effort) {
1156
2163
  dbg('rebuilding the copilot session for a BYOK model/effort switch', model);
1157
2164
  const closing = session;
1158
2165
  session = null;
@@ -1162,13 +2169,16 @@ export function createCopilotSdkSessionRunner({
1162
2169
  try { await closing?.disconnect?.(); } catch (error) {
1163
2170
  dbg('session disconnect before model switch failed', error?.message || String(error));
1164
2171
  }
1165
- return ensureSession(model, effort, relayMode);
2172
+ return ensureSession(model, effort);
1166
2173
  }
1167
2174
 
1168
- async function ensureSession(model, effort, relayMode) {
2175
+ async function ensureSession(model, effort) {
1169
2176
  await ensureClient();
1170
2177
  if (!session) {
1171
- const config = buildSessionConfig(model, relayMode, effort);
2178
+ // The tool list is part of the session: wait for the worker's first
2179
+ // registration answer (bounded, and it fails closed).
2180
+ await remoteRelayGate.ready();
2181
+ const config = buildSessionConfig(model, effort);
1172
2182
  // Resume first, always. On a brand-new conversation this costs one
1173
2183
  // failed RPC; on every other path (worker restart, idle shutdown,
1174
2184
  // relay restart) it is the difference between continuing the
@@ -1177,7 +2187,7 @@ export function createCopilotSdkSessionRunner({
1177
2187
  // exists and the NEXT reconnect must resume it.
1178
2188
  let resumed = false;
1179
2189
  try {
1180
- session = await client.resumeSession(sdkSessionId, config);
2190
+ session = await openSession((cfg) => client.resumeSession(sdkSessionId, cfg), config);
1181
2191
  resumed = true;
1182
2192
  dbg(`resumed copilot session ${sdkSessionId.slice(0, 8)}`);
1183
2193
  } catch (error) {
@@ -1191,9 +2201,13 @@ export function createCopilotSdkSessionRunner({
1191
2201
  throw error;
1192
2202
  }
1193
2203
  dbg('copilot session state not found, creating', error?.message || String(error));
1194
- session = await client.createSession(config);
2204
+ session = await openSession((cfg) => client.createSession(cfg), config);
1195
2205
  dbg(`created copilot session ${sdkSessionId.slice(0, 8)}`);
1196
2206
  }
2207
+ // Which experimental surfaces THIS runtime has (it auto-updates under
2208
+ // the relay): decides un-steer and the targeted Stop for the session.
2209
+ probeSessionRpc(session);
2210
+ scheduleQueueLaneRefresh();
1197
2211
  if (resumed && !byokProvider) {
1198
2212
  // A resumed HOSTED session keeps whatever model it was created with —
1199
2213
  // `config.model` is not guaranteed to be honoured on resume — so the
@@ -1225,11 +2239,13 @@ export function createCopilotSdkSessionRunner({
1225
2239
  publishModelSnapshot(resumed ? 'session-resume' : 'session-start');
1226
2240
  return session;
1227
2241
  }
1228
- return applySelection(model, effort, relayMode);
2242
+ return applySelection(model, effort);
1229
2243
  }
1230
2244
 
1231
2245
  async function stopRuntime(reason) {
1232
2246
  stopLifecycleTimer();
2247
+ // The next session is built from a fresh registration answer.
2248
+ void remoteRelayGate.refresh();
1233
2249
  try { detachRuntimeExit(); } catch { /* the observer is best-effort */ }
1234
2250
  detachRuntimeExit = () => {};
1235
2251
  const closingSession = session;
@@ -1241,14 +2257,33 @@ export function createCopilotSdkSessionRunner({
1241
2257
  // them: the tracked set is state about a process that no longer exists and
1242
2258
  // must not pin the next one. The replay gate is reset for the same reason —
1243
2259
  // the next connection resumes and arms its own window.
1244
- if (backgroundShells.size()) {
2260
+ if (backgroundShells.size() || backgroundTasks.size) {
1245
2261
  backgroundShells.reset();
1246
- // The shells died with the runtime; clear their panel cards too.
1247
- publishBackgroundShellTasks();
2262
+ // Background agents and shells die with the runtime (live-probed: a
2263
+ // disconnect cancels the agents and drops the shells from the registry);
2264
+ // clear their panel cards too.
2265
+ backgroundTasks = new Map();
2266
+ agentDetails.clear();
2267
+ publishBackgroundTasks({ immediate: true });
2268
+ }
2269
+ if (taskRefreshTimer) {
2270
+ clearTimeout(taskRefreshTimer);
2271
+ taskRefreshTimer = null;
1248
2272
  }
1249
2273
  replayGate.reset();
2274
+ // A settled turn waiting on a card (driveTurn) would wait for an answer
2275
+ // nothing can take any more: the agent that asked died with the runtime.
2276
+ for (const turn of publishingTurns) turn.abortController.abort();
1250
2277
  continuationDueSince = 0;
1251
2278
  pendingActivities = [];
2279
+ compactingSince = 0;
2280
+ queuedLane = new Map();
2281
+ waitingSteers = 0;
2282
+ queuedItemCount = 0;
2283
+ steerSendsInFlight = 0;
2284
+ sessionRpc = { queue: false, interruptMainTurn: false, tasks: false };
2285
+ askUserOverrideRejected = false;
2286
+ syncDeliveryReadiness();
1252
2287
  if (!closingSession && !closingClient) return;
1253
2288
  dbg(`stopping the copilot runtime (${reason})`);
1254
2289
  try { await closingSession?.disconnect?.(); } catch (error) {
@@ -1291,8 +2326,44 @@ export function createCopilotSdkSessionRunner({
1291
2326
  dbg('continuation grace expired with no continuation turn');
1292
2327
  continuationDueSince = 0;
1293
2328
  }
2329
+ if (sessionRpc.tasks) {
2330
+ // The registry is the truth: live agents and shells hold the runtime up
2331
+ // to the cap (the relay's background-task slider, 0 = unlimited), and on
2332
+ // expiry they are CANCELLED — the runtime can stop them now — rather
2333
+ // than forgotten. Bounded: a task whose cancel the runtime refuses a
2334
+ // few times is abandoned instead of pinning the runtime forever. A
2335
+ // re-read once per lifecycle poll catches state the events miss (a
2336
+ // shell that exited after a cancel emits no notification).
2337
+ const capMs = Number(getBackgroundTaskTimeoutMs()) || 0;
2338
+ const live = runningBackgroundTasks();
2339
+ if (!live.length) return false;
2340
+ if (capMs > 0) {
2341
+ for (const task of live) {
2342
+ const startedAt = Date.parse(String(task.startedAt || '')) || agentDetails.get(task.id)?.startedAt || 0;
2343
+ if (!startedAt || now - startedAt < capMs) continue;
2344
+ const refusals = taskCancelRefusals.get(task.id) || 0;
2345
+ if (refusals >= MAX_TASK_CANCEL_REFUSALS) {
2346
+ dbg('background task cap reached and the runtime would not cancel it; abandoning it', task.id);
2347
+ abandonedTasks.add(task.id);
2348
+ backgroundTasks.delete(task.id);
2349
+ publishBackgroundTasks({ immediate: true });
2350
+ continue;
2351
+ }
2352
+ dbg('background task cap reached; cancelling it', task.id, task.description || task.command || '');
2353
+ void stopBackgroundTask(task.id).then((cancelled) => {
2354
+ if (!cancelled) taskCancelRefusals.set(task.id, (taskCancelRefusals.get(task.id) || 0) + 1);
2355
+ });
2356
+ }
2357
+ if (!runningBackgroundTasks().length) return false;
2358
+ }
2359
+ if (now - lastTaskRefreshAt >= lifecyclePollMs) scheduleTaskRefresh();
2360
+ return true;
2361
+ }
1294
2362
  if (!backgroundShells.size()) return false;
1295
- const capMs = Number(getBackgroundTaskTimeoutMs()) || 0;
2363
+ // Without the registry there is no Stop and no card worth the name, so an
2364
+ // unlimited slider (0) must not mean "a forgotten `sleep 99999` pins the
2365
+ // runtime forever": the old 30-minute default applies on this path.
2366
+ const capMs = Number(getBackgroundTaskTimeoutMs()) || DEFAULT_BACKGROUND_TASK_TIMEOUT_MS;
1296
2367
  let expiredAny = false;
1297
2368
  for (const shell of backgroundShells.expireOlderThan(capMs)) {
1298
2369
  expiredAny = true;
@@ -1310,7 +2381,9 @@ export function createCopilotSdkSessionRunner({
1310
2381
  // A pending question card means a human is mid-answer; tearing the runtime
1311
2382
  // down under them would discard the session the answer belongs to. A turn
1312
2383
  // that is still publishing counts too: it settled, but its rows are live.
1313
- if (disposed || activeTurn || publishingTurns.size > 0 || pendingHumanRequests > 0 || !client) return;
2384
+ if (
2385
+ disposed || activeTurn || publishingTurns.size > 0 || pendingHumanRequests > 0 || pendingRelayToolCalls > 0 || !client
2386
+ ) return;
1314
2387
  if (!(idleShutdownMs > 0)) return;
1315
2388
  // Evaluated before the idle clock so the caps still expire on a runtime
1316
2389
  // that has been quiet far longer than the idle window.
@@ -1339,18 +2412,81 @@ export function createCopilotSdkSessionRunner({
1339
2412
  return String(message?.providerModel || '').trim() || defaultModel;
1340
2413
  }
1341
2414
 
1342
- function createTurn(message, { kind = 'delivered' } = {}) {
1343
- const continuation = kind === 'continuation';
1344
- const turn = {
1345
- // 'delivered' (a queue row arrived) | 'continuation' (the runtime started
1346
- // work by itself and this worker minted a synthetic row for it).
1347
- kind,
1348
- message,
1349
- normalizer: createNormalizerImpl(),
1350
- state: { lastStreamedText: '' },
2415
+ /**
2416
+ * A queue row this turn owes an answer.
2417
+ *
2418
+ * `role`: 'primary' (the delivered row that opened the turn), 'steer' (a row
2419
+ * delivered mid-turn and pushed into the same runtime interaction) or
2420
+ * 'self' (the synthetic row of a self-initiated turn). An entry is owned —
2421
+ * heartbeat-claimed, crash-guard-requeued — from creation until `released`.
2422
+ */
2423
+ function createEntry(turn, message, role) {
2424
+ const entry = {
2425
+ turn,
2426
+ message,
2427
+ role,
2428
+ // The id `send()` resolved; the key `user.message.messageId` matches.
2429
+ runtimeId: null,
2430
+ // A root `user.message` named this entry: the runtime has taken the
2431
+ // prompt. `delivery` is the runtime's own word for how.
2432
+ consumed: false,
2433
+ delivery: null,
2434
+ interactionId: null,
2435
+ // The entry whose run absorbed this one (`delivery:"steering"`).
2436
+ foldedInto: null,
2437
+ consumedMarked: false,
2438
+ // Its outcome was decided and its publishes started / it landed.
2439
+ settled: false,
2440
+ released: false,
2441
+ cancelled: false,
2442
+ // Something worth keeping was published on this row: the run's own
2443
+ // work (a tool line, a thought, a lane), or a carried-in line that
2444
+ // reports a failure — not bookkeeping, and not the other lines carried
2445
+ // in from between turns (`showsWork`). Decides whether an empty
2446
+ // continuation may vanish.
2447
+ showedWork: false,
2448
+ // `lastStreamedText` is what this row last received on /api/stream —
2449
+ // the stream gate and the abort/error fallback text both read it.
2450
+ state: { lastStreamedText: '' },
2451
+ };
2452
+ entry.done = new Promise((resolve) => { entry.resolveDone = resolve; });
2453
+ turn.entries.push(entry);
2454
+ return entry;
2455
+ }
2456
+
2457
+ function assignRuntimeId(turn, entry, runtimeId) {
2458
+ const id = String(runtimeId || '').trim();
2459
+ entry.runtimeId = id || null;
2460
+ if (!id) return;
2461
+ turn.pushed.set(id, entry);
2462
+ // The runtime can start work on a prompt before the send() that pushed it
2463
+ // has resolved with its id. The boundary was remembered; reconcile it now,
2464
+ // on the dispatch chain so it orders after the events already captured.
2465
+ const unclaimed = turn.unclaimedUserMessages.get(id);
2466
+ if (!unclaimed) return;
2467
+ turn.unclaimedUserMessages.delete(id);
2468
+ dispatchChain = dispatchChain
2469
+ .then(() => adoptSegment(turn, entry, unclaimed))
2470
+ .catch((error) => { dbg('late prompt-boundary reconcile failed', error?.message || String(error)); });
2471
+ }
2472
+
2473
+ function createTurn(message, { kind = 'delivered', adoptEntry = null } = {}) {
2474
+ const continuation = kind === 'continuation';
2475
+ const turn = {
2476
+ // 'delivered' (a queue row arrived) | 'continuation' (the runtime started
2477
+ // work by itself and this worker minted a synthetic row for it).
2478
+ kind,
2479
+ message,
2480
+ model: '',
2481
+ // The runner's cross-turn knowledge of background agents rides in, so a
2482
+ // later turn's normalizer opens no lane for an agent spawned earlier.
2483
+ normalizer: createNormalizerImpl({ isBackgroundAgent: (agentId) => agentDetails.has(agentId) }),
1351
2484
  result: null,
2485
+ failure: null,
1352
2486
  settled: false,
1353
2487
  aborted: false,
2488
+ // The row the user pressed Stop on (from the abort control), when known.
2489
+ abortedRowId: null,
1354
2490
  stallTimer: null,
1355
2491
  planBoardPosted: false,
1356
2492
  // Set when a mutating tool was actually approved and run this turn.
@@ -1365,45 +2501,61 @@ export function createCopilotSdkSessionRunner({
1365
2501
  // rather than failing the worker.
1366
2502
  discarded: false,
1367
2503
  controlState: null,
1368
- // How many prompts have been SENT into this interaction. The runtime
1369
- // consumes queued prompts in order, and opens one `user.message` segment
1370
- // per prompt as it picks each up, so send order IS segment order.
1371
- //
1372
- // Deliberately not derived from the normalizer's live segment count: at
1373
- // the moment a steering send happens the runtime may not have opened the
1374
- // previous prompt's segment yet, and the steered row would then be
1375
- // attributed the PREVIOUS prompt's answer.
1376
- //
1377
- // Starts at 1, not 0: this turn's own prompt is always its first, and
1378
- // counting it here rather than after `send()` resolves closes the race
1379
- // where a steering delivery lands between the two. A continuation sent no
1380
- // prompt at all, so it starts at 0.
1381
- promptsSent: continuation ? 0 : 1,
1382
- // The segment this turn's OWN reply lands in. For a delivered turn that
1383
- // is where its prompt is picked up — non-zero when a previous interaction
1384
- // left prompts queued inside the runtime, which are consumed first. A
1385
- // continuation has no `user.message` of its own, so the normalizer's
1386
- // implicit first segment is its own.
1387
- baseSegment: continuation ? 0 : carriedPrompts,
1388
- // The segment the NEXT prompt steered into this interaction will be
1389
- // answered in. Tracked explicitly rather than derived, because the two
1390
- // kinds differ: a delivered turn's own prompt occupies `baseSegment`, so
1391
- // the next is `baseSegment + 1`; a continuation occupies segment 0
1392
- // without having sent anything, so the first steered prompt opens
1393
- // segment 1. Getting this wrong cross-publishes the continuation's reply
1394
- // into the user's row.
1395
- nextSegmentIndex: continuation ? 1 : carriedPrompts + 1,
1396
- // The lowest segment index any prompt SENT by this turn can occupy — the
1397
- // floor for the carried-prompt arithmetic when the interaction ends.
1398
- firstSentSegment: continuation ? 1 : carriedPrompts,
1399
2504
  // Cancels any relay question card this turn is blocked on, so an aborted
1400
2505
  // turn does not leave a human answering into the void.
1401
2506
  abortController: new AbortController(),
1402
- // Queue rows delivered mid-turn and steered into this interaction. Each
1403
- // one is owed a response by THIS turn — the runtime answers them all
1404
- // under a single `session.idle`, so nothing else will settle them.
1405
- steeredRows: [],
2507
+ // Cancels the `remote_relay` calls that started under this turn — on the
2508
+ // user's Stop only. Not `abortController`: that one also fires when the
2509
+ // turn settles normally, and a background agent's call must outlive that.
2510
+ remoteRelayStop: new AbortController(),
2511
+ // Cards (questions, ask-mode approvals, elicitations) open on this
2512
+ // turn's row; `humansIdle` resolves when the last one is done. A turn
2513
+ // that settles with one still open publishes only after it (driveTurn).
2514
+ humanRequests: 0,
2515
+ humansIdle: null,
2516
+ // Whether any card was ever raised on this turn's row.
2517
+ askedHuman: false,
2518
+ // Opened by `ensureInteractiveTurn` for a question rather than by the
2519
+ // runtime, and whether root work has joined it since.
2520
+ humanOrigin: false,
2521
+ rootWork: false,
2522
+ // Every row this turn owes an answer, in push order.
2523
+ entries: [],
2524
+ // runtime message id → entry, for the `user.message` acknowledgement.
2525
+ pushed: new Map(),
2526
+ // Normalizer segment index → the entry whose row the segment's text
2527
+ // belongs to. A `delivery:"steering"` prompt's segment belongs to the
2528
+ // run it was folded into; a `queued`/`idle` one starts its own run.
2529
+ segmentOwners: [],
2530
+ // The entry whose run the runtime is working on right now.
2531
+ currentOwner: null,
2532
+ primaryEntry: null,
2533
+ // Early settles (a previous run's row, published when the next run
2534
+ // opened) run off the event dispatch chain; driveTurn awaits them
2535
+ // before releasing ownership.
2536
+ settlePromises: [],
2537
+ // False until the turn-opening send() has resolved: the delivery hold.
2538
+ primarySent: continuation,
2539
+ // Root `user.message`s whose id no pushed entry knew yet (the event beat
2540
+ // the send() resolution): runtime id → boundary, reconciled by
2541
+ // assignRuntimeId.
2542
+ unclaimedUserMessages: new Map(),
1406
2543
  };
2544
+ if (adoptEntry) {
2545
+ // A pushed row whose run the runtime opened after the turn it was pushed
2546
+ // into had already settled: it becomes the primary of a turn of its own.
2547
+ adoptEntry.turn = turn;
2548
+ turn.entries.push(adoptEntry);
2549
+ turn.primaryEntry = adoptEntry;
2550
+ if (adoptEntry.runtimeId) turn.pushed.set(adoptEntry.runtimeId, adoptEntry);
2551
+ turn.primarySent = true;
2552
+ } else {
2553
+ turn.primaryEntry = createEntry(turn, message, continuation ? 'self' : 'primary');
2554
+ }
2555
+ turn.currentOwner = turn.primaryEntry;
2556
+ if (continuation) turn.segmentOwners[0] = turn.primaryEntry;
2557
+ turn.primaryReady = new Promise((resolve) => { turn.resolvePrimaryReady = resolve; });
2558
+ if (turn.primarySent) turn.resolvePrimaryReady();
1407
2559
  if (continuation) {
1408
2560
  // The single in-flight registration for this turn's synthetic row, and
1409
2561
  // the signal that abandons it. One promise, stored ON the turn, so the
@@ -1425,18 +2577,19 @@ export function createCopilotSdkSessionRunner({
1425
2577
  turn.rejectDone = reject;
1426
2578
  });
1427
2579
  // The stall watchdog and the runtime-exit observer can reject `turn.done`
1428
- // before `runTurn` reaches its `await` — an unhandled rejection that the
1429
- // worker crash guard would escalate into a whole-worker failure. This
1430
- // handler exists only to mark the promise as handled; the real await path
1431
- // still sees the rejection.
2580
+ // before anything awaits it — an unhandled rejection that the worker crash
2581
+ // guard would escalate into a whole-worker failure. This handler exists
2582
+ // only to mark the promise as handled; driveTurn still sees the rejection.
1432
2583
  turn.done.catch(() => {});
1433
2584
  turn.settle = () => {
1434
2585
  if (turn.settled) return;
1435
2586
  turn.settled = true;
1436
2587
  turn.disarmStall();
1437
2588
  // The terminal event releases the event stream at once — see
1438
- // `beginPublishing`. The queue row stays owned until the publish lands.
2589
+ // `beginPublishing`. The queue rows stay owned until the publishes land.
1439
2590
  beginPublishing(turn);
2591
+ turn.resolvePrimaryReady();
2592
+ syncDeliveryReadiness();
1440
2593
  turn.resolveDone();
1441
2594
  };
1442
2595
  turn.fail = (error) => {
@@ -1444,6 +2597,8 @@ export function createCopilotSdkSessionRunner({
1444
2597
  turn.settled = true;
1445
2598
  turn.disarmStall();
1446
2599
  beginPublishing(turn);
2600
+ turn.resolvePrimaryReady();
2601
+ syncDeliveryReadiness();
1447
2602
  turn.rejectDone(error);
1448
2603
  };
1449
2604
  turn.disarmStall = () => {
@@ -1458,7 +2613,9 @@ export function createCopilotSdkSessionRunner({
1458
2613
  // A human staring at a question card is not a stalled runtime. Re-arm
1459
2614
  // rather than fail: the card has its own (much longer) timeout, and
1460
2615
  // failing the row here would settle it while the answer is still coming.
1461
- if (pendingHumanRequests > 0) {
2616
+ // A remote_relay call waiting on another relay is the same kind of
2617
+ // silence; the relay bounds that wait itself.
2618
+ if (pendingHumanRequests > 0 || pendingRelayToolCalls > 0) {
1462
2619
  turn.armStall();
1463
2620
  return;
1464
2621
  }
@@ -1472,44 +2629,517 @@ export function createCopilotSdkSessionRunner({
1472
2629
  return turn;
1473
2630
  }
1474
2631
 
2632
+ // ----------------------------------------------------- prompt boundaries --
2633
+
2634
+ function claimSegment(turn, index, owner) {
2635
+ // Leading unowned segments (text the runtime produced before any prompt
2636
+ // boundary, e.g. the answer to a prompt carried over from a previous
2637
+ // interaction) go to the first owner: that is where the merged note sent
2638
+ // the reader.
2639
+ for (let j = 0; j <= index; j += 1) {
2640
+ if (!turn.segmentOwners[j]) turn.segmentOwners[j] = owner;
2641
+ }
2642
+ }
2643
+
2644
+ /**
2645
+ * A root `user.message` arrived: the runtime started work on a prompt. Called
2646
+ * on the dispatch chain right after the normalizer opened the prompt's
2647
+ * segment, so `promptCount() - 1` is that segment.
2648
+ */
2649
+ async function noteUserMessage(turn, event) {
2650
+ const data = event?.data && typeof event.data === 'object' ? event.data : {};
2651
+ const index = Math.max(0, turn.normalizer.promptCount() - 1);
2652
+ const runtimeId = String(data.messageId || '').trim();
2653
+ const boundary = {
2654
+ index,
2655
+ delivery: String(data.delivery || '').trim().toLowerCase(),
2656
+ interactionId: String(data.interactionId || '').trim(),
2657
+ };
2658
+ const entry = runtimeId ? turn.pushed.get(runtimeId) : null;
2659
+ if (entry) {
2660
+ await adoptSegment(turn, entry, boundary);
2661
+ return;
2662
+ }
2663
+ // Not one of ours (yet): a prompt the runtime injected itself (an autopilot
2664
+ // nudge, a carried-over prompt from a previous interaction), or a pushed
2665
+ // prompt whose send() has not resolved with its id. Its text belongs to
2666
+ // the run in progress until proven otherwise.
2667
+ if (runtimeId && !turn.unclaimedUserMessages.has(runtimeId)) turn.unclaimedUserMessages.set(runtimeId, boundary);
2668
+ claimSegment(turn, index, turn.currentOwner || turn.primaryEntry);
2669
+ }
2670
+
2671
+ /**
2672
+ * Bind a prompt boundary to the entry it names.
2673
+ *
2674
+ * `delivery:"steering"`: the runtime folded the prompt into the run already
2675
+ * in progress — the reply that follows belongs to that run's row, and this
2676
+ * row will settle with the fold marker (Claude's `folded`). The relay is
2677
+ * told the prompt is consumed at once, so a crash between now and the
2678
+ * marker fails the row instead of re-running it.
2679
+ *
2680
+ * Anything else (`queued`, `idle`, unknown): the prompt starts its own run
2681
+ * on the same interaction. The previous run is over — nothing more will be
2682
+ * produced for its row, so it is settled right away rather than at the
2683
+ * drain's end, exactly as a Claude context finalizes when the CLI opens the
2684
+ * next one.
2685
+ */
2686
+ async function adoptSegment(turn, entry, { index, delivery, interactionId }) {
2687
+ entry.consumed = true;
2688
+ noteCancellableChange();
2689
+ entry.delivery = delivery || null;
2690
+ entry.interactionId = interactionId || null;
2691
+ const running = turn.currentOwner || turn.primaryEntry;
2692
+ if (delivery === 'steering' && running && running !== entry && !running.settled) {
2693
+ entry.foldedInto = running;
2694
+ turn.segmentOwners[index] = running;
2695
+ claimSegment(turn, index, running);
2696
+ if (entry.role === 'steer' && !entry.consumedMarked) {
2697
+ entry.consumedMarked = true;
2698
+ void markConsumed(entry.message.conversationId, [entry.message]);
2699
+ }
2700
+ dbg('steered prompt folded into the running turn', entry.message.id, `→ ${running.message.id || '(continuation)'}`);
2701
+ return;
2702
+ }
2703
+ turn.segmentOwners[index] = entry;
2704
+ claimSegment(turn, index, entry);
2705
+ if (running === entry) return;
2706
+ turn.currentOwner = entry;
2707
+ if (running && !running.settled) {
2708
+ dbg('pushed prompt runs as its own turn; settling the previous run', entry.message.id, `after ${running.message.id || '(continuation)'}`);
2709
+ // Off the dispatch chain: a slow relay must not stall the events of
2710
+ // the run that just opened (or its terminator) behind this publish.
2711
+ // A continuation whose row is not minted yet (a steer pushed past the
2712
+ // hold after its wait ran out) settles once the row exists — publishing
2713
+ // now would name no row — or is discarded with it.
2714
+ const settled = running.message?.id || !turn.rowReady
2715
+ ? settleEntry(turn, running, { type: 'completed' })
2716
+ : turn.rowReady.then((ready) => settleEntry(turn, running, { type: ready ? 'completed' : 'discarded' }));
2717
+ turn.settlePromises.push(settled);
2718
+ }
2719
+ }
2720
+
2721
+ // -------------------------------------------------------------- settling --
2722
+
2723
+ /**
2724
+ * Whether a completed row with no text has nothing to show at all: the
2725
+ * runtime opened it by itself (typically right after a background agent
2726
+ * finished), its run published no tool line, thought or lane, no card was
2727
+ * raised on it, no user message was folded into it, and nothing carried in
2728
+ * reported a failure. Such a row would only ever read "the turn completed
2729
+ * without a text reply", and the task card already showed the agent
2730
+ * finishing (live, gpt-5.6-luna, 2026-09-26). A failed agent's card is gone
2731
+ * by then, so the row carrying its failure line is kept (`showsWork`).
2732
+ * Every delivered row keeps the note — somebody asked something there.
2733
+ */
2734
+ function settlesSilently(turn, entry, folded) {
2735
+ return turn.kind === 'continuation'
2736
+ && entry.role === 'self'
2737
+ && !turn.humanOrigin
2738
+ && !turn.askedHuman
2739
+ && !turn.planBoardPosted
2740
+ && !entry.showedWork
2741
+ && !folded.length
2742
+ && Boolean(entry.message?.id);
2743
+ }
2744
+
2745
+ /**
2746
+ * Settle a silent continuation by tearing its row down: the requeue route's
2747
+ * continuation branch drops a processing continuation without an answer
2748
+ * (`dropped: 'continuation'`), the same teardown a dead worker's rows get.
2749
+ * A lost request leaves the row to that same stale-continuation recovery.
2750
+ */
2751
+ async function dropSilentContinuation(entry) {
2752
+ dbg('continuation ended with nothing to show; dropping its row', entry.message.id);
2753
+ await api('POST', '/api/requeue', { messageId: entry.message.id, ...attemptFields(entry.message) })
2754
+ .catch((error) => { dbg('silent continuation drop failed', entry.message.id, error?.message || String(error)); });
2755
+ }
2756
+
2757
+ /**
2758
+ * Decide and publish one row's outcome. Idempotent per entry. A run owner's
2759
+ * settle also settles the steers folded into its run (they were answered by
2760
+ * its reply), in push order, after the reply itself.
2761
+ */
2762
+ async function settleEntry(turn, entry, outcome) {
2763
+ if (entry.settled) return;
2764
+ entry.settled = true;
2765
+ const model = turn.result?.model || turn.normalizer.model || turn.model || null;
2766
+ const folded = turn.entries.filter((other) => other !== entry && !other.settled && other.foldedInto === entry);
2767
+ try {
2768
+ switch (outcome.type) {
2769
+ case 'completed': {
2770
+ const text = finalTextFor(turn, entry, turn.result);
2771
+ if (!text && settlesSilently(turn, entry, folded)) {
2772
+ await dropSilentContinuation(entry);
2773
+ break;
2774
+ }
2775
+ const published = text || EMPTY_TURN_COMPLETION_NOTE;
2776
+ if (entry.role !== 'steer' && shouldPostPlanBoard({
2777
+ relayMode: entry.message.relayMode,
2778
+ finalText: text,
2779
+ alreadyPosted: turn.planBoardPosted,
2780
+ acted: turn.acted === true,
2781
+ })) {
2782
+ await publishPlanBoard(turn, text, 'plan-mode-fallback');
2783
+ }
2784
+ await publishFinalStream(entry.message, published);
2785
+ await publishResponse(entry.message, {
2786
+ text: published,
2787
+ model,
2788
+ consumedSteerIds: folded.map((other) => other.message),
2789
+ });
2790
+ break;
2791
+ }
2792
+ case 'aborted-by-user': {
2793
+ // The queue row's fate belongs to the server-side abort control,
2794
+ // exactly as in the Claude and Cursor workers. Publishing a response
2795
+ // here would double-settle the row.
2796
+ await publishFinalStream(entry.message, finalTextFor(turn, entry, turn.result));
2797
+ break;
2798
+ }
2799
+ case 'user-stopped': {
2800
+ // The run a Stop cut short while naming another row. The abort ack
2801
+ // fails only the row it names, so this one is failed here — as
2802
+ // stopped, never requeued: a requeue would re-run a prompt the user
2803
+ // stopped (the Claude worker's lingering-stopped rule). Claimed
2804
+ // first, so a crash meanwhile fails it rather than re-running it.
2805
+ const id = String(entry.message.id || '');
2806
+ const terminalError = buildRelayStopFailure();
2807
+ claimSettling(entry.message, 'stopped');
2808
+ try {
2809
+ await publishFinalStream(entry.message, finalTextFor(turn, entry, turn.result));
2810
+ const outcome = await publishResponse(entry.message, {
2811
+ text: terminalError.message,
2812
+ model,
2813
+ terminalError,
2814
+ requeueOnFailure: false,
2815
+ consumedSteerIds: folded.map((other) => other.message),
2816
+ });
2817
+ // Not even the stop landed: the heartbeat hands it to the relay to fail.
2818
+ if (outcome === 'failed' && id) settleFailedTerminals.set(id, terminalError);
2819
+ } finally {
2820
+ releaseSettling(id);
2821
+ }
2822
+ break;
2823
+ }
2824
+ case 'runtime-interrupted': {
2825
+ const own = finalTextFor(turn, entry, turn.result);
2826
+ const text = own ? `${own}\n\n${RUNTIME_INTERRUPTED_NOTE}` : RUNTIME_INTERRUPTED_NOTE;
2827
+ await publishFinalStream(entry.message, text);
2828
+ await publishResponse(entry.message, { text, model, consumedSteerIds: folded.map((other) => other.message) });
2829
+ break;
2830
+ }
2831
+ case 'error': {
2832
+ const { classified } = outcome;
2833
+ await publishFinalStream(entry.message, entry.state.lastStreamedText);
2834
+ await publishResponse(entry.message, {
2835
+ text: classified.text,
2836
+ model,
2837
+ terminalError: terminalErrorRecord(entry.message, classified),
2838
+ consumedSteerIds: folded.map((other) => other.message),
2839
+ });
2840
+ break;
2841
+ }
2842
+ case 'folded':
2843
+ await publishSettleMarker(entry.message, { text: STEER_FOLDED_TEXT, model, kind: 'folded', variant: 'folded' });
2844
+ break;
2845
+ case 'stopped':
2846
+ await publishSettleMarker(entry.message, { text: STEER_STOPPED_TEXT, model, kind: 'stopped', variant: 'stopped' });
2847
+ break;
2848
+ case 'merged-note':
2849
+ // Accepted by the runtime, never started: still queued inside it, so
2850
+ // it will be answered at the start of the next interaction — a
2851
+ // requeue would run it twice.
2852
+ await publishFinalStream(entry.message, STEERED_ROW_MERGED_NOTE);
2853
+ await publishResponse(entry.message, { text: STEERED_ROW_MERGED_NOTE, model });
2854
+ break;
2855
+ case 'cancelled':
2856
+ case 'discarded':
2857
+ default:
2858
+ break;
2859
+ }
2860
+ } catch (error) {
2861
+ dbg('settle publish failed', entry.message?.id || '(no id)', outcome.type, error?.message || String(error));
2862
+ } finally {
2863
+ entry.released = true;
2864
+ entry.resolveDone(true);
2865
+ }
2866
+ for (const other of folded) {
2867
+ const foldOutcome = outcome.type === 'completed' || outcome.type === 'merged-note'
2868
+ ? { type: 'folded' }
2869
+ : outcome.type === 'error' ? outcome : { type: 'stopped' };
2870
+ await settleEntry(turn, other, foldOutcome);
2871
+ }
2872
+ }
2873
+
2874
+ /** Remove our unconsumed prompts from the runtime's queued lane (best effort). */
2875
+ async function clearOwnedQueuedItems(entries) {
2876
+ if (!entries.length || !session || !sessionRpc.queue) return;
2877
+ await refreshQueueLane();
2878
+ for (const entry of entries) {
2879
+ const itemId = queuedLane.get(entry.runtimeId);
2880
+ if (!itemId) continue;
2881
+ try {
2882
+ await session.rpc.queue.removeAt({ id: itemId });
2883
+ dbg('removed a stopped prompt from the runtime queue', entry.message.id);
2884
+ } catch (error) {
2885
+ dbg('queue.removeAt after Stop failed', entry.message.id, error?.message || String(error));
2886
+ }
2887
+ }
2888
+ }
2889
+
2890
+ /**
2891
+ * Hold pushed-but-unstarted prompts for the orphan grace, then return the
2892
+ * ones the runtime still did not open (an opened one was adopted meanwhile
2893
+ * and belongs to a turn of its own now).
2894
+ */
2895
+ async function settleAfterOrphanGrace(turn, orphans) {
2896
+ if (!orphans.length) return [];
2897
+ if (orphanGraceMs > 0) {
2898
+ dbg('pushed prompts not started at idle; waiting for the runtime to open them', orphans.map((entry) => entry.message.id).join(','));
2899
+ await sleep(orphanGraceMs);
2900
+ }
2901
+ return orphans.filter((entry) => entry.turn === turn && !entry.settled);
2902
+ }
2903
+
2904
+ /**
2905
+ * Publish everything a finished turn still owes, per row.
2906
+ *
2907
+ * Shared by both turn kinds: the runtime does not distinguish a turn it
2908
+ * started from one it was asked for, so neither does the reporting.
2909
+ */
2910
+ async function finishTurn(turn) {
2911
+ const result = turn.result;
2912
+ const failure = turn.failure;
2913
+ // Capture only. The POST fires from driveTurn's `finally`, after the rows
2914
+ // have been published.
2915
+ captureTurnUsage(turn.primaryEntry.message, result);
2916
+
2917
+ const pending = turn.entries.filter((entry) => !entry.settled && entry.turn === turn);
2918
+ if (!pending.length) return;
2919
+
2920
+ if (failure) {
2921
+ const classified = classifyCopilotTurnException(failure);
2922
+ dbg('copilot turn failed', turn.message?.id || '(continuation)', classified.detail);
2923
+ for (const entry of pending) {
2924
+ if (entry.settled) continue;
2925
+ await settleEntry(turn, entry, { type: 'error', classified });
2926
+ }
2927
+ return;
2928
+ }
2929
+ if (result?.isError) {
2930
+ const classified = classifyCopilotSessionError(result.errorData || { message: result.errorMessage });
2931
+ dbg('turn failed', turn.message?.id || '(continuation)', classified.stableCode);
2932
+ for (const entry of pending) {
2933
+ if (entry.settled) continue;
2934
+ await settleEntry(turn, entry, { type: 'error', classified });
2935
+ }
2936
+ return;
2937
+ }
2938
+
2939
+ const live = turn.currentOwner || turn.primaryEntry;
2940
+ // The row the user pressed Stop on. Normally the live one; the relay's
2941
+ // live-turn picker can also land on another row this turn owns, in which
2942
+ // case THAT row is left to the relay's abort control and the live row
2943
+ // keeps its partial reply. A Stop naming a row that is no longer pending
2944
+ // (its early settle already published, or it is no row of this turn's)
2945
+ // still cut the live run short — and nobody else will settle that row.
2946
+ const namedPending = turn.aborted && turn.abortedRowId
2947
+ ? pending.find((entry) => String(entry.message?.id || '') === turn.abortedRowId) || null
2948
+ : null;
2949
+ const stopped = turn.aborted ? namedPending || (turn.abortedRowId ? null : live) : null;
2950
+ const cutRun = turn.aborted && !stopped && !live.settled ? live : null;
2951
+ const settleOne = async (entry) => {
2952
+ // A row that moved to a turn of its own (adoptOrphanedRun) is not ours.
2953
+ if (entry.turn !== turn || entry.settled) return;
2954
+ const ownsRun = entry === live || (!entry.foldedInto && entry.consumed);
2955
+ if (turn.aborted) {
2956
+ // A user Stop. The stopped row is settled server-side; every other
2957
+ // row pushed into the interaction went unanswered — the runtime
2958
+ // clears both its lanes on an interrupt — and says so, with Resend.
2959
+ if (entry === stopped) {
2960
+ dbg('turn aborted', entry.message.id);
2961
+ await settleEntry(turn, entry, { type: 'aborted-by-user' });
2962
+ } else if (entry === cutRun) {
2963
+ dbg('turn aborted by a Stop that named another row', entry.message.id, `named ${turn.abortedRowId}`);
2964
+ await settleEntry(turn, entry, { type: 'user-stopped' });
2965
+ } else if (ownsRun) {
2966
+ await settleEntry(turn, entry, { type: 'completed' });
2967
+ } else {
2968
+ await settleEntry(turn, entry, { type: 'stopped' });
2969
+ }
2970
+ return;
2971
+ }
2972
+ if (result?.aborted) {
2973
+ // The runtime interrupted the turn on its own. Nothing server-side is
2974
+ // waiting to settle these rows.
2975
+ if (entry === live) {
2976
+ dbg('turn interrupted by the runtime', entry.message.id);
2977
+ await settleEntry(turn, entry, { type: 'runtime-interrupted' });
2978
+ } else if (ownsRun) {
2979
+ await settleEntry(turn, entry, { type: 'completed' });
2980
+ } else {
2981
+ await settleEntry(turn, entry, { type: 'stopped' });
2982
+ }
2983
+ return;
2984
+ }
2985
+ if (ownsRun) {
2986
+ await settleEntry(turn, entry, { type: 'completed' });
2987
+ } else if (entry.foldedInto) {
2988
+ await settleEntry(turn, entry, { type: 'folded' });
2989
+ } else {
2990
+ await settleEntry(turn, entry, { type: 'merged-note' });
2991
+ }
2992
+ };
2993
+ // A normal end with pushed prompts the runtime never started: the runtime
2994
+ // gets a moment to open their runs (adoptOrphanedRun takes them) before
2995
+ // they are noted as carried over — AFTER the rows whose replies are
2996
+ // complete have been published, so a finished answer never waits on the
2997
+ // grace. Not on a Stop (the runtime cleared them) and not on an error.
2998
+ const orphans = (!turn.aborted && !result?.aborted)
2999
+ ? pending.filter((entry) => entry.role === 'steer' && !entry.consumed && !entry.foldedInto)
3000
+ : [];
3001
+ if (turn.aborted || result?.aborted) {
3002
+ // Belt and braces: `interruptMainTurn({flushQueued:false})` and
3003
+ // `abort()` both empty the runtime's steering AND queued lanes
3004
+ // (probe-verified 2026-09-25), so nothing pushed runs after a Stop. Any
3005
+ // prompt of ours still listed in the queued lane is removed by id anyway
3006
+ // before its row is marked stopped. (The id-less steering lane cannot be
3007
+ // targeted, and needs no help.)
3008
+ await clearOwnedQueuedItems(pending.filter((entry) => !entry.consumed && entry.runtimeId));
3009
+ }
3010
+ // The stopped row first: settling it before the live row's cascade means
3011
+ // the cascade (which settles the steers folded into a run) cannot touch
3012
+ // the row the relay's abort control owns. A cut run goes first too, so
3013
+ // the steers folded into it settle through its cascade, after its stop.
3014
+ const first = stopped || cutRun;
3015
+ const ordered = first ? [first, ...pending.filter((entry) => entry !== first)] : pending;
3016
+ for (const entry of ordered) {
3017
+ if (!orphans.includes(entry)) await settleOne(entry);
3018
+ }
3019
+ for (const entry of await settleAfterOrphanGrace(turn, orphans)) {
3020
+ await settleOne(entry);
3021
+ }
3022
+ }
3023
+
3024
+ // ------------------------------------------------------------ lifecycle --
3025
+
3026
+ /**
3027
+ * The turn a Stop is for. Every turn's control poller runs until its
3028
+ * `driveTurn` finishes, so a turn that is still publishing can claim a Stop
3029
+ * meant for another: the Stop goes to the turn owning the row it names, and
3030
+ * one naming no owned row goes to the turn that is running.
3031
+ */
3032
+ function stopTarget(turn, control) {
3033
+ const rowId = String(control?.queueMessageId || '').trim();
3034
+ if (rowId) {
3035
+ for (const candidate of [activeTurn, ...publishingTurns]) {
3036
+ if (candidate?.entries.some((entry) => !entry.released && String(entry.message?.id || '') === rowId)) {
3037
+ return candidate;
3038
+ }
3039
+ }
3040
+ }
3041
+ return turn.settled && activeTurn ? activeTurn : turn;
3042
+ }
3043
+
3044
+ /**
3045
+ * Handle a Stop for `turn`. When the row it names settled early and its
3046
+ * reply is still publishing, the Stop resolves only once that reply has
3047
+ * landed: the relay's ack then fails the named row, which is a no-op on a
3048
+ * completed one but would overwrite a reply still in flight.
3049
+ */
3050
+ async function interruptTurn(turn, control) {
3051
+ const rowId = String(control?.queueMessageId || '').trim() || null;
3052
+ const publishing = rowId
3053
+ ? turn.entries.find((entry) => String(entry.message?.id || '') === rowId && entry.settled && !entry.released)
3054
+ : null;
3055
+ await interruptRun(turn, rowId);
3056
+ if (publishing) await withTimeout(publishing.done, STOPPED_ROW_PUBLISH_WAIT_MS, false);
3057
+ }
3058
+
3059
+ /**
3060
+ * Interrupt the turn the user stopped. `interruptMainTurn` (when the
3061
+ * runtime has it) stops the main agent loop and nothing else: background
3062
+ * agents and promoted shells keep running under their own Stop, exactly
3063
+ * like the Claude worker's targeted interrupt. `session.abort()` — the
3064
+ * fallback — also cancels every background agent and clears both message
3065
+ * lanes. Either way the runtime answers with an aborted idle event, which
3066
+ * settles the turn through the normal terminator.
3067
+ */
3068
+ async function interruptRun(turn, rowId) {
3069
+ if (turn.settled) {
3070
+ // Its run is over and the main loop may already belong to the next
3071
+ // turn, so nothing is interrupted. While it waits on a card (driveTurn)
3072
+ // the Stop ends the cards, and the row settles as stopped.
3073
+ if (turn.humanRequests > 0) {
3074
+ turn.aborted = true;
3075
+ turn.abortedRowId = rowId;
3076
+ turn.abortController.abort();
3077
+ }
3078
+ return;
3079
+ }
3080
+ turn.aborted = true;
3081
+ turn.abortedRowId = rowId;
3082
+ // A pending `remote_relay` call ends now rather than when the runtime gets
3083
+ // round to it: the relay drops the request, withdraws its approval card
3084
+ // and stops waiting on the remote, and the watchdog hold is released.
3085
+ turn.remoteRelayStop?.abort();
3086
+ // While `ensureSession` is still connecting there is no session to abort
3087
+ // and this would be a silent no-op; `runTurn` re-checks `turn.aborted`
3088
+ // once the session exists and settles there instead.
3089
+ if (!session) return;
3090
+ if (sessionRpc.interruptMainTurn) {
3091
+ try {
3092
+ const outcome = await session.rpc.interruptMainTurn({ flushQueued: false });
3093
+ if (outcome?.interrupted === false) {
3094
+ // Nothing was processing: no aborted idle will come. The turn is
3095
+ // over as far as the runtime is concerned.
3096
+ dbg('interruptMainTurn found no main turn in flight; settling locally', turn.message?.id || '');
3097
+ turn.settle();
3098
+ }
3099
+ return;
3100
+ } catch (error) {
3101
+ dbg('interruptMainTurn failed; falling back to abort()', error?.message || String(error));
3102
+ }
3103
+ }
3104
+ await session.abort?.();
3105
+ }
3106
+
3107
+ /**
3108
+ * Start a delivered turn: bring the session up, send the prompt, and hand
3109
+ * the turn's lifecycle to `driveTurn`. Resolves when THIS row has settled —
3110
+ * which can be before the turn ends, when a message pushed later ran as its
3111
+ * own run and this one's reply was already complete.
3112
+ */
1475
3113
  async function runTurn(turn) {
1476
3114
  const { message } = turn;
3115
+ const entry = turn.primaryEntry;
1477
3116
  const model = resolvePerTurnModel(message);
1478
3117
  // The dequeued row's reasoning effort (audit #9). Normalisation happens in
1479
3118
  // the switcher: `none`/absent mean "the model's default".
1480
3119
  const effort = message?.reasoningEffort ?? null;
1481
3120
  const relayMode = message?.relayMode || 'agent';
1482
3121
  lastRelayMode = relayMode;
3122
+ turn.model = model;
1483
3123
  // Set before the session is touched: the heartbeat's owner-recovery guard
1484
3124
  // reads the active ids, so a cold-start delivery must already own its row.
1485
3125
  activeTurn = turn;
1486
-
1487
- const controlState = controlPoller?.start?.({
1488
- queueMessageId: message.id,
1489
- onAbortTurn: async () => {
1490
- turn.aborted = true;
1491
- // While `ensureSession` is still connecting there is no session to
1492
- // abort and this would be a silent no-op; `runTurn` re-checks
1493
- // `turn.aborted` once the session exists and settles there instead.
1494
- if (!session) return;
1495
- // The runtime answers an abort with `abort` → `agent.interrupted` →
1496
- // `assistant.turn_end` → `session.idle{aborted:true}`, which settles
1497
- // the turn through the normal terminator; this only asks for it.
1498
- await session.abort?.();
1499
- },
1500
- });
1501
-
3126
+ // The delivery hold begins: nothing steers in until the prompt is sent.
3127
+ syncDeliveryReadiness();
3128
+ // Session-wide, not per row: the client's Stop targets whichever of this
3129
+ // turn's rows is live, and the relay only hands out controls for rows
3130
+ // this worker owns.
3131
+ turn.controlState = controlPoller?.start?.({
3132
+ queueMessageId: '',
3133
+ onAbortTurn: (control) => interruptTurn(stopTarget(turn, control), control),
3134
+ }) || null;
3135
+ turn.drive = driveTurn(turn);
1502
3136
  try {
1503
- await ensureSession(model, effort, relayMode);
3137
+ await ensureSession(model, effort);
1504
3138
  if (turn.aborted) {
1505
3139
  // The abort landed while the session was still being built. Nothing
1506
- // was sent, so there is no runtime turn to interrupt — ask anyway (a
1507
- // queued prompt from a previous delivery could still be running) and
1508
- // settle locally rather than sending a prompt the user just cancelled.
3140
+ // was sent, so there is no runtime turn to interrupt — settle locally
3141
+ // rather than sending a prompt the user just cancelled.
1509
3142
  dbg('turn aborted before send', message.id);
1510
- try { await session?.abort?.(); } catch (error) {
1511
- dbg('abort during session setup failed', error?.message || String(error));
1512
- }
1513
3143
  turn.settle();
1514
3144
  } else {
1515
3145
  turn.armStall();
@@ -1522,26 +3152,147 @@ export function createCopilotSdkSessionRunner({
1522
3152
  // `mode` and `attachments` are FIELDS of the single MessageOptions
1523
3153
  // argument — `send()` takes no second parameter, so passing options
1524
3154
  // positionally drops them silently.
1525
- await session.send({
3155
+ const sending = session.send({
1526
3156
  prompt,
1527
3157
  ...(attachments?.length ? { attachments } : {}),
1528
3158
  ...(sendMode ? { mode: sendMode } : {}),
1529
3159
  agentMode: copilotAgentModeForRelayMode(relayMode),
1530
3160
  });
1531
- // Committed only now that `send()` accepted the prompt (audit #32): a
1532
- // failed send means the runtime never READ the mode guidance, and
1533
- // committing before it would make the same-mode retry omit it.
1534
- context?.commit();
1535
- // `send()` resolves once the runtime accepted the prompt — it is NOT
1536
- // the turn's completion; the event stream is.
1537
- await turn.done;
3161
+ // A send that never resolves (a wedged runtime) must not hold this
3162
+ // delivery hostage once the watchdog has failed the turn: the id is
3163
+ // still recorded if it ever arrives, but the wait ends with the turn.
3164
+ const settledFirst = Symbol('turn-settled');
3165
+ const runtimeId = await Promise.race([
3166
+ sending,
3167
+ turn.done.then(() => settledFirst, () => settledFirst),
3168
+ ]);
3169
+ if (runtimeId === settledFirst) {
3170
+ sending.then((id) => assignRuntimeId(turn, entry, id)).catch(() => {});
3171
+ } else {
3172
+ assignRuntimeId(turn, entry, runtimeId);
3173
+ // Committed only now that `send()` accepted the prompt (audit #32): a
3174
+ // failed send means the runtime never READ the mode guidance, and
3175
+ // committing before it would make the same-mode retry omit it.
3176
+ context?.commit();
3177
+ // The runtime has the prompt: the delivery hold ends and later
3178
+ // deliveries steer into this turn.
3179
+ turn.primarySent = true;
3180
+ turn.resolvePrimaryReady();
3181
+ syncDeliveryReadiness();
3182
+ }
1538
3183
  }
1539
- } finally {
1540
- controlPoller?.stop?.(controlState);
1541
- await quiesceTurn(turn);
3184
+ } catch (error) {
3185
+ // A failure that killed the session (or came from starting it) leaves a
3186
+ // handle nothing else can use; drop it so the next delivery rebuilds and
3187
+ // resumes rather than sending into a dead runtime. An UNCONFIRMED MODEL
3188
+ // SWITCH is the exception: the session is healthy and merely still on
3189
+ // its previous model, and tearing the runtime down over it would kill
3190
+ // any live background work for a selection problem the user fixes by
3191
+ // picking another model.
3192
+ if (!isModelSwitchUnconfirmedError(error)) {
3193
+ await stopRuntime('turn-failure').catch(() => {});
3194
+ }
3195
+ turn.fail(error);
1542
3196
  }
3197
+ // `send()` resolving is NOT the turn's completion; the event stream is.
3198
+ await awaitEntry(turn, entry);
3199
+ return true;
3200
+ }
1543
3201
 
1544
- return finishTurn(turn, model);
3202
+ /**
3203
+ * Wait for a row's settle. A row's settle is not necessarily the turn's
3204
+ * end: a prompt pushed later may still be running on the same interaction,
3205
+ * and this delivery must not wait for it. But when the turn IS over, wait
3206
+ * for its wrap-up too (ownership release, usage ingest), so a caller that
3207
+ * awaited the delivery sees a quiescent runner — the contract every test
3208
+ * and the crash guard rely on. `driveTurn` never rejects.
3209
+ */
3210
+ async function awaitEntry(turn, entry) {
3211
+ await entry.done;
3212
+ if (turn.settled && turn.drive) await turn.drive.catch(() => {});
3213
+ }
3214
+
3215
+ /**
3216
+ * Run a turn from its first event to its last publish.
3217
+ *
3218
+ * Row ownership is released only after every publish has landed: a
3219
+ * heartbeat firing inside the publish window with no active ids would tell
3220
+ * the relay this worker owns nothing, and the still-`processing` rows would
3221
+ * be recovered underneath it. (The EVENT stream was already released at the
3222
+ * terminator — see `beginPublishing` — so the runtime's next self-initiated
3223
+ * turn can open while this one is still writing.)
3224
+ */
3225
+ async function driveTurn(turn) {
3226
+ try {
3227
+ await turn.done;
3228
+ } catch (error) {
3229
+ turn.failure = turn.failure || error;
3230
+ }
3231
+ // A card still open on this row — a background agent asking while the
3232
+ // main loop went idle (a root question cannot get here: the loop is
3233
+ // blocked on its handler) — keeps the row processing until it is done:
3234
+ // the relay opens cards only on a processing row, and `/api/response`
3235
+ // cancels the ones still open. The event stream is already released
3236
+ // (`beginPublishing`), so new root work opens a turn of its own meanwhile.
3237
+ if (!turn.aborted && !turn.failure && turn.humanRequests > 0) await turn.humansIdle;
3238
+ try {
3239
+ await quiesceTurn(turn);
3240
+ if (turn.failure && turn.kind === 'delivered' && !isModelSwitchUnconfirmedError(turn.failure) && session) {
3241
+ // A stall or a runtime death mid-turn (a setup failure already tore
3242
+ // the runtime down in runTurn).
3243
+ await stopRuntime('turn-failure').catch(() => {});
3244
+ }
3245
+ if (turn.kind === 'continuation') {
3246
+ // Nothing can be published before the row exists. The buffer is
3247
+ // drained by the registration itself; this only covers actions that
3248
+ // arrived during the drain.
3249
+ await awaitContinuationRow(turn);
3250
+ // The invariant is the id, not the flag: registration can also give up
3251
+ // by throwing, or by taking longer than `awaitContinuationRow` waits,
3252
+ // and publishing against `messageId: null` would attribute the whole
3253
+ // turn to nothing at all.
3254
+ if (!turn.message.id) {
3255
+ if (!turn.discarded) abandonContinuationRegistration(turn, 'no relay row at publish time');
3256
+ dbg('continuation output dropped (no relay row)');
3257
+ const classified = classifyCopilotTurnException(
3258
+ turn.failure || new Error('the relay refused a continuation row for this turn'),
3259
+ );
3260
+ for (const entry of turn.entries) {
3261
+ if (entry.settled || entry.turn !== turn) continue;
3262
+ // The steering path is still waiting on any row it handed us, and
3263
+ // it has no row of its own to fall back to.
3264
+ await settleEntry(turn, entry, entry.role === 'self' ? { type: 'discarded' } : { type: 'error', classified });
3265
+ }
3266
+ return;
3267
+ }
3268
+ await flushBufferedActions(turn);
3269
+ }
3270
+ await finishTurn(turn);
3271
+ } catch (error) {
3272
+ dbg('turn publish failed', error?.message || String(error));
3273
+ } finally {
3274
+ // Early settles still in flight must land before the rows stop being
3275
+ // reported (settleEntry never rejects, but this is the ordering guard).
3276
+ await Promise.allSettled(turn.settlePromises);
3277
+ controlPoller?.stop?.(turn.controlState);
3278
+ turn.controlState = null;
3279
+ // Every path through the turn — published, failed, aborted, threw — has
3280
+ // finished by here. Anything still unresolved is released so no caller
3281
+ // waits forever; its row is left to the relay's recovery.
3282
+ for (const entry of turn.entries) {
3283
+ if (entry.turn !== turn || entry.released) continue;
3284
+ entry.settled = true;
3285
+ entry.released = true;
3286
+ entry.resolveDone(true);
3287
+ }
3288
+ // Released only once every publish for this turn's rows has landed.
3289
+ releaseTurnOwnership(turn);
3290
+ touch();
3291
+ syncDeliveryReadiness();
3292
+ // The ingest is advisory, never awaited, and must never be able to
3293
+ // delay a reply that is already written.
3294
+ postTurnUsage();
3295
+ }
1545
3296
  }
1546
3297
 
1547
3298
  /**
@@ -1563,111 +3314,6 @@ export function createCopilotSdkSessionRunner({
1563
3314
  await closeStraySubagentRuns(turn).catch(() => {});
1564
3315
  }
1565
3316
 
1566
- /**
1567
- * Publish a finished turn's outcome onto its queue row (and every row steered
1568
- * into it).
1569
- *
1570
- * Shared by both turn kinds. A continuation reaches it through
1571
- * `driveContinuation` instead of `runTurn`, but the outcomes are identical:
1572
- * the runtime does not distinguish a turn it started from one it was asked
1573
- * for, so neither does the reporting.
1574
- */
1575
- async function finishTurn(turn, model) {
1576
- const { message } = turn;
1577
- const result = turn.result;
1578
- const responseModel = result?.model || turn.normalizer.model || model || null;
1579
- // Capture only. The POST fires from `handlePendingPayload`'s `finally`,
1580
- // after this row has been published.
1581
- captureTurnUsage(message, result);
1582
-
1583
- // Whatever this interaction did not get to stays queued in the runtime and
1584
- // shifts the NEXT interaction's segments: the prompts this turn sent occupy
1585
- // `[firstSentSegment, nextSegmentIndex)`, and the ones the runtime never
1586
- // opened a segment for are still in its pending queue.
1587
- //
1588
- // The `max` against `firstSentSegment` is what makes this correct for a
1589
- // continuation, which occupies segment 0 without having sent anything: a
1590
- // continuation that opened no segment at all (an empty self-initiated turn)
1591
- // would otherwise report one carried prompt and shift the next real turn's
1592
- // answer by one.
1593
- const segmentsOpened = turn.normalizer.promptCount();
1594
- carriedPrompts = Math.max(0, turn.nextSegmentIndex - Math.max(turn.firstSentSegment, segmentsOpened));
1595
-
1596
- // This row's own reply. Falls back to the whole composed text when the
1597
- // runtime opened no segment at all (an empty or immediately-failed turn),
1598
- // which is strictly better than publishing nothing.
1599
- const ownText = String(
1600
- turn.normalizer.segmentText(turn.baseSegment) || result?.text || turn.state.lastStreamedText || '',
1601
- ).trim();
1602
-
1603
- // A user-initiated abort publishes the partial text and nothing else: the
1604
- // queue row's fate belongs to the server-side abort control, exactly as in
1605
- // the Claude and Cursor workers. Publishing a response here would
1606
- // double-settle the row.
1607
- if (turn.aborted) {
1608
- dbg('turn aborted', message.id);
1609
- // This row's segment only: the steered rows publish their own, and
1610
- // publishing the composed text here would show their replies twice.
1611
- await publishFinalStream(message, ownText);
1612
- // A steered row is NOT covered by the abort control (which knows only
1613
- // about the row the user aborted), so it still has to be settled here or
1614
- // it holds `processing` forever behind a renewing lease.
1615
- await settleSteeredRows(turn, result, responseModel);
1616
- return true;
1617
- }
1618
-
1619
- // The runtime aborted on its own (`result.aborted` with no relay-side
1620
- // abort control in flight). Nothing server-side is waiting to settle this
1621
- // row, so returning here would leave it pending until the delivery
1622
- // watchdog fails it with a misleading "Relay timeout" — the row has to be
1623
- // settled with a record that says what actually happened.
1624
- if (result?.aborted) {
1625
- dbg('turn interrupted by the runtime', message.id);
1626
- const text = ownText ? `${ownText}\n\n${RUNTIME_INTERRUPTED_NOTE}` : RUNTIME_INTERRUPTED_NOTE;
1627
- await publishFinalStream(message, text);
1628
- await publishResponse(message, { text, model: responseModel });
1629
- await settleSteeredRows(turn, result, responseModel);
1630
- return true;
1631
- }
1632
-
1633
- if (result?.isError) {
1634
- const classified = classifyCopilotSessionError(result.errorData || { message: result.errorMessage });
1635
- dbg('turn failed', message.id, classified.stableCode);
1636
- await publishFinalStream(message, turn.state.lastStreamedText);
1637
- await publishResponse(message, {
1638
- text: classified.text,
1639
- model: responseModel,
1640
- terminalError: terminalErrorRecord(message, classified),
1641
- });
1642
- // The steered rows failed with it — they were being answered by the same
1643
- // interaction. They get the same terminal record so each row says why.
1644
- await settleSteeredRows(turn, result, responseModel, classified);
1645
- return true;
1646
- }
1647
-
1648
- // The turn produced a plan but never called exit-plan-mode (or the runtime
1649
- // build has no such hook). Same text-shape fallback the siblings use, and
1650
- // it must go out BEFORE the response: `/api/relay-board` 409s once the
1651
- // queue row leaves `processing`.
1652
- if (shouldPostPlanBoard({
1653
- relayMode: message.relayMode,
1654
- finalText: ownText,
1655
- alreadyPosted: turn.planBoardPosted,
1656
- acted: turn.acted === true,
1657
- })) {
1658
- await publishPlanBoard(turn, ownText, 'plan-mode-fallback');
1659
- }
1660
-
1661
- // A terminal, non-error turn with no prose is COMPLETE, not a failed
1662
- // delivery — requeuing re-runs deterministically empty work until the
1663
- // retry cap fails the row with a misleading "Relay timeout".
1664
- const publishedText = ownText || EMPTY_TURN_COMPLETION_NOTE;
1665
- await publishFinalStream(message, publishedText);
1666
- await publishResponse(message, { text: publishedText, model: responseModel });
1667
- await settleSteeredRows(turn, result, responseModel);
1668
- return true;
1669
- }
1670
-
1671
3317
  // ----------------------------------------------------------- continuation --
1672
3318
 
1673
3319
  /**
@@ -1696,14 +3342,15 @@ export function createCopilotSdkSessionRunner({
1696
3342
  // The gap this pin covers has closed.
1697
3343
  continuationDueSince = 0;
1698
3344
  turn.armStall();
1699
- // Lines produced between turns (a settled shell's notification) belong to
1700
- // the turn they triggered.
3345
+ // Lines produced between turns (a settled shell's or background agent's
3346
+ // notification) belong to the turn they triggered.
1701
3347
  if (pendingActivities.length) {
1702
- for (const text of pendingActivities.splice(0)) {
1703
- turn.bufferedActions.push({ channel: 'activity', payload: { text, subagentRunId: null } });
3348
+ for (const { text, reportsFailure } of pendingActivities.splice(0)) {
3349
+ turn.bufferedActions.push({ channel: 'activity', payload: { text, subagentRunId: null }, carried: true, reportsFailure });
1704
3350
  }
1705
3351
  }
1706
3352
  dbg('opening a continuation turn for runtime-initiated work');
3353
+ syncDeliveryReadiness();
1707
3354
  // Both are fire-and-forget by design (the SDK's event callback is
1708
3355
  // synchronous and cannot await a turn), so both must swallow: an unhandled
1709
3356
  // rejection here would reach the worker crash guard and take the whole
@@ -1717,16 +3364,54 @@ export function createCopilotSdkSessionRunner({
1717
3364
  // has somewhere to go.
1718
3365
  turn.registered = true;
1719
3366
  turn.resolveRowReady?.(true);
3367
+ syncDeliveryReadiness();
1720
3368
  return;
1721
3369
  }
1722
3370
  abandonContinuationRegistration(turn, 'registration threw');
1723
3371
  });
1724
- driveContinuation(turn).catch((error) => {
3372
+ turn.drive = driveTurn(turn);
3373
+ turn.drive.catch((error) => {
1725
3374
  dbg('continuation driver threw', error?.message || String(error));
1726
3375
  });
1727
3376
  return turn;
1728
3377
  }
1729
3378
 
3379
+ /**
3380
+ * A pushed row whose run the runtime opened AFTER the turn it was pushed
3381
+ * into had settled (the drain's idle raced the send). It is not a
3382
+ * self-initiated turn — it is that row's own turn, and its reply belongs on
3383
+ * that row. Returns the new turn, or null when the id is not one of ours.
3384
+ */
3385
+ function adoptOrphanedRun(event) {
3386
+ if (String(event?.type || '') !== 'user.message' || event?.agentId) return null;
3387
+ const runtimeId = String(event?.data?.messageId || '').trim();
3388
+ if (!runtimeId) return null;
3389
+ for (const previous of publishingTurns) {
3390
+ const entry = previous.pushed.get(runtimeId);
3391
+ if (!entry || entry.consumed || entry.settled) continue;
3392
+ previous.entries = previous.entries.filter((other) => other !== entry);
3393
+ previous.pushed.delete(runtimeId);
3394
+ // From here the entry belongs to the new turn (`entry.turn`); the old
3395
+ // turn's settle passes skip rows that moved away.
3396
+ const turn = createTurn(entry.message, { kind: 'delivered', adoptEntry: entry });
3397
+ turn.model = resolvePerTurnModel(entry.message);
3398
+ activeTurn = turn;
3399
+ turn.armStall();
3400
+ turn.controlState = controlPoller?.start?.({
3401
+ queueMessageId: '',
3402
+ onAbortTurn: (control) => interruptTurn(stopTarget(turn, control), control),
3403
+ }) || null;
3404
+ dbg('a pushed prompt opened its own run after its turn settled; adopting it', entry.message.id);
3405
+ syncDeliveryReadiness();
3406
+ turn.drive = driveTurn(turn);
3407
+ turn.drive.catch((error) => {
3408
+ dbg('adopted turn driver threw', error?.message || String(error));
3409
+ });
3410
+ return turn;
3411
+ }
3412
+ return null;
3413
+ }
3414
+
1730
3415
  /**
1731
3416
  * Give up on a continuation's synthetic row: nothing buffered will ever
1732
3417
  * publish, and a registration still in flight must not adopt a row into this
@@ -1738,8 +3423,10 @@ export function createCopilotSdkSessionRunner({
1738
3423
  turn.discarded = true;
1739
3424
  turn.bufferedActions = [];
1740
3425
  turn.registrationAbort?.abort?.();
1741
- // Interactive handlers stop waiting and take their degraded path.
3426
+ // Interactive handlers stop waiting and take their degraded path, and the
3427
+ // delivery hold that waited for the row ends.
1742
3428
  turn.resolveRowReady?.(false);
3429
+ syncDeliveryReadiness();
1743
3430
  dbg('continuation registration abandoned:', reason);
1744
3431
  }
1745
3432
 
@@ -1813,16 +3500,14 @@ export function createCopilotSdkSessionRunner({
1813
3500
  // Only now is there a row to abort, so this is where the control poller can
1814
3501
  // start.
1815
3502
  turn.controlState = controlPoller?.start?.({
1816
- queueMessageId: turn.message.id,
1817
- onAbortTurn: async () => {
1818
- turn.aborted = true;
1819
- if (!session) return;
1820
- await session.abort?.();
1821
- },
3503
+ queueMessageId: '',
3504
+ onAbortTurn: (control) => interruptTurn(stopTarget(turn, control), control),
1822
3505
  }) || null;
1823
3506
  await flushBufferedActions(turn);
1824
3507
  turn.registered = true;
1825
3508
  turn.resolveRowReady?.(true);
3509
+ // The row exists: the delivery hold it imposed ends (canAcceptSteering).
3510
+ syncDeliveryReadiness();
1826
3511
  }
1827
3512
 
1828
3513
  /**
@@ -1846,184 +3531,347 @@ export function createCopilotSdkSessionRunner({
1846
3531
  }
1847
3532
  }
1848
3533
 
1849
- /**
1850
- * Run a continuation to its terminator and publish it, mirroring
1851
- * `handlePendingPayload`'s outer shape.
1852
- *
1853
- * Row ownership is released only after every publish has landed, exactly as
1854
- * for a delivered turn: a heartbeat firing inside the publish window with no
1855
- * active ids would tell the relay this worker owns nothing, and the
1856
- * still-`processing` synthetic row would be recovered underneath it. (The
1857
- * EVENT stream was already released at the terminator — see
1858
- * `beginPublishing` — so the runtime's next self-initiated turn can open
1859
- * while this one is still writing.)
1860
- */
1861
- async function driveContinuation(turn) {
1862
- let failure = null;
1863
- try {
1864
- await turn.done;
1865
- } catch (error) {
1866
- failure = error;
1867
- }
1868
- try {
1869
- await quiesceTurn(turn);
1870
- // Nothing can be published before the row exists. The buffer is drained
1871
- // by the registration itself; this only covers actions that arrived
1872
- // during the drain.
1873
- await awaitContinuationRow(turn);
1874
- // The invariant is the id, not the flag: registration can also give up by
1875
- // throwing, or by taking longer than `awaitContinuationRow` waits, and
1876
- // publishing against `messageId: null` would attribute the whole turn to
1877
- // nothing at all.
1878
- if (!turn.message.id) {
1879
- if (!turn.discarded) abandonContinuationRegistration(turn, 'no relay row at publish time');
1880
- dbg('continuation output dropped (no relay row)');
1881
- // The steering path is still waiting on any row it handed us, and it
1882
- // has no row of its own to fall back to.
1883
- await settleSteeredRows(turn, null, null, classifyCopilotTurnException(
1884
- failure || new Error('the relay refused a continuation row for this turn'),
1885
- )).catch(() => {});
1886
- return;
1887
- }
1888
- await flushBufferedActions(turn);
1889
- if (failure) {
1890
- const classified = classifyCopilotTurnException(failure);
1891
- dbg('continuation turn failed', turn.message.id, classified.detail);
1892
- await publishResponse(turn.message, {
1893
- text: classified.text,
1894
- model: null,
1895
- terminalError: terminalErrorRecord(turn.message, classified),
1896
- });
1897
- await settleSteeredRows(turn, null, null, classified).catch(() => {});
1898
- return;
1899
- }
1900
- await finishTurn(turn, '');
1901
- } catch (error) {
1902
- dbg('continuation publish failed', error?.message || String(error));
1903
- } finally {
1904
- controlPoller?.stop?.(turn.controlState);
1905
- turn.controlState = null;
1906
- releaseTurnOwnership(turn);
1907
- touch();
1908
- // A continuation spends real quota (the live capture burned a premium
1909
- // request on `read_bash` + the reply), so its numbers ride the same
1910
- // fire-and-forget ingest as a delivered turn's.
1911
- postTurnUsage();
1912
- }
1913
- }
3534
+ // --------------------------------------------------------------- steering --
1914
3535
 
1915
3536
  /**
1916
- * Settle every queue row that was steered into this interaction.
3537
+ * A delivery arrived while a turn was already running: push it into the
3538
+ * same runtime interaction as a steer.
1917
3539
  *
1918
- * The runtime answers the original prompt AND every prompt queued behind it
1919
- * under ONE `session.idle` (live-verified — see the plan doc §5), so no
1920
- * second turn will ever settle these rows. Each one is answered with the text
1921
- * of ITS OWN prompt segment, which the normalizer separates using the
1922
- * `user.message` events that mark where the runtime picked each prompt up.
3540
+ * `mode: "immediate"` makes it a real steer — injected at the next tool
3541
+ * boundary (`delivery:"steering"`, folded into the running reply), or run
3542
+ * as its own turn right after when the model is mid-stream
3543
+ * (`delivery:"queued"`). Either way the row is owned by this turn and
3544
+ * settled by it (see adoptSegment / finishTurn); nothing else will.
1923
3545
  *
1924
- * A row whose prompt the runtime accepted but never started work on has no
1925
- * segment. It is NOT requeued: the prompt is still sitting in the runtime's
1926
- * pending queue and will be answered at the start of the next turn, so
1927
- * redelivering it would run the same prompt twice. It gets a note instead —
1928
- * the same trade the Claude worker makes for a handed-off context.
1929
- */
1930
- async function settleSteeredRows(turn, result, responseModel, classified = null) {
1931
- if (!turn.steeredRows.length) return;
1932
- for (const steered of turn.steeredRows) {
1933
- const { message: steeredMessage, segmentIndex } = steered;
1934
- let text = String(turn.normalizer.segmentText(segmentIndex) || '').trim();
1935
- if (classified) {
1936
- text = classified.text;
1937
- } else if (!text) {
1938
- text = STEERED_ROW_MERGED_NOTE;
1939
- }
1940
- await publishFinalStream(steeredMessage, text);
1941
- await publishResponse(steeredMessage, {
1942
- text,
1943
- model: responseModel,
1944
- ...(classified ? { terminalError: terminalErrorRecord(steeredMessage, classified) } : {}),
1945
- });
1946
- steered.settle?.();
1947
- }
1948
- turn.steeredRows.length = 0;
1949
- }
1950
-
1951
- /**
1952
- * A delivery arrived while a turn was already running.
1953
- *
1954
- * The relay's worker socket is single-flight — it will not deliver a second
1955
- * row until `onDeliver` resolves — so this is not the normal path. It is
1956
- * reachable on a socket reconnect that redelivers, and it is the path a
1957
- * future relay change would take, so it steers rather than corrupting the
1958
- * turn: the prompt is queued into the SAME interaction (which is all
1959
- * `mode: "enqueue"` can do — it cannot interrupt an in-flight model call),
1960
- * and the row is adopted so the interaction's terminator settles it too.
1961
- *
1962
- * The row is registered BEFORE the send so a failure between the two cannot
1963
- * leave a row nobody owns.
3546
+ * The entry is registered BEFORE the send so a failure between the two
3547
+ * cannot leave a row nobody owns.
1964
3548
  */
1965
3549
  async function steerIntoActiveTurn(turn, message) {
1966
3550
  const { prompt: body, attachments } = buildMessageOptionsImpl(message);
1967
3551
  // A real relay round trip on the first turn of a mode — long enough for the
1968
3552
  // turn to finish underneath us.
1969
3553
  const context = await buildRelayContextPrefix(message).catch(() => null);
1970
- const prompt = withRelayContext(context?.prefix, body);
3554
+ // The hidden steer note (shared/steer-note.mjs): a steer that lands before
3555
+ // the model's first output otherwise tempts it to answer only the newest
3556
+ // message and drop the request it was working on (burn-in 2026-09-25).
3557
+ // Not in a continuation: there the running work is the runtime's own
3558
+ // follow-up, not a request of the user's to keep going with.
3559
+ const prompt = withRelayContext(context?.prefix, turn.kind === 'continuation' ? body : withSteerNote(body));
1971
3560
  // Re-checked AFTER the awaits and before anything is sent or registered.
1972
- // `settleSteeredRows` has already run if the turn settled during them, so a
1973
- // row pushed now would never be settled and its caller would wait forever —
1974
- // wedging the single-flight delivery socket. Nothing has been sent yet, so
1975
- // handing the row back to the normal path is free.
3561
+ // Nothing has been sent yet, so handing the row back to the normal path is
3562
+ // free.
1976
3563
  if (turn.settled) {
1977
3564
  dbg('turn settled while steering was preparing; running it as a fresh turn', message.id);
1978
3565
  return NOT_STEERED;
1979
3566
  }
1980
- // Prompts are consumed in the order they were sent, and each opens its own
1981
- // `user.message` segment as it is picked up, so this prompt's send position
1982
- // (after any prompts carried over from a previous interaction, and after
1983
- // whatever the interaction itself occupies) is its segment index.
1984
- const segmentIndex = turn.nextSegmentIndex;
1985
- turn.nextSegmentIndex += 1;
1986
- turn.promptsSent += 1;
1987
- const steered = { message, segmentIndex };
1988
- steered.done = new Promise((resolve) => { steered.settle = resolve; });
1989
- turn.steeredRows.push(steered);
1990
- dbg('steering a mid-turn delivery into the running turn', message.id, `segment=${segmentIndex}`);
3567
+ const entry = createEntry(turn, message, 'steer');
3568
+ dbg('steering a mid-turn delivery into the running turn', message.id);
3569
+ let runtimeId;
3570
+ // Counted until send() names the message (see `steerSendsInFlight`).
3571
+ steerSendsInFlight += 1;
3572
+ entry.sendSeq = ++steerSendSeq;
1991
3573
  try {
1992
- await session.send({
3574
+ runtimeId = await session.send({
1993
3575
  prompt,
1994
3576
  ...(attachments?.length ? { attachments } : {}),
1995
- mode: 'enqueue',
3577
+ mode: 'immediate',
1996
3578
  agentMode: copilotAgentModeForRelayMode(message?.relayMode || 'agent'),
1997
3579
  });
1998
3580
  } catch (error) {
1999
3581
  // The prompt never reached the runtime, so nothing will answer it and the
2000
3582
  // row is safe to requeue — unlike an accepted one.
2001
- turn.steeredRows = turn.steeredRows.filter((entry) => entry !== steered);
3583
+ turn.entries = turn.entries.filter((other) => other !== entry);
3584
+ entry.settled = true;
3585
+ entry.released = true;
3586
+ entry.resolveDone(true);
3587
+ steerSendsInFlight = Math.max(0, steerSendsInFlight - 1);
2002
3588
  dbg('steering send failed, requeuing the row', message.id, error?.message || String(error));
2003
3589
  await api('POST', '/api/requeue', { messageId: message.id, ...attemptFields(message) }).catch(() => {});
2004
3590
  return true;
2005
3591
  }
3592
+ assignRuntimeId(turn, entry, runtimeId);
3593
+ steerSendsInFlight = Math.max(0, steerSendsInFlight - 1);
2006
3594
  // Same commit-after-send rule as `runTurn` (audit #32): a steered prompt
2007
3595
  // whose send failed never delivered its guidance, so the retry must
2008
3596
  // include it again.
2009
3597
  context?.commit();
2010
- // Resolves when the interaction settles this row in `settleSteeredRows`.
2011
- await steered.done;
3598
+ scheduleQueueLaneRefresh();
3599
+ // Resolves when the interaction settles this row.
3600
+ await awaitEntry(turn, entry);
3601
+ return true;
3602
+ }
3603
+
3604
+ // ------------------------------------------------------------------ gate --
3605
+
3606
+ /**
3607
+ * Whether a message delivered now would steer into the live turn. False
3608
+ * while: no session / no live turn; the turn-opening prompt has not been
3609
+ * sent yet, or a continuation's row does not exist yet (the delivery hold —
3610
+ * a second send before the first would invert the runs, and a steer's run
3611
+ * would settle the continuation's reply against no row while its buffered
3612
+ * output flushed into the steered one); a question card, ask-mode approval
3613
+ * or elicitation is open (the relay must not push past a card); a
3614
+ * compaction is running.
3615
+ */
3616
+ function canAcceptSteering() {
3617
+ if (disposed || !session) return false;
3618
+ const turn = activeTurn;
3619
+ if (!turn || turn.settled || turn.aborted) return false;
3620
+ if (!turn.primarySent || awaitingRow(turn)) return false;
3621
+ if (pendingHumanRequests > 0) return false;
3622
+ if (isCompacting()) return false;
3623
+ return true;
3624
+ }
3625
+
3626
+ /** A continuation whose synthetic row is still being minted (and not given up on). */
3627
+ function awaitingRow(turn) {
3628
+ return turn.kind === 'continuation' && !turn.registered && !turn.discarded;
3629
+ }
3630
+
3631
+ /**
3632
+ * The one steering gate. A delivery arriving now is held — handed back to
3633
+ * the queue instead of pushed — when a turn is live (or a human request /
3634
+ * compaction is open) and it cannot accept a steer. The socket link consults
3635
+ * this for its readiness too, idle or mid-delivery.
3636
+ */
3637
+ function isDeliveryHeld() {
3638
+ if (disposed) return false;
3639
+ const turn = activeTurn;
3640
+ const live = Boolean(turn && !turn.settled);
3641
+ if (!live && pendingHumanRequests === 0 && !isCompacting()) return false;
3642
+ return !canAcceptSteering();
3643
+ }
3644
+
3645
+ function syncDeliveryReadiness() {
3646
+ const ready = !isDeliveryHeld();
3647
+ if (ready === lastReportedDeliveryReady) return;
3648
+ lastReportedDeliveryReady = ready;
3649
+ try {
3650
+ onDeliveryReadinessChange(ready);
3651
+ } catch (error) {
3652
+ dbg('delivery readiness listener failed', error?.message || String(error));
3653
+ }
3654
+ }
3655
+
3656
+ /**
3657
+ * Give a held delivery back to the queue with no retry penalty. The relay
3658
+ * re-delivers it once this worker signals ready again, i.e. when the hold
3659
+ * ends — the message then steers into the resumed turn. Pushing it into the
3660
+ * hold instead would bypass the question card or land inside a compaction.
3661
+ */
3662
+ async function handBackHeldDelivery(message) {
3663
+ dbg('steering held; handing the delivery back to the queue', message.id || '(no id)');
3664
+ if (!message.id) return false;
3665
+ await api('POST', '/api/requeue', {
3666
+ messageId: message.id,
3667
+ class: 'steering-held',
3668
+ ...attemptFields(message),
3669
+ }).catch((error) => {
3670
+ if (isStaleAttemptError(error)) {
3671
+ dbg('steering-held hand-back refused as stale_attempt', message.id);
3672
+ return;
3673
+ }
3674
+ dbg('steering-held hand-back failed', message.id, error?.message || String(error));
3675
+ });
3676
+ return false;
3677
+ }
3678
+
3679
+ function* ownedEntries() {
3680
+ if (activeTurn) yield* activeTurn.entries;
3681
+ for (const turn of publishingTurns) yield* turn.entries;
3682
+ }
3683
+
3684
+ /**
3685
+ * Pushed rows the client may still cancel: unconsumed and recallable — in
3686
+ * practice the newest waiting steer (immediate sends wait in the runtime's
3687
+ * id-less steering lane), or any row that sits in the queued lane.
3688
+ */
3689
+ function cancellableIds() {
3690
+ const ids = [];
3691
+ for (const entry of unconsumedSteers()) {
3692
+ if (!recallMethodFor(entry)) continue;
3693
+ const id = String(entry.message?.id || '').trim();
3694
+ if (id && !ids.includes(id)) ids.push(id);
3695
+ }
3696
+ return ids;
3697
+ }
3698
+
3699
+ /**
3700
+ * Composer-facing steering snapshot, published on the heartbeat: whether a
3701
+ * turn is live, whether a message typed now would steer into it, and — when
3702
+ * it would not — why. `supported` is how the client learns this Copilot
3703
+ * conversation steers at all (its provider type alone cannot tell an SDK
3704
+ * worker from the extension engine); `cancellableIds` are the pushed rows
3705
+ * that may still be pulled back out of the runtime (decision 8).
3706
+ */
3707
+ function steeringState() {
3708
+ const turn = activeTurn;
3709
+ const turnActive = Boolean(turn && !turn.settled);
3710
+ const canSteer = canAcceptSteering();
3711
+ let holdReason = null;
3712
+ if (turnActive && !canSteer) {
3713
+ if (pendingHumanRequests > 0) holdReason = 'question';
3714
+ else if (isCompacting()) holdReason = 'compaction';
3715
+ else if (!turn.primarySent || awaitingRow(turn)) holdReason = 'delivery';
3716
+ else holdReason = 'other';
3717
+ }
3718
+ const messageId = String(turn?.currentOwner?.message?.id || turn?.message?.id || '').trim() || null;
3719
+ return { turnActive, canSteer, holdReason, messageId, supported: true, cancellableIds: cancellableIds() };
3720
+ }
3721
+
3722
+ /**
3723
+ * Re-read the queue, and — only if the prompt is still recallable at that
3724
+ * instant — issue its remove request in the same tick as the check, so no
3725
+ * steer send can be issued in between (JSON-RPC keeps request order).
3726
+ */
3727
+ async function recallPushedPrompt(entry, id) {
3728
+ // Watched from before the read, so a fold that lands during the read or
3729
+ // the remove is seen however far behind the dispatch chain runs.
3730
+ const watch = { taken: false };
3731
+ recallWatches.set(entry.runtimeId, watch);
3732
+ try {
3733
+ // Only the READ is bounded: a runtime that will not answer it gets no
3734
+ // remove at all. Once a remove is sent it is seen through to its answer —
3735
+ // giving up on it could leave the prompt removed and the row uncancelled.
3736
+ const fresh = await withTimeout(refreshQueueLane().then(() => true), UNSTEER_TIMEOUT_MS, false);
3737
+ if (!fresh) {
3738
+ dbg('un-steer refused: the runtime did not answer the queue read in time', id);
3739
+ return false;
3740
+ }
3741
+ const target = session;
3742
+ const method = watch.taken ? null : recallMethodFor(entry);
3743
+ if (!target || !method) {
3744
+ dbg('un-steer refused: the runtime no longer holds this prompt as recallable', id);
3745
+ return false;
3746
+ }
3747
+ const request = method.kind === 'queued'
3748
+ ? target.rpc.queue.removeAt({ id: method.itemId })
3749
+ : target.rpc.queue.removeMostRecent();
3750
+ const removed = (await request)?.removed === true;
3751
+ if (removed && (watch.taken || entry.consumed || entry.settled)) {
3752
+ // The runtime took this prompt before it processed the remove, so
3753
+ // what was removed is some other waiting message — another client's,
3754
+ // never a runtime notification (see `recallWatches`). It cannot be put
3755
+ // back; this row settles through its normal path.
3756
+ dbg('UN-STEER MISMATCH: the runtime took the prompt before the remove; the remove took another message', id, method.kind);
3757
+ return false;
3758
+ }
3759
+ return removed;
3760
+ } finally {
3761
+ if (recallWatches.get(entry.runtimeId) === watch) recallWatches.delete(entry.runtimeId);
3762
+ }
3763
+ }
3764
+
3765
+ function withTimeout(promise, ms, fallback) {
3766
+ let timer = null;
3767
+ return Promise.race([
3768
+ promise,
3769
+ new Promise((resolve) => { timer = setTimeout(() => resolve(fallback), ms); timer.unref?.(); }),
3770
+ ]).finally(() => clearTimeout(timer));
3771
+ }
3772
+
3773
+ /**
3774
+ * Un-steer (decision 8, narrowed 2026-09-25 after the live check): pull a
3775
+ * pushed-but-unconsumed row back out of the runtime and cancel it. Immediate
3776
+ * sends wait in the runtime's id-less steering lane, so only the NEWEST
3777
+ * waiting one is recallable (`queue.removeMostRecent`); a row in the queued
3778
+ * lane goes by its stable id. A prompt the runtime already took cannot be
3779
+ * recalled; the control is then a no-op and the row settles normally.
3780
+ */
3781
+ async function cancelPushedMessage(messageId) {
3782
+ const id = String(messageId || '').trim();
3783
+ if (!id) return false;
3784
+ let entry = null;
3785
+ for (const candidate of ownedEntries()) {
3786
+ if (String(candidate.message?.id || '') === id) { entry = candidate; break; }
3787
+ }
3788
+ if (!entry || entry.consumed || entry.settled || !entry.runtimeId) {
3789
+ dbg('un-steer refused: row not cancellable', id, entry ? `consumed=${entry.consumed} settled=${entry.settled}` : 'unknown row');
3790
+ return false;
3791
+ }
3792
+ if (!session || !sessionRpc.queue) return false;
3793
+ // One un-steer at a time; each re-reads the queue (that read is bounded —
3794
+ // see recallPushedPrompt). Steer sends never wait on this chain.
3795
+ const run = unsteerChain.then(() => recallPushedPrompt(entry, id));
3796
+ unsteerChain = run.catch(() => false);
3797
+ const removed = await run.catch((error) => {
3798
+ dbg('un-steer failed', id, error?.message || String(error));
3799
+ return false;
3800
+ });
3801
+ if (!removed) return false;
3802
+ // Settled meanwhile by its turn's end, or taken since the answer (the
3803
+ // remove then took another message): it keeps its normal outcome.
3804
+ if (entry.consumed || entry.settled) {
3805
+ dbg('un-steer raced the runtime taking the prompt or the row settling; leaving it to settle normally', id);
3806
+ return false;
3807
+ }
3808
+ entry.cancelled = true;
3809
+ entry.settled = true;
3810
+ while (recalledRuntimeIds.size >= MAX_RECALLED_RUNTIME_IDS) recalledRuntimeIds.delete(recalledRuntimeIds.keys().next().value);
3811
+ recalledRuntimeIds.set(entry.runtimeId, id);
3812
+ dbg('un-steered a pushed prompt', id);
3813
+ // The prompt is gone from the runtime; the row must end cancelled and
3814
+ // never be re-delivered. It stays owned (settling, with the terminal
3815
+ // wording the crash guard would send) until the relay acknowledges the
3816
+ // cancel; if the relay never does, the heartbeat hands it over to fail.
3817
+ claimSettling(entry.message, 'cancelled');
3818
+ const cancelBody = {
3819
+ conversationId: entry.message.conversationId,
3820
+ messageId: id,
3821
+ ...attemptFields(entry.message),
3822
+ };
3823
+ let acknowledged = false;
3824
+ for (let attempt = 0; ; attempt += 1) {
3825
+ acknowledged = await api('POST', '/api/queue-cancelled', cancelBody).then(() => true, (error) => {
3826
+ dbg('queue-cancelled publish failed', id, error?.message || String(error));
3827
+ // The row already left `processing` (answered, recovered, or already
3828
+ // cancelled): nothing left for this worker to do.
3829
+ return Number(error?.status) === 404 || Number(error?.status) === 409;
3830
+ });
3831
+ if (acknowledged || attempt >= settleRetryDelaysMs.length) break;
3832
+ await sleep(settleRetryDelaysMs[attempt]);
3833
+ }
3834
+ if (!acknowledged) {
3835
+ dbg('queue-cancelled could not be posted; handing the row to the heartbeat', id);
3836
+ settleFailedTerminals.set(id, buildSteerSettleFailure(entry.message, { variant: 'cancelled', agentLabel: SETTLE_AGENT_LABEL }));
3837
+ }
3838
+ releaseSettling(id);
3839
+ entry.released = true;
3840
+ entry.resolveDone(true);
3841
+ const turn = entry.turn;
3842
+ if (turn) {
3843
+ turn.entries = turn.entries.filter((other) => other !== entry);
3844
+ turn.pushed.delete(entry.runtimeId);
3845
+ }
3846
+ scheduleQueueLaneRefresh();
2012
3847
  return true;
2013
3848
  }
2014
3849
 
2015
3850
  async function handlePendingPayload(pending) {
2016
3851
  const message = pending?.message || null;
2017
3852
  if (!message) return false;
3853
+ // Held (a card is open, a compaction is running, the turn-opening prompt
3854
+ // is not sent yet): hand the row back with no retry penalty; the relay
3855
+ // re-delivers it when the hold ends. An older relay (the worker updated on
3856
+ // disk before the relay restarted) treats the hand-back as a failed
3857
+ // delivery — retry, backoff, worker marked errored — so against one the
3858
+ // message is pushed the legacy way.
3859
+ if (isDeliveryHeld() && canHandBackHeldDelivery() !== false) return handBackHeldDelivery(message);
3860
+ // Legacy path only: never send a second prompt before the turn-opening one
3861
+ // (it would invert the runs), and never start a second turn beside one
3862
+ // that is still connecting.
3863
+ if (activeTurn && !activeTurn.settled && !activeTurn.primarySent) {
3864
+ await activeTurn.primaryReady;
3865
+ }
3866
+ // Nor steer into a continuation before its row exists (canAcceptSteering);
3867
+ // bounded like the registration itself, which can hang on a dead relay.
3868
+ if (activeTurn && !activeTurn.settled && awaitingRow(activeTurn)) {
3869
+ await withTimeout(activeTurn.rowReady, continuationRegistrationTimeoutMs, false);
3870
+ }
2018
3871
  // A delivery that lands while a turn is running is steered into it rather
2019
3872
  // than starting a second one: the runtime has a single conversation and a
2020
3873
  // concurrent `send` would interleave into the same interaction anyway —
2021
3874
  // this way the row is owned and settled instead of orphaned.
2022
- //
2023
- // Deliberately OUTSIDE the try/finally below: that `finally` clears
2024
- // `activeTurn`, and a steered call returns while the turn it was steered
2025
- // into is still publishing. Clearing there would tell the heartbeat this
2026
- // worker owns nothing and the relay would recover the live row.
2027
3875
  if (activeTurn && !activeTurn.settled && session) {
2028
3876
  const turn = activeTurn;
2029
3877
  try {
@@ -2040,75 +3888,30 @@ export function createCopilotSdkSessionRunner({
2040
3888
  return true;
2041
3889
  }
2042
3890
  }
2043
- // Created here rather than inside `runTurn` so the catch and finally below
2044
- // act on THIS turn by identity: under split ownership, `activeTurn` may
2045
- // already belong to a newer turn (a continuation the runtime opened while
2046
- // this one was publishing) by the time they run.
2047
3891
  const turn = createTurn(message);
2048
- try {
2049
- return await runTurn(turn);
2050
- } catch (error) {
2051
- const classified = classifyCopilotTurnException(error);
2052
- dbg('copilot turn failed', message.id, classified.detail);
2053
- // A failure that killed the session (or came from starting it) leaves a
2054
- // handle nothing else can use; drop it so the next delivery rebuilds and
2055
- // resumes rather than sending into a dead runtime. An UNCONFIRMED MODEL
2056
- // SWITCH is the exception: the session is healthy and merely still on
2057
- // its previous model, and tearing the runtime down over it would kill
2058
- // any live detached shells for a selection problem the user fixes by
2059
- // picking another model.
2060
- if (!isModelSwitchUnconfirmedError(error)) {
2061
- await stopRuntime('turn-failure').catch(() => {});
2062
- }
2063
- await publishResponse(message, {
2064
- text: classified.text,
2065
- model: null,
2066
- terminalError: terminalErrorRecord(message, classified),
2067
- });
2068
- // Rows steered into the turn that just threw are owed a response too —
2069
- // and, more urgently, `steerIntoActiveTurn` is still awaiting their
2070
- // settle. Skipping this would wedge that caller (and its queue row)
2071
- // forever behind a heartbeat that keeps renewing the lease.
2072
- await settleSteeredRows(turn, null, null, classified).catch(() => {});
2073
- await closeStraySubagentRuns(turn).catch(() => {});
2074
- return true;
2075
- } finally {
2076
- // Released only once every publish for this row has landed. A heartbeat
2077
- // that fired during the publish window with the row unowned would tell
2078
- // the relay this worker owns nothing, and the still-processing row would
2079
- // be recovered (`owner-heartbeat-idle`) and re-delivered — a duplicate
2080
- // execution racing the response that was already on its way.
2081
- releaseTurnOwnership(turn);
2082
- touch();
2083
- // Every path through the turn — published, failed, aborted, threw — has
2084
- // finished by here, which is the only safe place for the ingest: it is
2085
- // advisory, it is not awaited, and it must never be able to delay a reply
2086
- // that is already written.
2087
- postTurnUsage();
2088
- }
3892
+ return runTurn(turn);
2089
3893
  }
2090
3894
 
2091
3895
  // --------------------------------------------------------------- teardown --
2092
3896
 
2093
3897
  function getActiveQueueMessageId() {
2094
- if (activeTurn) return String(activeTurn.message?.id || '');
2095
- // A settled turn still publishing owns its row every bit as much: the
2096
- // heartbeat's owner-recovery guard must keep seeing it until the publish
2097
- // lands, or the relay recovers the row underneath the response in flight.
2098
- for (const turn of publishingTurns) {
2099
- const id = String(turn.message?.id || '');
2100
- if (id) return id;
3898
+ const live = activeTurn?.currentOwner;
3899
+ if (live && !live.released && live.message?.id) return String(live.message.id);
3900
+ for (const entry of ownedEntries()) {
3901
+ const id = String(entry.message?.id || '');
3902
+ if (id && !entry.released) return id;
2101
3903
  }
2102
3904
  return '';
2103
3905
  }
2104
3906
 
2105
3907
  /**
2106
- * Every row this worker owns — the running turn's own plus any steered into
2107
- * it, and the same for every settled turn still publishing — as
2108
- * `{ id, attemptId }` entries. A steered row missing from it would be
2109
- * recovered mid-flight as `owner-heartbeat-mismatch` and re-delivered while
2110
- * the runtime was still answering it; a publishing row missing from it would
2111
- * be recovered as `owner-heartbeat-idle` while its response was on the wire.
3908
+ * Every row this worker owns — the running turn's rows and the same for
3909
+ * every settled turn still publishing — as `{ id, attemptId }` entries; a
3910
+ * consumed row whose settle marker is still being saved carries the
3911
+ * terminal failure the crash guard must send instead of a requeue, because
3912
+ * its prompt must never run twice. A row missing from this list would be
3913
+ * recovered mid-flight and re-delivered while the runtime was still
3914
+ * answering it.
2112
3915
  *
2113
3916
  * Both the heartbeat (lease renewal) and the crash guard (requeue-on-exit)
2114
3917
  * read this: the crash guard takes the entries whole so its requeues stay
@@ -2117,24 +3920,36 @@ export function createCopilotSdkSessionRunner({
2117
3920
  */
2118
3921
  function getActiveQueueMessageIds() {
2119
3922
  const entries = [];
2120
- const push = (message) => {
2121
- const id = String(message?.id || '');
2122
- if (!id || entries.some((entry) => entry.id === id)) return;
2123
- entries.push({ id, attemptId: message?.attemptId || null });
2124
- };
2125
- const collect = (turn) => {
2126
- if (!turn) return;
2127
- push(turn.message);
2128
- for (const steered of turn.steeredRows || []) push(steered.message);
3923
+ const byId = new Map();
3924
+ const push = (message, terminalError = null) => {
3925
+ const id = String(message?.id || '').trim();
3926
+ if (!id) return;
3927
+ const existing = byId.get(id);
3928
+ if (existing) {
3929
+ if (terminalError && !existing.terminalError) existing.terminalError = terminalError;
3930
+ return;
3931
+ }
3932
+ const entry = { id, attemptId: message?.attemptId || null, ...(terminalError ? { terminalError } : {}) };
3933
+ byId.set(id, entry);
3934
+ entries.push(entry);
2129
3935
  };
2130
- collect(activeTurn);
2131
- for (const turn of publishingTurns) collect(turn);
3936
+ for (const entry of ownedEntries()) {
3937
+ if (entry.released) continue;
3938
+ push(entry.message);
3939
+ }
3940
+ for (const { message, variant } of settlingMessages.values()) {
3941
+ push(message, buildSteerSettleFailure(message, { variant, agentLabel: SETTLE_AGENT_LABEL }));
3942
+ }
2132
3943
  return entries;
2133
3944
  }
2134
3945
 
2135
3946
  async function dispose() {
2136
3947
  disposed = true;
2137
3948
  stopLifecycleTimer();
3949
+ if (queueLaneTimer) {
3950
+ clearTimeout(queueLaneTimer);
3951
+ queueLaneTimer = null;
3952
+ }
2138
3953
  // A question card left `pending` would sit in the UI inviting an answer
2139
3954
  // that nothing is left to read. Time them out before the socket goes.
2140
3955
  await questionBridge.cancelPendingQuestions?.().catch?.(() => {});
@@ -2145,6 +3960,18 @@ export function createCopilotSdkSessionRunner({
2145
3960
  handlePendingPayload,
2146
3961
  getActiveQueueMessageId,
2147
3962
  getActiveQueueMessageIds,
3963
+ // The delivery gate (the socket link's probes) and the composer snapshot
3964
+ // (the heartbeat's).
3965
+ canAcceptSteering,
3966
+ isDeliveryHeld,
3967
+ steeringState,
3968
+ getSettleFailed,
3969
+ acknowledgeSettleFailed,
3970
+ cancelPushedMessage,
3971
+ // The task panel's per-card Stop (`rpc.tasks.cancel`); also what a
3972
+ // subagent lane's Stop tries — the runtime tracks sync agents in the same
3973
+ // registry and refuses ids it does not know.
3974
+ stopBackgroundTask,
2148
3975
  // "Active" spans both ownerships: a settled turn still publishing must
2149
3976
  // keep the worker's idle/shutdown gates closed just like a running one.
2150
3977
  isTurnActive: () => !!activeTurn || publishingTurns.size > 0,
@@ -2157,6 +3984,14 @@ export function createCopilotSdkSessionRunner({
2157
3984
  whenUsagePosted: () => usagePostChain,
2158
3985
  // The in-flight model-catalog snapshot POST — the same kind of seam.
2159
3986
  whenModelSnapshotPosted: () => modelSnapshotChain,
3987
+ // The in-flight queued-lane read — same kind of seam.
3988
+ whenQueueLaneRefreshed: () => queueLaneRefreshChain,
3989
+ // The in-flight `rpc.tasks.list()` re-read — same kind of seam. A refresh
3990
+ // that is still debounced counts too, so a caller sees the registry read
3991
+ // that the last event scheduled.
3992
+ whenTasksRefreshed: () => (taskRefreshTimer
3993
+ ? new Promise((resolve) => { setTimeout(resolve, taskRefreshMs + 5); }).then(() => taskRefreshChain)
3994
+ : taskRefreshChain),
2160
3995
  // Test seams / observability.
2161
3996
  _getState: () => ({
2162
3997
  hasClient: !!client,
@@ -2171,7 +4006,23 @@ export function createCopilotSdkSessionRunner({
2171
4006
  // Settled turns whose relay publishes have not all landed yet.
2172
4007
  publishingTurnCount: publishingTurns.size,
2173
4008
  backgroundShells: backgroundShells.live(),
4009
+ backgroundTasks: sessionRpc.tasks ? registryCards() : shellTrackerCards(),
2174
4010
  continuationDueSince,
4011
+ compacting: isCompacting(),
4012
+ pendingHumanRequests,
4013
+ pendingRelayToolCalls,
4014
+ sessionRpc: { ...sessionRpc },
4015
+ activeEntries: activeTurn
4016
+ ? activeTurn.entries.map((entry) => ({
4017
+ id: entry.message?.id || null,
4018
+ role: entry.role,
4019
+ runtimeId: entry.runtimeId,
4020
+ consumed: entry.consumed,
4021
+ delivery: entry.delivery,
4022
+ settled: entry.settled,
4023
+ foldedInto: entry.foldedInto?.message?.id || null,
4024
+ }))
4025
+ : [],
2175
4026
  }),
2176
4027
  _evaluateLifecycle: evaluateLifecycle,
2177
4028
  };