@oar-sh/oar 0.9.8 → 0.9.10

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 (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -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 +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. package/shared/subagent-timeline.mjs +79 -0
@@ -5,7 +5,9 @@ import path from 'path';
5
5
  import { randomUUID } from 'crypto';
6
6
  import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
7
7
  import { resolveUploadMimeType } from '../services/mime-sniffer.mjs';
8
- import { applySafeServedContentHeaders } from '../services/safe-served-content.mjs';
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,
@@ -44,6 +46,17 @@ import { isLiveResend } from '../services/steer-resend.mjs';
44
46
  import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
45
47
  import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
46
48
  import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
49
+ import {
50
+ createSubagentTimelineService,
51
+ mapSubagentRunHeader,
52
+ sanitizeRunHeaderPatch,
53
+ TIMELINE_READ_DEFAULT_LIMIT,
54
+ } from '../services/subagent-timeline-service.mjs';
55
+ import { normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
56
+ import { RELAY_EVENT_MESSAGE_KIND } from '../../shared/relay-events.mjs';
57
+ import { isRelayShutdownQueued, relayShutdownGateReply } from '../services/relay-shutdown-gate.mjs';
58
+ import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limiter.mjs';
59
+ import { createSubagentTimelinePublisher } from '../services/subagent-viewer-rooms.mjs';
47
60
  import { buildClaudeCloudDelivery, isClaudeCloudProviderType } from '../services/claude-cloud-session-service.mjs';
48
61
  import {
49
62
  admitRemoteRelayRequest,
@@ -71,6 +84,11 @@ const STRANDED_SESSION_HEARTBEAT_FRESH_MS = 15_000;
71
84
  const RELAY_QUESTION_FINALIZATION_HOLD_MS = 5_000;
72
85
  const AUTO_MODEL_SENTINEL = 'auto';
73
86
  const USAGE_FETCH_RETRY_DELAY_MS = 250;
87
+ // Preview kinds whose bytes travel in the preview JSON; media and PDF are
88
+ // fetched by the browser from the raw URL instead.
89
+ const TEXT_PREVIEW_KINDS = new Set(['markdown', 'code', 'text']);
90
+ // An upload's URL names its bytes (the sha256), so a kept answer stays right.
91
+ const UPLOAD_CONTENT_CACHE_CONTROL = 'private, max-age=31536000, immutable';
74
92
 
75
93
  export function resolveOpenAIReasoningEffort(explicitReasoningEffort = '', model = '') {
76
94
  const explicit = String(explicitReasoningEffort || '').trim().toLowerCase();
@@ -1175,6 +1193,12 @@ export function buildDequeuedRelayMessage({
1175
1193
  fallbackEnvironmentId: getClaudeCloudProviderSettings()?.environmentId || '',
1176
1194
  })
1177
1195
  : null;
1196
+ // An event message the relay wrote (relay event subscriptions): the
1197
+ // workers deliver it without the steer note, which speaks as the user.
1198
+ let messageKind = '';
1199
+ try {
1200
+ messageKind = String(stmts.getMessageByConversation?.get?.(msg.id, msg.conversation_id)?.kind || '').trim();
1201
+ } catch {}
1178
1202
  return {
1179
1203
  id: msg.id,
1180
1204
  conversationId: msg.conversation_id,
@@ -1206,6 +1230,7 @@ export function buildDequeuedRelayMessage({
1206
1230
  ownerAssignedAt: msg.owner_assigned_at || null,
1207
1231
  ownerLeaseExpiresAt: msg.owner_lease_expires_at || null,
1208
1232
  imageOperationId: String(msg.image_operation_id || '').trim() || null,
1233
+ ...(messageKind === RELAY_EVENT_MESSAGE_KIND ? { messageKind } : {}),
1209
1234
  };
1210
1235
  }
1211
1236
 
@@ -1610,57 +1635,6 @@ export function cleanupPersistedGeneratedImagesForAssistantResponse({
1610
1635
  }
1611
1636
  }
1612
1637
 
1613
- function serveFileWithRangeSupport(req, res, filePath, meta, { safeName, cacheDelete = null } = {}) {
1614
- const fileSize = meta.size;
1615
- const name = safeName || path.basename(filePath).replace(/"/g, '');
1616
- const rangeHeader = req.headers['range'];
1617
-
1618
- res.setHeader('Accept-Ranges', 'bytes');
1619
- res.setHeader('Cache-Control', 'no-store');
1620
- // Neutralize browser-executable types (HTML/SVG/XML), force nosniff, and
1621
- // sandbox the response so a worker-written workspace file can't run as script
1622
- // on this origin. Media stays inline for preview; everything else downloads.
1623
- applySafeServedContentHeaders(res, meta.contentType, { fileName: name });
1624
-
1625
- const onStreamError = (error) => {
1626
- if (cacheDelete) cacheDelete(filePath);
1627
- if (res.headersSent) { res.destroy(error); return; }
1628
- if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
1629
- res.status(404).json({ error: 'File not found' });
1630
- return;
1631
- }
1632
- res.status(500).json({ error: 'Failed to read file' });
1633
- };
1634
-
1635
- if (rangeHeader) {
1636
- const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
1637
- if (!match) {
1638
- res.setHeader('Content-Range', `bytes */${fileSize}`);
1639
- res.status(416).end();
1640
- return;
1641
- }
1642
- const start = parseInt(match[1], 10);
1643
- const end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
1644
- if (start > end || start >= fileSize || end >= fileSize) {
1645
- res.setHeader('Content-Range', `bytes */${fileSize}`);
1646
- res.status(416).end();
1647
- return;
1648
- }
1649
- const chunkSize = end - start + 1;
1650
- res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
1651
- res.setHeader('Content-Length', String(chunkSize));
1652
- res.status(206);
1653
- const stream = fs.createReadStream(filePath, { start, end });
1654
- stream.on('error', onStreamError);
1655
- stream.pipe(res);
1656
- } else {
1657
- res.setHeader('Content-Length', String(fileSize));
1658
- const stream = fs.createReadStream(filePath);
1659
- stream.on('error', onStreamError);
1660
- stream.pipe(res);
1661
- }
1662
- }
1663
-
1664
1638
  // Structural sanitizer for the workflow-progress digest a Claude session
1665
1639
  // worker attaches to its background-task rows (docs/plans/workflow-progress-tree.md,
1666
1640
  // Phase 2). The relay never trusts worker JSON: strings are trimmed and
@@ -1769,6 +1743,10 @@ export function registerMessagesRoutes(app, deps) {
1769
1743
  fetchLinuxDirectoryEntries,
1770
1744
  mapLinuxDirectoryEntry,
1771
1745
  readWorkspaceFileMeta,
1746
+ // The cache readWorkspaceFileMeta fills. The routes drop an entry when a
1747
+ // read shows it is out of date; the name was used here without ever
1748
+ // being handed in, so each of those paths threw a ReferenceError.
1749
+ workspaceFileMetaCache,
1772
1750
  resolveWorkspaceFilePath,
1773
1751
  normalizeWorkspaceRelativePath,
1774
1752
  previewLanguageForWorkspaceFile,
@@ -1855,9 +1833,90 @@ export function registerMessagesRoutes(app, deps) {
1855
1833
  // agent-sent messages, the inbound switch, and mention unlocks. Absent in
1856
1834
  // older wiring and most tests, where every remote-relay step is a no-op.
1857
1835
  remoteRelayInbound = null,
1836
+ // Relay event subscriptions (services/relay-event-service.mjs) and the
1837
+ // per-session activity they read (services/session-activity-tracker.mjs).
1838
+ // Absent in older wiring and most tests: every hook is then a no-op.
1839
+ relayEventService = null,
1840
+ sessionActivityTracker = null,
1841
+ // A worker's SDK session id or a conversation id → the conversation row.
1842
+ resolveConversationRecord = null,
1858
1843
  } = deps;
1859
1844
 
1860
1845
  const ttyConsoleActive = runtimeState?.ttyConsoleActive === true;
1846
+
1847
+ // ─── Sub-agent runs: header, timeline, live events ─────────────────────────
1848
+ const subagentTimeline = createSubagentTimelineService({ db, stmts });
1849
+
1850
+ // The live background task a run belongs to (the panel's row), if any: the
1851
+ // task's spawning tool_use id is the run id.
1852
+ function liveTaskForSubagentRun(run) {
1853
+ const tasks = backgroundTaskStore?.get?.(run?.conversation_id) || [];
1854
+ const runId = String(run?.id || '');
1855
+ const taskId = String(run?.task_id || '');
1856
+ return tasks.find((task) => (task?.toolUseId && task.toolUseId === runId) || (taskId && task?.taskId === taskId)) || null;
1857
+ }
1858
+
1859
+ // Stop is offered while a run is running: a background run through its
1860
+ // task, a foreground run through the sub-agent cancel route.
1861
+ function isSubagentRunStoppable(run) {
1862
+ if (String(run?.status || '') !== 'running') return false;
1863
+ const task = liveTaskForSubagentRun(run);
1864
+ if (task) return task.stoppable !== false;
1865
+ return Number(run?.background || 0) !== 1;
1866
+ }
1867
+
1868
+ // Every subagent_status event carries the whole header and the run's own
1869
+ // turn, so a client can place it without having seen the run before.
1870
+ function subagentStatusEvent(run, extra = {}) {
1871
+ const header = mapSubagentRunHeader(run, { stoppable: isSubagentRunStoppable(run) });
1872
+ if (!header) return null;
1873
+ return { ...header, timestamp: new Date().toISOString(), ...extra };
1874
+ }
1875
+
1876
+ function emitSubagentStatus(run, extra = {}) {
1877
+ const event = subagentStatusEvent(run, extra);
1878
+ if (event) io.emit('subagent_status', event);
1879
+ try { sessionActivityTracker?.noteSubagentRun?.(run); } catch {}
1880
+ }
1881
+
1882
+ // Only to the pages whose viewer shows this run, and volatile: a batch a
1883
+ // connection cannot take is dropped (never kept for a reconnect replay);
1884
+ // the viewer sees the hole in `seq` and fetches it.
1885
+ const publishSubagentTimeline = createSubagentTimelinePublisher(io, deps.subagentTimelineDefer ? { defer: deps.subagentTimelineDefer } : {});
1886
+ function emitSubagentTimeline(run, entries) {
1887
+ if (!run || !entries?.length) return;
1888
+ publishSubagentTimeline({
1889
+ conversationId: String(run.conversation_id || ''),
1890
+ messageId: run.queue_message_id ? String(run.queue_message_id) : null,
1891
+ subagentRunId: String(run.id),
1892
+ entries,
1893
+ truncated: Number(run.truncated || 0) === 1,
1894
+ });
1895
+ }
1896
+
1897
+ // Copilot and Cursor publish their sub-agents on the live lanes only; the
1898
+ // relay copies each frame into the run's timeline (the Claude worker writes
1899
+ // its own and owns its runs' timelines).
1900
+ function mirrorSubagentFrame(frame) {
1901
+ if (!frame?.runId) return;
1902
+ try {
1903
+ const mirrored = subagentTimeline.mirrorFrame(frame);
1904
+ if (!mirrored) return;
1905
+ const run = mirrored.truncated ? (stmts.getSubagentRun?.get?.(frame.runId) || mirrored.run) : mirrored.run;
1906
+ if (mirrored.truncated) emitSubagentStatus(run);
1907
+ emitSubagentTimeline(run, mirrored.entries);
1908
+ } catch (error) {
1909
+ console.warn(`[${ts()}] SUBAGENT MIRROR failed run=${String(frame.runId).slice(0, 12)}: ${error?.message || error}`);
1910
+ }
1911
+ }
1912
+
1913
+ function activityTimelineFrame(text) {
1914
+ const value = String(text || '');
1915
+ const toolMatch = /^Tool \(([^)]+)\)/.exec(value);
1916
+ if (toolMatch) return { kind: 'tool_call', toolName: toolMatch[1], text: value };
1917
+ if (/^Tool failed:/i.test(value)) return { kind: 'tool_result', text: value, metadata: { isError: true } };
1918
+ return { kind: 'note', text: value };
1919
+ }
1861
1920
  const getMessageAttachmentsByConversation = db.prepare(`
1862
1921
  SELECT attachments
1863
1922
  FROM messages
@@ -2041,25 +2100,28 @@ export function registerMessagesRoutes(app, deps) {
2041
2100
  };
2042
2101
  }
2043
2102
 
2103
+ // One warning per superseded attempt: a worker streaming for a requeued
2104
+ // row writes several times a second until it learns of the refusal.
2105
+ const staleAttemptLog = createStaleAttemptLogLimiter({ warn: (line) => console.warn(`[${ts()}] ${line}`) });
2106
+
2044
2107
  function rejectSessionBinding(res, status, error, extra = {}) {
2045
2108
  return res.status(status).json({ error, ...extra });
2046
2109
  }
2047
2110
 
2048
2111
  function reconcileSubagentRunsForTerminalTurn(messageId, { status = 'failed' } = {}) {
2049
- const running = stmts.listRunningSubagentRunsByQueueMessage?.all?.(messageId) || [];
2112
+ // A background run outlives the turn that spawned it: its worker reports
2113
+ // its end (task_notification), and a dead worker's runs are closed by the
2114
+ // worker-death sweep. Closing it here marked live agents failed.
2115
+ const running = (stmts.listRunningSubagentRunsByQueueMessage?.all?.(messageId) || [])
2116
+ .filter((run) => Number(run.background || 0) !== 1 && !liveTaskForSubagentRun(run));
2050
2117
  if (!running.length) return 0;
2051
2118
  const now = new Date().toISOString();
2052
- stmts.closeRunningSubagentRunsByQueueMessage?.run?.(status, now, now, messageId);
2053
2119
  for (const run of running) {
2054
- io.emit('subagent_status', {
2055
- conversationId: run.conversation_id,
2056
- subagentRunId: run.id,
2057
- parentSubagentId: run.parent_subagent_id || null,
2058
- displayName: run.display_name || null,
2059
- status,
2060
- queueMessageId: messageId,
2061
- reconciled: true,
2062
- });
2120
+ stmts.updateSubagentRunStatus?.run?.(status, now, now, run.id);
2121
+ const closed = stmts.getSubagentRun?.get?.(run.id) || { ...run, status, completed_at: now };
2122
+ // messageId is the run's own turn: the browser drops a status event it
2123
+ // cannot place, which left these runs showing "running" until a reload.
2124
+ emitSubagentStatus(closed, { queueMessageId: messageId, reconciled: true });
2063
2125
  }
2064
2126
  return running.length;
2065
2127
  }
@@ -2146,6 +2208,19 @@ export function registerMessagesRoutes(app, deps) {
2146
2208
  });
2147
2209
  io.emit('message_status', { messageId, conversationId, status: 'failed' });
2148
2210
  void pushDispatchService?.notifyTurnFailed?.({ conversationId, messageId: responseId, sourceMessageId: messageId, text: responseText });
2211
+ // A Stop from the relay ends the turn; anything else failed it.
2212
+ try {
2213
+ if (String(failureRecord?.code || '') === 'turn-aborted') {
2214
+ relayEventService?.noteTurnEnd?.({ conversationId, queueMessageId: messageId, status: 'stopped' });
2215
+ } else {
2216
+ relayEventService?.noteError?.({
2217
+ conversationId,
2218
+ kind: 'turn_failed',
2219
+ message: failureRecord?.message || failureRecord?.error || responseText,
2220
+ queueMessageId: messageId,
2221
+ });
2222
+ }
2223
+ } catch {}
2149
2224
  cancelPendingRelayQuestionsForMessage(messageId);
2150
2225
  const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)
2151
2226
  ? normalizeSessionWorkerId(queueRow?.owner_sdk_session_id)
@@ -3408,9 +3483,10 @@ export function registerMessagesRoutes(app, deps) {
3408
3483
  if (!file) return res.status(404).json({ error: 'Not found' });
3409
3484
  const filePath = uploadPathForSha(sha256);
3410
3485
  if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Missing file on disk' });
3411
- res.setHeader('Cache-Control', 'private, max-age=31536000, immutable');
3412
- applySafeServedContentHeaders(res, file.mime_type, { fileName: file.name || file.file_name || '' });
3413
- fs.createReadStream(filePath).pipe(res);
3486
+ serveFileWithRangeSupport(req, res, filePath, { contentType: file.mime_type }, {
3487
+ safeName: String(file.name || file.file_name || '').replace(/"/g, ''),
3488
+ cacheControl: UPLOAD_CONTENT_CACHE_CONTROL,
3489
+ });
3414
3490
  });
3415
3491
 
3416
3492
  app.get('/api/generated-image/:conversationId/:messageId/:imageId/content', auth, (req, res) => {
@@ -3483,6 +3559,9 @@ export function registerMessagesRoutes(app, deps) {
3483
3559
  const filePath = resolveWorkspaceFilePath(requestedPath, rootOverride);
3484
3560
  if (!filePath || !normalizedPath) return res.status(400).json({ error: 'Invalid file path' });
3485
3561
 
3562
+ // The preview carries the version the download link is built from, so
3563
+ // it reads the file's metadata fresh, not from the short-lived cache.
3564
+ workspaceFileMetaCache.delete(filePath);
3486
3565
  let meta = null;
3487
3566
  try {
3488
3567
  meta = readWorkspaceFileMeta(filePath);
@@ -3538,10 +3617,12 @@ export function registerMessagesRoutes(app, deps) {
3538
3617
  size,
3539
3618
  truncated,
3540
3619
  previewBytes: contentBuffer.length,
3541
- rawUrl: `${remotePath}/api/files/${normalizedWebPath.split('/').map((part) => encodeURIComponent(part)).join('/')}${scopeSuffix}`,
3620
+ mtimeMs: meta.mtimeMs ?? null,
3621
+ version: fileVersionToken(meta),
3622
+ rawUrl: withFileVersion(`${remotePath}/api/files/${normalizedWebPath.split('/').map((part) => encodeURIComponent(part)).join('/')}${scopeSuffix}`, fileVersionToken(meta)),
3542
3623
  };
3543
3624
 
3544
- if (kind !== 'binary' && kind !== 'image' && kind !== 'video') {
3625
+ if (TEXT_PREVIEW_KINDS.has(kind)) {
3545
3626
  payload.content = contentBuffer.toString('utf8');
3546
3627
  }
3547
3628
 
@@ -3861,6 +3942,137 @@ export function registerMessagesRoutes(app, deps) {
3861
3942
  });
3862
3943
  });
3863
3944
 
3945
+ // ---------------------------------------------------------------------------
3946
+ // Explorer changes: new folder, rename, delete. Two routes per operation,
3947
+ // one for the conversation's workspace (relative paths, resolved exactly like
3948
+ // `/api/files/*`) and one for the drives and session roots (absolute paths,
3949
+ // checked exactly like `/api/drives/list`). Each write route therefore
3950
+ // reaches nothing its listing route would not show.
3951
+ // ---------------------------------------------------------------------------
3952
+
3953
+ function workspaceWebPath(relativePath) {
3954
+ return String(relativePath || '').split(path.sep).join('/');
3955
+ }
3956
+
3957
+ // Resolve a workspace entry from a request body: `{ absolutePath, relative }`
3958
+ // or `null` after answering the request with the error.
3959
+ function resolveWorkspaceMutationTarget(req, res, rawRelativePath) {
3960
+ const relative = normalizeWorkspaceRelativePath(rawRelativePath);
3961
+ if (!relative) {
3962
+ res.status(400).json({ error: 'Invalid path' });
3963
+ return null;
3964
+ }
3965
+ const absolutePath = resolveWorkspaceFilePath(relative, resolveScopedWorkspaceRootPath(req));
3966
+ if (!absolutePath) {
3967
+ res.status(400).json({ error: 'Invalid path' });
3968
+ return null;
3969
+ }
3970
+ return { absolutePath, relative };
3971
+ }
3972
+
3973
+ // Resolve an absolute drives/session path from a request body and hand it
3974
+ // on, with the function that turns an absolute path into the explorer's
3975
+ // path form for the answer. Answers the request itself on a bad path.
3976
+ function withDriveMutationTarget(req, res, rawPath, { allowRoot = false } = {}, next) {
3977
+ const requestedPath = String(rawPath || '').trim();
3978
+ if (process.platform !== 'win32') {
3979
+ const absolutePath = normalizeLinuxAbsolutePath(requestedPath);
3980
+ if (!absolutePath) return res.status(400).json({ error: 'Invalid Linux path' });
3981
+ if (absolutePath === '/' && !allowRoot) return res.status(400).json({ error: 'The root folder cannot be changed' });
3982
+ return next(absolutePath, (value) => value);
3983
+ }
3984
+ return fetchBrowsableDrives((drivesErr, drives) => {
3985
+ if (drivesErr) return res.status(500).json({ error: drivesErr.message || 'Failed to enumerate drives' });
3986
+ const allowedRoots = new Set(drives.map((drive) => drive.rootAbsolute.toUpperCase()));
3987
+ const absolutePath = normalizeDriveAbsolutePath(requestedPath);
3988
+ const rootAbsolute = driveRootFromAbsolutePath(absolutePath).toUpperCase();
3989
+ if (!absolutePath || !rootAbsolute || !allowedRoots.has(rootAbsolute)) {
3990
+ return res.status(400).json({ error: 'Invalid drive path' });
3991
+ }
3992
+ if (absolutePath.toUpperCase() === rootAbsolute && !allowRoot) {
3993
+ return res.status(400).json({ error: 'A drive root cannot be changed' });
3994
+ }
3995
+ return next(absolutePath, toDriveWebPath);
3996
+ });
3997
+ }
3998
+
3999
+ function validatedName(res, rawName) {
4000
+ const check = validateEntryName(rawName);
4001
+ if (!check.ok) {
4002
+ res.status(400).json({ error: check.error });
4003
+ return null;
4004
+ }
4005
+ return check.name;
4006
+ }
4007
+
4008
+ function sendMutationResult(res, result, extra = {}) {
4009
+ if (!result?.ok) return res.status(Number(result?.statusCode) || 500).json({ error: result?.error || 'Request failed' });
4010
+ res.setHeader('Cache-Control', 'no-store');
4011
+ return res.json({ ok: true, ...extra });
4012
+ }
4013
+
4014
+ app.post('/api/repo/mkdir', auth, (req, res) => {
4015
+ const name = validatedName(res, req.body?.name);
4016
+ if (!name) return;
4017
+ const parentRelative = normalizeWorkspaceRelativePath(req.body?.path);
4018
+ if (String(req.body?.path || '').trim() && !parentRelative) return res.status(400).json({ error: 'Invalid path' });
4019
+ const target = resolveWorkspaceMutationTarget(req, res, parentRelative ? `${workspaceWebPath(parentRelative)}/${name}` : name);
4020
+ if (!target) return;
4021
+ return sendMutationResult(res, createDirectory(target.absolutePath), { path: workspaceWebPath(target.relative) });
4022
+ });
4023
+
4024
+ app.post('/api/repo/rename', auth, (req, res) => {
4025
+ const newName = validatedName(res, req.body?.newName);
4026
+ if (!newName) return;
4027
+ const source = resolveWorkspaceMutationTarget(req, res, req.body?.path);
4028
+ if (!source) return;
4029
+ const parentWeb = workspaceWebPath(path.dirname(source.relative));
4030
+ const destinationWeb = parentWeb && parentWeb !== '.' ? `${parentWeb}/${newName}` : newName;
4031
+ // The destination goes through the same resolver: a new name can never
4032
+ // leave the workspace or land on the root.
4033
+ const destination = resolveWorkspaceMutationTarget(req, res, destinationWeb);
4034
+ if (!destination) return;
4035
+ return sendMutationResult(res, renameEntry(source.absolutePath, newName), { path: workspaceWebPath(destination.relative) });
4036
+ });
4037
+
4038
+ app.post('/api/repo/delete', auth, (req, res) => {
4039
+ const target = resolveWorkspaceMutationTarget(req, res, req.body?.path);
4040
+ if (!target) return;
4041
+ if (req.body?.dryRun === true) {
4042
+ const counted = countEntries(target.absolutePath);
4043
+ return sendMutationResult(res, counted, counted.ok ? { kind: counted.kind, files: counted.files, dirs: counted.dirs, capped: counted.capped } : {});
4044
+ }
4045
+ return sendMutationResult(res, removeEntry(target.absolutePath));
4046
+ });
4047
+
4048
+ app.post('/api/drives/mkdir', auth, (req, res) => {
4049
+ const name = validatedName(res, req.body?.name);
4050
+ if (!name) return;
4051
+ withDriveMutationTarget(req, res, req.body?.path, { allowRoot: true }, (parentAbsolute, webPathOf) => {
4052
+ const targetAbsolute = path.join(parentAbsolute, name);
4053
+ return sendMutationResult(res, createDirectory(targetAbsolute), { path: webPathOf(targetAbsolute) });
4054
+ });
4055
+ });
4056
+
4057
+ app.post('/api/drives/rename', auth, (req, res) => {
4058
+ const newName = validatedName(res, req.body?.newName);
4059
+ if (!newName) return;
4060
+ withDriveMutationTarget(req, res, req.body?.path, {}, (sourceAbsolute, webPathOf) => {
4061
+ const result = renameEntry(sourceAbsolute, newName);
4062
+ return sendMutationResult(res, result, result.ok ? { path: webPathOf(result.path) } : {});
4063
+ });
4064
+ });
4065
+
4066
+ app.post('/api/drives/delete', auth, (req, res) => {
4067
+ withDriveMutationTarget(req, res, req.body?.path, {}, (targetAbsolute) => {
4068
+ if (req.body?.dryRun === true) {
4069
+ const counted = countEntries(targetAbsolute);
4070
+ return sendMutationResult(res, counted, counted.ok ? { kind: counted.kind, files: counted.files, dirs: counted.dirs, capped: counted.capped } : {});
4071
+ }
4072
+ return sendMutationResult(res, removeEntry(targetAbsolute));
4073
+ });
4074
+ });
4075
+
3864
4076
  app.get('/api/drives/file', auth, (req, res) => {
3865
4077
  const requestedPath = String(req.query.path || '').trim();
3866
4078
 
@@ -3917,6 +4129,8 @@ export function registerMessagesRoutes(app, deps) {
3917
4129
  const filePath = normalizeLinuxAbsolutePath(requestedPath);
3918
4130
  if (!filePath) return res.status(400).json({ error: 'Invalid Linux file path' });
3919
4131
 
4132
+ // Fresh metadata: the download link's version comes from this answer.
4133
+ workspaceFileMetaCache.delete(filePath);
3920
4134
  let meta = null;
3921
4135
  try {
3922
4136
  meta = readWorkspaceFileMeta(filePath);
@@ -3969,10 +4183,12 @@ export function registerMessagesRoutes(app, deps) {
3969
4183
  size,
3970
4184
  truncated,
3971
4185
  previewBytes: contentBuffer.length,
3972
- rawUrl: `${remotePath}/api/drives/file?path=${encodeURIComponent(filePath)}`,
4186
+ mtimeMs: meta.mtimeMs ?? null,
4187
+ version: fileVersionToken(meta),
4188
+ rawUrl: withFileVersion(`${remotePath}/api/drives/file?path=${encodeURIComponent(filePath)}`, fileVersionToken(meta)),
3973
4189
  };
3974
4190
 
3975
- if (kind !== 'binary' && kind !== 'image' && kind !== 'video') {
4191
+ if (TEXT_PREVIEW_KINDS.has(kind)) {
3976
4192
  payload.content = contentBuffer.toString('utf8');
3977
4193
  }
3978
4194
 
@@ -3989,6 +4205,8 @@ export function registerMessagesRoutes(app, deps) {
3989
4205
  return res.status(400).json({ error: 'Invalid drive file path' });
3990
4206
  }
3991
4207
 
4208
+ // Fresh metadata: the download link's version comes from this answer.
4209
+ workspaceFileMetaCache.delete(filePath);
3992
4210
  let meta = null;
3993
4211
  try {
3994
4212
  meta = readWorkspaceFileMeta(filePath);
@@ -4043,10 +4261,12 @@ export function registerMessagesRoutes(app, deps) {
4043
4261
  size,
4044
4262
  truncated,
4045
4263
  previewBytes: contentBuffer.length,
4046
- rawUrl: `${remotePath}/api/drives/file?path=${encodeURIComponent(normalizedWebPath)}`,
4264
+ mtimeMs: meta.mtimeMs ?? null,
4265
+ version: fileVersionToken(meta),
4266
+ rawUrl: withFileVersion(`${remotePath}/api/drives/file?path=${encodeURIComponent(normalizedWebPath)}`, fileVersionToken(meta)),
4047
4267
  };
4048
4268
 
4049
- if (kind !== 'binary' && kind !== 'image' && kind !== 'video') {
4269
+ if (TEXT_PREVIEW_KINDS.has(kind)) {
4050
4270
  payload.content = contentBuffer.toString('utf8');
4051
4271
  }
4052
4272
 
@@ -5277,7 +5497,7 @@ export function registerMessagesRoutes(app, deps) {
5277
5497
  }
5278
5498
  const rowAttemptId = String(row.attempt_id || '').trim() || null;
5279
5499
  if (attemptId && rowAttemptId && attemptId !== rowAttemptId) {
5280
- console.warn(`[${ts()}] STALE ATTEMPT queue-cancelled ${messageId.slice(0, 8)} attempt=${attemptId.slice(0, 8)} current=${rowAttemptId.slice(0, 8)}`);
5500
+ staleAttemptLog.note({ messageId, attemptId, currentAttemptId: rowAttemptId, route: 'queue-cancelled', status: row.status });
5281
5501
  return res.status(409).json({ error: 'stale_attempt' });
5282
5502
  }
5283
5503
 
@@ -5732,6 +5952,9 @@ export function registerMessagesRoutes(app, deps) {
5732
5952
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
5733
5953
  }
5734
5954
  if (runtimeState.relayPaused) return res.json({ message: null, paused: true });
5955
+ // A queued shutdown or restart waits for the processing turns only: a
5956
+ // pending row handed out now would start a turn that postpones it again.
5957
+ if (isRelayShutdownQueued(runtimeState)) return res.json(relayShutdownGateReply(runtimeState));
5735
5958
  if (runtimeState.tunnelState?.blocking) {
5736
5959
  return res.json({
5737
5960
  message: null,
@@ -6717,7 +6940,7 @@ export function registerMessagesRoutes(app, deps) {
6717
6940
  // the legacy conditional semantics.
6718
6941
  const claimedAttemptId = String(req.body.attemptId || '').trim() || null;
6719
6942
  if (claimedAttemptId && q && String(q.attempt_id || '') !== claimedAttemptId) {
6720
- 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}`);
6943
+ staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: q.attempt_id, route: 'response', status: q.status });
6721
6944
  return res.status(409).json({ error: 'stale_attempt', currentStatus: q.status });
6722
6945
  }
6723
6946
  if (!claimedAttemptId && q && q.status === 'processing') {
@@ -6810,7 +7033,7 @@ export function registerMessagesRoutes(app, deps) {
6810
7033
  if (!failed) {
6811
7034
  const currentRow = stmts.findQById?.get(messageId) || null;
6812
7035
  if (claimedAttemptId && currentRow && String(currentRow.attempt_id || '') !== claimedAttemptId) {
6813
- 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}`);
7036
+ staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: currentRow.attempt_id, route: 'response-terminal', status: currentRow.status });
6814
7037
  return res.status(409).json({ error: 'stale_attempt', currentStatus: String(currentRow.status || 'unknown') });
6815
7038
  }
6816
7039
  console.log(`[${ts()}] RESPONSE ${messageId?.slice(0,8)} ignored=not_pending_or_processing`);
@@ -7026,7 +7249,7 @@ export function registerMessagesRoutes(app, deps) {
7026
7249
  const currentRow = stmts.findQById?.get(messageId) || null;
7027
7250
  const currentStatus = String(currentRow?.status || 'unknown');
7028
7251
  if (claimedAttemptId && currentRow && String(currentRow.attempt_id || '') !== claimedAttemptId) {
7029
- 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}`);
7252
+ staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: currentRow.attempt_id, route: 'response-finalize', status: currentStatus });
7030
7253
  return res.status(409).json({ error: 'stale_attempt', currentStatus });
7031
7254
  }
7032
7255
  console.log(`[${ts()}] RESPONSE ${messageId?.slice(0,8)} ignored=not_pending_or_processing actual_status=${currentStatus}`);
@@ -7202,6 +7425,25 @@ export function registerMessagesRoutes(app, deps) {
7202
7425
  sourceMessageId: messageId,
7203
7426
  text: resolvedText,
7204
7427
  });
7428
+ // A turn paused at the usage limit did not end: it is an error event
7429
+ // for whoever watches the session.
7430
+ try {
7431
+ if (usageLimitPause) {
7432
+ relayEventService?.noteError?.({
7433
+ conversationId: targetConversationId,
7434
+ kind: 'usage_limit',
7435
+ message: usageLimitPause.noteText,
7436
+ queueMessageId: messageId,
7437
+ });
7438
+ } else {
7439
+ relayEventService?.noteTurnEnd?.({
7440
+ conversationId: targetConversationId,
7441
+ queueMessageId: messageId,
7442
+ replyText: resolvedText,
7443
+ status: 'done',
7444
+ });
7445
+ }
7446
+ } catch {}
7205
7447
  }
7206
7448
  cancelPendingRelayQuestionsForMessage(messageId);
7207
7449
  res.json({ ok: true });
@@ -7298,6 +7540,37 @@ export function registerMessagesRoutes(app, deps) {
7298
7540
  res.json({ ok: true, messageId, conversationId, attemptId });
7299
7541
  });
7300
7542
 
7543
+ function resolveTaskConversationId(id) {
7544
+ const key = String(id || '').trim();
7545
+ if (!key) return '';
7546
+ try {
7547
+ return String(resolveConversationRecord?.({ conversationId: key, sdkSessionId: key })?.id || key);
7548
+ } catch {
7549
+ return key;
7550
+ }
7551
+ }
7552
+
7553
+ // POST /api/background-task-settled — how a background task ended, where
7554
+ // the worker learns it (the Claude worker's task_notification): status,
7555
+ // summary, and the reason for an unusual end. The task leaves the live set
7556
+ // on its own; this only says how it ended.
7557
+ app.post('/api/background-task-settled', auth, (req, res) => {
7558
+ touchCli();
7559
+ const conversationId = resolveTaskConversationId(req.body?.conversationId);
7560
+ const taskId = String(req.body?.taskId || '').trim();
7561
+ if (!conversationId || !taskId) return res.status(400).json({ error: 'Missing conversationId or taskId' });
7562
+ const exitCode = Number(req.body?.exitCode);
7563
+ sessionActivityTracker?.noteTaskSettled?.(conversationId, {
7564
+ taskId,
7565
+ toolUseId: sanitizeSubagentRunId(req.body?.toolUseId) || null,
7566
+ status: String(req.body?.status || '').trim().slice(0, 40) || 'completed',
7567
+ summary: String(req.body?.summary || '').trim().slice(0, 500) || null,
7568
+ exitCode: Number.isInteger(exitCode) ? exitCode : null,
7569
+ });
7570
+ relayEventService?.afterStateChange?.(conversationId);
7571
+ res.json({ ok: true });
7572
+ });
7573
+
7301
7574
  // POST /api/background-tasks — a session worker publishes its live
7302
7575
  // background-task set (REPLACE semantics, mirroring the SDK's
7303
7576
  // background_tasks_changed signal) so the composer panel tracks it live.
@@ -7324,10 +7597,40 @@ export function registerMessagesRoutes(app, deps) {
7324
7597
  // Sanitized digest or undefined: JSON serialization (store broadcast,
7325
7598
  // conversation payload) drops the key entirely for flat rows.
7326
7599
  workflowProgress: sanitizeWorkflowProgress(task?.workflowProgress) || undefined,
7600
+ // The spawning tool_use id: the sub-agent run this task is. The panel
7601
+ // opens the run's viewer from it.
7602
+ toolUseId: sanitizeSubagentRunId(task?.toolUseId) || undefined,
7327
7603
  }))
7328
7604
  .filter((task) => task.taskId);
7329
7605
  backgroundTaskStore?.replace?.(conversationId, tasks);
7330
7606
  io.emit('background_tasks', { conversationId, tasks });
7607
+ // The store is keyed as the worker posts (its SDK session id); the
7608
+ // tracker by the conversation.
7609
+ try {
7610
+ const trackedConversationId = resolveTaskConversationId(conversationId);
7611
+ sessionActivityTracker?.noteTaskSet?.(trackedConversationId, tasks);
7612
+ relayEventService?.afterStateChange?.(trackedConversationId);
7613
+ } catch {}
7614
+ // The panel's copy of a task lives in memory and goes when the task
7615
+ // settles; what the run's row needs of it is kept on the run.
7616
+ for (const task of tasks) {
7617
+ if (!task.toolUseId) continue;
7618
+ const before = stmts.getSubagentRun?.get?.(task.toolUseId) || null;
7619
+ if (!before || String(before.conversation_id || '') !== conversationId) continue;
7620
+ const patch = sanitizeRunHeaderPatch({
7621
+ model: task.model,
7622
+ modelInherited: task.model ? task.modelInherited : undefined,
7623
+ subagentType: task.subagentType,
7624
+ taskId: task.taskId,
7625
+ totalTokens: task.totalTokens,
7626
+ background: true,
7627
+ });
7628
+ if (!patch) continue;
7629
+ const after = subagentTimeline.applyHeader(before.id, patch);
7630
+ const changed = after && ['model', 'model_inherited', 'subagent_type', 'task_id', 'total_tokens', 'background']
7631
+ .some((column) => before[column] !== after[column]);
7632
+ if (changed) emitSubagentStatus(after);
7633
+ }
7331
7634
  res.json({ ok: true, count: tasks.length });
7332
7635
  });
7333
7636
 
@@ -7360,7 +7663,7 @@ export function registerMessagesRoutes(app, deps) {
7360
7663
  if (!claimed || !queueRow) return false;
7361
7664
  const current = String(queueRow.attempt_id || '');
7362
7665
  if (current === claimed) return false;
7363
- 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}`);
7666
+ staleAttemptLog.note({ messageId: queueRow.id, attemptId: claimed, currentAttemptId: current, route, status: queueRow.status });
7364
7667
  res.status(409).json({ error: 'stale_attempt', currentStatus: String(queueRow.status || 'unknown') });
7365
7668
  return true;
7366
7669
  }
@@ -7375,10 +7678,11 @@ export function registerMessagesRoutes(app, deps) {
7375
7678
 
7376
7679
  const q = stmts.findQById.get(messageId);
7377
7680
  if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'activity' })) return;
7681
+ try { sessionActivityTracker?.touch?.(q?.conversation_id || conversationId); } catch {}
7378
7682
  const responseMessageId = q?.response_message_id || null;
7379
7683
  const normalizedSubagentRunId = subagentRunId ? String(subagentRunId).trim() : null;
7380
7684
  const normalizedMetadata = sanitizeActivityMetadata(metadata);
7381
- stmts.insertActivity.run(
7685
+ const activityRow = stmts.insertActivity.run(
7382
7686
  messageId,
7383
7687
  responseMessageId,
7384
7688
  conversationId,
@@ -7388,6 +7692,14 @@ export function registerMessagesRoutes(app, deps) {
7388
7692
  normalizedSubagentRunId,
7389
7693
  normalizedMetadata ? JSON.stringify(normalizedMetadata) : null,
7390
7694
  );
7695
+ if (normalizedSubagentRunId) {
7696
+ // The lane keeps the capped line; the timeline copy keeps what came in.
7697
+ mirrorSubagentFrame({
7698
+ runId: normalizedSubagentRunId,
7699
+ key: `activity:${activityRow?.lastInsertRowid ?? Date.now()}`,
7700
+ ...activityTimelineFrame(String(text || '').trim() || activityText),
7701
+ });
7702
+ }
7391
7703
 
7392
7704
  io.emit('relay_activity', {
7393
7705
  messageId,
@@ -7422,6 +7734,7 @@ export function registerMessagesRoutes(app, deps) {
7422
7734
  return res.status(409).json({ error: 'Stream conversationId does not match queue conversation' });
7423
7735
  }
7424
7736
  if (rejectStaleAttemptWrite({ req, res, queueRow, route: 'stream' })) return;
7737
+ try { sessionActivityTracker?.touch?.(queueConversationId); } catch {}
7425
7738
 
7426
7739
  const isStreamSeqConstraintError = (error) => {
7427
7740
  const message = String(error?.message || '').toLowerCase();
@@ -7502,6 +7815,11 @@ export function registerMessagesRoutes(app, deps) {
7502
7815
  return res.status(500).json({ error: error?.message || 'Failed to persist stream event' });
7503
7816
  }
7504
7817
 
7818
+ if (normalizedSubagentRunId) {
7819
+ // One reply text per run and turn, updated in place as it grows.
7820
+ mirrorSubagentFrame({ runId: normalizedSubagentRunId, key: `stream:${messageId}`, kind: 'text', text: streamText, done: !!done });
7821
+ }
7822
+
7505
7823
  // Volatile keeps these out of the connection-state-recovery buffer. Each chunk
7506
7824
  // carries the full text so far, so replaying a turn's worth of them costs
7507
7825
  // megabytes to deliver what one reconnect resync already fetches from
@@ -7543,6 +7861,7 @@ export function registerMessagesRoutes(app, deps) {
7543
7861
  return res.status(409).json({ error: 'Thought conversationId does not match queue conversation' });
7544
7862
  }
7545
7863
  if (rejectStaleAttemptWrite({ req, res, queueRow, route: 'thought' })) return;
7864
+ try { sessionActivityTracker?.touch?.(queueConversationId); } catch {}
7546
7865
 
7547
7866
  const relayMode = normalizeRelayMode(mode) || DEFAULT_RELAY_MODE;
7548
7867
  const normalizedReasoningId = String(reasoningId || '').trim() || null;
@@ -7622,6 +7941,16 @@ export function registerMessagesRoutes(app, deps) {
7622
7941
  }
7623
7942
  }
7624
7943
 
7944
+ if (normalizedSubagentRunId && (normalizedReasoningId || done) && String(thoughtText).trim()) {
7945
+ mirrorSubagentFrame({
7946
+ runId: normalizedSubagentRunId,
7947
+ key: normalizedReasoningId ? `thought:${normalizedReasoningId}` : `thought:${messageId}:${seq}`,
7948
+ kind: 'thought',
7949
+ text: thoughtText,
7950
+ done: !!done,
7951
+ });
7952
+ }
7953
+
7625
7954
  io.emit('relay_thought', {
7626
7955
  messageId,
7627
7956
  conversationId,
@@ -7636,7 +7965,12 @@ export function registerMessagesRoutes(app, deps) {
7636
7965
  res.json({ ok: true, seq });
7637
7966
  });
7638
7967
 
7639
- // POST /api/subagent-run — relay notifies about subagent lifecycle events
7968
+ // POST /api/subagent-run — relay notifies about subagent lifecycle events.
7969
+ // A new run needs the turn that spawned it (messageId). An update to a known
7970
+ // run may come without one: a background agent outlives its turn, and its
7971
+ // end (or its header) arrives between turns or during a later turn; the run
7972
+ // row names its own turn. Header fields (model, type, task id, background,
7973
+ // who writes the timeline) are patched onto the run.
7640
7974
  app.post('/api/subagent-run', auth, (req, res) => {
7641
7975
  touchCli();
7642
7976
  const { messageId, conversationId, subagentRunId, parentSubagentId, displayName, status } = req.body || {};
@@ -7645,40 +7979,56 @@ export function registerMessagesRoutes(app, deps) {
7645
7979
  const normalizedSubagentRunId = sanitizeSubagentRunId(subagentRunId);
7646
7980
  const normalizedParentSubagentId = sanitizeSubagentRunId(parentSubagentId);
7647
7981
  const normalizedDisplayName = displayName ? String(displayName).trim() : null;
7648
- const normalizedStatus = status ? String(status).trim() : 'running';
7982
+ const normalizedStatus = status ? normalizeSubagentRunStatus(status) : 'running';
7983
+ const normalizedMessageId = String(messageId || '').trim();
7649
7984
 
7650
- if (!messageId || !conversationId || !normalizedSubagentRunId) {
7985
+ if (!conversationId || !normalizedSubagentRunId) {
7651
7986
  return res.status(400).json({ error: 'Missing required subagent-run fields: messageId, conversationId, subagentRunId' });
7652
7987
  }
7653
-
7654
- const q = stmts.findQById.get(messageId);
7655
- if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'subagent-run' })) return;
7656
7988
  const existingRun = stmts.getSubagentRun?.get(normalizedSubagentRunId);
7657
- const binding = validateSubagentRunBinding({
7658
- queueRow: q,
7659
- messageId,
7660
- conversationId,
7661
- existingRun,
7662
- });
7663
- if (!binding.ok) {
7664
- return res.status(binding.statusCode || 409).json({ error: binding.error || 'Subagent run binding mismatch' });
7989
+ if (!normalizedMessageId && !existingRun) {
7990
+ return res.status(400).json({ error: 'Missing required subagent-run fields: messageId, conversationId, subagentRunId' });
7991
+ }
7992
+
7993
+ let authoritativeConversationId;
7994
+ if (normalizedMessageId) {
7995
+ const q = stmts.findQById.get(normalizedMessageId);
7996
+ if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'subagent-run' })) return;
7997
+ const binding = validateSubagentRunBinding({
7998
+ queueRow: q,
7999
+ messageId: normalizedMessageId,
8000
+ conversationId,
8001
+ existingRun,
8002
+ });
8003
+ if (!binding.ok) {
8004
+ return res.status(binding.statusCode || 409).json({ error: binding.error || 'Subagent run binding mismatch' });
8005
+ }
8006
+ authoritativeConversationId = String(binding.conversationId || conversationId || '').trim();
8007
+ } else {
8008
+ authoritativeConversationId = String(existingRun.conversation_id || '').trim();
8009
+ if (authoritativeConversationId !== String(conversationId).trim()) {
8010
+ return res.status(409).json({ error: 'Subagent run conversation mismatch' });
8011
+ }
7665
8012
  }
7666
- const authoritativeConversationId = String(binding.conversationId || conversationId || '').trim();
7667
8013
 
7668
8014
  const now = new Date().toISOString();
7669
8015
 
7670
8016
  if (existingRun) {
7671
- const isTerminal = normalizedStatus === 'completed' || normalizedStatus === 'failed' || normalizedStatus === 'cancelled';
7672
- stmts.updateSubagentRunStatus?.run(
7673
- normalizedStatus,
7674
- now,
7675
- isTerminal ? now : null,
7676
- normalizedSubagentRunId,
7677
- );
8017
+ // Without a status the post is a header update and leaves the run's
8018
+ // state alone.
8019
+ if (status) {
8020
+ const isTerminal = normalizedStatus === 'completed' || normalizedStatus === 'failed' || normalizedStatus === 'cancelled';
8021
+ stmts.updateSubagentRunStatus?.run(
8022
+ normalizedStatus,
8023
+ now,
8024
+ isTerminal ? now : null,
8025
+ normalizedSubagentRunId,
8026
+ );
8027
+ }
7678
8028
  } else {
7679
8029
  stmts.insertSubagentRun?.run(
7680
8030
  normalizedSubagentRunId,
7681
- messageId,
8031
+ normalizedMessageId,
7682
8032
  authoritativeConversationId,
7683
8033
  normalizedParentSubagentId,
7684
8034
  normalizedDisplayName,
@@ -7687,20 +8037,107 @@ export function registerMessagesRoutes(app, deps) {
7687
8037
  now,
7688
8038
  );
7689
8039
  }
7690
-
7691
- io.emit('subagent_status', {
7692
- messageId,
7693
- conversationId: authoritativeConversationId,
7694
- subagentRunId: normalizedSubagentRunId,
7695
- parentSubagentId: normalizedParentSubagentId || undefined,
7696
- displayName: normalizedDisplayName || undefined,
7697
- status: normalizedStatus,
7698
- timestamp: now,
8040
+ const headerPatch = sanitizeRunHeaderPatch({
8041
+ model: req.body?.model,
8042
+ modelInherited: req.body?.modelInherited,
8043
+ subagentType: req.body?.subagentType,
8044
+ taskId: req.body?.taskId,
8045
+ background: req.body?.background === true,
8046
+ timelineOwner: req.body?.timelineOwner,
7699
8047
  });
8048
+ if (headerPatch) subagentTimeline.applyHeader(normalizedSubagentRunId, headerPatch);
8049
+
8050
+ const run = stmts.getSubagentRun?.get?.(normalizedSubagentRunId);
8051
+ if (run) {
8052
+ emitSubagentStatus(run);
8053
+ } else {
8054
+ io.emit('subagent_status', {
8055
+ messageId: normalizedMessageId || null,
8056
+ conversationId: authoritativeConversationId,
8057
+ subagentRunId: normalizedSubagentRunId,
8058
+ parentSubagentId: normalizedParentSubagentId || undefined,
8059
+ displayName: normalizedDisplayName || undefined,
8060
+ status: normalizedStatus,
8061
+ timestamp: now,
8062
+ });
8063
+ }
7700
8064
 
7701
8065
  res.json({ ok: true });
7702
8066
  });
7703
8067
 
8068
+ // POST /api/subagent-timeline — a worker publishes entries of a sub-agent's
8069
+ // record (and, optionally, a header patch) for a run it already announced.
8070
+ // Fenced by the run row, not by an open turn: a background agent keeps
8071
+ // writing after its turn ended, and its entries belong to that turn. A run
8072
+ // the relay does not know yet but whose parent it does (an agent spawned
8073
+ // inside another) is created under the parent's turn; anything else is a
8074
+ // 404 the worker retries once the spawn has landed.
8075
+ app.post('/api/subagent-timeline', auth, (req, res) => {
8076
+ touchCli();
8077
+ const conversationId = String(req.body?.conversationId || '').trim();
8078
+ const runId = sanitizeSubagentRunId(req.body?.subagentRunId);
8079
+ if (!conversationId || !runId) {
8080
+ return res.status(400).json({ error: 'Missing conversationId or subagentRunId' });
8081
+ }
8082
+ if (!subagentTimeline.supported()) {
8083
+ return res.status(503).json({ error: 'timeline_unavailable' });
8084
+ }
8085
+ const rawHeader = req.body?.header && typeof req.body.header === 'object' ? req.body.header : {};
8086
+ let run = stmts.getSubagentRun?.get?.(runId) || null;
8087
+ let created = false;
8088
+ if (!run) {
8089
+ const parentId = sanitizeSubagentRunId(rawHeader.parentSubagentId);
8090
+ const parent = parentId ? (stmts.getSubagentRun?.get?.(parentId) || null) : null;
8091
+ if (!parent) return res.status(404).json({ error: 'unknown_run' });
8092
+ if (String(parent.conversation_id || '') !== conversationId) {
8093
+ return res.status(409).json({ error: 'Subagent run conversation mismatch' });
8094
+ }
8095
+ const now = new Date().toISOString();
8096
+ const displayName = String(rawHeader.displayName || '').trim().slice(0, 500) || 'Subagent';
8097
+ stmts.insertSubagentRun?.run(runId, parent.queue_message_id, parent.conversation_id, parentId, displayName, 'running', now, now);
8098
+ run = stmts.getSubagentRun?.get?.(runId) || null;
8099
+ if (!run) return res.status(500).json({ error: 'Failed to create the run' });
8100
+ created = true;
8101
+ }
8102
+ if (String(run.conversation_id || '') !== conversationId) {
8103
+ return res.status(409).json({ error: 'Subagent run conversation mismatch' });
8104
+ }
8105
+ const patch = sanitizeRunHeaderPatch(rawHeader);
8106
+ if (patch) run = subagentTimeline.applyHeader(runId, patch) || run;
8107
+ let stored;
8108
+ try {
8109
+ stored = subagentTimeline.applyEntries(run, req.body?.entries);
8110
+ } catch (error) {
8111
+ return res.status(500).json({ error: error?.message || 'Failed to store the timeline' });
8112
+ }
8113
+ if (stored.truncated) run = stmts.getSubagentRun?.get?.(runId) || run;
8114
+ if (created || patch || stored.truncated) emitSubagentStatus(run);
8115
+ emitSubagentTimeline(run, stored.entries);
8116
+ try { sessionActivityTracker?.touch?.(conversationId); } catch {}
8117
+ res.json({ ok: true, stored: stored.entries.length, truncated: stored.truncated });
8118
+ });
8119
+
8120
+ // GET /api/conversation/:conversationId/subagent/:subagentRunId — one run's
8121
+ // record for the viewer: header, entries after `after` (plus open ones),
8122
+ // child runs. `complete` is false when more entries follow this page.
8123
+ app.get('/api/conversation/:conversationId/subagent/:subagentRunId', auth, (req, res) => {
8124
+ const conversationId = String(req.params.conversationId || '').trim();
8125
+ const runId = sanitizeSubagentRunId(req.params.subagentRunId);
8126
+ if (!conversationId || !runId) return res.status(400).json({ error: 'Missing conversationId or subagentRunId' });
8127
+ const run = stmts.getSubagentRun?.get?.(runId) || null;
8128
+ if (!run || String(run.conversation_id || '') !== conversationId) {
8129
+ return res.status(404).json({ error: 'Sub-agent run not found' });
8130
+ }
8131
+ const page = subagentTimeline.readTimeline(run, {
8132
+ afterSeq: Number(req.query?.after) || 0,
8133
+ limit: Number(req.query?.limit) || TIMELINE_READ_DEFAULT_LIMIT,
8134
+ isStoppable: isSubagentRunStoppable,
8135
+ });
8136
+ const task = liveTaskForSubagentRun(run);
8137
+ if (page.run && task?.taskId) page.run.taskId = page.run.taskId || task.taskId;
8138
+ res.json(page);
8139
+ });
8140
+
7704
8141
  // POST /api/requeue — relay re-queues a message it failed to process
7705
8142
  app.post('/api/requeue', auth, (req, res) => {
7706
8143
  const { messageId } = req.body;
@@ -7711,7 +8148,7 @@ export function registerMessagesRoutes(app, deps) {
7711
8148
  // terminal-fail a row that has already been requeued to someone else).
7712
8149
  const claimedAttemptId = String(req.body?.attemptId || '').trim() || null;
7713
8150
  if (claimedAttemptId && q && String(q.attempt_id || '') !== claimedAttemptId) {
7714
- 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}`);
8151
+ staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: q.attempt_id, route: 'requeue', status: q.status });
7715
8152
  return res.status(409).json({ error: 'stale_attempt', currentStatus: String(q.status || 'unknown') });
7716
8153
  }
7717
8154
  const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)