@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
@@ -15,6 +15,14 @@ import {
15
15
  persistConversationModelPreference as persistConversationModelPreferenceTx,
16
16
  } from '../services/conversation-preferences-service.mjs';
17
17
  import { killTmuxSession } from '../services/session-worker-launch-service.mjs';
18
+ import { normalizeWorkerSteeringSnapshot } from '../services/session-worker-registry-service.mjs';
19
+ import {
20
+ buildSteerSettleFailure,
21
+ steerAgentLabelForProvider,
22
+ STEER_SETTLE_FAILED_CODE,
23
+ STEER_SETTLE_FAILED_STABLE_CODE,
24
+ } from '../../shared/steer-settle-failure.mjs';
25
+ import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
18
26
  import { stopSessionWorkerProcesses } from '../services/session-worker-stop-service.mjs';
19
27
  import {
20
28
  fetchUsageSummaryPromise,
@@ -29,7 +37,17 @@ import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mj
29
37
  import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning-effort.mjs';
30
38
  import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
31
39
  import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
40
+ import { STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
41
+ import { isLiveResend } from '../services/steer-resend.mjs';
32
42
  import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
43
+ import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
44
+ import {
45
+ admitRemoteRelayRequest,
46
+ findMentionedRemoteRelays,
47
+ formatRemoteRelayMentionHint,
48
+ recordRemoteRelayUnlocks,
49
+ withRemoteRelayMentionHint,
50
+ } from '../services/remote-relay-inbound.mjs';
33
51
 
34
52
  export const SESSION_WORKER_OWNER_LEASE_MS = 120_000;
35
53
  export const SESSION_WORKER_TRANSIENT_DEQUEUE_RETRIES = 2;
@@ -177,12 +195,20 @@ function findRecentDuplicateUserMessage({
177
195
  text = '',
178
196
  now = Date.now(),
179
197
  windowMs = DUPLICATE_USER_MESSAGE_WINDOW_MS,
198
+ // A message the user deliberately sends again (Resend of a steer a Stop
199
+ // cut off) is not a duplicate of its own original, nor of an earlier Resend
200
+ // of it that was cancelled or failed.
201
+ exemptMessageIds = [],
180
202
  } = {}) {
181
203
  const fingerprint = normalizeDuplicateMessageText(text);
182
204
  if (!fingerprint) return null;
183
205
  const maxAgeMs = Math.max(60_000, Number(windowMs) || DUPLICATE_USER_MESSAGE_WINDOW_MS);
206
+ const exempt = new Set((Array.isArray(exemptMessageIds) ? exemptMessageIds : [])
207
+ .map((id) => String(id || '').trim()).filter(Boolean));
208
+ const isExempt = (row) => exempt.has(String(row?.id || '').trim());
184
209
 
185
210
  for (const row of Array.isArray(recentUserMessages) ? recentUserMessages : []) {
211
+ if (isExempt(row)) continue;
186
212
  const rowText = normalizeDuplicateMessageText(row?.text || '');
187
213
  if (!rowText || rowText !== fingerprint) continue;
188
214
  const rowTime = Date.parse(row?.timestamp || '');
@@ -193,6 +219,7 @@ function findRecentDuplicateUserMessage({
193
219
  }
194
220
 
195
221
  for (const row of Array.isArray(recentQueueRows) ? recentQueueRows : []) {
222
+ if (isExempt(row)) continue;
196
223
  const rowText = normalizeDuplicateMessageText(row?.text || '');
197
224
  if (!rowText || rowText !== fingerprint) continue;
198
225
  const rowTime = Date.parse(row?.timestamp || '');
@@ -1757,10 +1784,15 @@ export function registerMessagesRoutes(app, deps) {
1757
1784
  sanitizeActivityText,
1758
1785
  readSessionTranscriptMessages,
1759
1786
  inFlightStateForConversation,
1787
+ // Resolves the live turn among a conversation's processing rows (mid-turn
1788
+ // steering can leave more than one). Falls back to the newest-processing
1789
+ // query for older wiring/tests that do not supply it.
1790
+ resolveLiveTurnQueueRow = (conversationId) => stmts.getLatestProcessingQueueByConversation.get(conversationId) || null,
1760
1791
  emitToClientsExceptSessionId,
1761
1792
  relayBridgeOwnerService,
1762
1793
  relayRestartOrchestrator,
1763
1794
  requestRelayShutdown,
1795
+ cancelRelayShutdown = null,
1764
1796
  featureFlags,
1765
1797
  sessionWorkerRegistry,
1766
1798
  sessionWorkerSupervisor,
@@ -1778,6 +1810,10 @@ export function registerMessagesRoutes(app, deps) {
1778
1810
  opaqueResponseRecoveryPollMs = OPAQUE_RESPONSE_RECOVERY_POLL_MS,
1779
1811
  relayQuestionFinalizationHoldMs = RELAY_QUESTION_FINALIZATION_HOLD_MS,
1780
1812
  imageOperationService = null,
1813
+ // Remote relays (server/services/remote-relay-inbound.mjs): origin on
1814
+ // agent-sent messages, the inbound switch, and mention unlocks. Absent in
1815
+ // older wiring and most tests, where every remote-relay step is a no-op.
1816
+ remoteRelayInbound = null,
1781
1817
  } = deps;
1782
1818
 
1783
1819
  const ttyConsoleActive = runtimeState?.ttyConsoleActive === true;
@@ -1929,6 +1965,16 @@ export function registerMessagesRoutes(app, deps) {
1929
1965
  };
1930
1966
  }
1931
1967
 
1968
+ // The stored preferences, for a send that must leave them alone.
1969
+ function readConversationModelPreference(conversationId, fallbackRelayMode = DEFAULT_RELAY_MODE) {
1970
+ const row = stmts.getConvAnyStatus?.get?.(String(conversationId || '').trim()) || null;
1971
+ return {
1972
+ preferredRelayMode: normalizeRelayMode(row?.preferred_relay_mode) || fallbackRelayMode || DEFAULT_RELAY_MODE,
1973
+ preferredModel: String(row?.preferred_model || '').trim(),
1974
+ preferredReasoningEffort: String(row?.preferred_reasoning_effort || '').trim(),
1975
+ };
1976
+ }
1977
+
1932
1978
  function getConversationSessionState(conversationId) {
1933
1979
  const normalizedConversationId = String(conversationId || '').trim();
1934
1980
  if (!normalizedConversationId) {
@@ -1988,6 +2034,8 @@ export function registerMessagesRoutes(app, deps) {
1988
2034
  markWorkerError = true,
1989
2035
  executedProvider = null,
1990
2036
  attemptId = null,
2037
+ // Extra writes that must commit only with the (fenced) failure itself.
2038
+ withinTransaction = null,
1991
2039
  }) {
1992
2040
  const now = new Date().toISOString();
1993
2041
  const responseId = uuidv4();
@@ -2003,6 +2051,7 @@ export function registerMessagesRoutes(app, deps) {
2003
2051
  ? stmts.setFailedFenced.run(JSON.stringify(failureRecord), messageId, fencedAttemptId)
2004
2052
  : stmts.setFailed.run(JSON.stringify(failureRecord), messageId);
2005
2053
  if (result.changes === 0) return false;
2054
+ if (typeof withinTransaction === 'function') withinTransaction(now);
2006
2055
  stmts.setQueueResponseMessageId?.run(responseId, messageId);
2007
2056
  stmts.insertMsg.run(
2008
2057
  responseId,
@@ -2017,6 +2066,7 @@ export function registerMessagesRoutes(app, deps) {
2017
2066
  modelActual,
2018
2067
  modelOrigin,
2019
2068
  );
2069
+ stmts.setMessageSourceId?.run(messageId, responseId);
2020
2070
  // Provenance travels with terminal failures too — the original hijack
2021
2071
  // incident's signature was stolen turns dying on 402, which exited
2022
2072
  // exactly here with no executed_provider recorded.
@@ -2054,7 +2104,7 @@ export function registerMessagesRoutes(app, deps) {
2054
2104
  },
2055
2105
  });
2056
2106
  io.emit('message_status', { messageId, conversationId, status: 'failed' });
2057
- void pushDispatchService?.notifyTurnFailed?.({ conversationId, messageId: responseId, text: responseText });
2107
+ void pushDispatchService?.notifyTurnFailed?.({ conversationId, messageId: responseId, sourceMessageId: messageId, text: responseText });
2058
2108
  cancelPendingRelayQuestionsForMessage(messageId);
2059
2109
  const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)
2060
2110
  ? normalizeSessionWorkerId(queueRow?.owner_sdk_session_id)
@@ -2116,6 +2166,66 @@ export function registerMessagesRoutes(app, deps) {
2116
2166
  // pending — an answer may still be on its way in. A row with NO current
2117
2167
  // attempt (finalized by a legacy responder after a requeue cleared it) makes
2118
2168
  // every attempt-stamped card stale: the empty string matches none of them.
2169
+ // The name the steer-settle wording uses for the runtime that took the
2170
+ // prompt, from the conversation's provider type (the shared mapping the
2171
+ // server runtime's recovery path uses too).
2172
+ function steerSettleAgentLabelForConversation(conversationId) {
2173
+ const id = String(conversationId || '').trim();
2174
+ if (!id) return undefined;
2175
+ return steerAgentLabelForProvider(stmts.getRuntimeSessionByConversation?.get?.(id)?.provider_type);
2176
+ }
2177
+
2178
+ /**
2179
+ * Terminal failure for a row whose prompt the worker's runtime already
2180
+ * consumed — a folded or stopped steer whose settle never landed.
2181
+ * At-most-once: every path that would requeue such a row comes here instead,
2182
+ * since re-delivery would run the prompt a second time. `terminalError` lets
2183
+ * a worker supply the path-accurate wording; the default is the fold wording.
2184
+ */
2185
+ function failConsumedQueueRow(queueRow, { reason = 'recovery', terminalError = null, attemptId = null } = {}) {
2186
+ const agentLabel = steerSettleAgentLabelForConversation(queueRow?.conversation_id);
2187
+ const failure = terminalError && typeof terminalError === 'object'
2188
+ ? { ...buildSteerSettleFailure(queueRow, { agentLabel }), ...terminalError }
2189
+ : buildSteerSettleFailure(queueRow, { agentLabel });
2190
+ const failed = failQueueMessage({
2191
+ queueRow,
2192
+ messageId: queueRow.id,
2193
+ conversationId: queueRow.conversation_id,
2194
+ relayMode: normalizeRelayMode(queueRow.relay_mode) || DEFAULT_RELAY_MODE,
2195
+ model: queueRow.model || null,
2196
+ responseText: buildTerminalFailureTextForChat(failure),
2197
+ failureRecord: {
2198
+ ...failure,
2199
+ kind: 'terminal',
2200
+ error: 'terminal-error',
2201
+ reason: String(reason || 'recovery'),
2202
+ retryCount: Number(queueRow?.retry_count || 0),
2203
+ },
2204
+ markWorkerError: false,
2205
+ attemptId,
2206
+ });
2207
+ if (failed) {
2208
+ settleRelayAbortControlsForQueueMessage(queueRow.id, { ok: false, error: STEER_SETTLE_FAILED_CODE });
2209
+ settleRelaySubagentAbortControlsForQueueMessage(queueRow.id, { ok: false, error: STEER_SETTLE_FAILED_CODE });
2210
+ console.warn(`[${ts()}] FAILED ${String(queueRow.id).slice(0, 8)} reason=${reason} code=${STEER_SETTLE_FAILED_STABLE_CODE} (consumed; never re-run)`);
2211
+ }
2212
+ return failed;
2213
+ }
2214
+
2215
+ function normalizeConsumedSteerEntries(raw) {
2216
+ const seen = new Set();
2217
+ return (Array.isArray(raw) ? raw : [])
2218
+ .map((entry) => (entry && typeof entry === 'object'
2219
+ ? { id: String(entry.id || '').trim(), attemptId: String(entry.attemptId || '').trim() || null }
2220
+ : { id: String(entry || '').trim(), attemptId: null }))
2221
+ .filter((entry) => {
2222
+ if (!entry.id || seen.has(entry.id)) return false;
2223
+ seen.add(entry.id);
2224
+ return true;
2225
+ })
2226
+ .slice(0, 100);
2227
+ }
2228
+
2119
2229
  function cancelPendingRelayQuestionsForMessage(messageId) {
2120
2230
  const targetMessageId = String(messageId || '').trim();
2121
2231
  if (!targetMessageId || typeof stmts.cancelPendingQuestionsForStaleAttempts?.all !== 'function') return 0;
@@ -2149,7 +2259,7 @@ export function registerMessagesRoutes(app, deps) {
2149
2259
  AND rs.sdk_session_id = ?
2150
2260
  )
2151
2261
  AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?)
2152
- ORDER BY q.retry_count ASC, CASE WHEN q.next_attempt_at IS NULL THEN 0 ELSE 1 END ASC, COALESCE(q.next_attempt_at, q.timestamp) ASC, q.timestamp ASC
2262
+ ORDER BY q.timestamp ASC, q.rowid ASC
2153
2263
  LIMIT 1
2154
2264
  `);
2155
2265
  const transferPendingOwnerToSession = db.prepare(`
@@ -2230,6 +2340,7 @@ export function registerMessagesRoutes(app, deps) {
2230
2340
  WHERE id = ?
2231
2341
  AND owner_sdk_session_id = ?
2232
2342
  AND status = 'processing'
2343
+ AND consumed_at IS NULL
2233
2344
  `);
2234
2345
  const findMostRecentConversationForSession = db.prepare(`
2235
2346
  SELECT conversation_id
@@ -2409,15 +2520,7 @@ export function registerMessagesRoutes(app, deps) {
2409
2520
  }
2410
2521
 
2411
2522
  function buildRelayStopFailurePayload() {
2412
- return {
2413
- kind: 'turn-aborted',
2414
- error: 'turn-aborted',
2415
- code: 'turn-aborted',
2416
- stableCode: 'relay.turn-aborted',
2417
- message: 'System note: This turn was stopped from the relay UI before completion.',
2418
- guidance: 'Send a new message to continue when you are ready.',
2419
- failedAt: new Date().toISOString(),
2420
- };
2523
+ return buildRelayStopFailure();
2421
2524
  }
2422
2525
 
2423
2526
  function settleRelayAbortControlsForQueueMessage(queueMessageId, { ok = true, note = null, error = null } = {}) {
@@ -2500,6 +2603,10 @@ export function registerMessagesRoutes(app, deps) {
2500
2603
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
2501
2604
  continue;
2502
2605
  }
2606
+ if (row.consumed_at) {
2607
+ failConsumedQueueRow(row, { reason });
2608
+ continue;
2609
+ }
2503
2610
  const relayActivityRows = stmts.listActivityByQueueMessage?.all?.(row.id);
2504
2611
  const relayStreamRows = stmts.listStreamEventsByQueueMessage?.all?.(row.id);
2505
2612
  const fallbackRelayActivities = Array.isArray(relayActivityRows)
@@ -2740,6 +2847,7 @@ export function registerMessagesRoutes(app, deps) {
2740
2847
  });
2741
2848
 
2742
2849
  app.post('/api/conversation/:conversationId/cancel-turn', auth, (req, res) => {
2850
+ if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
2743
2851
  const conversationId = String(req.params.conversationId || '').trim();
2744
2852
  const requestedMessageId = String(req.body?.messageId || req.body?.queueMessageId || '').trim();
2745
2853
  const sessionState = getConversationSessionState(conversationId);
@@ -2747,7 +2855,10 @@ export function registerMessagesRoutes(app, deps) {
2747
2855
  return rejectSessionBinding(res, sessionState.status, sessionState.error);
2748
2856
  }
2749
2857
 
2750
- const queueRow = stmts.getLatestProcessingQueueByConversation.get(conversationId) || null;
2858
+ // The live turn, not merely the newest processing row: a steered message
2859
+ // folded into the running turn is also `processing`, and Stop must target
2860
+ // the turn actually executing (which the worker interrupts as a whole).
2861
+ const queueRow = resolveLiveTurnQueueRow(conversationId) || null;
2751
2862
  if (!queueRow) {
2752
2863
  return res.json({
2753
2864
  ok: true,
@@ -2946,7 +3057,46 @@ export function registerMessagesRoutes(app, deps) {
2946
3057
  });
2947
3058
  });
2948
3059
 
3060
+ // The session worker that can still pull a processing row back out of its
3061
+ // runtime: the row's owner first (the worker that claimed it), then the
3062
+ // conversation's bound session. Only a worker whose latest heartbeat lists
3063
+ // the id as cancellable qualifies — the relay never guesses at the runtime's
3064
+ // queue state.
3065
+ function findWorkerHoldingCancellableMessage({ queueRow, conversationId, messageId }) {
3066
+ if (typeof sessionWorkerRegistry?.getWorker !== 'function') return null;
3067
+ const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
3068
+ const candidates = [...new Set([
3069
+ normalizeSessionWorkerId(queueRow?.owner_sdk_session_id),
3070
+ normalizeSessionWorkerId(conversation?.sdk_session_id),
3071
+ ].filter(Boolean))];
3072
+ for (const sessionId of candidates) {
3073
+ const worker = sessionWorkerRegistry.getWorker(sessionId);
3074
+ const cancellableIds = Array.isArray(worker?.steering?.cancellableIds) ? worker.steering.cancellableIds : [];
3075
+ if (cancellableIds.includes(messageId)) return worker;
3076
+ }
3077
+ return null;
3078
+ }
3079
+
3080
+ // Asks the owning worker to remove a pushed-but-unconsumed row from its
3081
+ // runtime (`cancel_pushed_message`). True when the control went out over a
3082
+ // live worker socket; the worker then settles the row through
3083
+ // POST /api/queue-cancelled. False (no such worker, id not cancellable, or
3084
+ // the socket is gone) leaves the row on its ordinary path.
3085
+ function requestPushedMessageCancel({ queueRow, conversationId, messageId }) {
3086
+ const worker = findWorkerHoldingCancellableMessage({ queueRow, conversationId, messageId });
3087
+ if (!worker) return false;
3088
+ const sent = sendWorkerControl?.(worker.sdkSessionId, {
3089
+ type: 'cancel_pushed_message',
3090
+ messageId,
3091
+ conversationId,
3092
+ });
3093
+ if (!sent) return false;
3094
+ console.log(`[${ts()}] UNSTEER REQ ${messageId.slice(0, 8)} conv=${conversationId.slice(0, 8)}`);
3095
+ return true;
3096
+ }
3097
+
2949
3098
  app.post('/api/conversation/:conversationId/cancel-queued-turn', auth, (req, res) => {
3099
+ if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
2950
3100
  const conversationId = String(req.params.conversationId || '').trim();
2951
3101
  const requestedMessageId = String(req.body?.messageId || '').trim();
2952
3102
  if (!conversationId) {
@@ -2979,6 +3129,19 @@ export function registerMessagesRoutes(app, deps) {
2979
3129
 
2980
3130
  const currentStatus = String(queueRow.status || '').trim().toLowerCase();
2981
3131
  if (currentStatus === 'processing') {
3132
+ // Un-steer: a row the owning worker pushed into its runtime but reports
3133
+ // as still unconsumed (heartbeat `steering.cancellableIds`) can be pulled
3134
+ // back out. The worker answers through POST /api/queue-cancelled once the
3135
+ // runtime confirms the removal; until then the row stays processing.
3136
+ if (requestPushedMessageCancel({ queueRow, conversationId, messageId: requestedMessageId })) {
3137
+ return res.json({
3138
+ ok: true,
3139
+ cancelled: false,
3140
+ acknowledgement: 'cancel-requested',
3141
+ requestedMessageId,
3142
+ status: currentStatus,
3143
+ });
3144
+ }
2982
3145
  return res.json({
2983
3146
  ok: true,
2984
3147
  cancelled: false,
@@ -3851,6 +4014,52 @@ export function registerMessagesRoutes(app, deps) {
3851
4014
  });
3852
4015
  });
3853
4016
 
4017
+ // A Resend of a steer a Stop cut off unanswered. Honored only for a user
4018
+ // message of this conversation whose own settle marker is kind='stopped' and
4019
+ // whose text is what is being sent; anything else is an ordinary send (and
4020
+ // gets the ordinary duplicate guard). Returns { originId } to honor it,
4021
+ // { alreadyResentAs } when an earlier Resend of it still stands (from any
4022
+ // device, at any time), or null.
4023
+ function resolveStoppedSteerResend(conversationId, rawMessageId, text) {
4024
+ const messageId = String(rawMessageId || '').trim();
4025
+ if (!messageId || !conversationId) return null;
4026
+ try {
4027
+ const original = db.prepare(`
4028
+ SELECT id, text, mode FROM messages WHERE id = ? AND conversation_id = ? AND role = 'user'
4029
+ `).get(messageId, conversationId);
4030
+ if (!original) return null;
4031
+ // Stored user text may carry the relay's prompt context; the incoming
4032
+ // text is already stripped, so compare like with like.
4033
+ const originalText = stripRelayPromptContext(original.text, original.mode);
4034
+ if (normalizeDuplicateMessageText(originalText) !== normalizeDuplicateMessageText(text)) return null;
4035
+ const stopped = db.prepare(`
4036
+ SELECT 1 FROM messages
4037
+ WHERE conversation_id = ? AND role = 'assistant' AND kind = 'stopped'
4038
+ AND (source_message_id = ? OR id IN (SELECT response_message_id FROM queue WHERE id = ?))
4039
+ LIMIT 1
4040
+ `).get(conversationId, messageId, messageId);
4041
+ if (!stopped) return null;
4042
+ const earlier = db.prepare(`
4043
+ SELECT m.id,
4044
+ q.id AS queue_id,
4045
+ q.status AS queue_status,
4046
+ EXISTS (SELECT 1 FROM messages a WHERE a.role = 'assistant' AND a.source_message_id = m.id) AS answered
4047
+ FROM messages m
4048
+ LEFT JOIN queue q ON q.id = m.id
4049
+ WHERE m.conversation_id = ? AND m.role = 'user' AND m.resend_of_message_id = ?
4050
+ `).all(conversationId, messageId);
4051
+ const standing = earlier.find((row) => isLiveResend({
4052
+ hasQueueRow: !!row.queue_id,
4053
+ queueStatus: row.queue_status,
4054
+ answered: !!row.answered,
4055
+ }));
4056
+ if (standing) return { alreadyResentAs: standing.id };
4057
+ return { originId: messageId, exemptIds: [messageId, ...earlier.map((row) => row.id)] };
4058
+ } catch {
4059
+ return null;
4060
+ }
4061
+ }
4062
+
3854
4063
  // POST /api/message — browser sends a message
3855
4064
  app.post('/api/message', auth, async (req, res) => {
3856
4065
  const {
@@ -3869,6 +4078,12 @@ export function registerMessagesRoutes(app, deps) {
3869
4078
  attachments: rawAttachments,
3870
4079
  imageTarget: rawImageTarget,
3871
4080
  } = req.body;
4081
+ // A prompt from another relay's agent (body `origin` or the
4082
+ // X-OAR-Remote-Origin header) is refused while the inbound switch is off.
4083
+ // Otherwise its provenance is stored with the message, and it never
4084
+ // unlocks relays or touches the composer draft and preferences.
4085
+ const remoteRequest = admitRemoteRelayRequest(req, res, remoteRelayInbound);
4086
+ if (!remoteRequest) return;
3872
4087
  const sessionId = clientId || ensureSessionId(req, res);
3873
4088
  const requesterIdentity = readBridgeIdentity(req);
3874
4089
  const requesterSessionId = normalizeSessionWorkerId(requesterIdentity?.sessionId);
@@ -3877,6 +4092,10 @@ export function registerMessagesRoutes(app, deps) {
3877
4092
  const requestedRelayMode = normalizeRelayMode(relayMode || mode);
3878
4093
  const trimmedText = stripRelayPromptContext(text, requestedRelayMode || relayMode || mode);
3879
4094
  const normalizedAttachments = normalizeAttachments(rawAttachments);
4095
+ const requestedAttachmentCount = Math.min(
4096
+ MAX_UPLOAD_ATTACHMENTS,
4097
+ Array.isArray(rawAttachments) ? rawAttachments.filter((item) => item && typeof item === 'object').length : (rawAttachments ? 1 : 0),
4098
+ );
3880
4099
  const referenceResolution = collectReferenceAttachmentsFromText(trimmedText);
3881
4100
  const attachments = mergeMessageAttachments(normalizedAttachments, referenceResolution.attachments);
3882
4101
  const imageTarget = rawImageTarget && typeof rawImageTarget === 'object'
@@ -4346,7 +4565,25 @@ export function registerMessagesRoutes(app, deps) {
4346
4565
  }
4347
4566
  }
4348
4567
 
4349
- if (!shouldCreateConversation) {
4568
+ const resend = shouldCreateConversation
4569
+ ? null
4570
+ : resolveStoppedSteerResend(conversationId, req.body.resendOfMessageId, trimmedText);
4571
+ if (resend?.alreadyResentAs) {
4572
+ return res.json({
4573
+ ok: true,
4574
+ duplicate: true,
4575
+ alreadyResent: true,
4576
+ duplicateOfMessageId: resend.alreadyResentAs,
4577
+ conversationId,
4578
+ });
4579
+ }
4580
+ const resendOriginId = resend?.originId || null;
4581
+ // Another relay's agent sends every prompt with a fresh messageId, and a
4582
+ // retry of the same send hits the messageId check at insert. The text
4583
+ // window below would instead swallow a deliberate repeat ("run the tests
4584
+ // again"), which the agent cannot tell from a lost send.
4585
+ const exemptFromTextDuplicateCheck = remoteRequest.remote && !!clientMessageId;
4586
+ if (!shouldCreateConversation && !exemptFromTextDuplicateCheck) {
4350
4587
  const recentUserMessages = stmts.getRecentMessagesDesc.all(conversationId, 20);
4351
4588
  const recentQueueRows = db.prepare(`
4352
4589
  SELECT id, text, status, timestamp
@@ -4359,6 +4596,7 @@ export function registerMessagesRoutes(app, deps) {
4359
4596
  recentUserMessages,
4360
4597
  recentQueueRows,
4361
4598
  text: trimmedText,
4599
+ exemptMessageIds: resend?.exemptIds || [],
4362
4600
  });
4363
4601
  if (duplicateMessage) {
4364
4602
  return res.json({
@@ -4375,7 +4613,10 @@ export function registerMessagesRoutes(app, deps) {
4375
4613
 
4376
4614
  const convId = shouldCreateConversation ? uuidv4() : conversationId;
4377
4615
  if (shouldCreateConversation) {
4378
- getOrCreateConversation(convId, trimmedText || attachmentSummary(attachments) || 'Image');
4616
+ // An agent's prompt opens with the sending relay's header line, which
4617
+ // says who is talking, not what the conversation is about.
4618
+ const titleText = remoteRequest.remote ? stripRemotePromptHeader(trimmedText).trim() : trimmedText;
4619
+ getOrCreateConversation(convId, titleText || attachmentSummary(attachments) || 'Image');
4379
4620
  }
4380
4621
  let conversationWorkspaceRootState = null;
4381
4622
  if (workspaceRootUpdate.changed && workspaceRootUpdate.resolvedPath) {
@@ -4435,7 +4676,7 @@ export function registerMessagesRoutes(app, deps) {
4435
4676
  isNewConversation: shouldCreateConversation,
4436
4677
  });
4437
4678
  const msgId = clientMessageId || uuidv4();
4438
- const queueText = shouldApplySeed
4679
+ const promptText = shouldApplySeed
4439
4680
  ? [
4440
4681
  '[Carry-over context from previous compacted conversation]',
4441
4682
  String(convSeed.summary_seed).trim(),
@@ -4444,6 +4685,16 @@ export function registerMessagesRoutes(app, deps) {
4444
4685
  trimmedText || '(User sent image attachments only.)',
4445
4686
  ].join('\n')
4446
4687
  : trimmedText;
4688
+ // Only what a human typed unlocks a remote relay (decision 19). The hint
4689
+ // rides in the queued prompt only, so the stored message and the user's
4690
+ // bubble stay exactly what was typed.
4691
+ const mentionedRemoteRelays = remoteRequest.remote
4692
+ ? []
4693
+ : findMentionedRemoteRelays(remoteRelayInbound, trimmedText);
4694
+ const queueText = withRemoteRelayMentionHint(promptText, formatRemoteRelayMentionHint(mentionedRemoteRelays));
4695
+ // A Resend never came from the composer, and neither did another relay's
4696
+ // agent: whatever the user is typing there stays their draft.
4697
+ const keepsComposerDraft = !!resendOriginId || remoteRequest.remote;
4447
4698
 
4448
4699
  const requestedModelOrigin = deriveModelOrigin(requestedModelVariantId || requestedModel);
4449
4700
  let conversationPreferences;
@@ -4462,29 +4713,33 @@ export function registerMessagesRoutes(app, deps) {
4462
4713
  null,
4463
4714
  requestedModelOrigin,
4464
4715
  );
4716
+ if (remoteRequest.origin) remoteRelayInbound?.repository?.setMessageOrigin?.(msgId, remoteRequest.origin);
4465
4717
  linkUploadReferences(convId, msgId, attachments);
4466
4718
  stmts.updateConvTime.run(now, convId);
4467
- if (typeof stmts.updateConvDraft?.run === 'function') {
4468
- stmts.updateConvDraft.run(null, now, sessionId || null, convId);
4469
- } else {
4470
- db.prepare(`
4471
- UPDATE conversations
4472
- SET draft_text = NULL, draft_updated_at = ?, draft_updated_by_client_id = ?
4473
- WHERE id = ?
4474
- `).run(now, sessionId || null, convId);
4475
- }
4476
- // The message now owns these blobs, so the draft placeholders can go. The
4477
- // sent-message references were just inserted above, so nothing is orphaned.
4478
- if (typeof stmts.updateConvDraftAttachments?.run === 'function') {
4479
- stmts.updateConvDraftAttachments.run(null, now, sessionId || null, convId);
4480
- } else {
4481
- db.prepare(`
4482
- UPDATE conversations
4483
- SET draft_attachments = NULL, draft_updated_at = ?, draft_updated_by_client_id = ?
4484
- WHERE id = ?
4485
- `).run(now, sessionId || null, convId);
4719
+ if (resendOriginId) stmts.setMessageResendOf?.run(resendOriginId, msgId);
4720
+ if (!keepsComposerDraft) {
4721
+ if (typeof stmts.updateConvDraft?.run === 'function') {
4722
+ stmts.updateConvDraft.run(null, now, sessionId || null, convId);
4723
+ } else {
4724
+ db.prepare(`
4725
+ UPDATE conversations
4726
+ SET draft_text = NULL, draft_updated_at = ?, draft_updated_by_client_id = ?
4727
+ WHERE id = ?
4728
+ `).run(now, sessionId || null, convId);
4729
+ }
4730
+ // The message now owns these blobs, so the draft placeholders can go. The
4731
+ // sent-message references were just inserted above, so nothing is orphaned.
4732
+ if (typeof stmts.updateConvDraftAttachments?.run === 'function') {
4733
+ stmts.updateConvDraftAttachments.run(null, now, sessionId || null, convId);
4734
+ } else {
4735
+ db.prepare(`
4736
+ UPDATE conversations
4737
+ SET draft_attachments = NULL, draft_updated_at = ?, draft_updated_by_client_id = ?
4738
+ WHERE id = ?
4739
+ `).run(now, sessionId || null, convId);
4740
+ }
4741
+ stmts.deleteDraftUploadRefs?.run?.(convId);
4486
4742
  }
4487
- stmts.deleteDraftUploadRefs?.run?.(convId);
4488
4743
  // Cursor allows per-turn model switching, so the pinned provider_model has
4489
4744
  // to follow the accepted model; otherwise the conversation list and the
4490
4745
  // composer keep restoring the model chosen at bootstrap.
@@ -4509,13 +4764,17 @@ export function registerMessagesRoutes(app, deps) {
4509
4764
  ) {
4510
4765
  stmts.updateRuntimeSessionProvider.run('claude', claudeModel, claudeModel, now, runtimeSession.id);
4511
4766
  }
4512
- conversationPreferences = persistConversationModelPreference(
4513
- convId,
4514
- requestedRelayMode,
4515
- requestedModel,
4516
- requestedReasoningEffort,
4517
- now,
4518
- );
4767
+ // Another relay's agent picks the model and mode of its own turn; the
4768
+ // composer keeps restoring the user's choice for this conversation.
4769
+ conversationPreferences = remoteRequest.remote && !shouldCreateConversation
4770
+ ? readConversationModelPreference(convId, requestedRelayMode)
4771
+ : persistConversationModelPreference(
4772
+ convId,
4773
+ requestedRelayMode,
4774
+ requestedModel,
4775
+ requestedReasoningEffort,
4776
+ now,
4777
+ );
4519
4778
  stmts.insertQ.run(
4520
4779
  msgId,
4521
4780
  convId,
@@ -4574,14 +4833,21 @@ export function registerMessagesRoutes(app, deps) {
4574
4833
  }
4575
4834
  throw error;
4576
4835
  }
4577
- io.emit('conversation_draft_updated', {
4836
+ recordRemoteRelayUnlocks(remoteRelayInbound, {
4578
4837
  conversationId: convId,
4579
- draftText: '',
4580
- draftAttachments: [],
4581
- draftUpdatedAt: now,
4582
- draftUpdatedByClientId: sessionId || null,
4583
- senderClientId: sessionId || null,
4838
+ messageId: msgId,
4839
+ relays: mentionedRemoteRelays,
4584
4840
  });
4841
+ if (!keepsComposerDraft) {
4842
+ io.emit('conversation_draft_updated', {
4843
+ conversationId: convId,
4844
+ draftText: '',
4845
+ draftAttachments: [],
4846
+ draftUpdatedAt: now,
4847
+ draftUpdatedByClientId: sessionId || null,
4848
+ senderClientId: sessionId || null,
4849
+ });
4850
+ }
4585
4851
  if (ownerSessionId) {
4586
4852
  const existingWorker = sessionWorkerRegistry?.getWorker?.(ownerSessionId) || null;
4587
4853
  sessionWorkerRegistry?.upsertWorker?.({
@@ -4642,7 +4908,7 @@ export function registerMessagesRoutes(app, deps) {
4642
4908
  stmts.clearConvSeed.run(now, convId);
4643
4909
  }
4644
4910
 
4645
- console.log(`[${ts()}] QUEUED ${msgId.slice(0,8)} conv=${convId.slice(0,8)} rs=${String(runtimeSession?.id || 'none').slice(0,8)} owner=${String(ownerSessionId || 'none').slice(0,8)} new=${!conversationId || !!newConversation} model=${requestedModel} variant=${requestedModelVariantId}${requestedReasoningEffort ? ` effort=${requestedReasoningEffort}` : ''} mode=${requestedRelayMode} text="${trimmedText.slice(0,60)}"${shouldApplySeed ? ' seeded=1' : ''}${attachments.length ? ` attachments=${attachments.length}` : ''}`);
4911
+ console.log(`[${ts()}] QUEUED ${msgId.slice(0,8)} conv=${convId.slice(0,8)} rs=${String(runtimeSession?.id || 'none').slice(0,8)} owner=${String(ownerSessionId || 'none').slice(0,8)} new=${!conversationId || !!newConversation} model=${requestedModel} variant=${requestedModelVariantId}${requestedReasoningEffort ? ` effort=${requestedReasoningEffort}` : ''} mode=${requestedRelayMode} text="${trimmedText.slice(0,60)}"${shouldApplySeed ? ' seeded=1' : ''}${attachments.length ? ` attachments=${attachments.length}` : ''}${remoteRequest.remote ? ` remote=${remoteRequest.origin?.relayName || remoteRequest.origin?.relayId || 'unknown'} hops=${remoteRequest.origin?.hops ?? '?'}` : ''}${mentionedRemoteRelays.length ? ` mentions=${mentionedRemoteRelays.length}` : ''}`);
4646
4912
 
4647
4913
  emitToClientsExceptSessionId(
4648
4914
  'user_message',
@@ -4658,6 +4924,7 @@ export function registerMessagesRoutes(app, deps) {
4658
4924
  mode: requestedRelayMode,
4659
4925
  timestamp: now,
4660
4926
  attachments,
4927
+ ...(remoteRequest.origin ? { origin: remoteRequest.origin } : {}),
4661
4928
  },
4662
4929
  },
4663
4930
  sessionId,
@@ -4697,6 +4964,9 @@ export function registerMessagesRoutes(app, deps) {
4697
4964
  ...workspaceRootPayload(),
4698
4965
  referenceAttachmentCount: referenceResolution.attachments.length,
4699
4966
  skippedReferenceAttachments: referenceResolution.skipped,
4967
+ // Attachment references that no longer resolve (a deleted upload) are
4968
+ // dropped silently by normalizeAttachments; the sender is told how many.
4969
+ droppedAttachmentCount: Math.max(0, requestedAttachmentCount - normalizedAttachments.length),
4700
4970
  imageOperation: imageOperation
4701
4971
  ? {
4702
4972
  id: imageOperation.id,
@@ -4711,6 +4981,8 @@ export function registerMessagesRoutes(app, deps) {
4711
4981
  touchCli();
4712
4982
  const requester = readBridgeIdentity(req);
4713
4983
  const requesterSessionId = normalizeSessionWorkerId(requester?.sessionId);
4984
+ const settleFailedHandled = [];
4985
+ const settleFailedSkipped = [];
4714
4986
  const activeQueueMessageId = String(req.body?.activeQueueMessageId || '').trim();
4715
4987
  // A persistent-process worker can hold several live rows at once (the
4716
4988
  // running turn plus a delivered message queued behind it plus a
@@ -4746,7 +5018,71 @@ export function registerMessagesRoutes(app, deps) {
4746
5018
  pid: normalizedRequesterPid,
4747
5019
  });
4748
5020
  }
5021
+ // The worker's composer-facing steering snapshot rides the heartbeat;
5022
+ // store it on the registry entry (status payload spreads it to clients)
5023
+ // only when it actually changed, so quiet heartbeats stay write-free.
5024
+ // Normalized with the registry's own rules BEFORE comparing — comparing
5025
+ // raw against stored would never converge for values the registry drops.
5026
+ const steering = normalizeWorkerSteeringSnapshot(req.body?.steering);
5027
+ if (steering) {
5028
+ const workerForSteering = sessionWorkerRegistry?.getWorker?.(requesterSessionId) || null;
5029
+ const stored = workerForSteering?.steering || null;
5030
+ const storedCancellableIds = Array.isArray(stored?.cancellableIds) ? stored.cancellableIds : [];
5031
+ const changed = !stored
5032
+ || stored.turnActive !== steering.turnActive
5033
+ || stored.canSteer !== steering.canSteer
5034
+ || (stored.holdReason || null) !== (steering.holdReason || null)
5035
+ || (stored.messageId || null) !== (steering.messageId || null)
5036
+ // The Copilot SDK worker's opt-in and its un-steerable set: a flip
5037
+ // in either must reach the status payload (composer gate, bubble
5038
+ // Cancel), so it counts as a change like the hold fields do.
5039
+ || (stored.supported === true) !== (steering.supported === true)
5040
+ || storedCancellableIds.join('\n') !== steering.cancellableIds.join('\n');
5041
+ if (workerForSteering && changed) {
5042
+ sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
5043
+ // Pushed rather than left for the clients' status poll: the
5044
+ // composer's Steer/Queue label and the pushed rows' Cancel read it,
5045
+ // and the poll would add up to 4 s to a heartbeat that already lags.
5046
+ io.emit('session_worker_steering', { sdkSessionId: requesterSessionId, steering });
5047
+ }
5048
+ }
4749
5049
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
5050
+ // Consumed steers whose settle the worker could not save within its
5051
+ // retry budget (e.g. its writes keep failing): the relay fails them
5052
+ // itself with the worker's terminal error, never requeueing them.
5053
+ const settleFailedEntries = Array.isArray(req.body?.settleFailed) ? req.body.settleFailed.slice(0, 50) : [];
5054
+ const requesterConversationForSettle = String(requester?.conversationId || '').trim() || null;
5055
+ for (const entry of settleFailedEntries) {
5056
+ const id = String(entry?.id || '').trim();
5057
+ const row = id ? stmts.findQById.get(id) : null;
5058
+ if (!row || row.status !== 'processing') {
5059
+ if (id) settleFailedHandled.push(id);
5060
+ continue;
5061
+ }
5062
+ // Only the worker that owns the row may have it failed, and only in a
5063
+ // conversation that worker serves.
5064
+ // Anything else is acknowledged as skipped: the worker has no claim on
5065
+ // the row, so it stops reporting it (and releases its hold), and the
5066
+ // row takes the ordinary recovery path — where a consumed mark still
5067
+ // fails it rather than re-running it.
5068
+ const rowSession = normalizeSessionWorkerId(stmts.getRuntimeSessionByConversation?.get(row.conversation_id)?.sdk_session_id);
5069
+ if (normalizeSessionWorkerId(row.owner_sdk_session_id) !== requesterSessionId
5070
+ || (rowSession !== requesterSessionId && String(row.conversation_id) !== String(requesterConversationForSettle || ''))) {
5071
+ settleFailedSkipped.push(id);
5072
+ continue;
5073
+ }
5074
+ const attemptId = String(entry?.attemptId || '').trim() || null;
5075
+ if (attemptId && String(row.attempt_id || '') !== attemptId) {
5076
+ settleFailedHandled.push(id);
5077
+ continue;
5078
+ }
5079
+ failConsumedQueueRow(row, {
5080
+ reason: 'settle-failed',
5081
+ terminalError: entry?.terminalError && typeof entry.terminalError === 'object' ? entry.terminalError : null,
5082
+ attemptId,
5083
+ });
5084
+ settleFailedHandled.push(id);
5085
+ }
4750
5086
  if (activeQueueMessageIds.length) {
4751
5087
  const now = new Date().toISOString();
4752
5088
  const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
@@ -4765,7 +5101,91 @@ export function registerMessagesRoutes(app, deps) {
4765
5101
  }
4766
5102
  relayBridgeOwnerService?.observe?.(requester);
4767
5103
  const { pendingCount } = queueCounts();
4768
- res.json({ ok: true, pendingCount });
5104
+ res.json({
5105
+ ok: true,
5106
+ pendingCount,
5107
+ ...(settleFailedHandled.length ? { settleFailedHandled } : {}),
5108
+ ...(settleFailedSkipped.length ? { settleFailedSkipped } : {}),
5109
+ });
5110
+ });
5111
+
5112
+ // POST /api/queue-consumed — the Claude worker marks rows whose prompt the
5113
+ // CLI has taken when no response carries the mark (a turn the user stopped,
5114
+ // a turn handed off mid-reply). Consumed rows are at-most-once: recovery
5115
+ // fails them terminally instead of re-running them.
5116
+ app.post('/api/queue-consumed', auth, (req, res) => {
5117
+ touchCli();
5118
+ const requesterSessionId = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
5119
+ if (!requesterSessionId) return res.status(403).json({ error: 'Worker identity required' });
5120
+ const conversationId = String(req.body?.conversationId || '').trim();
5121
+ const entries = normalizeConsumedSteerEntries(req.body?.entries);
5122
+ if (!conversationId || !entries.length) return res.status(400).json({ error: 'Missing conversationId or entries' });
5123
+ // `consumed: false` un-marks a steer the CLI then replayed as a turn of
5124
+ // its own. Either way only the owning worker, on the row's current
5125
+ // attempt, may touch the mark.
5126
+ const statement = req.body?.consumed === false ? stmts.clearQueueConsumed : stmts.markQueueConsumed;
5127
+ const at = new Date().toISOString();
5128
+ let changed = 0;
5129
+ for (const entry of entries) {
5130
+ changed += Number(statement?.run({
5131
+ at,
5132
+ id: entry.id,
5133
+ conversationId,
5134
+ owner: requesterSessionId,
5135
+ attemptId: entry.attemptId,
5136
+ })?.changes || 0);
5137
+ }
5138
+ res.json({ ok: true, changed });
5139
+ });
5140
+
5141
+ // POST /api/queue-cancelled — the owning worker reports that a pushed but
5142
+ // still unconsumed row was removed from its runtime (an un-steer the user
5143
+ // asked for through cancel-queued-turn → `cancel_pushed_message`). The row
5144
+ // ends exactly like a pending cancel: deleted, its open cards cancelled, no
5145
+ // assistant row, `message_status: cancelled`. Fenced like /api/queue-consumed:
5146
+ // only the row's owner, on the row's current attempt, may settle it.
5147
+ app.post('/api/queue-cancelled', auth, (req, res) => {
5148
+ touchCli();
5149
+ const requesterSessionId = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
5150
+ if (!requesterSessionId) return res.status(403).json({ error: 'Worker identity required' });
5151
+ const conversationId = String(req.body?.conversationId || '').trim();
5152
+ const messageId = String(req.body?.messageId || '').trim();
5153
+ const attemptId = String(req.body?.attemptId || '').trim() || null;
5154
+ if (!conversationId || !messageId) return res.status(400).json({ error: 'Missing conversationId or messageId' });
5155
+
5156
+ const row = stmts.findQById.get(messageId) || null;
5157
+ if (!row) return res.status(404).json({ error: 'not-found' });
5158
+ if (String(row.conversation_id || '').trim() !== conversationId) {
5159
+ return res.status(409).json({ error: 'conversation-mismatch' });
5160
+ }
5161
+ const status = String(row.status || '').trim().toLowerCase();
5162
+ if (status !== 'processing') return res.status(409).json({ error: 'not-processing', status });
5163
+ if (normalizeSessionWorkerId(row.owner_sdk_session_id) !== requesterSessionId) {
5164
+ return res.status(409).json({ error: 'not-owned' });
5165
+ }
5166
+ const rowAttemptId = String(row.attempt_id || '').trim() || null;
5167
+ if (attemptId && rowAttemptId && attemptId !== rowAttemptId) {
5168
+ console.warn(`[${ts()}] STALE ATTEMPT queue-cancelled ${messageId.slice(0, 8)} attempt=${attemptId.slice(0, 8)} current=${rowAttemptId.slice(0, 8)}`);
5169
+ return res.status(409).json({ error: 'stale_attempt' });
5170
+ }
5171
+
5172
+ // Same transaction shape as the pending cancel above: delete first, so the
5173
+ // question cancel sees no current attempt and flips every pending card of
5174
+ // the row (with foreign keys on, the cascade has already taken them).
5175
+ const cancelPushedTurn = db.transaction(() => {
5176
+ stmts.deleteQueueById.run(messageId);
5177
+ cancelPendingRelayQuestionsForMessage(messageId);
5178
+ });
5179
+ cancelPushedTurn();
5180
+ // A processing row may carry open Stop controls (the live-turn picker can
5181
+ // land on it before it has output); they fail like on a requeue so no
5182
+ // caller waits on a row that no longer exists.
5183
+ settleRelayAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-cancelled' });
5184
+ settleRelaySubagentAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-cancelled' });
5185
+
5186
+ io.emit('message_status', { messageId, conversationId, status: 'cancelled' });
5187
+ console.log(`[${ts()}] UNSTEERED ${messageId.slice(0, 8)} conv=${conversationId.slice(0, 8)}`);
5188
+ res.json({ ok: true, cancelled: true });
4769
5189
  });
4770
5190
 
4771
5191
  // POST /api/grok-native-session — Grok worker persists the ACP session id so
@@ -5685,6 +6105,20 @@ export function registerMessagesRoutes(app, deps) {
5685
6105
  return res.json({ ok: true, ...result });
5686
6106
  });
5687
6107
 
6108
+ // Withdraw a queued (not yet started) shutdown/restart. Same trust boundary
6109
+ // as queueing one.
6110
+ app.post('/api/relay/shutdown/cancel', auth, (req, res) => {
6111
+ if (!isLoopbackRequest(req)) {
6112
+ return res.status(403).json({ error: 'Relay shutdown endpoint is localhost-only' });
6113
+ }
6114
+ if (typeof cancelRelayShutdown !== 'function') {
6115
+ return res.status(501).json({ error: 'Relay shutdown orchestration is unavailable' });
6116
+ }
6117
+ const requestedBy = String(req.body?.requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
6118
+ const result = cancelRelayShutdown({ requestedBy });
6119
+ return res.json({ ok: true, ...result });
6120
+ });
6121
+
5688
6122
  app.post('/api/image-operations/:operationId/execute', auth, async (req, res) => {
5689
6123
  if (!isLoopbackRequest(req)) {
5690
6124
  return res.status(403).json({ error: 'Image operation execution is localhost-only' });
@@ -6054,10 +6488,49 @@ export function registerMessagesRoutes(app, deps) {
6054
6488
  });
6055
6489
  });
6056
6490
 
6491
+ const SETTLED_STEER_KINDS = new Set(['folded', 'stopped']);
6492
+
6057
6493
  // POST /api/response — CLI submits response
6058
6494
  app.post('/api/response', auth, async (req, res) => {
6059
6495
  touchCli();
6060
6496
  const { messageId, conversationId, text, model, mode, generatedImages: rawGeneratedImages } = req.body;
6497
+ // A steering absorption (Claude worker): this reply ends where a message
6498
+ // pushed mid-turn was picked up, and the turn continues on that message's
6499
+ // own row. Stamped as kind='absorbed' so the transcript renders the two
6500
+ // turns as one merged flow.
6501
+ // A worker not yet respawned onto 0.9.3 still stamps its fold stubs
6502
+ // absorbed:true; the stub text identifies them, and 'absorbed' on a fold
6503
+ // would mis-merge the next ordinary message.
6504
+ const staleFoldStub = req.body.absorbed === true && String(text || '').trim() === STEER_FOLDED_TEXT;
6505
+ const absorbed = req.body.absorbed === true && !staleFoldStub;
6506
+ // The other settle stubs of a consumed steer (never re-run — the CLI saw
6507
+ // it): 'folded' was answered by the turn it was steered into and renders
6508
+ // as a marker on its own message; 'stopped' was steered into a turn the
6509
+ // user then stopped, so it went unanswered and the client offers Resend.
6510
+ const requestedKind = staleFoldStub ? 'folded' : String(req.body.kind || '').trim();
6511
+ const settledKind = absorbed
6512
+ ? 'absorbed'
6513
+ : (SETTLED_STEER_KINDS.has(requestedKind) ? requestedKind : null);
6514
+ // Steers pushed into the turn this response finishes and not yet settled:
6515
+ // the CLI has taken their prompts, so they are marked consumed together
6516
+ // with this commit and no recovery path may ever re-run them.
6517
+ const consumedSteerEntries = normalizeConsumedSteerEntries(req.body.consumedSteerIds)
6518
+ .filter((entry) => entry.id !== String(messageId || '').trim());
6519
+ // Runs inside the response's fenced write (both the success and the
6520
+ // terminal-failure transaction), so a superseded attempt's late publish
6521
+ // can never mark rows; the steers must share the finishing row's owner.
6522
+ const markSteersConsumed = (conversationIdForMark, atIso) => {
6523
+ const owner = normalizeSessionWorkerId(q?.owner_sdk_session_id);
6524
+ for (const entry of consumedSteerEntries) {
6525
+ stmts.markQueueConsumed?.run({
6526
+ at: atIso,
6527
+ id: entry.id,
6528
+ conversationId: conversationIdForMark,
6529
+ owner,
6530
+ attemptId: entry.attemptId,
6531
+ });
6532
+ }
6533
+ };
6061
6534
  const trimmedText = String(text || '').trim();
6062
6535
  const terminalFailure = resolveTerminalFailurePayload(req.body, { fallbackText: trimmedText });
6063
6536
  // Final digests of background workflows that settled during this turn —
@@ -6159,6 +6632,8 @@ export function registerMessagesRoutes(app, deps) {
6159
6632
  failedAt: new Date().toISOString(),
6160
6633
  },
6161
6634
  attemptId: claimedAttemptId,
6635
+ // An errored turn still took the steers pushed into it.
6636
+ withinTransaction: (atIso) => markSteersConsumed(targetConversationId, atIso),
6162
6637
  });
6163
6638
  if (!failed) {
6164
6639
  const currentRow = stmts.findQById?.get(messageId) || null;
@@ -6266,6 +6741,7 @@ export function registerMessagesRoutes(app, deps) {
6266
6741
  ? stmts.setDoneFenced.run(resolvedText, messageId, claimedAttemptId)
6267
6742
  : stmts.setDone.run(resolvedText, messageId);
6268
6743
  if (result.changes === 0) return false;
6744
+ markSteersConsumed(targetConversationId, now);
6269
6745
  stmts.setQueueResponseMessageId?.run(responseId, messageId);
6270
6746
  stmts.insertMsg.run(
6271
6747
  responseId,
@@ -6280,8 +6756,11 @@ export function registerMessagesRoutes(app, deps) {
6280
6756
  explicitModel,
6281
6757
  modelOrigin,
6282
6758
  );
6759
+ stmts.setMessageSourceId?.run(messageId, responseId);
6283
6760
  if (String(q?.kind || '') === 'continuation') {
6284
6761
  stmts.setMessageKind?.run('continuation', responseId);
6762
+ } else if (settledKind) {
6763
+ stmts.setMessageKind?.run(settledKind, responseId);
6285
6764
  }
6286
6765
  // The workflow cards persist atomically with the assistant message they
6287
6766
  // annotate — keyed directly on the response id (see workflow_runs DDL).
@@ -6509,6 +6988,9 @@ export function registerMessagesRoutes(app, deps) {
6509
6988
  messageId: responseId,
6510
6989
  message: {
6511
6990
  role: 'assistant',
6991
+ // In the message too, as on reload: a stopped marker's Resend finds
6992
+ // its original through it.
6993
+ sourceMessageId: messageId,
6512
6994
  text: resolvedText,
6513
6995
  attachments: emittedImageAttachments.length ? emittedImageAttachments : undefined,
6514
6996
  model: resolvedAssistantModel,
@@ -6520,18 +7002,27 @@ export function registerMessagesRoutes(app, deps) {
6520
7002
  activities,
6521
7003
  thoughts,
6522
7004
  executedProvider,
6523
- kind: String(q?.kind || '') === 'continuation' ? 'continuation' : undefined,
7005
+ kind: String(q?.kind || '') === 'continuation'
7006
+ ? 'continuation'
7007
+ : (settledKind || undefined),
6524
7008
  // Live-appended messages must show their workflow cards without a
6525
7009
  // reload; conversation reloads serve the same digests from the DB.
6526
7010
  workflowRuns: workflowRuns.length ? workflowRuns : undefined,
6527
7011
  },
6528
7012
  });
6529
7013
  io.emit('message_status', { messageId, conversationId: targetConversationId, status: 'done' });
6530
- void pushDispatchService?.notifyTurnComplete?.({
6531
- conversationId: targetConversationId,
6532
- messageId: responseId,
6533
- text: resolvedText,
6534
- });
7014
+ // An absorbed row is not a finished turn — the reply continues on the
7015
+ // steered message's row, whose own completion sends the one notification.
7016
+ // A stopped steer has no reply at all.
7017
+ if (!settledKind) {
7018
+ void pushDispatchService?.notifyTurnComplete?.({
7019
+ conversationId: targetConversationId,
7020
+ messageId: responseId,
7021
+ // The push service skips turns another relay's agent started.
7022
+ sourceMessageId: messageId,
7023
+ text: resolvedText,
7024
+ });
7025
+ }
6535
7026
  cancelPendingRelayQuestionsForMessage(messageId);
6536
7027
  res.json({ ok: true });
6537
7028
  });
@@ -6639,6 +7130,7 @@ export function registerMessagesRoutes(app, deps) {
6639
7130
  startedAt: Number(task?.startedAt) || null,
6640
7131
  summary: String(task?.summary || '').trim().slice(0, 500) || null,
6641
7132
  lastToolName: String(task?.lastToolName || '').trim() || null,
7133
+ lastToolCall: String(task?.lastToolCall || '').replace(/\s+/g, ' ').trim().slice(0, 300) || null,
6642
7134
  totalTokens: Number.isFinite(Number(task?.totalTokens)) ? Number(task.totalTokens) : null,
6643
7135
  subagentType: String(task?.subagentType || '').trim().slice(0, 120) || null,
6644
7136
  model: String(task?.model || '').trim().slice(0, 120) || null,
@@ -7091,6 +7583,11 @@ export function registerMessagesRoutes(app, deps) {
7091
7583
  console.log(`[${ts()}] CONTINUATION DROPPED ${messageId?.slice(0,8)} reason=requeue`);
7092
7584
  return res.json({ ok: true, dropped: 'continuation' });
7093
7585
  }
7586
+ if (q && q.status === 'processing' && q.consumed_at) {
7587
+ // The CLI already took this prompt: any requeue class would run it again.
7588
+ failConsumedQueueRow(q, { reason: 'requeue', attemptId: claimedAttemptId });
7589
+ return res.json({ ok: true, terminal: true, code: STEER_SETTLE_FAILED_STABLE_CODE });
7590
+ }
7094
7591
  if (q && q.status === 'processing' && String(req.body?.code || '').trim() === 'relay.provider-mismatch') {
7095
7592
  // A routing refusal, not a delivery failure: the legacy relay handed
7096
7593
  // back a turn that belongs to a session worker. No retry increment and
@@ -7105,6 +7602,25 @@ export function registerMessagesRoutes(app, deps) {
7105
7602
  console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=provider-mismatch retry=${Number(q.retry_count || 0)}`);
7106
7603
  return res.json({ ok: true, mismatch: true });
7107
7604
  }
7605
+ if (q && q.status === 'processing' && String(req.body?.class || '').trim() === 'steering-held') {
7606
+ // The worker refused to push the message because its live turn is
7607
+ // holding steering (an open question card, a compaction, an adoption).
7608
+ // Not a failure: no retry increment, no backoff, and the owner is kept —
7609
+ // the same worker takes it again the moment it signals ready, which is
7610
+ // when the hold ends, so the message steers into the resumed turn.
7611
+ const result = db.prepare(`
7612
+ UPDATE queue
7613
+ SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL
7614
+ WHERE id = ? AND status = 'processing'${claimedAttemptId ? ` AND attempt_id = ?` : ''}
7615
+ `).run(...(claimedAttemptId ? [messageId, claimedAttemptId] : [messageId]));
7616
+ if (result.changes > 0) {
7617
+ settleRelayAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
7618
+ settleRelaySubagentAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
7619
+ io.emit('message_status', { messageId, conversationId: q.conversation_id, status: 'pending' });
7620
+ console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=steering-held retry=${Number(q.retry_count || 0)}`);
7621
+ }
7622
+ return res.json({ ok: true, held: true });
7623
+ }
7108
7624
  if (q && q.status === 'processing') {
7109
7625
  const retryCount = Number(q.retry_count || 0) + 1;
7110
7626
  if (retryCount >= MAX_REQUEUE_RETRIES) {