@oar-sh/oar 0.9.9 → 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 (85) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/README.md +78 -9
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +2 -2
  5. package/server/README.md +286 -3
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +35 -1
  8. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  9. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  10. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  12. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  13. package/server/db-schema.mjs +36 -0
  14. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  15. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  16. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  17. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  18. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  19. package/server/public/app/api-client.js +61 -0
  20. package/server/public/app/archive-confirm.mjs +28 -0
  21. package/server/public/app/ask-user-view.js +3 -0
  22. package/server/public/app/attachments-view.js +516 -6
  23. package/server/public/app/background-tasks-view.mjs +18 -26
  24. package/server/public/app/bootstrap.js +186 -35
  25. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  26. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  27. package/server/public/app/composer-control-state.mjs +16 -0
  28. package/server/public/app/composer-keyboard.mjs +54 -0
  29. package/server/public/app/conversation-view.js +361 -85
  30. package/server/public/app/explorer-edit-state.mjs +68 -0
  31. package/server/public/app/host-suspend-ui.js +6 -3
  32. package/server/public/app/journal-view.js +146 -26
  33. package/server/public/app/no-conversation-state.mjs +58 -0
  34. package/server/public/app/provider-readiness.mjs +120 -0
  35. package/server/public/app/relay-board-view.js +3 -0
  36. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  37. package/server/public/app/relay-event-view.mjs +234 -0
  38. package/server/public/app/settings-modal.js +53 -1
  39. package/server/public/app/socket-handlers.js +59 -28
  40. package/server/public/app/store.js +19 -2
  41. package/server/public/app/subagent-row.mjs +138 -0
  42. package/server/public/app/subagent-timeline-view.mjs +186 -0
  43. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  44. package/server/public/app/subagent-viewer.mjs +534 -0
  45. package/server/public/app/task-row-format.mjs +29 -0
  46. package/server/public/app/thinking-follow.js +4 -0
  47. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  48. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  49. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  50. package/server/public/app/update-ui.js +1 -1
  51. package/server/public/app/viewer-history.mjs +5 -3
  52. package/server/public/index.html +396 -5
  53. package/server/repositories/message-repository.mjs +29 -3
  54. package/server/repositories/question-repository.mjs +9 -3
  55. package/server/repositories/remote-relay-repository.mjs +6 -2
  56. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  57. package/server/routes/ask-user-routes.mjs +3 -0
  58. package/server/routes/messages-routes.mjs +515 -50
  59. package/server/routes/relay-event-routes.mjs +157 -0
  60. package/server/routes/remote-relay-routes.mjs +15 -0
  61. package/server/routes/sessions-routes.mjs +72 -0
  62. package/server/server-runtime.mjs +245 -62
  63. package/server/services/explorer-entry-name.mjs +26 -0
  64. package/server/services/explorer-mutations.mjs +122 -0
  65. package/server/services/preview-proxy-server.mjs +41 -23
  66. package/server/services/push-dispatch-service.mjs +18 -0
  67. package/server/services/relay-event-link-service.mjs +289 -0
  68. package/server/services/relay-event-service.mjs +1935 -0
  69. package/server/services/relay-shutdown-gate.mjs +31 -0
  70. package/server/services/remote-relay-caller-context.mjs +52 -7
  71. package/server/services/remote-relay-dispatcher.mjs +494 -2
  72. package/server/services/remote-relay-registry-service.mjs +11 -1
  73. package/server/services/session-activity-tracker.mjs +487 -0
  74. package/server/services/session-history-refresh-service.mjs +7 -0
  75. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  76. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  77. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  78. package/server/services/subagent-record-retention.mjs +164 -0
  79. package/server/services/subagent-timeline-service.mjs +328 -0
  80. package/server/services/subagent-viewer-rooms.mjs +119 -0
  81. package/server/services/upgrade-socket-guard.mjs +16 -0
  82. package/shared/relay-events.mjs +371 -0
  83. package/shared/remote-relay-contract.mjs +157 -5
  84. package/shared/subagent-record-retention.mjs +31 -0
  85. package/shared/subagent-timeline.mjs +79 -0
@@ -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,
@@ -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,
@@ -1180,6 +1193,12 @@ export function buildDequeuedRelayMessage({
1180
1193
  fallbackEnvironmentId: getClaudeCloudProviderSettings()?.environmentId || '',
1181
1194
  })
