@oar-sh/oar 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +407 -424
  3. package/bin/oar.js +25 -10
  4. package/package.json +7 -4
  5. package/server/README.md +1 -1
  6. package/server/claude-worker/claude-session-process.mjs +981 -44
  7. package/server/claude-worker/claude-session-worker.mjs +45 -4
  8. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  9. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  10. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  11. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  12. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +101 -12
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +2269 -492
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  16. package/server/db-schema.mjs +20 -0
  17. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  18. package/server/public/app/api-client.js +22 -2
  19. package/server/public/app/ask-user-view.js +102 -13
  20. package/server/public/app/attachments-view.js +37 -2
  21. package/server/public/app/background-task-timeout-options.mjs +9 -0
  22. package/server/public/app/background-tasks-view.mjs +33 -18
  23. package/server/public/app/bootstrap.js +34 -8
  24. package/server/public/app/composer-control-state.mjs +52 -34
  25. package/server/public/app/conversation-draft-sync.mjs +187 -0
  26. package/server/public/app/conversation-list-filter.mjs +86 -0
  27. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  28. package/server/public/app/conversation-view.js +919 -194
  29. package/server/public/app/emoji-view.js +2 -0
  30. package/server/public/app/font-scaling.js +4 -37
  31. package/server/public/app/journal-view.js +145 -5
  32. package/server/public/app/model-selector-options.mjs +1 -1
  33. package/server/public/app/question-multi-select.mjs +52 -0
  34. package/server/public/app/relay-prompt-sanitizer.mjs +17 -2
  35. package/server/public/app/settings-modal.js +8 -6
  36. package/server/public/app/socket-handlers.js +19 -0
  37. package/server/public/app/steered-turn-merge.mjs +100 -0
  38. package/server/public/app/store.js +126 -15
  39. package/server/public/app/sync-outbox.mjs +16 -8
  40. package/server/public/app/tool-activity-emoji.mjs +36 -0
  41. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  42. package/server/public/index.html +206 -9
  43. package/server/public/sw.js +7 -0
  44. package/server/relay-tools.md +2 -0
  45. package/server/repositories/message-repository.mjs +61 -18
  46. package/server/repositories/question-repository.mjs +15 -0
  47. package/server/repositories/session-repository.mjs +7 -31
  48. package/server/routes/messages-routes.mjs +488 -46
  49. package/server/routes/sessions-routes.mjs +298 -55
  50. package/server/server-runtime.mjs +65 -25
  51. package/server/services/live-turn-picker.mjs +47 -0
  52. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  53. package/server/services/relay-question-context.mjs +46 -0
  54. package/server/services/sdk-session-import-service.mjs +49 -7
  55. package/server/services/session-worker-registry-service.mjs +75 -0
  56. package/server/services/session-worker-supervisor-service.mjs +7 -1
  57. package/server/services/session-worker-websocket-service.mjs +23 -1
  58. package/server/services/steer-resend.mjs +45 -0
  59. package/shared/background-task-timeout.mjs +12 -4
  60. package/shared/control-poller.mjs +4 -1
  61. package/shared/question-multi-select.mjs +16 -0
  62. package/shared/relay-stop-failure.mjs +15 -0
  63. package/shared/slash-command-guard.mjs +76 -0
  64. package/shared/steer-note.mjs +66 -0
  65. package/shared/steer-settle-failure.mjs +89 -0
  66. package/shared/steer-settle-markers.mjs +18 -0
  67. package/shared/worker-crash-guard.mjs +40 -25
  68. package/shared/worker-runtime/heartbeat.mjs +55 -1
  69. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  70. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  71. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  72. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  73. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  74. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  75. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  76. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  77. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  78. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  79. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  80. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  81. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  82. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  83. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  84. package/server/routes/messages-routes-test-harness.mjs +0 -111
  85. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  86. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  87. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  88. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  89. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  90. package/server/services/fixtures/cli-install-stub.sh +0 -4
  91. package/server/services/fixtures/grok-stub.cmd +0 -1
  92. package/server/services/fixtures/grok-stub.mjs +0 -89
  93. package/server/services/fixtures/grok-stub.sh +0 -4
  94. 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,6 +37,8 @@ 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';
33
43
 
34
44
  export const SESSION_WORKER_OWNER_LEASE_MS = 120_000;
