@oar-sh/oar 0.9.9 → 0.9.11

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 (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -6,6 +6,8 @@ import { randomUUID } from 'crypto';
6
6
  import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
7
7
  import { resolveUploadMimeType } from '../services/mime-sniffer.mjs';
8
8
  import { fileVersionToken, serveFileWithRangeSupport, withFileVersion } from '../services/file-serving.mjs';
9
+ import { validateEntryName } from '../services/explorer-entry-name.mjs';
10
+ import { countEntries, createDirectory, removeEntry, renameEntry } from '../services/explorer-mutations.mjs';
9
11
  import {
10
12
  shouldParkForRestart,
11
13
  parkPendingQueueForRestart,
@@ -39,14 +41,28 @@ import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mj
39
41
  import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning-effort.mjs';
40
42
  import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
41
43
  import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
44
+ import { normalizeBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
42
45
  import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
43
46
  import { isLiveResend } from '../services/steer-resend.mjs';
44
47
  import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
45
48
  import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
46
49
  import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
50
+ import {
51
+ createSubagentTimelineService,
52
+ mapSubagentRunHeader,
53
+ sanitizeRunHeaderPatch,
54
+ TIMELINE_READ_DEFAULT_LIMIT,
55
+ } from '../services/subagent-timeline-service.mjs';
56
+ import { normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
57
+ import { RELAY_EVENT_MESSAGE_KIND } from '../../shared/relay-events.mjs';
58
+ import { isRelayShutdownQueued, relayShutdownGateReply } from '../services/relay-shutdown-gate.mjs';
59
+ import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limiter.mjs';
60
+ import { createSubagentTimelinePublisher } from '../services/subagent-viewer-rooms.mjs';
61
+ import { createLocalRequestCheck } from '../services/request-boundary.mjs';
47
62
  import { buildClaudeCloudDelivery, isClaudeCloudProviderType } from '../services/claude-cloud-session-service.mjs';
48
63
  import {
49
64
  admitRemoteRelayRequest,
65
+ readRemoteRelayRequest,
50
66
  findMentionedRemoteRelays,
51
67
  findOriginRemoteRelay,
52
68
  formatRemoteRelayOriginHint,
@@ -1180,6 +1196,12 @@ export function buildDequeuedRelayMessage({
1180
1196
  fallbackEnvironmentId: getClaudeCloudProviderSettings()?.environmentId || '',
1181
1197
  })
1182
1198
  : null;
1199
+ // An event message the relay wrote (relay event subscriptions): the
1200
+ // workers deliver it without the steer note, which speaks as the user.
1201
+ let messageKind = '';
1202
+ try {
1203
+ messageKind = String(stmts.getMessageByConversation?.get?.(msg.id, msg.conversation_id)?.kind || '').trim();
1204
+ } catch {}
1183
1205
  return {
1184
1206
  id: msg.id,
1185
1207
  conversationId: msg.conversation_id,
@@ -1211,6 +1233,7 @@ export function buildDequeuedRelayMessage({
1211
1233
  ownerAssignedAt: msg.owner_assigned_at || null,
1212
1234
  ownerLeaseExpiresAt: msg.owner_lease_expires_at || null,
1213
1235
  imageOperationId: String(msg.image_operation_id || '').trim() || null,
1236
+ ...(messageKind === RELAY_EVENT_MESSAGE_KIND ? { messageKind } : {}),
1214
1237
  };
1215
1238
  }
1216
1239
 
@@ -1755,6 +1778,7 @@ export function registerMessagesRoutes(app, deps) {
1755
1778
  getModelCatalogState,
1756
1779
  buildRelayReadyBannerData,
1757
1780
  backgroundTaskStore,
1781
+ backgroundWorkView = null,
1758
1782
  sendWorkerControl,
1759
1783
  ensureSessionId,
1760
1784
  touchCli,
@@ -1791,6 +1815,10 @@ export function registerMessagesRoutes(app, deps) {
1791
1815
  relayRestartOrchestrator,
1792
1816
  requestRelayShutdown,
1793
1817
  cancelRelayShutdown = null,
1818
+ // Truly local = loopback peer, no tunnel marker, no forwarding header
1819
+ // (services/request-boundary.mjs). The runtime passes the check built
1820
+ // with the configured marker headers.
1821
+ isTrulyLocalRequest = createLocalRequestCheck().isTrulyLocal,
1794
1822
  featureFlags,
1795
1823
  sessionWorkerRegistry,
1796
1824
  sessionWorkerSupervisor,
@@ -1813,9 +1841,93 @@ export function registerMessagesRoutes(app, deps) {
1813
1841
  // agent-sent messages, the inbound switch, and mention unlocks. Absent in
1814
1842
  // older wiring and most tests, where every remote-relay step is a no-op.
1815
1843
  remoteRelayInbound = null,
1844
+ // Session groups (repositories/conversation-group-repository.mjs): the
1845
+ // group stop route. Absent in older wiring and most tests.
1846
+ conversationGroups = null,
1847
+ // Relay event subscriptions (services/relay-event-service.mjs) and the
1848
+ // per-session activity they read (services/session-activity-tracker.mjs).
1849
+ // Absent in older wiring and most tests: every hook is then a no-op.
1850
+ relayEventService = null,
1851
+ sessionActivityTracker = null,
1852
+ // A worker's SDK session id or a conversation id → the conversation row.
1853
+ resolveConversationRecord = null,
1816
1854
  } = deps;
1817
1855
 
1818
1856
  const ttyConsoleActive = runtimeState?.ttyConsoleActive === true;
1857
+
1858
+ // ─── Sub-agent runs: header, timeline, live events ─────────────────────────
1859
+ const subagentTimeline = createSubagentTimelineService({ db, stmts });
1860
+
1861
+ // The live background task a run belongs to (the panel's row), if any: the
1862
+ // task's spawning tool_use id is the run id.
1863
+ function liveTaskForSubagentRun(run) {
1864
+ const tasks = backgroundTaskStore?.get?.(run?.conversation_id) || [];
1865
+ const runId = String(run?.id || '');
1866
+ const taskId = String(run?.task_id || '');
1867
+ return tasks.find((task) => (task?.toolUseId && task.toolUseId === runId) || (taskId && task?.taskId === taskId)) || null;
1868
+ }
1869
+
1870
+ // Stop is offered while a run is running: a background run through its
1871
+ // task, a foreground run through the sub-agent cancel route.
1872
+ function isSubagentRunStoppable(run) {
1873
+ if (String(run?.status || '') !== 'running') return false;
1874
+ const task = liveTaskForSubagentRun(run);
1875
+ if (task) return task.stoppable !== false;
1876
+ return Number(run?.background || 0) !== 1;
1877
+ }
1878
+
1879
+ // Every subagent_status event carries the whole header and the run's own
1880
+ // turn, so a client can place it without having seen the run before.
1881
+ function subagentStatusEvent(run, extra = {}) {
1882
+ const header = mapSubagentRunHeader(run, { stoppable: isSubagentRunStoppable(run) });
1883
+ if (!header) return null;
1884
+ return { ...header, timestamp: new Date().toISOString(), ...extra };
1885
+ }
1886
+
1887
+ function emitSubagentStatus(run, extra = {}) {
1888
+ const event = subagentStatusEvent(run, extra);
1889
+ if (event) io.emit('subagent_status', event);
1890
+ try { sessionActivityTracker?.noteSubagentRun?.(run); } catch {}
1891
+ }
1892
+
1893
+ // Only to the pages whose viewer shows this run, and volatile: a batch a
1894
+ // connection cannot take is dropped (never kept for a reconnect replay);
1895
+ // the viewer sees the hole in `seq` and fetches it.
1896
+ const publishSubagentTimeline = createSubagentTimelinePublisher(io, deps.subagentTimelineDefer ? { defer: deps.subagentTimelineDefer } : {});
1897
+ function emitSubagentTimeline(run, entries) {
1898
+ if (!run || !entries?.length) return;
1899
+ publishSubagentTimeline({
1900
+ conversationId: String(run.conversation_id || ''),
1901
+ messageId: run.queue_message_id ? String(run.queue_message_id) : null,
1902
+ subagentRunId: String(run.id),
1903
+ entries,
1904
+ truncated: Number(run.truncated || 0) === 1,
1905
+ });
1906
+ }
1907
+
1908
+ // Copilot and Cursor publish their sub-agents on the live lanes only; the
1909
+ // relay copies each frame into the run's timeline (the Claude worker writes
1910
+ // its own and owns its runs' timelines).
1911
+ function mirrorSubagentFrame(frame) {
1912
+ if (!frame?.runId) return;
1913
+ try {
1914
+ const mirrored = subagentTimeline.mirrorFrame(frame);
1915
+ if (!mirrored) return;
1916
+ const run = mirrored.truncated ? (stmts.getSubagentRun?.get?.(frame.runId) || mirrored.run) : mirrored.run;
1917
+ if (mirrored.truncated) emitSubagentStatus(run);
1918
+ emitSubagentTimeline(run, mirrored.entries);
1919
+ } catch (error) {
1920
+ console.warn(`[${ts()}] SUBAGENT MIRROR failed run=${String(frame.runId).slice(0, 12)}: ${error?.message || error}`);
1921
+ }
1922
+ }
1923
+
1924
+ function activityTimelineFrame(text) {
1925
+ const value = String(text || '');
1926
+ const toolMatch = /^Tool \(([^)]+)\)/.exec(value);
1927
+ if (toolMatch) return { kind: 'tool_call', toolName: toolMatch[1], text: value };
1928
+ if (/^Tool failed:/i.test(value)) return { kind: 'tool_result', text: value, metadata: { isError: true } };
1929
+ return { kind: 'note', text: value };
1930
+ }
1819
1931
  const getMessageAttachmentsByConversation = db.prepare(`
1820
1932
  SELECT attachments
1821
1933
  FROM messages
@@ -1835,21 +1947,6 @@ export function registerMessagesRoutes(app, deps) {
1835
1947
  || event.includes('blocked');
1836
1948
  }
1837
1949
 
1838
- function isLoopbackAddress(value) {
1839
- const text = String(value || '').trim().toLowerCase();
1840
- if (!text) return false;
1841
- return text === '127.0.0.1'
1842
- || text === '::1'
1843
- || text === '::ffff:127.0.0.1'
1844
- || text === '::ffff:7f00:1';
1845
- }
1846
-
1847
- function isLoopbackRequest(req) {
1848
- const socketAddress = req?.socket?.remoteAddress;
1849
- const ipAddress = req?.ip;
1850
- return isLoopbackAddress(socketAddress) || isLoopbackAddress(ipAddress);
1851
- }
1852
-
1853
1950
  function readBridgeIdentity(req) {
1854
1951
  return relayBridgeOwnerService?.normalizeIdentity?.({
1855
1952
  pid: req.headers['x-relay-process-pid'],
@@ -1999,25 +2096,28 @@ export function registerMessagesRoutes(app, deps) {
1999
2096
  };
2000
2097
  }
2001
2098
 
2099
+ // One warning per superseded attempt: a worker streaming for a requeued
2100
+ // row writes several times a second until it learns of the refusal.
2101
+ const staleAttemptLog = createStaleAttemptLogLimiter({ warn: (line) => console.warn(`[${ts()}] ${line}`) });
2102
+
2002
2103
  function rejectSessionBinding(res, status, error, extra = {}) {
2003
2104
  return res.status(status).json({ error, ...extra });
2004
2105
  }
2005
2106
 
2006
2107
  function reconcileSubagentRunsForTerminalTurn(messageId, { status = 'failed' } = {}) {
2007
- const running = stmts.listRunningSubagentRunsByQueueMessage?.all?.(messageId) || [];
2108
+ // A background run outlives the turn that spawned it: its worker reports
2109
+ // its end (task_notification), and a dead worker's runs are closed by the
2110
+ // worker-death sweep. Closing it here marked live agents failed.
2111
+ const running = (stmts.listRunningSubagentRunsByQueueMessage?.all?.(messageId) || [])
2112
+ .filter((run) => Number(run.background || 0) !== 1 && !liveTaskForSubagentRun(run));
2008
2113
  if (!running.length) return 0;
2009
2114
  const now = new Date().toISOString();
2010
- stmts.closeRunningSubagentRunsByQueueMessage?.run?.(status, now, now, messageId);
2011
2115
  for (const run of running) {
2012
- io.emit('subagent_status', {
2013
- conversationId: run.conversation_id,
2014
- subagentRunId: run.id,
2015
- parentSubagentId: run.parent_subagent_id || null,
2016
- displayName: run.display_name || null,
2017
- status,
2018
- queueMessageId: messageId,
2019
- reconciled: true,
2020
- });
2116
+ stmts.updateSubagentRunStatus?.run?.(status, now, now, run.id);
2117
+ const closed = stmts.getSubagentRun?.get?.(run.id) || { ...run, status, completed_at: now };
2118
+ // messageId is the run's own turn: the browser drops a status event it
2119
+ // cannot place, which left these runs showing "running" until a reload.
2120
+ emitSubagentStatus(closed, { queueMessageId: messageId, reconciled: true });
2021
2121
  }
2022
2122
  return running.length;
2023
2123
  }
@@ -2104,6 +2204,19 @@ export function registerMessagesRoutes(app, deps) {
2104
2204
  });
2105
2205
  io.emit('message_status', { messageId, conversationId, status: 'failed' });
2106
2206
  void pushDispatchService?.notifyTurnFailed?.({ conversationId, messageId: responseId, sourceMessageId: messageId, text: responseText });
2207
+ // A Stop from the relay ends the turn; anything else failed it.
2208
+ try {
2209
+ if (String(failureRecord?.code || '') === 'turn-aborted') {
2210
+ relayEventService?.noteTurnEnd?.({ conversationId, queueMessageId: messageId, status: 'stopped' });
2211
+ } else {
2212
+ relayEventService?.noteError?.({
2213
+ conversationId,
2214
+ kind: 'turn_failed',
2215
+ message: failureRecord?.message || failureRecord?.error || responseText,
2216
+ queueMessageId: messageId,
2217
+ });
2218
+ }
2219
+ } catch {}
2107
2220
  cancelPendingRelayQuestionsForMessage(messageId);
2108
2221
  const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)
2109
2222
  ? normalizeSessionWorkerId(queueRow?.owner_sdk_session_id)
@@ -2849,36 +2962,54 @@ export function registerMessagesRoutes(app, deps) {
2849
2962
  if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
2850
2963
  const conversationId = String(req.params.conversationId || '').trim();
2851
2964
  const requestedMessageId = String(req.body?.messageId || req.body?.queueMessageId || '').trim();
2965
+ const outcome = requestTurnStop(conversationId, {
2966
+ requestedMessageId,
2967
+ clientId: String(req.body?.clientId || '').trim() || null,
2968
+ });
2969
+ if (!outcome.ok) return rejectSessionBinding(res, outcome.status, outcome.error);
2970
+ return res.json(outcome.body);
2971
+ });
2972
+
2973
+ /**
2974
+ * The work of POST /api/conversation/:id/cancel-turn: an `abort_turn`
2975
+ * control for the live turn. `{ ok: true, body }` with the route's answer,
2976
+ * or `{ ok: false, status, error }` when the conversation is unknown.
2977
+ */
2978
+ function requestTurnStop(conversationId, { requestedMessageId = '', clientId = null } = {}) {
2852
2979
  const sessionState = getConversationSessionState(conversationId);
2853
- if (!sessionState.ok) {
2854
- return rejectSessionBinding(res, sessionState.status, sessionState.error);
2855
- }
2980
+ if (!sessionState.ok) return { ok: false, status: sessionState.status, error: sessionState.error };
2856
2981
 
2857
2982
  // The live turn, not merely the newest processing row: a steered message
2858
2983
  // folded into the running turn is also `processing`, and Stop must target
2859
2984
  // the turn actually executing (which the worker interrupts as a whole).
2860
2985
  const queueRow = resolveLiveTurnQueueRow(conversationId) || null;
2861
2986
  if (!queueRow) {
2862
- return res.json({
2987
+ return {
2863
2988
  ok: true,
2864
- queued: false,
2865
- acknowledgement: 'no-active-turn',
2866
- requestedMessageId: requestedMessageId || null,
2867
- activeMessageId: null,
2868
- });
2989
+ body: {
2990
+ ok: true,
2991
+ queued: false,
2992
+ acknowledgement: 'no-active-turn',
2993
+ requestedMessageId: requestedMessageId || null,
2994
+ activeMessageId: null,
2995
+ },
2996
+ };
2869
2997
  }
2870
2998
 
2871
2999
  const activeMessageId = String(queueRow.id || '').trim();
2872
3000
  if (requestedMessageId && activeMessageId && requestedMessageId !== activeMessageId) {
2873
3001
  const requestedRow = stmts.findQById.get(requestedMessageId) || null;
2874
3002
  const requestedStatus = String(requestedRow?.status || '').trim().toLowerCase();
2875
- return res.json({
3003
+ return {
2876
3004
  ok: true,
2877
- queued: false,
2878
- acknowledgement: requestedStatus === 'processing' ? 'message-mismatch' : 'already-finished',
2879
- requestedMessageId,
2880
- activeMessageId,
2881
- });
3005
+ body: {
3006
+ ok: true,
3007
+ queued: false,
3008
+ acknowledgement: requestedStatus === 'processing' ? 'message-mismatch' : 'already-finished',
3009
+ requestedMessageId,
3010
+ activeMessageId,
3011
+ },
3012
+ };
2882
3013
  }
2883
3014
 
2884
3015
  const sdkSessionId = normalizeSessionWorkerId(
@@ -2887,33 +3018,39 @@ export function registerMessagesRoutes(app, deps) {
2887
3018
  || sessionState.conversationSdkSessionId,
2888
3019
  );
2889
3020
  if (!sdkSessionId) {
2890
- return res.json({
3021
+ return {
2891
3022
  ok: true,
2892
- queued: false,
2893
- acknowledgement: 'active-turn-unbound',
2894
- requestedMessageId: activeMessageId || requestedMessageId || null,
2895
- activeMessageId,
2896
- });
3023
+ body: {
3024
+ ok: true,
3025
+ queued: false,
3026
+ acknowledgement: 'active-turn-unbound',
3027
+ requestedMessageId: activeMessageId || requestedMessageId || null,
3028
+ activeMessageId,
3029
+ },
3030
+ };
2897
3031
  }
2898
3032
 
2899
3033
  const existing = findActiveRelayControlByQueueMessage.get(activeMessageId, 'abort_turn') || null;
2900
3034
  if (existing) {
2901
- return res.json({
3035
+ return {
2902
3036
  ok: true,
2903
- queued: true,
2904
- duplicate: true,
2905
- acknowledgement: 'already-requested',
2906
- requestedMessageId: activeMessageId || requestedMessageId || null,
2907
- activeMessageId,
2908
- control: formatRelayControlResponse(existing),
2909
- });
3037
+ body: {
3038
+ ok: true,
3039
+ queued: true,
3040
+ duplicate: true,
3041
+ acknowledgement: 'already-requested',
3042
+ requestedMessageId: activeMessageId || requestedMessageId || null,
3043
+ activeMessageId,
3044
+ control: formatRelayControlResponse(existing),
3045
+ },
3046
+ };
2910
3047
  }
2911
3048
 
2912
3049
  const now = new Date().toISOString();
2913
3050
  const controlId = uuidv4();
2914
3051
  const requestPayload = JSON.stringify({
2915
3052
  source: 'relay-ui',
2916
- requestedByClientId: String(req.body?.clientId || '').trim() || null,
3053
+ requestedByClientId: clientId || null,
2917
3054
  });
2918
3055
  insertRelayControlRequest.run(
2919
3056
  controlId,
@@ -2926,14 +3063,59 @@ export function registerMessagesRoutes(app, deps) {
2926
3063
  now,
2927
3064
  );
2928
3065
  const control = getRelayControlRequestById.get(controlId) || null;
2929
- return res.json({
3066
+ return {
2930
3067
  ok: true,
2931
- queued: true,
2932
- acknowledgement: 'stop-queued',
2933
- requestedMessageId: activeMessageId || requestedMessageId || null,
2934
- activeMessageId,
2935
- control: formatRelayControlResponse(control),
2936
- });
3068
+ body: {
3069
+ ok: true,
3070
+ queued: true,
3071
+ acknowledgement: 'stop-queued',
3072
+ requestedMessageId: activeMessageId || requestedMessageId || null,
3073
+ activeMessageId,
3074
+ control: formatRelayControlResponse(control),
3075
+ },
3076
+ };
3077
+ }
3078
+
3079
+ // POST /api/conversation-groups/:id/stop — "Stop all running" on a session
3080
+ // group's head row: a stop for every member's live turn, and a stop for
3081
+ // every stoppable background task. Tasks the panel shows no Stop button
3082
+ // for are listed as `unstoppable`. The owner's action only: a paired
3083
+ // relay's agent is refused.
3084
+ app.post('/api/conversation-groups/:id/stop', auth, (req, res) => {
3085
+ if (readRemoteRelayRequest(req, remoteRelayInbound).remote) {
3086
+ return res.status(403).json({ error: 'Session groups are managed by this relay\'s owner only.' });
3087
+ }
3088
+ const groupId = String(req.params.id || '').trim();
3089
+ const group = groupId ? conversationGroups?.getGroup?.(groupId) : null;
3090
+ if (!group) return res.status(404).json({ ok: false, error: 'Group not found' });
3091
+ const clientId = String(req.body?.clientId || '').trim() || null;
3092
+ const turns = [];
3093
+ const tasks = [];
3094
+ const unstoppable = [];
3095
+ for (const member of conversationGroups.membersOf(groupId)) {
3096
+ if (member.archived) continue;
3097
+ const outcome = requestTurnStop(member.id, { clientId });
3098
+ if (outcome.ok && outcome.body.queued === true) turns.push({ id: member.id, messageId: outcome.body.activeMessageId });
3099
+ const conversation = stmts.getConvAnyStatus?.get?.(member.id) || null;
3100
+ const sessionId = String(conversation?.sdk_session_id || member.id);
3101
+ const liveTasks = [...new Set([member.id, sessionId])].flatMap((key) => backgroundTaskStore?.get?.(key) || []);
3102
+ for (const task of liveTasks) {
3103
+ // The store holds what the publish route keeps: `taskId`.
3104
+ const taskId = String(task?.taskId || '').trim();
3105
+ if (!taskId) continue;
3106
+ if (task?.stoppable === false) {
3107
+ unstoppable.push({ id: member.id, taskId, reason: 'no-stop' });
3108
+ continue;
3109
+ }
3110
+ const sent = sendWorkerControl?.(sessionId, { type: 'stop_background_task', taskId });
3111
+ if (sent) tasks.push({ id: member.id, taskId });
3112
+ else unstoppable.push({ id: member.id, taskId, reason: 'worker-not-connected' });
3113
+ }
3114
+ }
3115
+ if (turns.length || tasks.length) {
3116
+ console.log(`[${ts()}] GROUP STOP ${groupId.slice(0, 12)} turns=${turns.length} tasks=${tasks.length}`);
3117
+ }
3118
+ return res.json({ ok: true, turns, tasks, unstoppable });
2937
3119
  });
2938
3120
 
2939
3121
  app.post('/api/conversation/:conversationId/subagent/:subagentRunId/cancel', auth, (req, res) => {
@@ -3825,6 +4007,137 @@ export function registerMessagesRoutes(app, deps) {
3825
4007
  });
3826
4008
  });
3827
4009
 
4010
+ // ---------------------------------------------------------------------------
4011
+ // Explorer changes: new folder, rename, delete. Two routes per operation,
4012
+ // one for the conversation's workspace (relative paths, resolved exactly like
4013
+ // `/api/files/*`) and one for the drives and session roots (absolute paths,
4014
+ // checked exactly like `/api/drives/list`). Each write route therefore
4015
+ // reaches nothing its listing route would not show.
4016
+ // ---------------------------------------------------------------------------
4017
+
4018
+ function workspaceWebPath(relativePath) {
4019
+ return String(relativePath || '').split(path.sep).join('/');
4020
+ }
4021
+
4022
+ // Resolve a workspace entry from a request body: `{ absolutePath, relative }`
4023
+ // or `null` after answering the request with the error.
4024
+ function resolveWorkspaceMutationTarget(req, res, rawRelativePath) {
4025
+ const relative = normalizeWorkspaceRelativePath(rawRelativePath);
4026
+ if (!relative) {
4027
+ res.status(400).json({ error: 'Invalid path' });
4028
+ return null;
4029
+ }
4030
+ const absolutePath = resolveWorkspaceFilePath(relative, resolveScopedWorkspaceRootPath(req));
4031
+ if (!absolutePath) {
4032
+ res.status(400).json({ error: 'Invalid path' });
4033
+ return null;
4034
+ }
4035
+ return { absolutePath, relative };
4036
+ }
4037
+
4038
+ // Resolve an absolute drives/session path from a request body and hand it
4039
+ // on, with the function that turns an absolute path into the explorer's
4040
+ // path form for the answer. Answers the request itself on a bad path.
4041
+ function withDriveMutationTarget(req, res, rawPath, { allowRoot = false } = {}, next) {
4042
+ const requestedPath = String(rawPath || '').trim();
4043
+ if (process.platform !== 'win32') {
4044
+ const absolutePath = normalizeLinuxAbsolutePath(requestedPath);
4045
+ if (!absolutePath) return res.status(400).json({ error: 'Invalid Linux path' });
4046
+ if (absolutePath === '/' && !allowRoot) return res.status(400).json({ error: 'The root folder cannot be changed' });
4047
+ return next(absolutePath, (value) => value);
4048
+ }
4049
+ return fetchBrowsableDrives((drivesErr, drives) => {
4050
+ if (drivesErr) return res.status(500).json({ error: drivesErr.message || 'Failed to enumerate drives' });
4051
+ const allowedRoots = new Set(drives.map((drive) => drive.rootAbsolute.toUpperCase()));
4052
+ const absolutePath = normalizeDriveAbsolutePath(requestedPath);
4053
+ const rootAbsolute = driveRootFromAbsolutePath(absolutePath).toUpperCase();
4054
+ if (!absolutePath || !rootAbsolute || !allowedRoots.has(rootAbsolute)) {
4055
+ return res.status(400).json({ error: 'Invalid drive path' });
4056
+ }
4057
+ if (absolutePath.toUpperCase() === rootAbsolute && !allowRoot) {
4058
+ return res.status(400).json({ error: 'A drive root cannot be changed' });
4059
+ }
4060
+ return next(absolutePath, toDriveWebPath);
4061
+ });
4062
+ }
4063
+
4064
+ function validatedName(res, rawName) {
4065
+ const check = validateEntryName(rawName);
4066
+ if (!check.ok) {
4067
+ res.status(400).json({ error: check.error });
4068
+ return null;
4069
+ }
4070
+ return check.name;
4071
+ }
4072
+
4073
+ function sendMutationResult(res, result, extra = {}) {
4074
+ if (!result?.ok) return res.status(Number(result?.statusCode) || 500).json({ error: result?.error || 'Request failed' });
4075
+ res.setHeader('Cache-Control', 'no-store');
4076
+ return res.json({ ok: true, ...extra });
4077
+ }
4078
+
4079
+ app.post('/api/repo/mkdir', auth, (req, res) => {
4080
+ const name = validatedName(res, req.body?.name);
4081
+ if (!name) return;
4082
+ const parentRelative = normalizeWorkspaceRelativePath(req.body?.path);
4083
+ if (String(req.body?.path || '').trim() && !parentRelative) return res.status(400).json({ error: 'Invalid path' });
4084
+ const target = resolveWorkspaceMutationTarget(req, res, parentRelative ? `${workspaceWebPath(parentRelative)}/${name}` : name);
4085
+ if (!target) return;
4086
+ return sendMutationResult(res, createDirectory(target.absolutePath), { path: workspaceWebPath(target.relative) });
4087
+ });
4088
+
4089
+ app.post('/api/repo/rename', auth, (req, res) => {
4090
+ const newName = validatedName(res, req.body?.newName);
4091
+ if (!newName) return;
4092
+ const source = resolveWorkspaceMutationTarget(req, res, req.body?.path);
4093
+ if (!source) return;
4094
+ const parentWeb = workspaceWebPath(path.dirname(source.relative));
4095
+ const destinationWeb = parentWeb && parentWeb !== '.' ? `${parentWeb}/${newName}` : newName;
4096
+ // The destination goes through the same resolver: a new name can never
4097
+ // leave the workspace or land on the root.
4098
+ const destination = resolveWorkspaceMutationTarget(req, res, destinationWeb);
4099
+ if (!destination) return;
4100
+ return sendMutationResult(res, renameEntry(source.absolutePath, newName), { path: workspaceWebPath(destination.relative) });
4101
+ });
4102
+
4103
+ app.post('/api/repo/delete', auth, (req, res) => {
4104
+ const target = resolveWorkspaceMutationTarget(req, res, req.body?.path);
4105
+ if (!target) return;
4106
+ if (req.body?.dryRun === true) {
4107
+ const counted = countEntries(target.absolutePath);
4108
+ return sendMutationResult(res, counted, counted.ok ? { kind: counted.kind, files: counted.files, dirs: counted.dirs, capped: counted.capped } : {});
4109
+ }
4110
+ return sendMutationResult(res, removeEntry(target.absolutePath));
4111
+ });
4112
+
4113
+ app.post('/api/drives/mkdir', auth, (req, res) => {
4114
+ const name = validatedName(res, req.body?.name);
4115
+ if (!name) return;
4116
+ withDriveMutationTarget(req, res, req.body?.path, { allowRoot: true }, (parentAbsolute, webPathOf) => {
4117
+ const targetAbsolute = path.join(parentAbsolute, name);
4118
+ return sendMutationResult(res, createDirectory(targetAbsolute), { path: webPathOf(targetAbsolute) });
4119
+ });
4120
+ });
4121
+
4122
+ app.post('/api/drives/rename', auth, (req, res) => {
4123
+ const newName = validatedName(res, req.body?.newName);
4124
+ if (!newName) return;
4125
+ withDriveMutationTarget(req, res, req.body?.path, {}, (sourceAbsolute, webPathOf) => {
4126
+ const result = renameEntry(sourceAbsolute, newName);
4127
+ return sendMutationResult(res, result, result.ok ? { path: webPathOf(result.path) } : {});
4128
+ });
4129
+ });
4130
+
4131
+ app.post('/api/drives/delete', auth, (req, res) => {
4132
+ withDriveMutationTarget(req, res, req.body?.path, {}, (targetAbsolute) => {
4133
+ if (req.body?.dryRun === true) {
4134
+ const counted = countEntries(targetAbsolute);
4135
+ return sendMutationResult(res, counted, counted.ok ? { kind: counted.kind, files: counted.files, dirs: counted.dirs, capped: counted.capped } : {});
4136
+ }
4137
+ return sendMutationResult(res, removeEntry(targetAbsolute));
4138
+ });
4139
+ });
4140
+
3828
4141
  app.get('/api/drives/file', auth, (req, res) => {
3829
4142
  const requestedPath = String(req.query.path || '').trim();
3830
4143
 
@@ -5065,6 +5378,7 @@ export function registerMessagesRoutes(app, deps) {
5065
5378
  const requesterSessionId = normalizeSessionWorkerId(requester?.sessionId);
5066
5379
  const settleFailedHandled = [];
5067
5380
  const settleFailedSkipped = [];
5381
+ let resyncBackgroundTasks = false;
5068
5382
  const activeQueueMessageId = String(req.body?.activeQueueMessageId || '').trim();
5069
5383
  // A persistent-process worker can hold several live rows at once (the
5070
5384
  // running turn plus a delivered message queued behind it plus a
@@ -5131,6 +5445,32 @@ export function registerMessagesRoutes(app, deps) {
5131
5445
  }
5132
5446
  }
5133
5447
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
5448
+ // The worker's digest of its live background-task ids against the set
5449
+ // held for it (keyed as the worker posts, by its session id). They
5450
+ // differ after a relay restart, which starts with no sets, and after a
5451
+ // lost post either way round: the answer then asks for the full set
5452
+ // again. A worker that sends no digest has no opinion and is never
5453
+ // asked; a relay that holds a set while the worker reports none asks
5454
+ // too, and the empty answer ends those tasks.
5455
+ // The process this beat comes from holds the session: a set post of
5456
+ // another process is a late one (see the task route).
5457
+ backgroundTaskStore?.noteHolder?.(requesterSessionId, normalizedRequesterPid);
5458
+ const taskDigest = normalizeBackgroundTaskDigest(req.body?.backgroundTasks);
5459
+ if (taskDigest && typeof backgroundTaskStore?.matches === 'function') {
5460
+ resyncBackgroundTasks = !backgroundTaskStore.matches(taskDigest, requesterSessionId, requesterConversationId);
5461
+ }
5462
+ // The placeholder for background work this runtime was never told the
5463
+ // details of (a worker that cannot resync yet): the pages hear when it
5464
+ // appears and when it goes without a set having replaced it.
5465
+ if (!resyncBackgroundTasks && backgroundWorkView) {
5466
+ const unreported = backgroundWorkView.hasUnreportedWork(requesterSessionId) === true;
5467
+ if (backgroundTaskStore?.announce?.(requesterSessionId, unreported)) {
5468
+ io.emit('background_tasks', {
5469
+ conversationId: requesterSessionId,
5470
+ tasks: backgroundWorkView.panelTasks(requesterSessionId, requesterConversationId),
5471
+ });
5472
+ }
5473
+ }
5134
5474
  // Consumed steers whose settle the worker could not save within its
5135
5475
  // retry budget (e.g. its writes keep failing): the relay fails them
5136
5476
  // itself with the worker's terminal error, never requeueing them.
@@ -5190,6 +5530,7 @@ export function registerMessagesRoutes(app, deps) {
5190
5530
  pendingCount,
5191
5531
  ...(settleFailedHandled.length ? { settleFailedHandled } : {}),
5192
5532
  ...(settleFailedSkipped.length ? { settleFailedSkipped } : {}),
5533
+ ...(resyncBackgroundTasks ? { resyncBackgroundTasks: true } : {}),
5193
5534
  });
5194
5535
  });
5195
5536
 
@@ -5249,7 +5590,7 @@ export function registerMessagesRoutes(app, deps) {
5249
5590
  }
5250
5591
  const rowAttemptId = String(row.attempt_id || '').trim() || null;
5251
5592
  if (attemptId && rowAttemptId && attemptId !== rowAttemptId) {
5252
- console.warn(`[${ts()}] STALE ATTEMPT queue-cancelled ${messageId.slice(0, 8)} attempt=${attemptId.slice(0, 8)} current=${rowAttemptId.slice(0, 8)}`);
5593
+ staleAttemptLog.note({ messageId, attemptId, currentAttemptId: rowAttemptId, route: 'queue-cancelled', status: row.status });
5253
5594
  return res.status(409).json({ error: 'stale_attempt' });
5254
5595
  }
5255
5596
 
@@ -5704,6 +6045,9 @@ export function registerMessagesRoutes(app, deps) {
5704
6045
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
5705
6046
  }
5706
6047
  if (runtimeState.relayPaused) return res.json({ message: null, paused: true });
6048
+ // A queued shutdown or restart waits for the processing turns only: a
6049
+ // pending row handed out now would start a turn that postpones it again.
6050
+ if (isRelayShutdownQueued(runtimeState)) return res.json(relayShutdownGateReply(runtimeState));
5707
6051
  if (runtimeState.tunnelState?.blocking) {
5708
6052
  return res.json({
5709
6053
  message: null,
@@ -6170,10 +6514,10 @@ export function registerMessagesRoutes(app, deps) {
6170
6514
  return res.json({ ok: true, recovered: rows.length, maxAgeMs });
6171
6515
  });
6172
6516
 
6517
+ // Emptying the queue, restarting the relay and withdrawing a restart are
6518
+ // the user's own maintenance actions: the browser app calls them from a
6519
+ // phone, over the tunnel and over the LAN. The token is the boundary.
6173
6520
  app.post('/api/queue/empty', auth, (req, res) => {
6174
- if (!isLoopbackRequest(req)) {
6175
- return res.status(403).json({ error: 'Queue empty endpoint is localhost-only' });
6176
- }
6177
6521
  const rows = stmts.listQueueForPauseDrop.all();
6178
6522
  const droppedCount = rows.length;
6179
6523
  if (!droppedCount) {
@@ -6201,9 +6545,6 @@ export function registerMessagesRoutes(app, deps) {
6201
6545
  });
6202
6546
 
6203
6547
  app.post('/api/relay/shutdown', auth, (req, res) => {
6204
- if (!isLoopbackRequest(req)) {
6205
- return res.status(403).json({ error: 'Relay shutdown endpoint is localhost-only' });
6206
- }
6207
6548
  if (typeof requestRelayShutdown !== 'function') {
6208
6549
  return res.status(501).json({ error: 'Relay shutdown orchestration is unavailable' });
6209
6550
  }
@@ -6221,9 +6562,6 @@ export function registerMessagesRoutes(app, deps) {
6221
6562
  // Withdraw a queued (not yet started) shutdown/restart. Same trust boundary
6222
6563
  // as queueing one.
6223
6564
  app.post('/api/relay/shutdown/cancel', auth, (req, res) => {
6224
- if (!isLoopbackRequest(req)) {
6225
- return res.status(403).json({ error: 'Relay shutdown endpoint is localhost-only' });
6226
- }
6227
6565
  if (typeof cancelRelayShutdown !== 'function') {
6228
6566
  return res.status(501).json({ error: 'Relay shutdown orchestration is unavailable' });
6229
6567
  }
@@ -6232,9 +6570,11 @@ export function registerMessagesRoutes(app, deps) {
6232
6570
  return res.json({ ok: true, ...result });
6233
6571
  });
6234
6572
 
6573
+ // Called by the relay's own worker on this machine; nothing that came in
6574
+ // through a proxy or the tunnel may start a provider image request.
6235
6575
  app.post('/api/image-operations/:operationId/execute', auth, async (req, res) => {
6236
- if (!isLoopbackRequest(req)) {
6237
- return res.status(403).json({ error: 'Image operation execution is localhost-only' });
6576
+ if (!isTrulyLocalRequest(req)) {
6577
+ return res.status(403).json({ error: 'Image operation execution is reachable from this machine only' });
6238
6578
  }
6239
6579
  if (featureFlags?.IMAGE_CONVERSATION_CONTINUITY_ENABLED !== true) {
6240
6580
  return res.status(404).json({ error: 'Image continuity is not enabled' });
@@ -6689,7 +7029,7 @@ export function registerMessagesRoutes(app, deps) {
6689
7029
  // the legacy conditional semantics.
6690
7030
  const claimedAttemptId = String(req.body.attemptId || '').trim() || null;
6691
7031
  if (claimedAttemptId && q && String(q.attempt_id || '') !== claimedAttemptId) {
6692
- console.warn(`[${ts()}] STALE ATTEMPT ${messageId?.slice(0,8)} route=response claimed=${claimedAttemptId.slice(0,8)} current=${String(q.attempt_id || '').slice(0,8) || 'none'} status=${q.status}`);
7032
+ staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: q.attempt_id, route: 'response', status: q.status });
6693
7033
  return res.status(409).json({ error: 'stale_attempt', currentStatus: q.status });
6694
7034
  }
6695
7035
  if (!claimedAttemptId && q && q.status === 'processing') {
@@ -6782,7 +7122,7 @@ export function registerMessagesRoutes(app, deps) {
6782
7122
  if (!failed) {
6783
7123
  const currentRow = stmts.findQById?.get(messageId) || null;
6784
7124
  if (claimedAttemptId && currentRow && String(currentRow.attempt_id || '') !== claimedAttemptId) {
6785
- console.warn(`[${ts()}] STALE ATTEMPT ${messageId?.slice(0,8)} route=response-terminal claimed=${claimedAttemptId.slice(0,8)} current=${String(currentRow.attempt_id || '').slice(0,8) || 'none'} status=${currentRow.status}`);
7125
+ staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: currentRow.attempt_id, route: 'response-terminal', status: currentRow.status });
6786
7126
  return res.status(409).json({ error: 'stale_attempt', currentStatus: String(currentRow.status || 'unknown') });
6787
7127
  }
6788
7128
  console.log(`[${ts()}] RESPONSE ${messageId?.slice(0,8)} ignored=not_pending_or_processing`);
@@ -6998,7 +7338,7 @@ export function registerMessagesRoutes(app, deps) {
6998
7338
  const currentRow = stmts.findQById?.get(messageId) || null;
6999
7339
  const currentStatus = String(currentRow?.status || 'unknown');
7000
7340
  if (claimedAttemptId && currentRow && String(currentRow.attempt_id || '') !== claimedAttemptId) {
7001
- console.warn(`[${ts()}] STALE ATTEMPT ${messageId?.slice(0,8)} route=response-finalize claimed=${claimedAttemptId.slice(0,8)} current=${String(currentRow.attempt_id || '').slice(0,8) || 'none'} status=${currentStatus}`);
7341
+ staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: currentRow.attempt_id, route: 'response-finalize', status: currentStatus });
7002
7342
  return res.status(409).json({ error: 'stale_attempt', currentStatus });
7003
7343
  }
7004
7344
  console.log(`[${ts()}] RESPONSE ${messageId?.slice(0,8)} ignored=not_pending_or_processing actual_status=${currentStatus}`);
@@ -7174,6 +7514,25 @@ export function registerMessagesRoutes(app, deps) {
7174
7514
  sourceMessageId: messageId,
7175
7515
  text: resolvedText,
7176
7516
  });
7517
+ // A turn paused at the usage limit did not end: it is an error event
7518
+ // for whoever watches the session.
7519
+ try {
7520
+ if (usageLimitPause) {
7521
+ relayEventService?.noteError?.({
7522
+ conversationId: targetConversationId,
7523
+ kind: 'usage_limit',
7524
+ message: usageLimitPause.noteText,
7525
+ queueMessageId: messageId,
7526
+ });
7527
+ } else {
7528
+ relayEventService?.noteTurnEnd?.({
7529
+ conversationId: targetConversationId,
7530
+ queueMessageId: messageId,
7531
+ replyText: resolvedText,
7532
+ status: 'done',
7533
+ });
7534
+ }
7535
+ } catch {}
7177
7536
  }
7178
7537
  cancelPendingRelayQuestionsForMessage(messageId);
7179
7538
  res.json({ ok: true });
@@ -7270,9 +7629,53 @@ export function registerMessagesRoutes(app, deps) {
7270
7629
  res.json({ ok: true, messageId, conversationId, attemptId });
7271
7630
  });
7272
7631
 
7632
+ function resolveTaskConversationId(id) {
7633
+ const key = String(id || '').trim();
7634
+ if (!key) return '';
7635
+ try {
7636
+ return String(resolveConversationRecord?.({ conversationId: key, sdkSessionId: key })?.id || key);
7637
+ } catch {
7638
+ return key;
7639
+ }
7640
+ }
7641
+
7642
+ // POST /api/background-task-settled — how a background task ended, where
7643
+ // the worker learns it (the Claude worker's task_notification): status,
7644
+ // summary, and the reason for an unusual end. The task leaves the live set
7645
+ // on its own; this only says how it ended.
7646
+ app.post('/api/background-task-settled', auth, (req, res) => {
7647
+ touchCli();
7648
+ const conversationId = resolveTaskConversationId(req.body?.conversationId);
7649
+ const taskId = String(req.body?.taskId || '').trim();
7650
+ if (!conversationId || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
7651
+ const exitCode = Number(req.body?.exitCode);
7652
+ const settledDescription = String(req.body?.description || '').trim().slice(0, 500);
7653
+ sessionActivityTracker?.noteTaskSettled?.(conversationId, {
7654
+ taskId,
7655
+ toolUseId: sanitizeSubagentRunId(req.body?.toolUseId) || null,
7656
+ status: String(req.body?.status || '').trim().slice(0, 40) || 'completed',
7657
+ summary: String(req.body?.summary || '').trim().slice(0, 500) || null,
7658
+ exitCode: Number.isInteger(exitCode) ? exitCode : null,
7659
+ // What the task was, from a worker that sends it: the end of a task
7660
+ // this runtime never knew (it ended while the relay was away) is
7661
+ // still an event then.
7662
+ ...(settledDescription ? {
7663
+ description: settledDescription,
7664
+ taskType: String(req.body?.taskType || '').trim().slice(0, 80),
7665
+ startedAt: Number(req.body?.startedAt) || null,
7666
+ } : {}),
7667
+ });
7668
+ relayEventService?.afterStateChange?.(conversationId);
7669
+ res.json({ ok: true });
7670
+ });
7671
+
7273
7672
  // POST /api/background-tasks — a session worker publishes its live
7274
7673
  // background-task set (REPLACE semantics, mirroring the SDK's
7275
7674
  // background_tasks_changed signal) so the composer panel tracks it live.
7675
+ // `resync: true` marks the set as the answer to a heartbeat that asked for
7676
+ // it again (`resyncBackgroundTasks`): the same replace, and the pages get
7677
+ // the set again, but a task the relay had forgotten is not a new start.
7678
+ // `order` lets the relay drop a set that a newer one overtook.
7276
7679
  app.post('/api/background-tasks', auth, (req, res) => {
7277
7680
  touchCli();
7278
7681
  const conversationId = String(req.body?.conversationId || '').trim();
@@ -7296,11 +7699,58 @@ export function registerMessagesRoutes(app, deps) {
7296
7699
  // Sanitized digest or undefined: JSON serialization (store broadcast,
7297
7700
  // conversation payload) drops the key entirely for flat rows.
7298
7701
  workflowProgress: sanitizeWorkflowProgress(task?.workflowProgress) || undefined,
7702
+ // The spawning tool_use id: the sub-agent run this task is. The panel
7703
+ // opens the run's viewer from it.
7704
+ toolUseId: sanitizeSubagentRunId(task?.toolUseId) || undefined,
7299
7705
  }))
7300
7706
  .filter((task) => task.taskId);
7707
+ const resync = req.body?.resync === true;
7708
+ // Posts can overtake each other on the way (an ordinary publish and the
7709
+ // answer to a resync, two publishes): a worker numbers its sets
7710
+ // (`order: { epoch, seq }`), and one older than the newest applied is
7711
+ // dropped, or a task of the newer set would read as ended. A worker
7712
+ // that numbers none is taken as it comes.
7713
+ // A drop is never silent: it is logged, and the answer says `stale`,
7714
+ // on which the worker asks for a heartbeat (which makes it the holder
7715
+ // of the session if it was not yet, and has the relay ask for the set).
7716
+ const setOrder = backgroundTaskStore?.acceptOrder?.(conversationId, req.body?.order, {
7717
+ pid: readBridgeIdentity(req)?.pid,
7718
+ });
7719
+ if (setOrder && setOrder.accepted === false) {
7720
+ console.log(`[${typeof ts === 'function' ? ts() : new Date().toISOString()}] BG-TASKS set dropped (${setOrder.reason}) session=${conversationId.slice(0, 8)} epoch=${Number(req.body?.order?.epoch) || 0} seq=${Number(req.body?.order?.seq) || 0} tasks=${tasks.length}`);
7721
+ return res.json({ ok: true, count: tasks.length, stale: true, reason: setOrder.reason });
7722
+ }
7301
7723
  backgroundTaskStore?.replace?.(conversationId, tasks);
7724
+ backgroundTaskStore?.announce?.(conversationId, false);
7302
7725
  io.emit('background_tasks', { conversationId, tasks });
7303
- res.json({ ok: true, count: tasks.length });
7726
+ // The store is keyed as the worker posts (its SDK session id); the
7727
+ // tracker by the conversation.
7728
+ try {
7729
+ const trackedConversationId = resolveTaskConversationId(conversationId);
7730
+ sessionActivityTracker?.noteTaskSet?.(trackedConversationId, tasks, { resync });
7731
+ relayEventService?.afterStateChange?.(trackedConversationId);
7732
+ } catch {}
7733
+ // The panel's copy of a task lives in memory and goes when the task
7734
+ // settles; what the run's row needs of it is kept on the run.
7735
+ for (const task of tasks) {
7736
+ if (!task.toolUseId) continue;
7737
+ const before = stmts.getSubagentRun?.get?.(task.toolUseId) || null;
7738
+ if (!before || String(before.conversation_id || '') !== conversationId) continue;
7739
+ const patch = sanitizeRunHeaderPatch({
7740
+ model: task.model,
7741
+ modelInherited: task.model ? task.modelInherited : undefined,
7742
+ subagentType: task.subagentType,
7743
+ taskId: task.taskId,
7744
+ totalTokens: task.totalTokens,
7745
+ background: true,
7746
+ });
7747
+ if (!patch) continue;
7748
+ const after = subagentTimeline.applyHeader(before.id, patch);
7749
+ const changed = after && ['model', 'model_inherited', 'subagent_type', 'task_id', 'total_tokens', 'background']
7750
+ .some((column) => before[column] !== after[column]);
7751
+ if (changed) emitSubagentStatus(after);
7752
+ }
7753
+ res.json({ ok: true, count: tasks.length, ...(resync ? { resync: true } : {}) });
7304
7754
  });
7305
7755
 
7306
7756
  // POST /api/conversation/:conversationId/background-task/:taskId/stop —
@@ -7332,7 +7782,7 @@ export function registerMessagesRoutes(app, deps) {
7332
7782
  if (!claimed || !queueRow) return false;
7333
7783
  const current = String(queueRow.attempt_id || '');
7334
7784
  if (current === claimed) return false;
7335
- console.warn(`[${ts()}] STALE ATTEMPT ${String(queueRow.id || '').slice(0,8)} route=${route} claimed=${claimed.slice(0,8)} current=${current.slice(0,8) || 'none'} status=${queueRow.status}`);
7785
+ staleAttemptLog.note({ messageId: queueRow.id, attemptId: claimed, currentAttemptId: current, route, status: queueRow.status });
7336
7786
  res.status(409).json({ error: 'stale_attempt', currentStatus: String(queueRow.status || 'unknown') });
7337
7787
  return true;
7338
7788
  }
@@ -7347,10 +7797,11 @@ export function registerMessagesRoutes(app, deps) {
7347
7797
 
7348
7798
  const q = stmts.findQById.get(messageId);
7349
7799
  if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'activity' })) return;
7800
+ try { sessionActivityTracker?.touch?.(q?.conversation_id || conversationId); } catch {}
7350
7801
  const responseMessageId = q?.response_message_id || null;
7351
7802
  const normalizedSubagentRunId = subagentRunId ? String(subagentRunId).trim() : null;
7352
7803
  const normalizedMetadata = sanitizeActivityMetadata(metadata);
7353
- stmts.insertActivity.run(
7804
+ const activityRow = stmts.insertActivity.run(
7354
7805
  messageId,
7355
7806
  responseMessageId,
7356
7807
  conversationId,
@@ -7360,6 +7811,14 @@ export function registerMessagesRoutes(app, deps) {
7360
7811
  normalizedSubagentRunId,
7361
7812
  normalizedMetadata ? JSON.stringify(normalizedMetadata) : null,
7362
7813
  );
7814
+ if (normalizedSubagentRunId) {
7815
+ // The lane keeps the capped line; the timeline copy keeps what came in.
7816
+ mirrorSubagentFrame({
7817
+ runId: normalizedSubagentRunId,
7818
+ key: `activity:${activityRow?.lastInsertRowid ?? Date.now()}`,
7819
+ ...activityTimelineFrame(String(text || '').trim() || activityText),
7820
+ });
7821
+ }
7363
7822
 
7364
7823
  io.emit('relay_activity', {
7365
7824
  messageId,
@@ -7394,6 +7853,7 @@ export function registerMessagesRoutes(app, deps) {
7394
7853
  return res.status(409).json({ error: 'Stream conversationId does not match queue conversation' });
7395
7854
  }
7396
7855
  if (rejectStaleAttemptWrite({ req, res, queueRow, route: 'stream' })) return;
7856
+ try { sessionActivityTracker?.touch?.(queueConversationId); } catch {}
7397
7857
 
7398
7858
  const isStreamSeqConstraintError = (error) => {
7399
7859
  const message = String(error?.message || '').toLowerCase();
@@ -7474,6 +7934,11 @@ export function registerMessagesRoutes(app, deps) {
7474
7934
  return res.status(500).json({ error: error?.message || 'Failed to persist stream event' });
7475
7935
  }
7476
7936
 
7937
+ if (normalizedSubagentRunId) {
7938
+ // One reply text per run and turn, updated in place as it grows.
7939
+ mirrorSubagentFrame({ runId: normalizedSubagentRunId, key: `stream:${messageId}`, kind: 'text', text: streamText, done: !!done });
7940
+ }
7941
+
7477
7942
  // Volatile keeps these out of the connection-state-recovery buffer. Each chunk
7478
7943
  // carries the full text so far, so replaying a turn's worth of them costs
7479
7944
  // megabytes to deliver what one reconnect resync already fetches from
@@ -7515,6 +7980,7 @@ export function registerMessagesRoutes(app, deps) {
7515
7980
  return res.status(409).json({ error: 'Thought conversationId does not match queue conversation' });
7516
7981
  }
7517
7982
  if (rejectStaleAttemptWrite({ req, res, queueRow, route: 'thought' })) return;
7983
+ try { sessionActivityTracker?.touch?.(queueConversationId); } catch {}
7518
7984
 
7519
7985
  const relayMode = normalizeRelayMode(mode) || DEFAULT_RELAY_MODE;
7520
7986
  const normalizedReasoningId = String(reasoningId || '').trim() || null;
@@ -7594,6 +8060,16 @@ export function registerMessagesRoutes(app, deps) {
7594
8060
  }
7595
8061
  }
7596
8062
 
8063
+ if (normalizedSubagentRunId && (normalizedReasoningId || done) && String(thoughtText).trim()) {
8064
+ mirrorSubagentFrame({
8065
+ runId: normalizedSubagentRunId,
8066
+ key: normalizedReasoningId ? `thought:${normalizedReasoningId}` : `thought:${messageId}:${seq}`,
8067
+ kind: 'thought',
8068
+ text: thoughtText,
8069
+ done: !!done,
8070
+ });
8071
+ }
8072
+
7597
8073
  io.emit('relay_thought', {
7598
8074
  messageId,
7599
8075
  conversationId,
@@ -7608,7 +8084,12 @@ export function registerMessagesRoutes(app, deps) {
7608
8084
  res.json({ ok: true, seq });
7609
8085
  });
7610
8086
 
7611
- // POST /api/subagent-run — relay notifies about subagent lifecycle events
8087
+ // POST /api/subagent-run — relay notifies about subagent lifecycle events.
8088
+ // A new run needs the turn that spawned it (messageId). An update to a known
8089
+ // run may come without one: a background agent outlives its turn, and its
8090
+ // end (or its header) arrives between turns or during a later turn; the run
8091
+ // row names its own turn. Header fields (model, type, task id, background,
8092
+ // who writes the timeline) are patched onto the run.
7612
8093
  app.post('/api/subagent-run', auth, (req, res) => {
7613
8094
  touchCli();
7614
8095
  const { messageId, conversationId, subagentRunId, parentSubagentId, displayName, status } = req.body || {};
@@ -7617,40 +8098,56 @@ export function registerMessagesRoutes(app, deps) {
7617
8098
  const normalizedSubagentRunId = sanitizeSubagentRunId(subagentRunId);
7618
8099
  const normalizedParentSubagentId = sanitizeSubagentRunId(parentSubagentId);
7619
8100
  const normalizedDisplayName = displayName ? String(displayName).trim() : null;
7620
- const normalizedStatus = status ? String(status).trim() : 'running';
8101
+ const normalizedStatus = status ? normalizeSubagentRunStatus(status) : 'running';
8102
+ const normalizedMessageId = String(messageId || '').trim();
7621
8103
 
7622
- if (!messageId || !conversationId || !normalizedSubagentRunId) {
8104
+ if (!conversationId || !normalizedSubagentRunId) {
7623
8105
  return res.status(400).json({ error: 'Missing required subagent-run fields: messageId, conversationId, subagentRunId' });
7624
8106
  }
7625
-
7626
- const q = stmts.findQById.get(messageId);
7627
- if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'subagent-run' })) return;
7628
8107
  const existingRun = stmts.getSubagentRun?.get(normalizedSubagentRunId);
7629
- const binding = validateSubagentRunBinding({
7630
- queueRow: q,
7631
- messageId,
7632
- conversationId,
7633
- existingRun,
7634
- });
7635
- if (!binding.ok) {
7636
- return res.status(binding.statusCode || 409).json({ error: binding.error || 'Subagent run binding mismatch' });
8108
+ if (!normalizedMessageId && !existingRun) {
8109
+ return res.status(400).json({ error: 'Missing required subagent-run fields: messageId, conversationId, subagentRunId' });
8110
+ }
8111
+
8112
+ let authoritativeConversationId;
8113
+ if (normalizedMessageId) {
8114
+ const q = stmts.findQById.get(normalizedMessageId);
8115
+ if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'subagent-run' })) return;
8116
+ const binding = validateSubagentRunBinding({
8117
+ queueRow: q,
8118
+ messageId: normalizedMessageId,
8119
+ conversationId,
8120
+ existingRun,
8121
+ });
8122
+ if (!binding.ok) {
8123
+ return res.status(binding.statusCode || 409).json({ error: binding.error || 'Subagent run binding mismatch' });
8124
+ }
8125
+ authoritativeConversationId = String(binding.conversationId || conversationId || '').trim();
8126
+ } else {
8127
+ authoritativeConversationId = String(existingRun.conversation_id || '').trim();
8128
+ if (authoritativeConversationId !== String(conversationId).trim()) {
8129
+ return res.status(409).json({ error: 'Subagent run conversation mismatch' });
8130
+ }
7637
8131
  }
7638
- const authoritativeConversationId = String(binding.conversationId || conversationId || '').trim();
7639
8132
 
7640
8133
  const now = new Date().toISOString();
7641
8134
 
7642
8135
  if (existingRun) {
7643
- const isTerminal = normalizedStatus === 'completed' || normalizedStatus === 'failed' || normalizedStatus === 'cancelled';
7644
- stmts.updateSubagentRunStatus?.run(
7645
- normalizedStatus,
7646
- now,
7647
- isTerminal ? now : null,
7648
- normalizedSubagentRunId,
7649
- );
8136
+ // Without a status the post is a header update and leaves the run's
8137
+ // state alone.
8138
+ if (status) {
8139
+ const isTerminal = normalizedStatus === 'completed' || normalizedStatus === 'failed' || normalizedStatus === 'cancelled';
8140
+ stmts.updateSubagentRunStatus?.run(
8141
+ normalizedStatus,
8142
+ now,
8143
+ isTerminal ? now : null,
8144
+ normalizedSubagentRunId,
8145
+ );
8146
+ }
7650
8147
  } else {
7651
8148
  stmts.insertSubagentRun?.run(
7652
8149
  normalizedSubagentRunId,
7653
- messageId,
8150
+ normalizedMessageId,
7654
8151
  authoritativeConversationId,
7655
8152
  normalizedParentSubagentId,
7656
8153
  normalizedDisplayName,
@@ -7659,20 +8156,107 @@ export function registerMessagesRoutes(app, deps) {
7659
8156
  now,
7660
8157
  );
7661
8158
  }
7662
-
7663
- io.emit('subagent_status', {
7664
- messageId,
7665
- conversationId: authoritativeConversationId,
7666
- subagentRunId: normalizedSubagentRunId,
7667
- parentSubagentId: normalizedParentSubagentId || undefined,
7668
- displayName: normalizedDisplayName || undefined,
7669
- status: normalizedStatus,
7670
- timestamp: now,
8159
+ const headerPatch = sanitizeRunHeaderPatch({
8160
+ model: req.body?.model,
8161
+ modelInherited: req.body?.modelInherited,
8162
+ subagentType: req.body?.subagentType,
8163
+ taskId: req.body?.taskId,
8164
+ background: req.body?.background === true,
8165
+ timelineOwner: req.body?.timelineOwner,
7671
8166
  });
8167
+ if (headerPatch) subagentTimeline.applyHeader(normalizedSubagentRunId, headerPatch);
8168
+
8169
+ const run = stmts.getSubagentRun?.get?.(normalizedSubagentRunId);
8170
+ if (run) {
8171
+ emitSubagentStatus(run);
8172
+ } else {
8173
+ io.emit('subagent_status', {
8174
+ messageId: normalizedMessageId || null,
8175
+ conversationId: authoritativeConversationId,
8176
+ subagentRunId: normalizedSubagentRunId,
8177
+ parentSubagentId: normalizedParentSubagentId || undefined,
8178
+ displayName: normalizedDisplayName || undefined,
8179
+ status: normalizedStatus,
8180
+ timestamp: now,
8181
+ });
8182
+ }
7672
8183
 
7673
8184
  res.json({ ok: true });
7674
8185
  });
7675
8186
 
8187
+ // POST /api/subagent-timeline — a worker publishes entries of a sub-agent's
8188
+ // record (and, optionally, a header patch) for a run it already announced.
8189
+ // Fenced by the run row, not by an open turn: a background agent keeps
8190
+ // writing after its turn ended, and its entries belong to that turn. A run
8191
+ // the relay does not know yet but whose parent it does (an agent spawned
8192
+ // inside another) is created under the parent's turn; anything else is a
8193
+ // 404 the worker retries once the spawn has landed.
8194
+ app.post('/api/subagent-timeline', auth, (req, res) => {
8195
+ touchCli();
8196
+ const conversationId = String(req.body?.conversationId || '').trim();
8197
+ const runId = sanitizeSubagentRunId(req.body?.subagentRunId);
8198
+ if (!conversationId || !runId) {
8199
+ return res.status(400).json({ error: 'Missing conversationId or subagentRunId' });
8200
+ }
8201
+ if (!subagentTimeline.supported()) {
8202
+ return res.status(503).json({ error: 'timeline_unavailable' });
8203
+ }
8204
+ const rawHeader = req.body?.header && typeof req.body.header === 'object' ? req.body.header : {};
8205
+ let run = stmts.getSubagentRun?.get?.(runId) || null;
8206
+ let created = false;
8207
+ if (!run) {
8208
+ const parentId = sanitizeSubagentRunId(rawHeader.parentSubagentId);
8209
+ const parent = parentId ? (stmts.getSubagentRun?.get?.(parentId) || null) : null;
8210
+ if (!parent) return res.status(404).json({ error: 'unknown_run' });
8211
+ if (String(parent.conversation_id || '') !== conversationId) {
8212
+ return res.status(409).json({ error: 'Subagent run conversation mismatch' });
8213
+ }
8214
+ const now = new Date().toISOString();
8215
+ const displayName = String(rawHeader.displayName || '').trim().slice(0, 500) || 'Subagent';
8216
+ stmts.insertSubagentRun?.run(runId, parent.queue_message_id, parent.conversation_id, parentId, displayName, 'running', now, now);
8217
+ run = stmts.getSubagentRun?.get?.(runId) || null;
8218
+ if (!run) return res.status(500).json({ error: 'Failed to create the run' });
8219
+ created = true;
8220
+ }
8221
+ if (String(run.conversation_id || '') !== conversationId) {
8222
+ return res.status(409).json({ error: 'Subagent run conversation mismatch' });
8223
+ }
8224
+ const patch = sanitizeRunHeaderPatch(rawHeader);
8225
+ if (patch) run = subagentTimeline.applyHeader(runId, patch) || run;
8226
+ let stored;
8227
+ try {
8228
+ stored = subagentTimeline.applyEntries(run, req.body?.entries);
8229
+ } catch (error) {
8230
+ return res.status(500).json({ error: error?.message || 'Failed to store the timeline' });
8231
+ }
8232
+ if (stored.truncated) run = stmts.getSubagentRun?.get?.(runId) || run;
8233
+ if (created || patch || stored.truncated) emitSubagentStatus(run);
8234
+ emitSubagentTimeline(run, stored.entries);
8235
+ try { sessionActivityTracker?.touch?.(conversationId); } catch {}
8236
+ res.json({ ok: true, stored: stored.entries.length, truncated: stored.truncated });
8237
+ });
8238
+
8239
+ // GET /api/conversation/:conversationId/subagent/:subagentRunId — one run's
8240
+ // record for the viewer: header, entries after `after` (plus open ones),
8241
+ // child runs. `complete` is false when more entries follow this page.
8242
+ app.get('/api/conversation/:conversationId/subagent/:subagentRunId', auth, (req, res) => {
8243
+ const conversationId = String(req.params.conversationId || '').trim();
8244
+ const runId = sanitizeSubagentRunId(req.params.subagentRunId);
8245
+ if (!conversationId || !runId) return res.status(400).json({ error: 'Missing conversationId or subagentRunId' });
8246
+ const run = stmts.getSubagentRun?.get?.(runId) || null;
8247
+ if (!run || String(run.conversation_id || '') !== conversationId) {
8248
+ return res.status(404).json({ error: 'Sub-agent run not found' });
8249
+ }
8250
+ const page = subagentTimeline.readTimeline(run, {
8251
+ afterSeq: Number(req.query?.after) || 0,
8252
+ limit: Number(req.query?.limit) || TIMELINE_READ_DEFAULT_LIMIT,
8253
+ isStoppable: isSubagentRunStoppable,
8254
+ });
8255
+ const task = liveTaskForSubagentRun(run);
8256
+ if (page.run && task?.taskId) page.run.taskId = page.run.taskId || task.taskId;
8257
+ res.json(page);
8258
+ });
8259
+
7676
8260
  // POST /api/requeue — relay re-queues a message it failed to process
7677
8261
  app.post('/api/requeue', auth, (req, res) => {
7678
8262
  const { messageId } = req.body;
@@ -7683,7 +8267,7 @@ export function registerMessagesRoutes(app, deps) {
7683
8267
  // terminal-fail a row that has already been requeued to someone else).
7684
8268
  const claimedAttemptId = String(req.body?.attemptId || '').trim() || null;
7685
8269
  if (claimedAttemptId && q && String(q.attempt_id || '') !== claimedAttemptId) {
7686
- console.warn(`[${ts()}] STALE ATTEMPT ${messageId?.slice(0,8)} route=requeue claimed=${claimedAttemptId.slice(0,8)} current=${String(q.attempt_id || '').slice(0,8) || 'none'} status=${q.status}`);
8270
+ staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: q.attempt_id, route: 'requeue', status: q.status });
7687
8271
  return res.status(409).json({ error: 'stale_attempt', currentStatus: String(q.status || 'unknown') });
7688
8272
  }
7689
8273
  const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)