1182
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 {}
1183
1202
  return {
1184
1203
  id: msg.id,
1185
1204
  conversationId: msg.conversation_id,
@@ -1211,6 +1230,7 @@ export function buildDequeuedRelayMessage({
1211
1230
  ownerAssignedAt: msg.owner_assigned_at || null,
1212
1231
  ownerLeaseExpiresAt: msg.owner_lease_expires_at || null,
1213
1232
  imageOperationId: String(msg.image_operation_id || '').trim() || null,
1233
+ ...(messageKind === RELAY_EVENT_MESSAGE_KIND ? { messageKind } : {}),
1214
1234
  };
1215
1235
  }
1216
1236
 
@@ -1813,9 +1833,90 @@ export function registerMessagesRoutes(app, deps) {
1813
1833
  // agent-sent messages, the inbound switch, and mention unlocks. Absent in
1814
1834
  // older wiring and most tests, where every remote-relay step is a no-op.
1815
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,
1816
1843
  } = deps;
1817
1844
 
1818
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
+ }
1819
1920
  const getMessageAttachmentsByConversation = db.prepare(`
1820
1921
  SELECT attachments
1821
1922
  FROM messages
@@ -1999,25 +2100,28 @@ export function registerMessagesRoutes(app, deps) {
1999
2100
  };
2000
2101
  }
2001
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
+
2002
2107
  function rejectSessionBinding(res, status, error, extra = {}) {
2003
2108
  return res.status(status).json({ error, ...extra });
2004
2109
  }
2005
2110
 
2006
2111
  function reconcileSubagentRunsForTerminalTurn(messageId, { status = 'failed' } = {}) {
2007
- 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));
2008
2117
  if (!running.length) return 0;
2009
2118
  const now = new Date().toISOString();
2010
- stmts.closeRunningSubagentRunsByQueueMessage?.run?.(status, now, now, messageId);
2011
2119
  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
- });
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 });
2021
2125
  }
2022
2126
  return running.length;
2023
2127
  }
@@ -2104,6 +2208,19 @@ export function registerMessagesRoutes(app, deps) {
2104
2208
  });
2105
2209
  io.emit('message_status', { messageId, conversationId, status: 'failed' });
2106
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 {}
2107
2224
  cancelPendingRelayQuestionsForMessage(messageId);
2108
2225
  const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)
2109
2226
  ? normalizeSessionWorkerId(queueRow?.owner_sdk_session_id)
@@ -3825,6 +3942,137 @@ export function registerMessagesRoutes(app, deps) {
3825
3942
  });
3826
3943
  });
3827
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
+
3828
4076
  app.get('/api/drives/file', auth, (req, res) => {
3829
4077
  const requestedPath = String(req.query.path || '').trim();
3830
4078
 
@@ -5249,7 +5497,7 @@ export function registerMessagesRoutes(app, deps) {
5249
5497
  }
5250
5498
  const rowAttemptId = String(row.attempt_id || '').trim() || null;
5251
5499
  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)}`);
5500
+ staleAttemptLog.note({ messageId, attemptId, currentAttemptId: rowAttemptId, route: 'queue-cancelled', status: row.status });
5253
5501
  return res.status(409).json({ error: 'stale_attempt' });
5254
5502
  }
5255
5503
 
@@ -5704,6 +5952,9 @@ export function registerMessagesRoutes(app, deps) {
5704
5952
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
5705
5953
  }
5706
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));
5707
5958
  if (runtimeState.tunnelState?.blocking) {
5708
5959
  return res.json({
5709
5960
  message: null,
@@ -6689,7 +6940,7 @@ export function registerMessagesRoutes(app, deps) {
6689
6940
  // the legacy conditional semantics.
6690
6941
  const claimedAttemptId = String(req.body.attemptId || '').trim() || null;
6691
6942
  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}`);
6943
+ staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: q.attempt_id, route: 'response', status: q.status });
6693
6944
  return res.status(409).json({ error: 'stale_attempt', currentStatus: q.status });
6694
6945
  }
6695
6946
  if (!claimedAttemptId && q && q.status === 'processing') {
@@ -6782,7 +7033,7 @@ export function registerMessagesRoutes(app, deps) {
6782
7033
  if (!failed) {
6783
7034
  const currentRow = stmts.findQById?.get(messageId) || null;
6784
7035
  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}`);
7036
+ staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: currentRow.attempt_id, route: 'response-terminal', status: currentRow.status });
6786
7037
  return res.status(409).json({ error: 'stale_attempt', currentStatus: String(currentRow.status || 'unknown') });
6787
7038
  }
6788
7039
  console.log(`[${ts()}] RESPONSE ${messageId?.slice(0,8)} ignored=not_pending_or_processing`);
