@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
@@ -24,6 +24,7 @@ import {
24
24
  normalizeWorkspaceRootKey,
25
25
  } from './services/workspace-root-path-policy.mjs';
26
26
  import { stopSessionWorkerProcesses } from './services/session-worker-stop-service.mjs';
27
+ import { applyCdnNoStoreHeaders } from './services/file-serving.mjs';
27
28
  import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
28
29
  import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
29
30
  import { sanitizeRelayQuestionContext as sanitizeRelayQuestionContextWith } from './services/relay-question-context.mjs';
@@ -42,6 +43,7 @@ import { registerGitRoutes } from './routes/git-routes.mjs';
42
43
  import { registerClaudeCloudRoutes } from './routes/claude-cloud-routes.mjs';
43
44
  import { registerPreviewRoutes } from './routes/preview-routes.mjs';
44
45
  import { registerRemoteRelayRoutes } from './routes/remote-relay-routes.mjs';
46
+ import { registerRelayEventRoutes } from './routes/relay-event-routes.mjs';
45
47
  import { createRemoteRelayRepository } from './repositories/remote-relay-repository.mjs';
46
48
  import { createRemoteRelayClient } from './services/remote-relay-client.mjs';
47
49
  import { createRemoteRelayLoopbackClient } from './services/remote-relay-loopback.mjs';
@@ -67,6 +69,9 @@ import webpush from 'web-push';
67
69
  import { createPushDispatchService, ensurePushVapidKeys } from './services/push-dispatch-service.mjs';
68
70
  import { createHostSuspendService } from './services/host-suspend-service.mjs';
69
71
  import { USAGE_LIMIT_ACCOUNT_SOCKET_EVENT, createUsageLimitPauseService } from './services/usage-limit-pause-service.mjs';
72
+ import { createSessionActivityTracker } from './services/session-activity-tracker.mjs';
73
+ import { createRelayEventService } from './services/relay-event-service.mjs';
74
+ import { createRelayEventLinkService } from './services/relay-event-link-service.mjs';
70
75
  import { buildClaudeAttributionSettings, normalizeClaudeAttributionMode, resolveClaudeAttributionMode, DEFAULT_CLAUDE_ATTRIBUTION_MODE } from '../shared/claude-attribution.mjs';
71
76
  import { createWorkerLaunchNoteService } from './services/worker-launch-note-service.mjs';
72
77
  import { createGitHubCiActivityService } from './services/github-ci-activity-service.mjs';