@@ -177,12 +187,20 @@ function findRecentDuplicateUserMessage({
177
187
  text = '',
178
188
  now = Date.now(),
179
189
  windowMs = DUPLICATE_USER_MESSAGE_WINDOW_MS,
190
+ // A message the user deliberately sends again (Resend of a steer a Stop
191
+ // cut off) is not a duplicate of its own original, nor of an earlier Resend
192
+ // of it that was cancelled or failed.
193
+ exemptMessageIds = [],
180
194
  } = {}) {
181
195
  const fingerprint = normalizeDuplicateMessageText(text);
182
196
  if (!fingerprint) return null;
183
197
  const maxAgeMs = Math.max(60_000, Number(windowMs) || DUPLICATE_USER_MESSAGE_WINDOW_MS);
198
+ const exempt = new Set((Array.isArray(exemptMessageIds) ? exemptMessageIds : [])
199
+ .map((id) => String(id || '').trim()).filter(Boolean));
200
+ const isExempt = (row) => exempt.has(String(row?.id || '').trim());
184
201
 
185
202
  for (const row of Array.isArray(recentUserMessages) ? recentUserMessages : []) {
203
+ if (isExempt(row)) continue;
186
204
  const rowText = normalizeDuplicateMessageText(row?.text || '');
187
205
  if (!rowText || rowText !== fingerprint) continue;
188
206
  const rowTime = Date.parse(row?.timestamp || '');
@@ -193,6 +211,7 @@ function findRecentDuplicateUserMessage({
193
211
  }
194
212
 
195
213
  for (const row of Array.isArray(recentQueueRows) ? recentQueueRows : []) {
214
+ if (isExempt(row)) continue;
196
215
  const rowText = normalizeDuplicateMessageText(row?.text || '');
197
216
  if (!rowText || rowText !== fingerprint) continue;
198
217
  const rowTime = Date.parse(row?.timestamp || '');
@@ -1757,6 +1776,10 @@ export function registerMessagesRoutes(app, deps) {
1757
1776
  sanitizeActivityText,
1758
1777
  readSessionTranscriptMessages,
1759
1778
  inFlightStateForConversation,
1779
+ // Resolves the live turn among a conversation's processing rows (mid-turn
1780
+ // steering can leave more than one). Falls back to the newest-processing
1781
+ // query for older wiring/tests that do not supply it.
1782
+ resolveLiveTurnQueueRow = (conversationId) => stmts.getLatestProcessingQueueByConversation.get(conversationId) || null,
1760
1783
  emitToClientsExceptSessionId,
1761
1784
  relayBridgeOwnerService,
1762
1785
  relayRestartOrchestrator,
@@ -1988,6 +2011,8 @@ export function registerMessagesRoutes(app, deps) {
1988
2011
  markWorkerError = true,
1989
2012
  executedProvider = null,
1990
2013
  attemptId = null,
2014
+ // Extra writes that must commit only with the (fenced) failure itself.
2015
+ withinTransaction = null,
1991
2016
  }) {
1992
2017
  const now = new Date().toISOString();
1993
2018
  const responseId = uuidv4();
@@ -2003,6 +2028,7 @@ export function registerMessagesRoutes(app, deps) {
2003
2028
  ? stmts.setFailedFenced.run(JSON.stringify(failureRecord), messageId, fencedAttemptId)
2004
2029
  : stmts.setFailed.run(JSON.stringify(failureRecord), messageId);
2005
2030
  if (result.changes === 0) return false;
2031
+ if (typeof withinTransaction === 'function') withinTransaction(now);
2006
2032
  stmts.setQueueResponseMessageId?.run(responseId, messageId);
2007
2033
  stmts.insertMsg.run(
2008
2034
  responseId,
@@ -2017,6 +2043,7 @@ export function registerMessagesRoutes(app, deps) {
2017
2043
  modelActual,
2018
2044
  modelOrigin,
2019
2045
  );
2046
+ stmts.setMessageSourceId?.run(messageId, responseId);
2020
2047
  // Provenance travels with terminal failures too — the original hijack
2021
2048
  // incident's signature was stolen turns dying on 402, which exited
2022
2049
  // exactly here with no executed_provider recorded.
@@ -2116,6 +2143,66 @@ export function registerMessagesRoutes(app, deps) {
2116
2143
  // pending — an answer may still be on its way in. A row with NO current
2117
2144
  // attempt (finalized by a legacy responder after a requeue cleared it) makes
2118
2145
  // every attempt-stamped card stale: the empty string matches none of them.
2146
+ // The name the steer-settle wording uses for the runtime that took the
2147
+ // prompt, from the conversation's provider type (the shared mapping the
2148
+ // server runtime's recovery path uses too).
2149
+ function steerSettleAgentLabelForConversation(conversationId) {
2150
+ const id = String(conversationId || '').trim();
2151
+ if (!id) return undefined;
2152
+ return steerAgentLabelForProvider(stmts.getRuntimeSessionByConversation?.get?.(id)?.provider_type);
2153
+ }
2154
+
2155
+ /**
2156
+ * Terminal failure for a row whose prompt the worker's runtime already
2157
+ * consumed — a folded or stopped steer whose settle never landed.
2158
+ * At-most-once: every path that would requeue such a row comes here instead,
2159
+ * since re-delivery would run the prompt a second time. `terminalError` lets
2160
+ * a worker supply the path-accurate wording; the default is the fold wording.
2161
+ */
2162
+ function failConsumedQueueRow(queueRow, { reason = 'recovery', terminalError = null, attemptId = null } = {}) {
2163
+ const agentLabel = steerSettleAgentLabelForConversation(queueRow?.conversation_id);
2164
+ const failure = terminalError && typeof terminalError === 'object'
2165
+ ? { ...buildSteerSettleFailure(queueRow, { agentLabel }), ...terminalError }
2166
+ : buildSteerSettleFailure(queueRow, { agentLabel });
2167
+ const failed = failQueueMessage({
2168
+ queueRow,
2169
+ messageId: queueRow.id,
2170
+ conversationId: queueRow.conversation_id,
2171
+ relayMode: normalizeRelayMode(queueRow.relay_mode) || DEFAULT_RELAY_MODE,
2172
+ model: queueRow.model || null,
2173
+ responseText: buildTerminalFailureTextForChat(failure),
2174
+ failureRecord: {
2175
+ ...failure,
2176
+ kind: 'terminal',
2177
+ error: 'terminal-error',
2178
+ reason: String(reason || 'recovery'),
2179
+ retryCount: Number(queueRow?.retry_count || 0),
2180
+ },
2181
+ markWorkerError: false,
2182
+ attemptId,
2183
+ });
2184
+ if (failed) {
2185
+ settleRelayAbortControlsForQueueMessage(queueRow.id, { ok: false, error: STEER_SETTLE_FAILED_CODE });
2186
+ settleRelaySubagentAbortControlsForQueueMessage(queueRow.id, { ok: false, error: STEER_SETTLE_FAILED_CODE });
2187
+ console.warn(`[${ts()}] FAILED ${String(queueRow.id).slice(0, 8)} reason=${reason} code=${STEER_SETTLE_FAILED_STABLE_CODE} (consumed; never re-run)`);
2188
+ }
2189
+ return failed;
2190
+ }
2191
+
2192
+ function normalizeConsumedSteerEntries(raw) {
2193
+ const seen = new Set();
2194
+ return (Array.isArray(raw) ? raw : [])
2195
+ .map((entry) => (entry && typeof entry === 'object'
2196
+ ? { id: String(entry.id || '').trim(), attemptId: String(entry.attemptId || '').trim() || null }
2197
+ : { id: String(entry || '').trim(), attemptId: null }))
2198
+ .filter((entry) => {
2199
+ if (!entry.id || seen.has(entry.id)) return false;
2200
+ seen.add(entry.id);
2201
+ return true;
2202
+ })
2203
+ .slice(0, 100);
2204
+ }
2205
+
2119
2206
  function cancelPendingRelayQuestionsForMessage(messageId) {
2120
2207
  const targetMessageId = String(messageId || '').trim();
2121
2208
  if (!targetMessageId || typeof stmts.cancelPendingQuestionsForStaleAttempts?.all !== 'function') return 0;
@@ -2149,7 +2236,7 @@ export function registerMessagesRoutes(app, deps) {
2149
2236
  AND rs.sdk_session_id = ?
2150
2237
  )
2151
2238
  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
2239
+ ORDER BY q.timestamp ASC, q.rowid ASC
2153
2240
  LIMIT 1
2154
2241
  `);
2155
2242
  const transferPendingOwnerToSession = db.prepare(`
@@ -2230,6 +2317,7 @@ export function registerMessagesRoutes(app, deps) {
2230
2317
  WHERE id = ?
2231
2318
  AND owner_sdk_session_id = ?
2232
2319
  AND status = 'processing'
2320
+ AND consumed_at IS NULL
2233
2321
  `);
2234
2322
  const findMostRecentConversationForSession = db.prepare(`
2235
2323
  SELECT conversation_id
@@ -2409,15 +2497,7 @@ export function registerMessagesRoutes(app, deps) {
2409
2497
  }
2410
2498
 
2411
2499
  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
- };
2500
+ return buildRelayStopFailure();
2421
2501
  }
2422
2502
 
2423
2503
  function settleRelayAbortControlsForQueueMessage(queueMessageId, { ok = true, note = null, error = null } = {}) {
@@ -2500,6 +2580,10 @@ export function registerMessagesRoutes(app, deps) {
2500
2580
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
2501
2581
  continue;
2502
2582
  }
2583
+ if (row.consumed_at) {
2584
+ failConsumedQueueRow(row, { reason });
2585
+ continue;
2586
+ }
2503
2587
  const relayActivityRows = stmts.listActivityByQueueMessage?.all?.(row.id);
2504
2588
  const relayStreamRows = stmts.listStreamEventsByQueueMessage?.all?.(row.id);
2505
2589
  const fallbackRelayActivities = Array.isArray(relayActivityRows)
@@ -2747,7 +2831,10 @@ export function registerMessagesRoutes(app, deps) {
2747
2831
  return rejectSessionBinding(res, sessionState.status, sessionState.error);
2748
2832
  }
2749
2833
 
2750
- const queueRow = stmts.getLatestProcessingQueueByConversation.get(conversationId) || null;
2834
+ // The live turn, not merely the newest processing row: a steered message
2835
+ // folded into the running turn is also `processing`, and Stop must target
2836
+ // the turn actually executing (which the worker interrupts as a whole).
2837
+ const queueRow = resolveLiveTurnQueueRow(conversationId) || null;
2751
2838
  if (!queueRow) {
2752
2839
  return res.json({
2753
2840
  ok: true,
@@ -2946,6 +3033,44 @@ export function registerMessagesRoutes(app, deps) {
2946
3033
  });
2947
3034
  });
2948
3035
 
3036
+ // The session worker that can still pull a processing row back out of its
3037
+ // runtime: the row's owner first (the worker that claimed it), then the
3038
+ // conversation's bound session. Only a worker whose latest heartbeat lists
3039
+ // the id as cancellable qualifies — the relay never guesses at the runtime's
3040
+ // queue state.
3041
+ function findWorkerHoldingCancellableMessage({ queueRow, conversationId, messageId }) {
3042
+ if (typeof sessionWorkerRegistry?.getWorker !== 'function') return null;
3043
+ const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
3044
+ const candidates = [...new Set([
3045
+ normalizeSessionWorkerId(queueRow?.owner_sdk_session_id),
3046
+ normalizeSessionWorkerId(conversation?.sdk_session_id),
3047
+ ].filter(Boolean))];
3048
+ for (const sessionId of candidates) {
3049
+ const worker = sessionWorkerRegistry.getWorker(sessionId);
3050
+ const cancellableIds = Array.isArray(worker?.steering?.cancellableIds) ? worker.steering.cancellableIds : [];
3051
+ if (cancellableIds.includes(messageId)) return worker;
3052
+ }
3053
+ return null;
3054
+ }
3055
+
3056
+ // Asks the owning worker to remove a pushed-but-unconsumed row from its
3057
+ // runtime (`cancel_pushed_message`). True when the control went out over a
3058
+ // live worker socket; the worker then settles the row through
3059
+ // POST /api/queue-cancelled. False (no such worker, id not cancellable, or
3060
+ // the socket is gone) leaves the row on its ordinary path.
3061
+ function requestPushedMessageCancel({ queueRow, conversationId, messageId }) {
3062
+ const worker = findWorkerHoldingCancellableMessage({ queueRow, conversationId, messageId });
3063
+ if (!worker) return false;
3064
+ const sent = sendWorkerControl?.(worker.sdkSessionId, {
3065
+ type: 'cancel_pushed_message',
3066
+ messageId,
3067
+ conversationId,
3068
+ });
3069
+ if (!sent) return false;
3070
+ console.log(`[${ts()}] UNSTEER REQ ${messageId.slice(0, 8)} conv=${conversationId.slice(0, 8)}`);
3071
+ return true;
3072
+ }
3073
+
2949
3074
  app.post('/api/conversation/:conversationId/cancel-queued-turn', auth, (req, res) => {
2950
3075
  const conversationId = String(req.params.conversationId || '').trim();
2951
3076
  const requestedMessageId = String(req.body?.messageId || '').trim();
@@ -2979,6 +3104,19 @@ export function registerMessagesRoutes(app, deps) {
2979
3104
 
2980
3105
  const currentStatus = String(queueRow.status || '').trim().toLowerCase();
2981
3106
  if (currentStatus === 'processing') {
3107
+ // Un-steer: a row the owning worker pushed into its runtime but reports
3108
+ // as still unconsumed (heartbeat `steering.cancellableIds`) can be pulled
3109
+ // back out. The worker answers through POST /api/queue-cancelled once the
3110
+ // runtime confirms the removal; until then the row stays processing.
3111
+ if (requestPushedMessageCancel({ queueRow, conversationId, messageId: requestedMessageId })) {
3112
+ return res.json({
3113
+ ok: true,
3114
+ cancelled: false,
3115
+ acknowledgement: 'cancel-requested',
3116
+ requestedMessageId,
3117
+ status: currentStatus,
3118
+ });
3119
+ }
2982
3120
  return res.json({
2983
3121
  ok: true,
2984
3122
  cancelled: false,
@@ -3851,6 +3989,52 @@ export function registerMessagesRoutes(app, deps) {
3851
3989
  });
3852
3990
  });
3853
3991
 
3992
+ // A Resend of a steer a Stop cut off unanswered. Honored only for a user
3993
+ // message of this conversation whose own settle marker is kind='stopped' and
3994
+ // whose text is what is being sent; anything else is an ordinary send (and
3995
+ // gets the ordinary duplicate guard). Returns { originId } to honor it,
3996
+ // { alreadyResentAs } when an earlier Resend of it still stands (from any
3997
+ // device, at any time), or null.
3998
+ function resolveStoppedSteerResend(conversationId, rawMessageId, text) {
3999
+ const messageId = String(rawMessageId || '').trim();
4000
+ if (!messageId || !conversationId) return null;
4001
+ try {
4002
+ const original = db.prepare(`
4003
+ SELECT id, text, mode FROM messages WHERE id = ? AND conversation_id = ? AND role = 'user'
4004
+ `).get(messageId, conversationId);
4005
+ if (!original) return null;
4006
+ // Stored user text may carry the relay's prompt context; the incoming
4007
+ // text is already stripped, so compare like with like.
4008
+ const originalText = stripRelayPromptContext(original.text, original.mode);
4009
+ if (normalizeDuplicateMessageText(originalText) !== normalizeDuplicateMessageText(text)) return null;
4010
+ const stopped = db.prepare(`
4011
+ SELECT 1 FROM messages
4012
+ WHERE conversation_id = ? AND role = 'assistant' AND kind = 'stopped'
4013
+ AND (source_message_id = ? OR id IN (SELECT response_message_id FROM queue WHERE id = ?))
4014
+ LIMIT 1
4015
+ `).get(conversationId, messageId, messageId);
4016
+ if (!stopped) return null;
4017
+ const earlier = db.prepare(`
4018
+ SELECT m.id,
4019
+ q.id AS queue_id,
4020
+ q.status AS queue_status,
4021
+ EXISTS (SELECT 1 FROM messages a WHERE a.role = 'assistant' AND a.source_message_id = m.id) AS answered
4022
+ FROM messages m
4023
+ LEFT JOIN queue q ON q.id = m.id
4024
+ WHERE m.conversation_id = ? AND m.role = 'user' AND m.resend_of_message_id = ?
4025
+ `).all(conversationId, messageId);
4026
+ const standing = earlier.find((row) => isLiveResend({
4027
+ hasQueueRow: !!row.queue_id,
4028
+ queueStatus: row.queue_status,
4029
+ answered: !!row.answered,
4030
+ }));
4031
+ if (standing) return { alreadyResentAs: standing.id };
4032
+ return { originId: messageId, exemptIds: [messageId, ...earlier.map((row) => row.id)] };
4033
+ } catch {
4034
+ return null;
4035
+ }
4036
+ }
4037
+
3854
4038
  // POST /api/message — browser sends a message
3855
4039
  app.post('/api/message', auth, async (req, res) => {
3856
4040
  const {
@@ -3877,6 +4061,10 @@ export function registerMessagesRoutes(app, deps) {
3877
4061
  const requestedRelayMode = normalizeRelayMode(relayMode || mode);
3878
4062
  const trimmedText = stripRelayPromptContext(text, requestedRelayMode || relayMode || mode);
3879
4063
  const normalizedAttachments = normalizeAttachments(rawAttachments);
4064
+ const requestedAttachmentCount = Math.min(
4065
+ MAX_UPLOAD_ATTACHMENTS,
4066
+ Array.isArray(rawAttachments) ? rawAttachments.filter((item) => item && typeof item === 'object').length : (rawAttachments ? 1 : 0),
4067
+ );
3880
4068
  const referenceResolution = collectReferenceAttachmentsFromText(trimmedText);
3881
4069
  const attachments = mergeMessageAttachments(normalizedAttachments, referenceResolution.attachments);
3882
4070
  const imageTarget = rawImageTarget && typeof rawImageTarget === 'object'
@@ -4346,6 +4534,19 @@ export function registerMessagesRoutes(app, deps) {
4346
4534
  }
4347
4535
  }
4348
4536
 
4537
+ const resend = shouldCreateConversation
4538
+ ? null
4539
+ : resolveStoppedSteerResend(conversationId, req.body.resendOfMessageId, trimmedText);
4540
+ if (resend?.alreadyResentAs) {
4541
+ return res.json({
4542
+ ok: true,
4543
+ duplicate: true,
4544
+ alreadyResent: true,
4545
+ duplicateOfMessageId: resend.alreadyResentAs,
4546
+ conversationId,
4547
+ });
4548
+ }
4549
+ const resendOriginId = resend?.originId || null;
4349
4550
  if (!shouldCreateConversation) {
4350
4551
  const recentUserMessages = stmts.getRecentMessagesDesc.all(conversationId, 20);
4351
4552
  const recentQueueRows = db.prepare(`
@@ -4359,6 +4560,7 @@ export function registerMessagesRoutes(app, deps) {
4359
4560
  recentUserMessages,
4360
4561
  recentQueueRows,
4361
4562
  text: trimmedText,
4563
+ exemptMessageIds: resend?.exemptIds || [],
4362
4564
  });
4363
4565
  if (duplicateMessage) {
4364
4566
  return res.json({
@@ -4464,27 +4666,32 @@ export function registerMessagesRoutes(app, deps) {
4464
4666
  );
4465
4667
  linkUploadReferences(convId, msgId, attachments);
4466
4668
  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);
4669
+ if (resendOriginId) stmts.setMessageResendOf?.run(resendOriginId, msgId);
4670
+ // A Resend never came from the composer: whatever the user is typing
4671
+ // there stays their draft.
4672
+ if (!resendOriginId) {
4673
+ if (typeof stmts.updateConvDraft?.run === 'function') {
4674
+ stmts.updateConvDraft.run(null, now, sessionId || null, convId);
4675
+ } else {
4676
+ db.prepare(`
4677
+ UPDATE conversations
4678
+ SET draft_text = NULL, draft_updated_at = ?, draft_updated_by_client_id = ?
4679
+ WHERE id = ?
4680
+ `).run(now, sessionId || null, convId);
4681
+ }
4682
+ // The message now owns these blobs, so the draft placeholders can go. The
4683
+ // sent-message references were just inserted above, so nothing is orphaned.
4684
+ if (typeof stmts.updateConvDraftAttachments?.run === 'function') {
4685
+ stmts.updateConvDraftAttachments.run(null, now, sessionId || null, convId);
4686
+ } else {
4687
+ db.prepare(`
4688
+ UPDATE conversations
4689
+ SET draft_attachments = NULL, draft_updated_at = ?, draft_updated_by_client_id = ?
4690
+ WHERE id = ?
4691
+ `).run(now, sessionId || null, convId);
4692
+ }
4693
+ stmts.deleteDraftUploadRefs?.run?.(convId);
4486
4694
  }
4487
- stmts.deleteDraftUploadRefs?.run?.(convId);
4488
4695
  // Cursor allows per-turn model switching, so the pinned provider_model has
4489
4696
  // to follow the accepted model; otherwise the conversation list and the
4490
4697
  // composer keep restoring the model chosen at bootstrap.
@@ -4574,14 +4781,16 @@ export function registerMessagesRoutes(app, deps) {
4574
4781
  }
4575
4782
  throw error;
4576
4783
  }
4577
- io.emit('conversation_draft_updated', {
4578
- conversationId: convId,
4579
- draftText: '',
4580
- draftAttachments: [],
4581
- draftUpdatedAt: now,
4582
- draftUpdatedByClientId: sessionId || null,
4583
- senderClientId: sessionId || null,
4584
- });
4784
+ if (!resendOriginId) {
4785
+ io.emit('conversation_draft_updated', {
4786
+ conversationId: convId,
4787
+ draftText: '',
4788
+ draftAttachments: [],
4789
+ draftUpdatedAt: now,
4790
+ draftUpdatedByClientId: sessionId || null,
4791
+ senderClientId: sessionId || null,
4792
+ });
4793
+ }
4585
4794
  if (ownerSessionId) {
4586
4795
  const existingWorker = sessionWorkerRegistry?.getWorker?.(ownerSessionId) || null;
4587
4796
  sessionWorkerRegistry?.upsertWorker?.({
@@ -4697,6 +4906,9 @@ export function registerMessagesRoutes(app, deps) {
4697
4906
  ...workspaceRootPayload(),
4698
4907
  referenceAttachmentCount: referenceResolution.attachments.length,
4699
4908
  skippedReferenceAttachments: referenceResolution.skipped,
4909
+ // Attachment references that no longer resolve (a deleted upload) are
4910
+ // dropped silently by normalizeAttachments; the sender is told how many.
4911
+ droppedAttachmentCount: Math.max(0, requestedAttachmentCount - normalizedAttachments.length),
4700
4912
  imageOperation: imageOperation
4701
4913
  ? {
4702
4914
  id: imageOperation.id,
@@ -4711,6 +4923,8 @@ export function registerMessagesRoutes(app, deps) {
4711
4923
  touchCli();
4712
4924
  const requester = readBridgeIdentity(req);
4713
4925
  const requesterSessionId = normalizeSessionWorkerId(requester?.sessionId);
4926
+ const settleFailedHandled = [];
4927
+ const settleFailedSkipped = [];
4714
4928
  const activeQueueMessageId = String(req.body?.activeQueueMessageId || '').trim();
4715
4929
  // A persistent-process worker can hold several live rows at once (the
4716
4930
  // running turn plus a delivered message queued behind it plus a
@@ -4746,7 +4960,71 @@ export function registerMessagesRoutes(app, deps) {
4746
4960
  pid: normalizedRequesterPid,
4747
4961
  });
4748
4962
  }
4963
+ // The worker's composer-facing steering snapshot rides the heartbeat;
4964
+ // store it on the registry entry (status payload spreads it to clients)
4965
+ // only when it actually changed, so quiet heartbeats stay write-free.
4966
+ // Normalized with the registry's own rules BEFORE comparing — comparing
4967
+ // raw against stored would never converge for values the registry drops.
4968
+ const steering = normalizeWorkerSteeringSnapshot(req.body?.steering);
4969
+ if (steering) {
4970
+ const workerForSteering = sessionWorkerRegistry?.getWorker?.(requesterSessionId) || null;
4971
+ const stored = workerForSteering?.steering || null;
4972
+ const storedCancellableIds = Array.isArray(stored?.cancellableIds) ? stored.cancellableIds : [];
4973
+ const changed = !stored
4974
+ || stored.turnActive !== steering.turnActive
4975
+ || stored.canSteer !== steering.canSteer
4976
+ || (stored.holdReason || null) !== (steering.holdReason || null)
4977
+ || (stored.messageId || null) !== (steering.messageId || null)
4978
+ // The Copilot SDK worker's opt-in and its un-steerable set: a flip
4979
+ // in either must reach the status payload (composer gate, bubble
4980
+ // Cancel), so it counts as a change like the hold fields do.
4981
+ || (stored.supported === true) !== (steering.supported === true)
4982
+ || storedCancellableIds.join('\n') !== steering.cancellableIds.join('\n');
4983
+ if (workerForSteering && changed) {
4984
+ sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
4985
+ // Pushed rather than left for the clients' status poll: the
4986
+ // composer's Steer/Queue label and the pushed rows' Cancel read it,
4987
+ // and the poll would add up to 4 s to a heartbeat that already lags.
4988
+ io.emit('session_worker_steering', { sdkSessionId: requesterSessionId, steering });
4989
+ }
4990
+ }
4749
4991
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
4992
+ // Consumed steers whose settle the worker could not save within its
4993
+ // retry budget (e.g. its writes keep failing): the relay fails them
4994
+ // itself with the worker's terminal error, never requeueing them.
4995
+ const settleFailedEntries = Array.isArray(req.body?.settleFailed) ? req.body.settleFailed.slice(0, 50) : [];
4996
+ const requesterConversationForSettle = String(requester?.conversationId || '').trim() || null;
4997
+ for (const entry of settleFailedEntries) {
4998
+ const id = String(entry?.id || '').trim();
4999
+ const row = id ? stmts.findQById.get(id) : null;
5000
+ if (!row || row.status !== 'processing') {
5001
+ if (id) settleFailedHandled.push(id);
5002
+ continue;
5003
+ }
5004
+ // Only the worker that owns the row may have it failed, and only in a
5005
+ // conversation that worker serves.
5006
+ // Anything else is acknowledged as skipped: the worker has no claim on
5007
+ // the row, so it stops reporting it (and releases its hold), and the
5008
+ // row takes the ordinary recovery path — where a consumed mark still
5009
+ // fails it rather than re-running it.
5010
+ const rowSession = normalizeSessionWorkerId(stmts.getRuntimeSessionByConversation?.get(row.conversation_id)?.sdk_session_id);
5011
+ if (normalizeSessionWorkerId(row.owner_sdk_session_id) !== requesterSessionId
5012
+ || (rowSession !== requesterSessionId && String(row.conversation_id) !== String(requesterConversationForSettle || ''))) {
5013
+ settleFailedSkipped.push(id);
5014
+ continue;
5015
+ }
5016
+ const attemptId = String(entry?.attemptId || '').trim() || null;
5017
+ if (attemptId && String(row.attempt_id || '') !== attemptId) {
5018
+ settleFailedHandled.push(id);
5019
+ continue;
5020
+ }
5021
+ failConsumedQueueRow(row, {
5022
+ reason: 'settle-failed',
5023
+ terminalError: entry?.terminalError && typeof entry.terminalError === 'object' ? entry.terminalError : null,
5024
+ attemptId,
5025
+ });
5026
+ settleFailedHandled.push(id);
5027
+ }
4750
5028
  if (activeQueueMessageIds.length) {
4751
5029
  const now = new Date().toISOString();
4752
5030
  const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
@@ -4765,7 +5043,91 @@ export function registerMessagesRoutes(app, deps) {
4765
5043
  }
4766
5044
  relayBridgeOwnerService?.observe?.(requester);
4767
5045
  const { pendingCount } = queueCounts();
4768
- res.json({ ok: true, pendingCount });
5046
+ res.json({
5047
+ ok: true,
5048
+ pendingCount,
5049
+ ...(settleFailedHandled.length ? { settleFailedHandled } : {}),
5050
+ ...(settleFailedSkipped.length ? { settleFailedSkipped } : {}),
5051
+ });
5052
+ });
5053
+
5054
+ // POST /api/queue-consumed — the Claude worker marks rows whose prompt the
5055
+ // CLI has taken when no response carries the mark (a turn the user stopped,
5056
+ // a turn handed off mid-reply). Consumed rows are at-most-once: recovery
5057
+ // fails them terminally instead of re-running them.
5058
+ app.post('/api/queue-consumed', auth, (req, res) => {
5059
+ touchCli();
5060
+ const requesterSessionId = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
5061
+ if (!requesterSessionId) return res.status(403).json({ error: 'Worker identity required' });
5062
+ const conversationId = String(req.body?.conversationId || '').trim();
5063
+ const entries = normalizeConsumedSteerEntries(req.body?.entries);
5064
+ if (!conversationId || !entries.length) return res.status(400).json({ error: 'Missing conversationId or entries' });
5065
+ // `consumed: false` un-marks a steer the CLI then replayed as a turn of
5066
+ // its own. Either way only the owning worker, on the row's current
5067
+ // attempt, may touch the mark.
5068
+ const statement = req.body?.consumed === false ? stmts.clearQueueConsumed : stmts.markQueueConsumed;
5069
+ const at = new Date().toISOString();
5070
+ let changed = 0;
5071
+ for (const entry of entries) {
5072
+ changed += Number(statement?.run({
5073
+ at,
5074
+ id: entry.id,
5075
+ conversationId,
5076
+ owner: requesterSessionId,
5077
+ attemptId: entry.attemptId,
5078
+ })?.changes || 0);
5079
+ }
5080
+ res.json({ ok: true, changed });
5081
+ });
5082
+
5083
+ // POST /api/queue-cancelled — the owning worker reports that a pushed but
5084
+ // still unconsumed row was removed from its runtime (an un-steer the user
5085
+ // asked for through cancel-queued-turn → `cancel_pushed_message`). The row
5086
+ // ends exactly like a pending cancel: deleted, its open cards cancelled, no
5087
+ // assistant row, `message_status: cancelled`. Fenced like /api/queue-consumed:
5088
+ // only the row's owner, on the row's current attempt, may settle it.
5089
+ app.post('/api/queue-cancelled', auth, (req, res) => {
5090
+ touchCli();
5091
+ const requesterSessionId = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
5092
+ if (!requesterSessionId) return res.status(403).json({ error: 'Worker identity required' });
5093
+ const conversationId = String(req.body?.conversationId || '').trim();
5094
+ const messageId = String(req.body?.messageId || '').trim();
5095
+ const attemptId = String(req.body?.attemptId || '').trim() || null;
5096
+ if (!conversationId || !messageId) return res.status(400).json({ error: 'Missing conversationId or messageId' });
5097
+
5098
+ const row = stmts.findQById.get(messageId) || null;
5099
+ if (!row) return res.status(404).json({ error: 'not-found' });
5100
+ if (String(row.conversation_id || '').trim() !== conversationId) {
5101
+ return res.status(409).json({ error: 'conversation-mismatch' });
5102
+ }
5103
+ const status = String(row.status || '').trim().toLowerCase();
5104
+ if (status !== 'processing') return res.status(409).json({ error: 'not-processing', status });
5105
+ if (normalizeSessionWorkerId(row.owner_sdk_session_id) !== requesterSessionId) {
5106
+ return res.status(409).json({ error: 'not-owned' });
5107
+ }
5108
+ const rowAttemptId = String(row.attempt_id || '').trim() || null;
5109
+ if (attemptId && rowAttemptId && attemptId !== rowAttemptId) {
5110
+ console.warn(`[${ts()}] STALE ATTEMPT queue-cancelled ${messageId.slice(0, 8)} attempt=${attemptId.slice(0, 8)} current=${rowAttemptId.slice(0, 8)}`);
5111
+ return res.status(409).json({ error: 'stale_attempt' });
5112
+ }
5113
+
5114
+ // Same transaction shape as the pending cancel above: delete first, so the
5115
+ // question cancel sees no current attempt and flips every pending card of
5116
+ // the row (with foreign keys on, the cascade has already taken them).
5117
+ const cancelPushedTurn = db.transaction(() => {
5118
+ stmts.deleteQueueById.run(messageId);
5119
+ cancelPendingRelayQuestionsForMessage(messageId);
5120
+ });
5121
+ cancelPushedTurn();
5122
+ // A processing row may carry open Stop controls (the live-turn picker can
5123
+ // land on it before it has output); they fail like on a requeue so no
5124
+ // caller waits on a row that no longer exists.
5125
+ settleRelayAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-cancelled' });
5126
+ settleRelaySubagentAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-cancelled' });
5127
+
5128
+ io.emit('message_status', { messageId, conversationId, status: 'cancelled' });
5129
+ console.log(`[${ts()}] UNSTEERED ${messageId.slice(0, 8)} conv=${conversationId.slice(0, 8)}`);
5130
+ res.json({ ok: true, cancelled: true });
4769
5131
  });
4770
5132
 
4771
5133
  // POST /api/grok-native-session — Grok worker persists the ACP session id so
@@ -6054,10 +6416,49 @@ export function registerMessagesRoutes(app, deps) {
6054
6416
  });
6055
6417
  });
6056
6418
 
6419
+ const SETTLED_STEER_KINDS = new Set(['folded', 'stopped']);
6420
+
6057
6421
  // POST /api/response — CLI submits response
6058
6422
  app.post('/api/response', auth, async (req, res) => {
6059
6423
  touchCli();
6060
6424
  const { messageId, conversationId, text, model, mode, generatedImages: rawGeneratedImages } = req.body;
6425
+ // A steering absorption (Claude worker): this reply ends where a message
6426
+ // pushed mid-turn was picked up, and the turn continues on that message's
6427
+ // own row. Stamped as kind='absorbed' so the transcript renders the two
6428
+ // turns as one merged flow.
6429
+ // A worker not yet respawned onto 0.9.3 still stamps its fold stubs
6430
+ // absorbed:true; the stub text identifies them, and 'absorbed' on a fold
6431
+ // would mis-merge the next ordinary message.
6432
+ const staleFoldStub = req.body.absorbed === true && String(text || '').trim() === STEER_FOLDED_TEXT;
6433
+ const absorbed = req.body.absorbed === true && !staleFoldStub;
6434
+ // The other settle stubs of a consumed steer (never re-run — the CLI saw
6435
+ // it): 'folded' was answered by the turn it was steered into and renders
6436
+ // as a marker on its own message; 'stopped' was steered into a turn the
6437
+ // user then stopped, so it went unanswered and the client offers Resend.
6438
+ const requestedKind = staleFoldStub ? 'folded' : String(req.body.kind || '').trim();
6439
+ const settledKind = absorbed
6440
+ ? 'absorbed'
6441
+ : (SETTLED_STEER_KINDS.has(requestedKind) ? requestedKind : null);
6442
+ // Steers pushed into the turn this response finishes and not yet settled:
6443
+ // the CLI has taken their prompts, so they are marked consumed together
6444
+ // with this commit and no recovery path may ever re-run them.
6445
+ const consumedSteerEntries = normalizeConsumedSteerEntries(req.body.consumedSteerIds)
6446
+ .filter((entry) => entry.id !== String(messageId || '').trim());
6447
+ // Runs inside the response's fenced write (both the success and the
6448
+ // terminal-failure transaction), so a superseded attempt's late publish
6449
+ // can never mark rows; the steers must share the finishing row's owner.
6450
+ const markSteersConsumed = (conversationIdForMark, atIso) => {
6451
+ const owner = normalizeSessionWorkerId(q?.owner_sdk_session_id);
6452
+ for (const entry of consumedSteerEntries) {
6453
+ stmts.markQueueConsumed?.run({
6454
+ at: atIso,
6455
+ id: entry.id,
6456
+ conversationId: conversationIdForMark,
6457
+ owner,
6458
+ attemptId: entry.attemptId,
6459
+ });
6460
+ }
6461
+ };
6061
6462
  const trimmedText = String(text || '').trim();
6062
6463
  const terminalFailure = resolveTerminalFailurePayload(req.body, { fallbackText: trimmedText });
6063
6464
  // Final digests of background workflows that settled during this turn —
@@ -6159,6 +6560,8 @@ export function registerMessagesRoutes(app, deps) {
6159
6560
  failedAt: new Date().toISOString(),
6160
6561
  },
6161
6562
  attemptId: claimedAttemptId,
6563
+ // An errored turn still took the steers pushed into it.
6564
+ withinTransaction: (atIso) => markSteersConsumed(targetConversationId, atIso),
6162
6565
  });
6163
6566
  if (!failed) {
6164
6567
  const currentRow = stmts.findQById?.get(messageId) || null;
@@ -6266,6 +6669,7 @@ export function registerMessagesRoutes(app, deps) {
6266
6669
  ? stmts.setDoneFenced.run(resolvedText, messageId, claimedAttemptId)
6267
6670
  : stmts.setDone.run(resolvedText, messageId);
6268
6671
  if (result.changes === 0) return false;
6672
+ markSteersConsumed(targetConversationId, now);
6269
6673
  stmts.setQueueResponseMessageId?.run(responseId, messageId);
6270
6674
  stmts.insertMsg.run(
6271
6675
  responseId,
@@ -6280,8 +6684,11 @@ export function registerMessagesRoutes(app, deps) {
6280
6684
  explicitModel,
6281
6685
  modelOrigin,
6282
6686
  );
6687
+ stmts.setMessageSourceId?.run(messageId, responseId);
6283
6688
  if (String(q?.kind || '') === 'continuation') {
6284
6689
  stmts.setMessageKind?.run('continuation', responseId);
6690
+ } else if (settledKind) {
6691
+ stmts.setMessageKind?.run(settledKind, responseId);
6285
6692
  }
6286
6693
  // The workflow cards persist atomically with the assistant message they
6287
6694
  // annotate — keyed directly on the response id (see workflow_runs DDL).
@@ -6509,6 +6916,9 @@ export function registerMessagesRoutes(app, deps) {
6509
6916
  messageId: responseId,
6510
6917
  message: {
6511
6918
  role: 'assistant',
6919
+ // In the message too, as on reload: a stopped marker's Resend finds
6920
+ // its original through it.
6921
+ sourceMessageId: messageId,
6512
6922
  text: resolvedText,
6513
6923
  attachments: emittedImageAttachments.length ? emittedImageAttachments : undefined,
6514
6924
  model: resolvedAssistantModel,
@@ -6520,18 +6930,25 @@ export function registerMessagesRoutes(app, deps) {
6520
6930
  activities,
6521
6931
  thoughts,
6522
6932
  executedProvider,
6523
- kind: String(q?.kind || '') === 'continuation' ? 'continuation' : undefined,
6933
+ kind: String(q?.kind || '') === 'continuation'
6934
+ ? 'continuation'
6935
+ : (settledKind || undefined),
6524
6936
  // Live-appended messages must show their workflow cards without a
6525
6937
  // reload; conversation reloads serve the same digests from the DB.
6526
6938
  workflowRuns: workflowRuns.length ? workflowRuns : undefined,
6527
6939
  },
6528
6940
  });
6529
6941
  io.emit('message_status', { messageId, conversationId: targetConversationId, status: 'done' });
6530
- void pushDispatchService?.notifyTurnComplete?.({
6531
- conversationId: targetConversationId,
6532
- messageId: responseId,
6533
- text: resolvedText,
6534
- });
6942
+ // An absorbed row is not a finished turn — the reply continues on the
6943
+ // steered message's row, whose own completion sends the one notification.
6944
+ // A stopped steer has no reply at all.
6945
+ if (!settledKind) {
6946
+ void pushDispatchService?.notifyTurnComplete?.({
6947
+ conversationId: targetConversationId,
6948
+ messageId: responseId,
6949
+ text: resolvedText,
6950
+ });
6951
+ }
6535
6952
  cancelPendingRelayQuestionsForMessage(messageId);
6536
6953
  res.json({ ok: true });
6537
6954
  });
@@ -6639,6 +7056,7 @@ export function registerMessagesRoutes(app, deps) {
6639
7056
  startedAt: Number(task?.startedAt) || null,
6640
7057
  summary: String(task?.summary || '').trim().slice(0, 500) || null,
6641
7058
  lastToolName: String(task?.lastToolName || '').trim() || null,
7059
+ lastToolCall: String(task?.lastToolCall || '').replace(/\s+/g, ' ').trim().slice(0, 300) || null,
6642
7060
  totalTokens: Number.isFinite(Number(task?.totalTokens)) ? Number(task.totalTokens) : null,
6643
7061
  subagentType: String(task?.subagentType || '').trim().slice(0, 120) || null,
6644
7062
  model: String(task?.model || '').trim().slice(0, 120) || null,
@@ -7091,6 +7509,11 @@ export function registerMessagesRoutes(app, deps) {
7091
7509
  console.log(`[${ts()}] CONTINUATION DROPPED ${messageId?.slice(0,8)} reason=requeue`);
7092
7510
  return res.json({ ok: true, dropped: 'continuation' });
7093
7511
  }
7512
+ if (q && q.status === 'processing' && q.consumed_at) {
7513
+ // The CLI already took this prompt: any requeue class would run it again.
7514
+ failConsumedQueueRow(q, { reason: 'requeue', attemptId: claimedAttemptId });
7515
+ return res.json({ ok: true, terminal: true, code: STEER_SETTLE_FAILED_STABLE_CODE });
7516
+ }
7094
7517
  if (q && q.status === 'processing' && String(req.body?.code || '').trim() === 'relay.provider-mismatch') {
7095
7518
  // A routing refusal, not a delivery failure: the legacy relay handed
7096
7519
  // back a turn that belongs to a session worker. No retry increment and
@@ -7105,6 +7528,25 @@ export function registerMessagesRoutes(app, deps) {
7105
7528
  console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=provider-mismatch retry=${Number(q.retry_count || 0)}`);
7106
7529
  return res.json({ ok: true, mismatch: true });
7107
7530
  }
7531
+ if (q && q.status === 'processing' && String(req.body?.class || '').trim() === 'steering-held') {
7532
+ // The worker refused to push the message because its live turn is
7533
+ // holding steering (an open question card, a compaction, an adoption).
7534
+ // Not a failure: no retry increment, no backoff, and the owner is kept —
7535
+ // the same worker takes it again the moment it signals ready, which is
7536
+ // when the hold ends, so the message steers into the resumed turn.
7537
+ const result = db.prepare(`
7538
+ UPDATE queue
7539
+ SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL
7540
+ WHERE id = ? AND status = 'processing'${claimedAttemptId ? ` AND attempt_id = ?` : ''}
7541
+ `).run(...(claimedAttemptId ? [messageId, claimedAttemptId] : [messageId]));
7542
+ if (result.changes > 0) {
7543
+ settleRelayAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
7544
+ settleRelaySubagentAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
7545
+ io.emit('message_status', { messageId, conversationId: q.conversation_id, status: 'pending' });
7546
+ console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=steering-held retry=${Number(q.retry_count || 0)}`);
7547
+ }
7548
+ return res.json({ ok: true, held: true });
7549
+ }
7108
7550
  if (q && q.status === 'processing') {
7109
7551
  const retryCount = Number(q.retry_count || 0) + 1;
7110
7552
  if (retryCount >= MAX_REQUEUE_RETRIES) {