@@ -6998,7 +7249,7 @@ export function registerMessagesRoutes(app, deps) {
6998
7249
  const currentRow = stmts.findQById?.get(messageId) || null;
6999
7250
  const currentStatus = String(currentRow?.status || 'unknown');
7000
7251
  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}`);
7252
+ staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: currentRow.attempt_id, route: 'response-finalize', status: currentStatus });
7002
7253
  return res.status(409).json({ error: 'stale_attempt', currentStatus });
7003
7254
  }
7004
7255
  console.log(`[${ts()}] RESPONSE ${messageId?.slice(0,8)} ignored=not_pending_or_processing actual_status=${currentStatus}`);
@@ -7174,6 +7425,25 @@ export function registerMessagesRoutes(app, deps) {
7174
7425
  sourceMessageId: messageId,
7175
7426
  text: resolvedText,
7176
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 {}
7177
7447
  }
7178
7448
  cancelPendingRelayQuestionsForMessage(messageId);
7179
7449
  res.json({ ok: true });
@@ -7270,6 +7540,37 @@ export function registerMessagesRoutes(app, deps) {
7270
7540
  res.json({ ok: true, messageId, conversationId, attemptId });
7271
7541
  });
7272
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
+
7273
7574
  // POST /api/background-tasks — a session worker publishes its live
7274
7575
  // background-task set (REPLACE semantics, mirroring the SDK's
7275
7576
  // background_tasks_changed signal) so the composer panel tracks it live.
@@ -7296,10 +7597,40 @@ export function registerMessagesRoutes(app, deps) {
7296
7597
  // Sanitized digest or undefined: JSON serialization (store broadcast,
7297
7598
  // conversation payload) drops the key entirely for flat rows.
7298
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,
7299
7603
  }))
7300
7604
  .filter((task) => task.taskId);
7301
7605
  backgroundTaskStore?.replace?.(conversationId, tasks);
7302
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
+ }
7303
7634
  res.json({ ok: true, count: tasks.length });
7304
7635
  });
7305
7636
 
@@ -7332,7 +7663,7 @@ export function registerMessagesRoutes(app, deps) {
7332
7663
  if (!claimed || !queueRow) return false;
7333
7664
  const current = String(queueRow.attempt_id || '');
7334
7665
  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}`);
7666
+ staleAttemptLog.note({ messageId: queueRow.id, attemptId: claimed, currentAttemptId: current, route, status: queueRow.status });
7336
7667
  res.status(409).json({ error: 'stale_attempt', currentStatus: String(queueRow.status || 'unknown') });
7337
7668
  return true;
7338
7669
  }
@@ -7347,10 +7678,11 @@ export function registerMessagesRoutes(app, deps) {
7347
7678
 
7348
7679
  const q = stmts.findQById.get(messageId);
7349
7680
  if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'activity' })) return;
7681
+ try { sessionActivityTracker?.touch?.(q?.conversation_id || conversationId); } catch {}
7350
7682
  const responseMessageId = q?.response_message_id || null;
7351
7683
  const normalizedSubagentRunId = subagentRunId ? String(subagentRunId).trim() : null;
7352
7684
  const normalizedMetadata = sanitizeActivityMetadata(metadata);