@@ -115,12 +120,14 @@ import {
115
120
  import { createPreviewProxyServer } from './services/preview-proxy-server.mjs';
116
121
  import { createPreviewHealthProbe } from './services/preview-health-probe.mjs';
117
122
  import { createTunnelWorkerPathGuard } from './services/tunnel-worker-path-guard.mjs';
123
+ import { guardUpgradeSocket } from './services/upgrade-socket-guard.mjs';
118
124
  import { createWindowsAutostartService } from './services/windows-autostart-service.mjs';
119
125
  import { createWindowsBootAutostartService } from './services/windows-boot-autostart-service.mjs';
120
126
  import { createSessionWorkerWebSocketService } from './services/session-worker-websocket-service.mjs';
121
127
  import { createTmuxInspectorAccessPolicy } from './services/tmux-inspector-access-policy.mjs';
122
128
  import { createTmuxInspectorStreamService } from './services/tmux-inspector-stream-service.mjs';
123
129
  import { createTmuxInspectorSocketService } from './services/tmux-inspector-socket-service.mjs';
130
+ import { createSubagentViewerRooms } from './services/subagent-viewer-rooms.mjs';
124
131
  import {
125
132
  resolveDefaultSessionWorkspaceRootState as resolveDefaultSessionWorkspaceRootStateFromService,
126
133
  resolveLaunchWorkspaceRootPath,
@@ -141,6 +148,19 @@ import {
141
148
  readTurnCeilingSetting,
142
149
  turnCeilingMinutesToMs,
143
150
  } from '../shared/turn-ceiling.mjs';
151
+ import {
152
+ SUBAGENT_RECORD_RETENTION_SETTING_KEY,
153
+ parseSubagentRecordRetentionUpdate,
154
+ readSubagentRecordRetentionSetting,
155
+ } from '../shared/subagent-record-retention.mjs';
156
+ import { createSubagentRecordRetention } from './services/subagent-record-retention.mjs';
157
+ import { isRelayShutdownQueued, queuedShutdownCanProceed, relayShutdownGateReply } from './services/relay-shutdown-gate.mjs';
158
+ import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
159
+ import {
160
+ STALE_SUBAGENT_RUN_BOOT_DELAY_MS,
161
+ STALE_SUBAGENT_RUN_SWEEP_INTERVAL_MS,
162
+ createStaleSubagentRunSweep,
163
+ } from './services/stale-subagent-run-sweep.mjs';
144
164
  import {
145
165
  normalizePwaAppName,
146
166
  readPwaAppNameSetting,
@@ -177,6 +197,7 @@ import {
177
197
  } from '../shared/model-id.mjs';
178
198
  import { createModelVariantCatalogService } from './services/model-variant-catalog-service.mjs';
179
199
  import { applyPwaManifestTheme, normalizePwaTheme } from './services/pwa-manifest-theme.mjs';
200
+ import { mapSubagentRunHeader } from './services/subagent-timeline-service.mjs';
180
201
 
181
202
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
182
203
  const REPO_ROOT = path.resolve(__dirname, '..');
@@ -1510,6 +1531,17 @@ const WORKSPACE_CONTENT_TYPES = Object.freeze({
1510
1531
  '.gif': 'image/gif',
1511
1532
  '.webp': 'image/webp',
1512
1533
  '.ico': 'image/x-icon',
1534
+ '.bmp': 'image/bmp',
1535
+ '.avif': 'image/avif',
1536
+ '.mp3': 'audio/mpeg',
1537
+ '.wav': 'audio/wav',
1538
+ '.m4a': 'audio/mp4',
1539
+ '.aac': 'audio/aac',
1540
+ '.ogg': 'audio/ogg',
1541
+ '.oga': 'audio/ogg',
1542
+ '.opus': 'audio/ogg',
1543
+ '.flac': 'audio/flac',
1544
+ '.weba': 'audio/webm',
1513
1545
  '.mp4': 'video/mp4',
1514
1546
  '.m4v': 'video/mp4',
1515
1547
  '.mov': 'video/quicktime',
@@ -1650,6 +1682,7 @@ const WORKSPACE_CODE_EXTENSIONS = new Set(Object.keys(WORKSPACE_PREVIEW_LANGUAGE
1650
1682
  .filter((ext) => !WORKSPACE_MARKDOWN_EXTENSIONS.has(ext)));
1651
1683
  const WORKSPACE_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif']);
1652
1684
  const WORKSPACE_VIDEO_EXTENSIONS = new Set(['.mp4', '.m4v', '.mov', '.webm', '.mkv', '.avi', '.ogv', '.wmv']);
1685
+ const WORKSPACE_AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.aac', '.ogg', '.oga', '.opus', '.flac', '.weba']);
1653
1686
  const REPO_HEAVY_DIR_NAMES = new Set(['.git', 'node_modules']);
1654
1687
  const MAX_REPO_TREE_NODES = 20_000;
1655
1688
  const MAX_REPO_TREE_DEPTH = 64;
@@ -1779,6 +1812,8 @@ const OFFLINE_STALE_RECOVER_MS = 45_000;
1779
1812
  // reaches the browser.
1780
1813
  const SHUTDOWN_SOCKET_FLUSH_MS = 300;
1781
1814
  let runtimeShutdownStarted = false;
1815
+ // Set once the database is open (see the retention sweep near the other sweeps).
1816
+ let subagentRecordRetention = null;
1782
1817
  const runtimeTimers = {
1783
1818
  cliStatus: null,
1784
1819
  ownerWatchdog: null,
@@ -1987,6 +2022,22 @@ function setTurnCeilingMinutes(value) {
1987
2022
  return { ok: true, ceilingMinutes: parsed.minutes };
1988
2023
  }
1989
2024
 
2025
+ function getSubagentRecordRetentionDays() {
2026
+ return readSubagentRecordRetentionSetting(readAppSettingValue(SUBAGENT_RECORD_RETENTION_SETTING_KEY));
2027
+ }
2028
+
2029
+ function setSubagentRecordRetentionDays(value) {
2030
+ if (typeof stmts?.upsertAppSetting?.run !== 'function') {
2031
+ return { ok: false, error: 'Sub-agent record settings are unavailable' };
2032
+ }
2033
+ const parsed = parseSubagentRecordRetentionUpdate(value);
2034
+ if (!parsed.ok) return parsed;
2035
+ stmts.upsertAppSetting.run(SUBAGENT_RECORD_RETENTION_SETTING_KEY, String(parsed.days), new Date().toISOString());
2036
+ // A shorter period takes effect now, not at the next daily sweep.
2037
+ subagentRecordRetention?.scheduleSweep(5_000);
2038
+ return { ok: true, retentionDays: parsed.days };
2039
+ }
2040
+
1990
2041
  const PWA_APP_NAME_SETTING_KEY = 'pwa_app_name';
1991
2042
 
1992
2043
  /** '' means "no custom name": the manifest falls back to the template default. */
@@ -2528,6 +2579,14 @@ const stmts = {
2528
2579
  ...createImageConversationRepository(db),
2529
2580
  };
2530
2581
  const statusEventService = createStatusEventService(db);
2582
+ // Relay events (built further down, next to the usage-limit pause): declared
2583
+ // here so recovery code that runs before then finds null, not a TDZ error.
2584
+ let sessionActivityTracker = null;
2585
+ let relayEventService = null;
2586
+ // Fetches the events of subscriptions on paired relays (built with the
2587
+ // remote relays below; the event service asks it to fetch at once when an
2588
+ // end or a new watcher must reach one).
2589
+ let relayEventLinkService = null;
2531
2590
  // Owns the `claude auth …` child processes, so the runtime (not the route
2532
2591
  // module) constructs it: shutdownRuntime() has to be able to dispose a login
2533
2592
  // PTY that is still holding a process group.
@@ -3331,26 +3390,6 @@ function cancelRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
3331
3390
  return { cancelled: true, dropped, ...relayShutdownStatePayload() };
3332
3391
  }
3333
3392
 
3334
- const countPendingRowsByOwnerSession = db.prepare(`
3335
- SELECT owner_sdk_session_id AS sdk_session_id, COUNT(*) AS n
3336
- FROM queue
3337
- WHERE status = 'pending' AND owner_sdk_session_id IS NOT NULL AND owner_sdk_session_id != ''
3338
- GROUP BY owner_sdk_session_id
3339
- `);
3340
-
3341
- function countPendingRowsOfStoppedSessions() {
3342
- try {
3343
- let stalled = 0;
3344
- for (const row of countPendingRowsByOwnerSession.all()) {
3345
- const lifecycle = sessionWorkerSupervisor?.getLifecycleState?.(String(row.sdk_session_id || '').trim());
3346
- if (lifecycle?.retriesStopped === true) stalled += Number(row.n || 0);
3347
- }
3348
- return stalled;
3349
- } catch {
3350
- return 0;
3351
- }
3352
- }
3353
-
3354
3393
  function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localhost-api', restart = false } = {}) {
3355
3394
  const safeReason = String(reason || 'manual-request').trim().slice(0, 140) || 'manual-request';
3356
3395
  const safeRequestedBy = String(requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
@@ -3366,7 +3405,11 @@ function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localh
3366
3405
  };
3367
3406
  }
3368
3407
 
3369
- // Shutdown is intentionally deferred until the queue is idle; it will not interrupt an active turn.
3408
+ // Shutdown is deferred until no turn is processing; it never interrupts an
3409
+ // active turn. From here on no pending row is handed out (the dequeue paths
3410
+ // read relayShutdownQueued): a turn started meanwhile would postpone the
3411
+ // restart again. Pending and parked rows wait in the database and run
3412
+ // after the restart.
3370
3413
  if (!pendingRelayShutdownRequest) {
3371
3414
  pendingRelayShutdownRequest = {
3372
3415
  action: requestedAction,
@@ -3387,12 +3430,7 @@ function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localh
3387
3430
  const tryShutdownWhenIdle = () => {
3388
3431
  if (runtimeShutdownStarted) return;
3389
3432
  const counts = queueCounts();
3390
- // A row queued for a session whose launches the relay has stopped
3391
- // retrying makes no progress until somebody presses Retry; it must not
3392
- // hold the restart (which, fresh, is itself the next launch attempt).
3393
- const stalledPending = countPendingRowsOfStoppedSessions();
3394
- const queueFree = (counts.pendingCount - stalledPending + counts.processingCount + counts.parkedCount) <= 0;
3395
- if (!queueFree) return;
3433
+ if (!queuedShutdownCanProceed(counts)) return;
3396
3434
  const requestInfo = pendingRelayShutdownRequest;
3397
3435
  pendingRelayShutdownRequest = null;
3398
3436
  if (runtimeTimers.shutdownDrain) {
@@ -3403,7 +3441,7 @@ function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localh
3403
3441
  const exitCode = action === 'restart' ? RELAY_RESTART_EXIT_CODE : 0;
3404
3442
  console.log(
3405
3443
  `${runtimeLogPrefix()}Relay ${action} request accepted by ${requestInfo?.requestedBy || 'unknown'}`
3406
- + `; queue is idle, exiting now (reason=${requestInfo?.reason || 'manual-request'})`,
3444
+ + `; no turn is processing (${counts.pendingCount} pending, ${counts.parkedCount} parked wait), exiting now (reason=${requestInfo?.reason || 'manual-request'})`,
3407
3445
  );
3408
3446
  sessionWorkerWebSocketService.emitDraining(`api-${action}:${requestInfo?.reason || 'manual-request'}`);
3409
3447
  void shutdownRuntime(`api-${action}:${requestInfo?.reason || 'manual-request'}`, { exitCode });
@@ -4148,6 +4186,7 @@ function readWorkspaceFileMeta(filePath) {
4148
4186
  return cacheWorkspaceFileMeta(filePath, {
4149
4187
  kind: 'file',
4150
4188
  size: Number(stat.size || 0),
4189
+ mtimeMs: Number(stat.mtimeMs || 0),
4151
4190
  contentType: workspaceContentType(filePath),
4152
4191
  });
4153
4192
  }
@@ -4214,6 +4253,8 @@ function workspacePreviewKindForMeta(filePath, contentType) {
4214
4253
  if (WORKSPACE_MARKDOWN_EXTENSIONS.has(normalizedExt)) return 'markdown';
4215
4254
  if (WORKSPACE_IMAGE_EXTENSIONS.has(normalizedExt) || normalizedType.startsWith('image/')) return 'image';
4216
4255
  if (WORKSPACE_VIDEO_EXTENSIONS.has(normalizedExt) || normalizedType.startsWith('video/')) return 'video';
4256
+ if (WORKSPACE_AUDIO_EXTENSIONS.has(normalizedExt) || normalizedType.startsWith('audio/')) return 'audio';
4257
+ if (normalizedExt === '.pdf' || normalizedType === 'application/pdf') return 'pdf';
4217
4258
  if (WORKSPACE_CODE_EXTENSIONS.has(normalizedExt) || WORKSPACE_PREVIEW_LANGUAGE_BY_FILENAME[filename]) return 'code';
4218
4259
  if (isLikelyTextContentType(normalizedType)) return 'text';
4219
4260
  return 'binary';
@@ -5033,19 +5074,18 @@ function subagentRunsForResponse(responseMessageId) {
5033
5074
  return rows.map(mapSubagentRunRow).filter(Boolean).slice(0, 96);
5034
5075
  }
5035
5076
 
5077
+ // The run as a row of the panel, the reply bubble and the viewer header. Stop
5078
+ // is offered while it runs: a background run through its live task, a
5079
+ // foreground run through the sub-agent cancel route.
5036
5080
  function mapSubagentRunRow(row) {
5037
- const subagentRunId = String(row?.id || '').trim();
5038
- if (!subagentRunId) return null;
5039
- return {
5040
- subagentRunId,
5041
- messageId: String(row?.queue_message_id || '').trim() || null,
5042
- conversationId: String(row?.conversation_id || '').trim() || null,
5043
- parentSubagentId: row?.parent_subagent_id ? String(row.parent_subagent_id).trim() : null,
5044
- displayName: row?.display_name ? String(row.display_name).trim() : null,
5045
- status: String(row?.status || 'running').trim().toLowerCase() || 'running',
5046
- startedAt: row?.started_at || null,
5047
- updatedAt: row?.updated_at || null,
5048
- };
5081
+ const status = String(row?.status || 'running').trim().toLowerCase();
5082
+ let stoppable = false;
5083
+ if (status === 'running') {
5084
+ const tasks = backgroundTaskStore.get(row?.conversation_id);
5085
+ const task = tasks.find((entry) => entry?.toolUseId && entry.toolUseId === String(row?.id || ''));
5086
+ stoppable = task ? task.stoppable !== false : Number(row?.background || 0) !== 1;
5087
+ }
5088
+ return mapSubagentRunHeader(row, { stoppable });
5049
5089
  }
5050
5090
 
5051
5091
  // Final workflow digests persisted with an assistant message (the "Finished
@@ -5212,6 +5252,46 @@ const usageLimitPauseService = createUsageLimitPauseService({
5212
5252
  logger: console,
5213
5253
  });
5214
5254
 
5255
+ // ─── Relay events ─────────────────────────────────────────────────────────────
5256
+ // What each session is doing (state, tasks, quiet time) and the subscriptions
5257
+ // through which a watching session is woken with the events of the sessions
5258
+ // it watches (docs: server/README.md "Relay event subscriptions").
5259
+ sessionActivityTracker = createSessionActivityTracker({
5260
+ db,
5261
+ onEdge: (edge) => relayEventService?.noteTaskEdge(edge),
5262
+ logger: console,
5263
+ });
5264
+ relayEventService = createRelayEventService({
5265
+ db,
5266
+ stmts,
5267
+ tracker: sessionActivityTracker,
5268
+ emit: (event, payload) => io.emit(event, payload),
5269
+ // The event row is an ordinary pending message: the primer starts a
5270
+ // stopped worker, the socket service hands it to a running one.
5271
+ wakeWatcher: () => {
5272
+ void primePendingSessionWorkers('relay-event');
5273
+ sessionWorkerWebSocketService?.emitQueueChanged?.('relay-event');
5274
+ },
5275
+ relayName: () => remoteRelayRegistry?.selfName?.() || getPwaAppName?.() || '',
5276
+ onRemoteChange: (relayId) => { void relayEventLinkService?.fetchNow(relayId); },
5277
+ logger: console,
5278
+ });
5279
+
5280
+ /** A worker died: its tasks are gone, and whoever watches its sessions hears of it. */
5281
+ function noteWorkerDeathForEvents(sessionId, conversationIds, reason) {
5282
+ const ids = new Set((conversationIds || []).map((id) => String(id || '').trim()).filter(Boolean));
5283
+ if (!ids.size) {
5284
+ const conversation = resolveConversationRecord({ conversationId: sessionId, sdkSessionId: sessionId });
5285
+ if (conversation?.id) ids.add(String(conversation.id));
5286
+ }
5287
+ for (const conversationId of ids) {
5288
+ try {
5289
+ sessionActivityTracker?.noteWorkerDied(conversationId);
5290
+ relayEventService?.noteError({ conversationId, kind: 'worker_died', message: `the session's worker process ended (${reason})` });
5291
+ } catch {}
5292
+ }
5293
+ }
5294
+
5215
5295
  // The live turn among a conversation's processing rows. Mid-turn steering can
5216
5296
  // leave several processing at once — the running turn, steered messages the
5217
5297
  // CLI folded into it (no output of their own), a handed-off row still settling
@@ -5314,6 +5394,9 @@ function failRecoveredRowTerminally(row, reason, { failureRecord = null, failure
5314
5394
  message: { role: 'assistant', text: failureText, model: requestedModel || 'unknown', mode: String(row?.relay_mode || '').trim() || null, timestamp: now },
5315
5395
  });
5316
5396
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
5397
+ try {
5398
+ relayEventService?.noteError({ conversationId: row.conversation_id, kind: 'turn_failed', message: failureText, queueMessageId: row.id });
5399
+ } catch {}
5317
5400
  console.warn(`${runtimeLogPrefix()}${failureRecord ? 'CONSUMED FAILED' : 'RECOVERY LIMIT'} ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} retries=${retryCount} reason=${reason}`);
5318
5401
  }
5319
5402
 
@@ -5338,6 +5421,12 @@ function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso
5338
5421
  for (const row of staleContinuations) {
5339
5422
  stmts.dropStaleContinuation?.run?.(row.id);
5340
5423
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
5424
+ // A teardown is a turn ending with no reply; it must be visible in the
5425
+ // console log (the only trace of a silent one was `ignored=already_failed`
5426
+ // hours later, when the worker's reply arrived for the dead row).
5427
+ const lastSeen = row.owner_last_claimed_at || row.processing_at || row.timestamp || null;
5428
+ const reason = lastSeen && lastSeen < cutoffIso ? `inactive since ${lastSeen}` : `ceiling (started ${row.processing_at || row.timestamp})`;
5429
+ console.warn(`${runtimeLogPrefix()}STALE CONTINUATION ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} dropped: ${reason}`);
5341
5430
  }
5342
5431
  const listedRows = stmts.listRecoverableProcessing.all(params);
5343
5432
  for (const row of listedRows.filter((entry) => entry?.consumed_at)) failConsumedRowTerminally(row, 'stale-recovery');
@@ -5733,6 +5822,10 @@ httpServer.prependListener('request', (req, _res) => {
5733
5822
  httpServer.prependListener('upgrade', (req, _socket, _head) => {
5734
5823
  rewriteSocketIoRequestPath(req, remotePath);
5735
5824
  });
5825
+ // A client reset during an upgrade must not end the relay; see the guard.
5826
+ httpServer.prependListener('upgrade', (_req, socket, _head) => {
5827
+ guardUpgradeSocket(socket);
5828
+ });
5736
5829
  // Public tunnels (Cloudflare, or a VPS/Caddy front) forward every path on the
5737
5830
  // bound hostname to this port, including the session-worker WebSocket plumbing.
5738
5831
  // Reject those before auth; local workers connect over 127.0.0.1 with no edge
@@ -5942,6 +6035,9 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
5942
6035
  if (runtimeState.relayPaused) {
5943
6036
  return { message: null, paused: true, reason: 'relay_paused' };
5944
6037
  }
6038
+ if (isRelayShutdownQueued(runtimeState)) {
6039
+ return relayShutdownGateReply(runtimeState);
6040
+ }
5945
6041
  if (runtimeState.tunnelState?.blocking) {
5946
6042
  return {
5947
6043
  message: null,
@@ -6156,11 +6252,36 @@ async function isWorkerSessionProcessAlive(sessionId, hintPid = null) {
6156
6252
  return false;
6157
6253
  }
6158
6254
 
6255
+ // Runs a dead worker can no longer end: marked failed, each with the full
6256
+ // header and its own turn's messageId, so an open page settles the row
6257
+ // without a reload (the browser drops a status event it cannot place).
6258
+ function closeStrandedSubagentRuns(runs, queueMessageId = null) {
6259
+ if (!runs.length) return;
6260
+ const nowIso = new Date().toISOString();
6261
+ for (const run of runs) {
6262
+ stmts.updateSubagentRunStatus?.run?.('failed', nowIso, nowIso, run.id);
6263
+ const closed = stmts.getSubagentRun?.get?.(run.id) || { ...run, status: 'failed', completed_at: nowIso };
6264
+ const header = mapSubagentRunHeader(closed, { stoppable: false });
6265
+ if (header) {
6266
+ io.emit('subagent_status', {
6267
+ ...header,
6268
+ timestamp: nowIso,
6269
+ queueMessageId: queueMessageId || header.messageId,
6270
+ reconciled: true,
6271
+ });
6272
+ }
6273
+ }
6274
+ }
6275
+
6159
6276
  function recoverDeadWorkerProcessingRows(sessionId, reason) {
6160
6277
  const sid = String(sessionId || '').trim();
6161
6278
  if (!sid) return 0;
6279
+ // A background agent outlives its turn, so its run is closed with the
6280
+ // worker rather than with a processing row (its turn is usually done).
6281
+ closeStrandedSubagentRuns(stmts.listRunningBackgroundSubagentRunsForSession?.all?.(sid, sid) || []);
6162
6282
  const rows = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
6163
6283
  if (!rows.length) return 0;
6284
+ noteWorkerDeathForEvents(sid, rows.map((row) => row.conversation_id), reason);
6164
6285
  let recovered = 0;
6165
6286
  const requeueAt = addMsIso(2_000);
6166
6287
  for (const row of rows) {
@@ -6180,22 +6301,7 @@ function recoverDeadWorkerProcessingRows(sessionId, reason) {
6180
6301
  }
6181
6302
  // The dead worker's subagent runs can never be terminated by the replay
6182
6303
  // (fresh worker, fresh call ids) — close them out now.
6183
- const strandedRuns = stmts.listRunningSubagentRunsByQueueMessage?.all?.(row.id) || [];
6184
- if (strandedRuns.length) {
6185
- const nowIso = new Date().toISOString();
6186
- stmts.closeRunningSubagentRunsByQueueMessage?.run?.('failed', nowIso, nowIso, row.id);
6187
- for (const run of strandedRuns) {
6188
- io.emit('subagent_status', {
6189
- conversationId: run.conversation_id,
6190
- subagentRunId: run.id,
6191
- parentSubagentId: run.parent_subagent_id || null,
6192
- displayName: run.display_name || null,
6193
- status: 'failed',
6194
- queueMessageId: row.id,
6195
- reconciled: true,
6196
- });
6197
- }
6198
- }
6304
+ closeStrandedSubagentRuns(stmts.listRunningSubagentRunsByQueueMessage?.all?.(row.id) || [], row.id);
6199
6305
  stmts.recoverProcessingRowKeepOwner?.run?.(requeueAt, row.id);
6200
6306
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
6201
6307
  recovered += 1;
@@ -6219,14 +6325,22 @@ function handleWorkerSocketClosed({ sessionId, pid = null } = {}) {
6219
6325
  // A dead worker's steering snapshot must not keep holding the composer even
6220
6326
  // when it owned no rows (the hold can outlive them, e.g. a continuation's).
6221
6327
  const heldSteering = Boolean(sessionWorkerRegistry?.getWorker?.(sid)?.steering);
6222
- if (!owned.length && !heldSteering) return;
6328
+ // Background agents run between turns too: a worker that dies then owns no
6329
+ // row, but its agents' runs still read "running".
6330
+ const backgroundRuns = () => stmts.listRunningBackgroundSubagentRunsForSession?.all?.(sid, sid) || [];
6331
+ if (!owned.length && !heldSteering && !backgroundRuns().length) return;
6223
6332
  const timer = setTimeout(async () => {
6224
6333
  try {
6225
6334
  if (runtimeShutdownStarted) return;
6226
6335
  if (await isWorkerSessionProcessAlive(sid, pid)) return; // reconnect or socket flap
6227
6336
  sessionWorkerRegistry?.clearSteering?.(sid);
6228
6337
  const stillOwned = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
6229
- if (!stillOwned.length) return;
6338
+ if (!stillOwned.length) {
6339
+ const stranded = backgroundRuns();
6340
+ closeStrandedSubagentRuns(stranded);
6341
+ if (stranded.length) noteWorkerDeathForEvents(sid, stranded.map((run) => run.conversation_id), 'socket-close');
6342
+ return;
6343
+ }
6230
6344
  console.warn(`${runtimeLogPrefix()}WORKER DIED session=${sid.slice(0, 8)} rows=${stillOwned.length} trigger=socket-close — recovering owned turns`);
6231
6345
  recoverDeadWorkerProcessingRows(sid, 'socket-close');
6232
6346
  } catch (error) {
@@ -6341,6 +6455,10 @@ app.use((req, _res, next) => {
6341
6455
  stripRequestPathPrefix(req, remotePath);
6342
6456
  next();
6343
6457
  });
6458
+ function isApiRequestPath(requestPath) {
6459
+ return /^\/api(?:\/|$)/.test(String(requestPath || ''));
6460
+ }
6461
+
6344
6462
  // Baseline security headers on every response. Served-file/attachment endpoints
6345
6463
  // layer a stricter sandbox CSP on top (see applySafeServedContentHeaders). A
6346
6464
  // global script-src CSP is intentionally omitted for now: the SPA relies on
@@ -6349,6 +6467,14 @@ app.use((req, res, next) => {
6349
6467
  res.setHeader('X-Content-Type-Options', 'nosniff');
6350
6468
  res.setHeader('X-Frame-Options', 'SAMEORIGIN');
6351
6469
  res.setHeader('Referrer-Policy', 'no-referrer');
6470
+ // Nothing this relay answers belongs in a CDN's cache: it is all behind a
6471
+ // login. These two headers bind the edge itself, whatever cache rule the
6472
+ // zone in front of a tunnel has.
6473
+ applyCdnNoStoreHeaders(res);
6474
+ // An API answer is never stored by default, errors included (a 404 or a
6475
+ // 401 for a file must not outlive the file's absence). A route that
6476
+ // serves content-addressed bytes sets its own, longer-lived value.
6477
+ if (isApiRequestPath(req.path)) res.setHeader('Cache-Control', 'no-store');
6352
6478
  next();
6353
6479
  });
6354
6480
  app.get('/socket.io/socket.io.js', (req, res, next) => {
@@ -6409,6 +6535,9 @@ const runtimeState = {
6409
6535
  set relayPaused(value) { relayPaused = value; },
6410
6536
  get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
6411
6537
  get relayShutdown() { return getRelayShutdownState(); },
6538
+ // A shutdown or restart waits for the processing turns: no pending row
6539
+ // goes out meanwhile (see requestRelayShutdown).
6540
+ get relayShutdownQueued() { return !!pendingRelayShutdownRequest && !runtimeShutdownStarted; },
6412
6541
  get hostSuspend() { return hostSuspendService.getState(); },
6413
6542
  get usageLimit() {
6414
6543
  return { account: usageLimitPauseService.getAccountState(), pauses: usageLimitPauseService.listPauses() };
@@ -6462,6 +6591,13 @@ const remoteRelayRegistry = createRemoteRelayRegistry({
6462
6591
  platform: process.platform,
6463
6592
  version: RUNNING_VERSION,
6464
6593
  emit: (event, payload) => io.emit(event, payload),
6594
+ onRemove: (entry) => { void relayEventLinkService?.noteRelayRemoved(entry); },
6595
+ logger: console,
6596
+ });
6597
+ relayEventLinkService = createRelayEventLinkService({
6598
+ relayEvents: relayEventService,
6599
+ registry: remoteRelayRegistry,
6600
+ client: remoteRelayClient,
6465
6601
  logger: console,
6466
6602
  });
6467
6603
  const remoteRelayPairing = createRemoteRelayPairing({
@@ -6492,7 +6628,10 @@ const remoteRelayCallerContext = createRemoteRelayCallerContext({
6492
6628
  normalizeRelayMode,
6493
6629
  defaultRelayMode: DEFAULT_RELAY_MODE,
6494
6630
  emit: (event, payload) => io.emit(event, payload),
6495
- notifyQuestion: (question) => pushDispatchService.notifyQuestion?.(question),
6631
+ notifyQuestion: (question) => {
6632
+ try { relayEventService?.noteQuestion(question); } catch {}
6633
+ return pushDispatchService.notifyQuestion?.(question);
6634
+ },
6496
6635
  uuid: uuidv4,
6497
6636
  logger: console,
6498
6637
  });
@@ -6513,6 +6652,8 @@ const remoteRelayDispatcher = createRemoteRelayDispatcher({
6513
6652
  requestApproval: (request) => remoteRelayCallerContext.requestApproval(request),
6514
6653
  emitStatusEvent: ({ type, ...details }) => statusEventService.recordEvent(type, details),
6515
6654
  resolveConversationId: (id) => resolveConversationRecord({ conversationId: id, sdkSessionId: id })?.id || id,
6655
+ relayEvents: relayEventService,
6656
+ relayEventLink: relayEventLinkService,
6516
6657
  logger: console,
6517
6658
  });
6518
6659
 
@@ -6552,6 +6693,7 @@ const sharedRouteDeps = {
6552
6693
  compareRepoDirEntries,
6553
6694
  shouldSkipRepoEntryName,
6554
6695
  readWorkspaceFileMeta,
6696
+ workspaceFileMetaCache,
6555
6697
  resolveWorkspaceFilePath,
6556
6698
  normalizeWorkspaceRelativePath,
6557
6699
  previewLanguageForWorkspaceFile,
@@ -6700,6 +6842,8 @@ const sharedRouteDeps = {
6700
6842
  resolveCursorSessionRoot: cursorSessionRootResolver.resolveCursorSessionRoot,
6701
6843
  getTurnCeilingMinutes,
6702
6844
  setTurnCeilingMinutes,
6845
+ getSubagentRecordRetentionDays,
6846
+ setSubagentRecordRetentionDays,
6703
6847
  getBackgroundTaskTimeoutMinutes,
6704
6848
  setBackgroundTaskTimeoutMinutes,
6705
6849
  getFeatureFlagsSettingsState,
@@ -6712,6 +6856,9 @@ const sharedRouteDeps = {
6712
6856
  hostSuspendService,
6713
6857
  collectHostActivity,
6714
6858
  usageLimitPauseService,
6859
+ relayEventService,
6860
+ sessionActivityTracker,
6861
+ resolveConversationRecord,
6715
6862
  markSharedViewerPresence,
6716
6863
  getSharedWatcherCount,
6717
6864
  statusEventService,
@@ -6745,6 +6892,12 @@ registerRemoteRelayRoutes(app, {
6745
6892
  registry: remoteRelayRegistry,
6746
6893
  pairing: remoteRelayPairing,
6747
6894
  dispatcher: remoteRelayDispatcher,
6895
+ remoteRelayInbound,
6896
+ });
6897
+ registerRelayEventRoutes(app, {
6898
+ auth,
6899
+ relayEventService,
6900
+ remoteRelayInbound,
6748
6901
  });
6749
6902
  function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
6750
6903
  cliLastSeen = null;
@@ -6788,9 +6941,22 @@ if (managedOwnerPid) {
6788
6941
  // The window is measured from the last heartbeat that named the message, not
6789
6942
  // from when the turn began, so a turn stays alive for as long as its worker
6790
6943
  // keeps reporting it — however many minutes of tool calls and subagents that
6791
- // takes. The separate, user-configurable ceiling is the only elapsed-time cap,
6792
- // and both exempt a turn that is waiting on an unanswered question.
6944
+ // takes. The separate, user-configurable ceiling is the only elapsed-time cap;
6945
+ // it counts from the last answered question rather than from the start, and
6946
+ // both exempt a turn that is waiting on an unanswered question (continuation
6947
+ // rows included — an interview hangs its cards on one).
6948
+ //
6949
+ // After a wall-clock jump (the host slept, the event loop was blocked) every
6950
+ // heartbeat looks old at once although the workers may be alive: the guard
6951
+ // skips recovery for a full inactivity window, so a live worker can report.
6952
+ const STALE_RECOVERY_INTERVAL_MS = 15_000;
6953
+ const staleRecoveryClockGuard = createStaleRecoveryClockGuard({
6954
+ intervalMs: STALE_RECOVERY_INTERVAL_MS,
6955
+ graceMs: Math.max(processingTimeoutMs, 60_000),
6956
+ log: (line) => console.log(`${runtimeLogPrefix()}${line}`),
6957
+ });
6793
6958
  function recoverStaleMessages() {
6959
+ if (staleRecoveryClockGuard.tick().skip) return;
6794
6960
  const staleWindowMs = cliOnline
6795
6961
  ? processingTimeoutMs
6796
6962
  : Math.min(processingTimeoutMs, OFFLINE_STALE_RECOVER_MS);
@@ -6806,7 +6972,7 @@ function recoverStaleMessages() {
6806
6972
  );
6807
6973
  }
6808
6974
  }
6809
- runtimeTimers.staleRecovery = setInterval(recoverStaleMessages, 15_000);
6975
+ runtimeTimers.staleRecovery = setInterval(recoverStaleMessages, STALE_RECOVERY_INTERVAL_MS);
6810
6976
  recoverStaleMessages(); // run immediately on startup
6811
6977
 
6812
6978
  function expirePendingQuestions() {
@@ -6836,6 +7002,40 @@ if (sweptUploads > 0) {
6836
7002
  }
6837
7003
  runtimeTimers.uploadSweep = setInterval(sweepUnreferencedUploadBlobs, 6 * 60 * 60 * 1000);
6838
7004
  if (typeof runtimeTimers.uploadSweep.unref === 'function') runtimeTimers.uploadSweep.unref();
7005
+ // Old sub-agent records lose their tool inputs and output: shortly after
7006
+ // start (not in the boot path), then once a day.
7007
+ subagentRecordRetention = createSubagentRecordRetention({
7008
+ db,
7009
+ getRetentionDays: getSubagentRecordRetentionDays,
7010
+ isStopped: () => runtimeShutdownStarted,
7011
+ log: (line) => console.log(`${runtimeLogPrefix()}${line}`),
7012
+ warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
7013
+ });
7014
+ subagentRecordRetention.scheduleSweep(30_000);
7015
+ runtimeTimers.subagentRecordRetention = setInterval(() => { void subagentRecordRetention.sweep(); }, 24 * 60 * 60 * 1000);
7016
+ if (typeof runtimeTimers.subagentRecordRetention.unref === 'function') runtimeTimers.subagentRecordRetention.unref();
7017
+ // Sub-agent runs left `running` by a worker that is gone are closed: once
7018
+ // the previous runtime's workers have had time to reconnect after boot, then
7019
+ // periodically. A live worker's runs are never touched.
7020
+ const staleSubagentRunSweep = createStaleSubagentRunSweep({
7021
+ db,
7022
+ isConversationWorkerAlive: (conversationId) => {
7023
+ if (hostActivityWorkerAlive(conversationId)) return true;
7024
+ const sdkSessionId = String(stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.sdk_session_id || '').trim();
7025
+ return !!sdkSessionId && hostActivityWorkerAlive(sdkSessionId);
7026
+ },
7027
+ log: (line) => console.log(`${runtimeLogPrefix()}${line}`),
7028
+ warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
7029
+ });
7030
+ runtimeTimers.staleSubagentRunsBoot = setTimeout(() => {
7031
+ runtimeTimers.staleSubagentRunsBoot = null;
7032
+ if (!runtimeShutdownStarted) staleSubagentRunSweep.sweep();
7033
+ }, STALE_SUBAGENT_RUN_BOOT_DELAY_MS);
7034
+ if (typeof runtimeTimers.staleSubagentRunsBoot.unref === 'function') runtimeTimers.staleSubagentRunsBoot.unref();
7035
+ runtimeTimers.staleSubagentRuns = setInterval(() => {
7036
+ if (!runtimeShutdownStarted) staleSubagentRunSweep.sweep();
7037
+ }, STALE_SUBAGENT_RUN_SWEEP_INTERVAL_MS);
7038
+ if (typeof runtimeTimers.staleSubagentRuns.unref === 'function') runtimeTimers.staleSubagentRuns.unref();
6839
7039
  // Arms the 12h update poller only when the user opted in; otherwise silent.
6840
7040
  updateCheckService?.startIfEnabled();
6841
7041
  const runtimeBindingsBootstrapped = bootstrapRuntimeSessionBindings();
@@ -7038,6 +7238,10 @@ function touchCli() {
7038
7238
  }
7039
7239
  sessionWorkerWebSocketService.start();
7040
7240
  void primePendingSessionWorkers('startup');
7241
+ // Undelivered events get their timers back; stalled, all_idle and the
7242
+ // heartbeat are swept from here on.
7243
+ relayEventService.start();
7244
+ relayEventLinkService.start();
7041
7245
  runtimeTimers.pendingWorkerPrime = setInterval(() => {
7042
7246
  void primePendingSessionWorkers('maintenance');
7043
7247
  }, 5_000);
@@ -7084,6 +7288,10 @@ function socketAuthToken(socket) {
7084
7288
  const cookies = parseCookies(socket.request.headers.cookie);
7085
7289
  return socket.handshake.auth?.token || socket.handshake.query?.token || cookies[AUTH_COOKIE];
7086
7290
  }
7291
+ // A sub-agent's record goes live only to the pages whose viewer shows it.
7292
+ const subagentViewerRooms = createSubagentViewerRooms({
7293
+ getRun: (runId) => stmts.getSubagentRun?.get?.(runId) || null,
7294
+ });
7087
7295
  io.use((socket, next) => {
7088
7296
  if (tokensMatch(socketAuthToken(socket), config.authToken)) return next();
7089
7297
  next(new Error('Unauthorized'));
@@ -7121,6 +7329,7 @@ io.on('connection', (socket) => {
7121
7329
  // deviceVisible flag restored by connectionStateRecovery.
7122
7330
  activeDeviceTracker.registerSocket(socket);
7123
7331
  tmuxInspectorSocketService.registerSocket(socket);
7332
+ subagentViewerRooms.registerSocket(socket);
7124
7333
  });
7125
7334
 
7126
7335
  // ─── SSH Reverse Tunnel ────────────────────────────────────────────────────────
@@ -7230,6 +7439,7 @@ function clearRuntimeTimers() {
7230
7439
  if (!timer) continue;
7231
7440
  try { clearInterval(timer); } catch {}
7232
7441
  }
7442
+ subagentRecordRetention?.stop();
7233
7443
  runtimeTimers.cliStatus = null;
7234
7444
  runtimeTimers.ownerWatchdog = null;
7235
7445
  runtimeTimers.staleRecovery = null;
@@ -7277,6 +7487,8 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
7277
7487
  console.warn(`${runtimeLogPrefix()}Grok auth service shutdown failed: ${error?.message || error}`);
7278
7488
  }
7279
7489
  sessionWorkerWebSocketService.stop();
7490
+ try { relayEventService.stop(); } catch {}
7491
+ try { relayEventLinkService.stop(); } catch {}
7280
7492
  tmuxInspectorSocketService.stop();
7281
7493
  stopWorkspaceFileWatcher();
7282
7494
  sshTunnelManager.stop();