7353
- stmts.insertActivity.run(
7685
+ const activityRow = stmts.insertActivity.run(
7354
7686
  messageId,
7355
7687
  responseMessageId,
7356
7688
  conversationId,
@@ -7360,6 +7692,14 @@ export function registerMessagesRoutes(app, deps) {
7360
7692
  normalizedSubagentRunId,
7361
7693
  normalizedMetadata ? JSON.stringify(normalizedMetadata) : null,
7362
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
+ }
7363
7703
 
7364
7704
  io.emit('relay_activity', {
7365
7705
  messageId,
@@ -7394,6 +7734,7 @@ export function registerMessagesRoutes(app, deps) {
7394
7734
  return res.status(409).json({ error: 'Stream conversationId does not match queue conversation' });
7395
7735
  }
7396
7736
  if (rejectStaleAttemptWrite({ req, res, queueRow, route: 'stream' })) return;
7737
+ try { sessionActivityTracker?.touch?.(queueConversationId); } catch {}
7397
7738
 
7398
7739
  const isStreamSeqConstraintError = (error) => {
7399
7740
  const message = String(error?.message || '').toLowerCase();
@@ -7474,6 +7815,11 @@ export function registerMessagesRoutes(app, deps) {
7474
7815
  return res.status(500).json({ error: error?.message || 'Failed to persist stream event' });
7475
7816
  }
7476
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
+
7477
7823
  // Volatile keeps these out of the connection-state-recovery buffer. Each chunk
7478
7824
  // carries the full text so far, so replaying a turn's worth of them costs
7479
7825
  // megabytes to deliver what one reconnect resync already fetches from
@@ -7515,6 +7861,7 @@ export function registerMessagesRoutes(app, deps) {
7515
7861
  return res.status(409).json({ error: 'Thought conversationId does not match queue conversation' });
7516
7862
  }
7517
7863
  if (rejectStaleAttemptWrite({ req, res, queueRow, route: 'thought' })) return;
7864
+ try { sessionActivityTracker?.touch?.(queueConversationId); } catch {}
7518
7865
 
7519
7866
  const relayMode = normalizeRelayMode(mode) || DEFAULT_RELAY_MODE;
7520
7867
  const normalizedReasoningId = String(reasoningId || '').trim() || null;
@@ -7594,6 +7941,16 @@ export function registerMessagesRoutes(app, deps) {
7594
7941
  }
7595
7942
  }
7596
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
+
7597
7954
  io.emit('relay_thought', {
7598
7955
  messageId,
7599
7956
  conversationId,
@@ -7608,7 +7965,12 @@ export function registerMessagesRoutes(app, deps) {
7608
7965
  res.json({ ok: true, seq });
7609
7966
  });
7610
7967
 
7611
- // 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.
7612
7974
  app.post('/api/subagent-run', auth, (req, res) => {
7613
7975
  touchCli();
7614
7976
  const { messageId, conversationId, subagentRunId, parentSubagentId, displayName, status } = req.body || {};
@@ -7617,40 +7979,56 @@ export function registerMessagesRoutes(app, deps) {
7617
7979
  const normalizedSubagentRunId = sanitizeSubagentRunId(subagentRunId);
7618
7980
  const normalizedParentSubagentId = sanitizeSubagentRunId(parentSubagentId);
7619
7981
  const normalizedDisplayName = displayName ? String(displayName).trim() : null;
7620
- const normalizedStatus = status ? String(status).trim() : 'running';
7982
+ const normalizedStatus = status ? normalizeSubagentRunStatus(status) : 'running';
7983
+ const normalizedMessageId = String(messageId || '').trim();
7621
7984
 
7622
- if (!messageId || !conversationId || !normalizedSubagentRunId) {
7985
+ if (!conversationId || !normalizedSubagentRunId) {
7623
7986
  return res.status(400).json({ error: 'Missing required subagent-run fields: messageId, conversationId, subagentRunId' });
7624
7987
  }
7625
-
7626
- const q = stmts.findQById.get(messageId);
7627
- if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'subagent-run' })) return;
7628
7988
  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' });
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
+ }
7637
8012
  }
7638
- const authoritativeConversationId = String(binding.conversationId || conversationId || '').trim();
7639
8013
 
7640
8014
  const now = new Date().toISOString();
7641
8015
 
7642
8016
  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
- );
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
+ }
7650
8028
  } else {
7651
8029
  stmts.insertSubagentRun?.run(
7652
8030
  normalizedSubagentRunId,
7653
- messageId,
8031
+ normalizedMessageId,
7654
8032
  authoritativeConversationId,
7655
8033
  normalizedParentSubagentId,
7656
8034
  normalizedDisplayName,
@@ -7659,20 +8037,107 @@ export function registerMessagesRoutes(app, deps) {
7659
8037
  now,
7660
8038
  );
7661
8039
  }
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,
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,
7671
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
+ }
7672
8064
 
7673
8065
  res.json({ ok: true });
7674
8066
  });
7675
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
+
7676
8141
  // POST /api/requeue — relay re-queues a message it failed to process
7677
8142
  app.post('/api/requeue', auth, (req, res) => {
7678
8143
  const { messageId } = req.body;
@@ -7683,7 +8148,7 @@ export function registerMessagesRoutes(app, deps) {
7683
8148
  // terminal-fail a row that has already been requeued to someone else).
7684
8149
  const claimedAttemptId = String(req.body?.attemptId || '').trim() || null;
7685
8150
  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}`);
8151
+ staleAttemptLog.note({ messageId, attemptId: claimedAttemptId, currentAttemptId: q.attempt_id, route: 'requeue', status: q.status });
7687
8152
  return res.status(409).json({ error: 'stale_attempt', currentStatus: String(q.status || 'unknown') });
7688
8153
  }
7689
8154
  const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)