@oar-sh/oar 0.9.9 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -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 { createDetachedWorkerWatch } from './services/detached-worker-watch-service.mjs';
27
28
  import { applyCdnNoStoreHeaders } from './services/file-serving.mjs';
28
29
  import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
29
30
  import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
@@ -43,7 +44,11 @@ import { registerGitRoutes } from './routes/git-routes.mjs';
43
44
  import { registerClaudeCloudRoutes } from './routes/claude-cloud-routes.mjs';
44
45
  import { registerPreviewRoutes } from './routes/preview-routes.mjs';
45
46
  import { registerRemoteRelayRoutes } from './routes/remote-relay-routes.mjs';
47
+ import { registerRelayEventRoutes } from './routes/relay-event-routes.mjs';
46
48
  import { createRemoteRelayRepository } from './repositories/remote-relay-repository.mjs';
49
+ import { createConversationGroupRepository } from './repositories/conversation-group-repository.mjs';
50
+ import { createShareRepository } from './repositories/share-repository.mjs';
51
+ import { registerConversationGroupRoutes } from './routes/conversation-group-routes.mjs';
47
52
  import { createRemoteRelayClient } from './services/remote-relay-client.mjs';
48
53
  import { createRemoteRelayLoopbackClient } from './services/remote-relay-loopback.mjs';
49
54
  import { createRemoteRelayRegistry } from './services/remote-relay-registry-service.mjs';
@@ -68,12 +73,19 @@ import webpush from 'web-push';
68
73
  import { createPushDispatchService, ensurePushVapidKeys } from './services/push-dispatch-service.mjs';
69
74
  import { createHostSuspendService } from './services/host-suspend-service.mjs';
70
75
  import { USAGE_LIMIT_ACCOUNT_SOCKET_EVENT, createUsageLimitPauseService } from './services/usage-limit-pause-service.mjs';
76
+ import { createSessionActivityTracker, resolveBootReportingWindowMs } from './services/session-activity-tracker.mjs';
77
+ import { createBackgroundTaskStore } from './services/background-task-store.mjs';
78
+ import { createBackgroundWorkView } from './services/background-work-view.mjs';
79
+ import { createRelayEventService } from './services/relay-event-service.mjs';
80
+ import { createRelayEventLinkService } from './services/relay-event-link-service.mjs';
71
81
  import { buildClaudeAttributionSettings, normalizeClaudeAttributionMode, resolveClaudeAttributionMode, DEFAULT_CLAUDE_ATTRIBUTION_MODE } from '../shared/claude-attribution.mjs';
72
82
  import { createWorkerLaunchNoteService } from './services/worker-launch-note-service.mjs';
73
83
  import { createGitHubCiActivityService } from './services/github-ci-activity-service.mjs';
74
84
  import { runHostSuspendToRam } from './services/host-suspend-command.mjs';
75
85
  import { createActiveDeviceTracker } from './services/push-active-device-service.mjs';
76
86
  import { registerPushRoutes } from './routes/push-routes.mjs';
87
+ import { registerAboutRoutes } from './routes/about-routes.mjs';
88
+ import { createAboutService, resolveBuildCommit } from './services/about-service.mjs';
77
89
  import { createImageOperationService } from './services/image-operation-service.mjs';
78
90
  import {
79
91
  normalizeDriveAbsolutePath as _normalizeDriveAbsolutePath,
@@ -115,13 +127,24 @@ import {
115
127
  } from './services/preview-registry-service.mjs';
116
128
  import { createPreviewProxyServer } from './services/preview-proxy-server.mjs';
117
129
  import { createPreviewHealthProbe } from './services/preview-health-probe.mjs';
130
+ import { createSqlitePreviewStore, isSameHostBoot, readHostBootId } from './services/preview-store.mjs';
131
+ import { validateStaticRootInAny } from './services/preview-static-handler.mjs';
118
132
  import { createTunnelWorkerPathGuard } from './services/tunnel-worker-path-guard.mjs';
133
+ import {
134
+ createLocalRequestCheck,
135
+ expressRequestView,
136
+ isSameOriginRequest,
137
+ resolveTunnelMarkerHeaders,
138
+ } from './services/request-boundary.mjs';
139
+ import { createRelayAuth } from './services/relay-auth.mjs';
140
+ import { guardUpgradeSocket } from './services/upgrade-socket-guard.mjs';
119
141
  import { createWindowsAutostartService } from './services/windows-autostart-service.mjs';
120
142
  import { createWindowsBootAutostartService } from './services/windows-boot-autostart-service.mjs';
121
143
  import { createSessionWorkerWebSocketService } from './services/session-worker-websocket-service.mjs';
122
144
  import { createTmuxInspectorAccessPolicy } from './services/tmux-inspector-access-policy.mjs';
123
145
  import { createTmuxInspectorStreamService } from './services/tmux-inspector-stream-service.mjs';
124
146
  import { createTmuxInspectorSocketService } from './services/tmux-inspector-socket-service.mjs';
147
+ import { createSubagentViewerRooms } from './services/subagent-viewer-rooms.mjs';
125
148
  import {
126
149
  resolveDefaultSessionWorkspaceRootState as resolveDefaultSessionWorkspaceRootStateFromService,
127
150
  resolveLaunchWorkspaceRootPath,
@@ -142,6 +165,24 @@ import {
142
165
  readTurnCeilingSetting,
143
166
  turnCeilingMinutesToMs,
144
167
  } from '../shared/turn-ceiling.mjs';
168
+ import {
169
+ SUBAGENT_RECORD_RETENTION_SETTING_KEY,
170
+ parseSubagentRecordRetentionUpdate,
171
+ readSubagentRecordRetentionSetting,
172
+ } from '../shared/subagent-record-retention.mjs';
173
+ import { createSubagentRecordRetention } from './services/subagent-record-retention.mjs';
174
+ import {
175
+ SHARE_PATH_PRIVACY_SETTING_KEY,
176
+ parseSharePathPrivacyUpdate,
177
+ readSharePathPrivacySetting,
178
+ } from '../shared/share-path-privacy.mjs';
179
+ import { isRelayShutdownQueued, queuedShutdownCanProceed, relayShutdownGateReply } from './services/relay-shutdown-gate.mjs';
180
+ import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
181
+ import {
182
+ STALE_SUBAGENT_RUN_BOOT_DELAY_MS,
183
+ STALE_SUBAGENT_RUN_SWEEP_INTERVAL_MS,
184
+ createStaleSubagentRunSweep,
185
+ } from './services/stale-subagent-run-sweep.mjs';
145
186
  import {
146
187
  normalizePwaAppName,
147
188
  readPwaAppNameSetting,
@@ -178,6 +219,8 @@ import {
178
219
  } from '../shared/model-id.mjs';
179
220
  import { createModelVariantCatalogService } from './services/model-variant-catalog-service.mjs';
180
221
  import { applyPwaManifestTheme, normalizePwaTheme } from './services/pwa-manifest-theme.mjs';
222
+ import { mapSubagentRunHeader } from './services/subagent-timeline-service.mjs';
223
+ import { describeExecError, describeSpawnEnvironment, windowsSystemToolPath } from '../shared/windows-system-tools.mjs';
181
224
 
182
225
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
183
226
  const REPO_ROOT = path.resolve(__dirname, '..');
@@ -195,6 +238,10 @@ const RUNNING_VERSION = (() => {
195
238
  }
196
239
  })();
197
240
  const INSTALL_METHOD = fs.existsSync(path.join(PACKAGE_ROOT, '.git')) ? 'git-checkout' : 'npm-global';
241
+ // When this relay process started, and the commit its code came from (read
242
+ // once: a checkout moved on later still runs the code it started with).
243
+ const RELAY_STARTED_AT = new Date(Date.now() - process.uptime() * 1000).toISOString();
244
+ const BUILD_COMMIT = resolveBuildCommit(PACKAGE_ROOT);
198
245
  const PUBLIC_DIR = path.join(__dirname, 'public');
199
246
  const INDEX_HTML_PATH = path.join(PUBLIC_DIR, 'index.html');
200
247
  const PWA_MANIFEST_PATH = path.join(PUBLIC_DIR, 'manifest.webmanifest');
@@ -1792,6 +1839,8 @@ const OFFLINE_STALE_RECOVER_MS = 45_000;
1792
1839
  // reaches the browser.
1793
1840
  const SHUTDOWN_SOCKET_FLUSH_MS = 300;
1794
1841
  let runtimeShutdownStarted = false;
1842
+ // Set once the database is open (see the retention sweep near the other sweeps).
1843
+ let subagentRecordRetention = null;
1795
1844
  const runtimeTimers = {
1796
1845
  cliStatus: null,
1797
1846
  ownerWatchdog: null,
@@ -2000,6 +2049,51 @@ function setTurnCeilingMinutes(value) {
2000
2049
  return { ok: true, ceilingMinutes: parsed.minutes };
2001
2050
  }
2002
2051
 
2052
+ function getSubagentRecordRetentionDays() {
2053
+ return readSubagentRecordRetentionSetting(readAppSettingValue(SUBAGENT_RECORD_RETENTION_SETTING_KEY));
2054
+ }
2055
+
2056
+ function setSubagentRecordRetentionDays(value) {
2057
+ if (typeof stmts?.upsertAppSetting?.run !== 'function') {
2058
+ return { ok: false, error: 'Sub-agent record settings are unavailable' };
2059
+ }
2060
+ const parsed = parseSubagentRecordRetentionUpdate(value);
2061
+ if (!parsed.ok) return parsed;
2062
+ stmts.upsertAppSetting.run(SUBAGENT_RECORD_RETENTION_SETTING_KEY, String(parsed.days), new Date().toISOString());
2063
+ // A shorter period takes effect now, not at the next daily sweep.
2064
+ subagentRecordRetention?.scheduleSweep(5_000);
2065
+ return { ok: true, retentionDays: parsed.days };
2066
+ }
2067
+
2068
+ function getSharePathPrivacyMode() {
2069
+ return readSharePathPrivacySetting(readAppSettingValue(SHARE_PATH_PRIVACY_SETTING_KEY));
2070
+ }
2071
+
2072
+ function setSharePathPrivacyMode(value) {
2073
+ if (typeof stmts?.upsertAppSetting?.run !== 'function') {
2074
+ return { ok: false, error: 'Path privacy settings are unavailable' };
2075
+ }
2076
+ const parsed = parseSharePathPrivacyUpdate(value);
2077
+ if (!parsed.ok) return parsed;
2078
+ stmts.upsertAppSetting.run(SHARE_PATH_PRIVACY_SETTING_KEY, parsed.mode, new Date().toISOString());
2079
+ return { ok: true, mode: parsed.mode };
2080
+ }
2081
+
2082
+ // The relay host's own roots a shared view must not show: home, the relay's
2083
+ // checkout, data and upload dirs, the workspace roots it knows, and the OS
2084
+ // user name. The share routes add the conversation's own roots.
2085
+ function sharePathKnownRoots() {
2086
+ const roots = [REPO_ROOT, DATA_DIR, UPLOAD_DIR, currentWorkspaceRootPath(), INITIAL_WORKSPACE_ROOT];
2087
+ const userNames = [];
2088
+ try { roots.push(os.homedir()); } catch {}
2089
+ try { userNames.push(os.userInfo().username); } catch {}
2090
+ try { roots.push(resolveDefaultSessionWorkspaceRootState()?.path || ''); } catch {}
2091
+ try { roots.push(...listRecentWorkspaceRoots()); } catch {}
2092
+ try { roots.push(resolveSessionStateRoot?.() || ''); } catch {}
2093
+ roots.push(...workspaceRootAllowList);
2094
+ return { roots: roots.filter(Boolean), userNames: userNames.filter(Boolean) };
2095
+ }
2096
+
2003
2097
  const PWA_APP_NAME_SETTING_KEY = 'pwa_app_name';
2004
2098
 
2005
2099
  /** '' means "no custom name": the manifest falls back to the template default. */
@@ -2470,7 +2564,7 @@ function runCopilotCliCommand(args = [], timeoutMs = 30_000) {
2470
2564
  if (process.platform === 'win32') {
2471
2565
  const escapedArgs = commandArgs.map((part) => `'${String(part).replace(/'/g, "''")}'`).join(' ');
2472
2566
  const script = escapedArgs ? `& 'copilot' ${escapedArgs}` : "& 'copilot'";
2473
- execFile('powershell.exe', ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { windowsHide: true, timeout }, (error, stdout, stderr) => {
2567
+ execFile(windowsSystemToolPath('powershell.exe'), ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { windowsHide: true, timeout }, (error, stdout, stderr) => {
2474
2568
  if (error) {
2475
2569
  const detail = String(stderr || stdout || error.message || '').trim();
2476
2570
  reject(new Error(detail || error.message || 'copilot command failed'));
@@ -2541,6 +2635,29 @@ const stmts = {
2541
2635
  ...createImageConversationRepository(db),
2542
2636
  };
2543
2637
  const statusEventService = createStatusEventService(db);
2638
+ // Public shares and their access log (migration 0016).
2639
+ const shareRepository = createShareRepository(db);
2640
+ // Session groups in the sidebar (migration 0014). Without the migration it
2641
+ // sees no groups and writes nothing. What a delete that did not know groups
2642
+ // left behind (a head whose row is gone, a group without a member) is
2643
+ // repaired once at boot.
2644
+ const conversationGroups = createConversationGroupRepository(db);
2645
+ try {
2646
+ const repaired = conversationGroups.repair();
2647
+ if (repaired.headsDetached > 0 || repaired.groupsRemoved > 0) {
2648
+ console.log(`[session-groups] repaired: ${repaired.headsDetached} headless group(s), ${repaired.groupsRemoved} empty group(s) removed`);
2649
+ }
2650
+ } catch (error) {
2651
+ console.warn(`[session-groups] repair failed: ${error?.message || error}`);
2652
+ }
2653
+ // Relay events (built further down, next to the usage-limit pause): declared
2654
+ // here so recovery code that runs before then finds null, not a TDZ error.
2655
+ let sessionActivityTracker = null;
2656
+ let relayEventService = null;
2657
+ // Fetches the events of subscriptions on paired relays (built with the
2658
+ // remote relays below; the event service asks it to fetch at once when an
2659
+ // end or a new watcher must reach one).
2660
+ let relayEventLinkService = null;
2544
2661
  // Owns the `claude auth …` child processes, so the runtime (not the route
2545
2662
  // module) constructs it: shutdownRuntime() has to be able to dispose a login
2546
2663
  // PTY that is still holding a process group.
@@ -2681,7 +2798,7 @@ const imageOperationService = createImageOperationService({
2681
2798
 
2682
2799
  modelVariantCatalog.bindDatabase(db);
2683
2800
 
2684
- const deleteArchiveService = createDeleteArchiveService(db, null, { resolveSessionStateRoot });
2801
+ const deleteArchiveService = createDeleteArchiveService(db, null, { resolveSessionStateRoot, conversationGroups });
2685
2802
  void deleteArchiveService.retryPendingDeletesOnStartup()
2686
2803
  .then((result) => {
2687
2804
  if (result?.pendingCount > 0) {
@@ -2909,8 +3026,13 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
2909
3026
  }
2910
3027
  // An unreadable process list must not stop every launch on the relay
2911
3028
  // (2026-10-01: a control byte in one command line did, for an hour).
3029
+ // The environment goes with the first report of a launch only; the pid poll
3030
+ // reads the list up to ten more times.
3031
+ let processListEnvironmentLogged = false;
2912
3032
  const warnProcessListUnreadable = (error) => {
2913
- console.warn(`${runtimeLogPrefix()}worker launcher: process list unreadable, launching session=${normalizedTargetSessionId.slice(0, 8)} without the reuse check: ${error?.message || error}`);
3033
+ const environment = processListEnvironmentLogged ? '' : `; ${describeSpawnEnvironment(process.env)}`;
3034
+ processListEnvironmentLogged = true;
3035
+ console.warn(`${runtimeLogPrefix()}worker launcher: process list unreadable, launching session=${normalizedTargetSessionId.slice(0, 8)} without the reuse check: ${describeExecError(error)}${environment}`);
2914
3036
  };
2915
3037
  if (allowProcessReuse) {
2916
3038
  let liveWorker = null;
@@ -2937,6 +3059,7 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
2937
3059
  processInspector: sessionWorkerProcessInspector,
2938
3060
  allowProcessReuse,
2939
3061
  onProcessListError: warnProcessListUnreadable,
3062
+ log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
2940
3063
  });
2941
3064
  const workerPid = Number.isInteger(Number(launched?.pid)) ? Number(launched.pid) : null;
2942
3065
  const workerId = `worker-${normalizedTargetSessionId.slice(0, 8)}`;
@@ -3344,26 +3467,6 @@ function cancelRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
3344
3467
  return { cancelled: true, dropped, ...relayShutdownStatePayload() };
3345
3468
  }
3346
3469
 
3347
- const countPendingRowsByOwnerSession = db.prepare(`
3348
- SELECT owner_sdk_session_id AS sdk_session_id, COUNT(*) AS n
3349
- FROM queue
3350
- WHERE status = 'pending' AND owner_sdk_session_id IS NOT NULL AND owner_sdk_session_id != ''
3351
- GROUP BY owner_sdk_session_id
3352
- `);
3353
-
3354
- function countPendingRowsOfStoppedSessions() {
3355
- try {
3356
- let stalled = 0;
3357
- for (const row of countPendingRowsByOwnerSession.all()) {
3358
- const lifecycle = sessionWorkerSupervisor?.getLifecycleState?.(String(row.sdk_session_id || '').trim());
3359
- if (lifecycle?.retriesStopped === true) stalled += Number(row.n || 0);
3360
- }
3361
- return stalled;
3362
- } catch {
3363
- return 0;
3364
- }
3365
- }
3366
-
3367
3470
  function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localhost-api', restart = false } = {}) {
3368
3471
  const safeReason = String(reason || 'manual-request').trim().slice(0, 140) || 'manual-request';
3369
3472
  const safeRequestedBy = String(requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
@@ -3379,7 +3482,11 @@ function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localh
3379
3482
  };
3380
3483
  }
3381
3484
 
3382
- // Shutdown is intentionally deferred until the queue is idle; it will not interrupt an active turn.
3485
+ // Shutdown is deferred until no turn is processing; it never interrupts an
3486
+ // active turn. From here on no pending row is handed out (the dequeue paths
3487
+ // read relayShutdownQueued): a turn started meanwhile would postpone the
3488
+ // restart again. Pending and parked rows wait in the database and run
3489
+ // after the restart.
3383
3490
  if (!pendingRelayShutdownRequest) {
3384
3491
  pendingRelayShutdownRequest = {
3385
3492
  action: requestedAction,
@@ -3400,12 +3507,7 @@ function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localh
3400
3507
  const tryShutdownWhenIdle = () => {
3401
3508
  if (runtimeShutdownStarted) return;
3402
3509
  const counts = queueCounts();
3403
- // A row queued for a session whose launches the relay has stopped
3404
- // retrying makes no progress until somebody presses Retry; it must not
3405
- // hold the restart (which, fresh, is itself the next launch attempt).
3406
- const stalledPending = countPendingRowsOfStoppedSessions();
3407
- const queueFree = (counts.pendingCount - stalledPending + counts.processingCount + counts.parkedCount) <= 0;
3408
- if (!queueFree) return;
3510
+ if (!queuedShutdownCanProceed(counts)) return;
3409
3511
  const requestInfo = pendingRelayShutdownRequest;
3410
3512
  pendingRelayShutdownRequest = null;
3411
3513
  if (runtimeTimers.shutdownDrain) {
@@ -3416,7 +3518,7 @@ function requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localh
3416
3518
  const exitCode = action === 'restart' ? RELAY_RESTART_EXIT_CODE : 0;
3417
3519
  console.log(
3418
3520
  `${runtimeLogPrefix()}Relay ${action} request accepted by ${requestInfo?.requestedBy || 'unknown'}`
3419
- + `; queue is idle, exiting now (reason=${requestInfo?.reason || 'manual-request'})`,
3521
+ + `; no turn is processing (${counts.pendingCount} pending, ${counts.parkedCount} parked wait), exiting now (reason=${requestInfo?.reason || 'manual-request'})`,
3420
3522
  );
3421
3523
  sessionWorkerWebSocketService.emitDraining(`api-${action}:${requestInfo?.reason || 'manual-request'}`);
3422
3524
  void shutdownRuntime(`api-${action}:${requestInfo?.reason || 'manual-request'}`, { exitCode });
@@ -4347,7 +4449,7 @@ function fetchDriveDirectoryEntries(absoluteDirPath, { includeHidden = false } =
4347
4449
  '$rows | ConvertTo-Json -Depth 4 -Compress',
4348
4450
  ].join(' ');
4349
4451
 
4350
- execFile('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', psScript], { windowsHide: true }, (err, stdout, stderr) => {
4452
+ execFile(windowsSystemToolPath('powershell.exe'), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', psScript], { windowsHide: true }, (err, stdout, stderr) => {
4351
4453
  if (err) return cb(new Error(`drive list failed: ${stderr || err.message}`));
4352
4454
  const text = String(stdout || '').trim();
4353
4455
  if (!text) return cb(null, []);
@@ -4940,6 +5042,9 @@ function createCompactedConversation(sourceConversationId) {
4940
5042
  stmts.insertConv.run(targetConversationId, nextTitle, now, now);
4941
5043
  stmts.updateConvSeed.run(summarySeed, 1, sourceConversationId, now, targetConversationId);
4942
5044
  stmts.markConvCompacted.run(targetConversationId, now, sourceConversationId);
5045
+ // The new conversation stays in the old one's session group, and heads
5046
+ // it if the old one did; the archived source stays a member.
5047
+ conversationGroups.carryToCompacted(sourceConversationId, targetConversationId, now);
4943
5048
  targetRuntimeSession = ensureRuntimeSessionBinding(targetConversationId, null, now);
4944
5049
  });
4945
5050
  tx();
@@ -5049,19 +5154,18 @@ function subagentRunsForResponse(responseMessageId) {
5049
5154
  return rows.map(mapSubagentRunRow).filter(Boolean).slice(0, 96);
5050
5155
  }
5051
5156
 
5157
+ // The run as a row of the panel, the reply bubble and the viewer header. Stop
5158
+ // is offered while it runs: a background run through its live task, a
5159
+ // foreground run through the sub-agent cancel route.
5052
5160
  function mapSubagentRunRow(row) {
5053
- const subagentRunId = String(row?.id || '').trim();
5054
- if (!subagentRunId) return null;
5055
- return {
5056
- subagentRunId,
5057
- messageId: String(row?.queue_message_id || '').trim() || null,
5058
- conversationId: String(row?.conversation_id || '').trim() || null,
5059
- parentSubagentId: row?.parent_subagent_id ? String(row.parent_subagent_id).trim() : null,
5060
- displayName: row?.display_name ? String(row.display_name).trim() : null,
5061
- status: String(row?.status || 'running').trim().toLowerCase() || 'running',
5062
- startedAt: row?.started_at || null,
5063
- updatedAt: row?.updated_at || null,
5064
- };
5161
+ const status = String(row?.status || 'running').trim().toLowerCase();
5162
+ let stoppable = false;
5163
+ if (status === 'running') {
5164
+ const tasks = backgroundTaskStore.get(row?.conversation_id);
5165
+ const task = tasks.find((entry) => entry?.toolUseId && entry.toolUseId === String(row?.id || ''));
5166
+ stoppable = task ? task.stoppable !== false : Number(row?.background || 0) !== 1;
5167
+ }
5168
+ return mapSubagentRunHeader(row, { stoppable });
5065
5169
  }
5066
5170
 
5067
5171
  // Final workflow digests persisted with an assistant message (the "Finished
@@ -5100,25 +5204,10 @@ function previewCardsForResponse(responseMessageId) {
5100
5204
  .slice(0, 5);
5101
5205
  }
5102
5206
 
5103
- // Live background-task sets per conversation, published by Claude session
5104
- // workers (REPLACE semantics, mirroring the SDK's background_tasks_changed
5105
- // signal). In-memory only: the worker republishes on every change and clears
5106
- // on teardown, so a relay restart simply starts empty until the next update.
5107
- const backgroundTaskStore = {
5108
- sets: new Map(),
5109
- replace(conversationId, tasks) {
5110
- const id = String(conversationId || '').trim();
5111
- if (!id) return;
5112
- if (!Array.isArray(tasks) || tasks.length === 0) {
5113
- this.sets.delete(id);
5114
- return;
5115
- }
5116
- this.sets.set(id, { tasks, updatedAt: Date.now() });
5117
- },
5118
- get(conversationId) {
5119
- return this.sets.get(String(conversationId || '').trim())?.tasks || [];
5120
- },
5121
- };
5207
+ // Live background-task sets, as the session workers publish them. Memory
5208
+ // only: after a runtime start the heartbeat's digest brings each set back
5209
+ // (services/background-task-store.mjs).
5210
+ const backgroundTaskStore = createBackgroundTaskStore();
5122
5211
 
5123
5212
  // ─── Deferred host suspend ───────────────────────────────────────────────────
5124
5213
  // "💤 Suspend host" waits until nothing runs any more: no queued/processing
@@ -5151,6 +5240,17 @@ function hostActivityTitle(conversationId, fallbackTitle = null) {
5151
5240
  const title = String(fallbackTitle || stmts.getConv?.get?.(conversationId)?.title || '').trim();
5152
5241
  return title || String(conversationId || '').slice(0, 8);
5153
5242
  }
5243
+ const backgroundWorkView = createBackgroundWorkView({
5244
+ store: backgroundTaskStore,
5245
+ db,
5246
+ getRegistry: () => sessionWorkerRegistry,
5247
+ getTracker: () => sessionActivityTracker,
5248
+ isWorkerAlive: hostActivityWorkerAlive,
5249
+ sessionIdOf: (conversationId) => stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.sdk_session_id,
5250
+ conversationIdOf: (sessionId) => resolveConversationRecord({ conversationId: sessionId, sdkSessionId: sessionId })?.id,
5251
+ providerOf: (conversationId) => stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.provider_type,
5252
+ titleOf: (conversationId) => hostActivityTitle(conversationId),
5253
+ });
5154
5254
  function collectHostActivity({ request = null } = {}) {
5155
5255
  const blockers = [];
5156
5256
  const turnsByConversation = stmts.listActiveQueueCountsByConversation?.all?.() || [];
@@ -5174,20 +5274,11 @@ function collectHostActivity({ request = null } = {}) {
5174
5274
  : (count > 1 ? `${count} turns running` : 'turn running'),
5175
5275
  });
5176
5276
  }
5177
- for (const [key, entry] of backgroundTaskStore.sets) {
5178
- const tasks = Array.isArray(entry?.tasks) ? entry.tasks : [];
5179
- if (!tasks.length || !hostActivityWorkerAlive(key)) continue;
5180
- const worker = sessionWorkerRegistry?.getWorker?.(key) || sessionWorkerRegistry?.getWorkerByConversationId?.(key) || null;
5181
- const conversationId = String(worker?.conversationId || key);
5182
- hostSuspendWatchedConversationIds.add(conversationId);
5183
- const what = tasks.slice(0, 3).map((task) => String(task?.description || task?.taskType || 'task').slice(0, 80)).join('; ');
5184
- blockers.push({
5185
- kind: 'background',
5186
- conversationId,
5187
- title: hostActivityTitle(conversationId),
5188
- count: tasks.length,
5189
- detail: `${tasks.length} background agent${tasks.length === 1 ? '' : 's'}: ${what}`,
5190
- });
5277
+ // Background work of live workers, and a runtime too young to know of it
5278
+ // (services/background-work-view.mjs).
5279
+ for (const blocker of backgroundWorkView.blockers()) {
5280
+ if (blocker.conversationId) hostSuspendWatchedConversationIds.add(blocker.conversationId);
5281
+ blockers.push(blocker);
5191
5282
  }
5192
5283
  const runningSubagents = Number(stmts.countRunningSubagentRuns?.get?.()?.cnt || 0);
5193
5284
  // CI is only worth a network round-trip for workspaces that were busy since
@@ -5228,6 +5319,50 @@ const usageLimitPauseService = createUsageLimitPauseService({
5228
5319
  logger: console,
5229
5320
  });
5230
5321
 
5322
+ // ─── Relay events ─────────────────────────────────────────────────────────────
5323
+ // What each session is doing (state, tasks, quiet time) and the subscriptions
5324
+ // through which a watching session is woken with the events of the sessions
5325
+ // it watches (docs: server/README.md "Relay event subscriptions").
5326
+ sessionActivityTracker = createSessionActivityTracker({
5327
+ db,
5328
+ onEdge: (edge) => relayEventService?.noteTaskEdge(edge),
5329
+ hasUnreportedBackgroundWork: (conversationId) => backgroundWorkView.hasUnreportedWork(conversationId),
5330
+ listUnreportedBackgroundWork: () => backgroundWorkView.listUnreportedConversationIds(),
5331
+ runListedByWorker: (conversationId, row) => backgroundWorkView.runListed(conversationId, row),
5332
+ bootReportingWindowMs: resolveBootReportingWindowMs(process.env, (line) => console.log(`[activity] ${line}`)),
5333
+ logger: console,
5334
+ });
5335
+ relayEventService = createRelayEventService({
5336
+ db,
5337
+ stmts,
5338
+ tracker: sessionActivityTracker,
5339
+ emit: (event, payload) => io.emit(event, payload),
5340
+ // The event row is an ordinary pending message: the primer starts a
5341
+ // stopped worker, the socket service hands it to a running one.
5342
+ wakeWatcher: () => {
5343
+ void primePendingSessionWorkers('relay-event');
5344
+ sessionWorkerWebSocketService?.emitQueueChanged?.('relay-event');
5345
+ },
5346
+ relayName: () => remoteRelayRegistry?.selfName?.() || getPwaAppName?.() || '',
5347
+ onRemoteChange: (relayId) => { void relayEventLinkService?.fetchNow(relayId); },
5348
+ logger: console,
5349
+ });
5350
+
5351
+ /** A worker died: its tasks are gone, and whoever watches its sessions hears of it. */
5352
+ function noteWorkerDeathForEvents(sessionId, conversationIds, reason) {
5353
+ const ids = new Set((conversationIds || []).map((id) => String(id || '').trim()).filter(Boolean));
5354
+ if (!ids.size) {
5355
+ const conversation = resolveConversationRecord({ conversationId: sessionId, sdkSessionId: sessionId });
5356
+ if (conversation?.id) ids.add(String(conversation.id));
5357
+ }
5358
+ for (const conversationId of ids) {
5359
+ try {
5360
+ sessionActivityTracker?.noteWorkerDied(conversationId);
5361
+ relayEventService?.noteError({ conversationId, kind: 'worker_died', message: `the session's worker process ended (${reason})` });
5362
+ } catch {}
5363
+ }
5364
+ }
5365
+
5231
5366
  // The live turn among a conversation's processing rows. Mid-turn steering can
5232
5367
  // leave several processing at once — the running turn, steered messages the
5233
5368
  // CLI folded into it (no output of their own), a handed-off row still settling
@@ -5330,6 +5465,9 @@ function failRecoveredRowTerminally(row, reason, { failureRecord = null, failure
5330
5465
  message: { role: 'assistant', text: failureText, model: requestedModel || 'unknown', mode: String(row?.relay_mode || '').trim() || null, timestamp: now },
5331
5466
  });
5332
5467
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
5468
+ try {
5469
+ relayEventService?.noteError({ conversationId: row.conversation_id, kind: 'turn_failed', message: failureText, queueMessageId: row.id });
5470
+ } catch {}
5333
5471
  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}`);
5334
5472
  }
5335
5473
 
@@ -5354,6 +5492,12 @@ function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso
5354
5492
  for (const row of staleContinuations) {
5355
5493
  stmts.dropStaleContinuation?.run?.(row.id);
5356
5494
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
5495
+ // A teardown is a turn ending with no reply; it must be visible in the
5496
+ // console log (the only trace of a silent one was `ignored=already_failed`
5497
+ // hours later, when the worker's reply arrived for the dead row).
5498
+ const lastSeen = row.owner_last_claimed_at || row.processing_at || row.timestamp || null;
5499
+ const reason = lastSeen && lastSeen < cutoffIso ? `inactive since ${lastSeen}` : `ceiling (started ${row.processing_at || row.timestamp})`;
5500
+ console.warn(`${runtimeLogPrefix()}STALE CONTINUATION ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} dropped: ${reason}`);
5357
5501
  }
5358
5502
  const listedRows = stmts.listRecoverableProcessing.all(params);
5359
5503
  for (const row of listedRows.filter((entry) => entry?.consumed_at)) failConsumedRowTerminally(row, 'stale-recovery');
@@ -5731,9 +5875,11 @@ const app = express();
5731
5875
  // Trust boundary for X-Forwarded-* headers. Default 'loopback' matches the
5732
5876
  // localhost-first deployment (a cloudflared/caddy tunnel runs on the same host
5733
5877
  // and connects over 127.0.0.1), so req.protocol/req.secure/req.ip reflect the
5734
- // real edge only when the immediate peer is trusted — a direct client can no
5735
- // longer forge those headers. Operators behind a remote proxy can widen this
5736
- // via config.trustProxy (e.g. a hop count or subnet).
5878
+ // real edge only when the immediate peer is trusted — a direct client cannot
5879
+ // forge those headers. Code that needs the client's address, host or scheme
5880
+ // reads req.ip / req.protocol / requestHostOf (services/request-boundary.mjs),
5881
+ // never the raw X-Forwarded-* headers. Operators behind a remote proxy can
5882
+ // widen this via config.trustProxy (e.g. a hop count or subnet).
5737
5883
  app.set('trust proxy', config.trustProxy ?? 'loopback');
5738
5884
  const httpServer = http.createServer(app);
5739
5885
  // Node closes an idle keep-alive connection after 5 s. A client that pools
@@ -5749,15 +5895,27 @@ httpServer.prependListener('request', (req, _res) => {
5749
5895
  httpServer.prependListener('upgrade', (req, _socket, _head) => {
5750
5896
  rewriteSocketIoRequestPath(req, remotePath);
5751
5897
  });
5898
+ // A client reset during an upgrade must not end the relay; see the guard.
5899
+ httpServer.prependListener('upgrade', (_req, socket, _head) => {
5900
+ guardUpgradeSocket(socket);
5901
+ });
5902
+ // "Truly local": a loopback peer with no tunnel marker and no forwarding
5903
+ // header (services/request-boundary.mjs). The marker headers are `cf-ray`
5904
+ // plus config.tunnelMarkerHeaders plus COPILOT_TUNNEL_MARKER_HEADERS, merged:
5905
+ // neither source switches the other off.
5906
+ const localRequestCheck = createLocalRequestCheck({
5907
+ markerHeaders: resolveTunnelMarkerHeaders(
5908
+ config.tunnelMarkerHeaders,
5909
+ process.env.COPILOT_TUNNEL_MARKER_HEADERS,
5910
+ ),
5911
+ });
5752
5912
  // Public tunnels (Cloudflare, or a VPS/Caddy front) forward every path on the
5753
5913
  // bound hostname to this port, including the session-worker WebSocket plumbing.
5754
- // Reject those before auth; local workers connect over 127.0.0.1 with no edge
5755
- // marker header and are unaffected.
5914
+ // Reject every worker-path request that is not truly local before auth; local
5915
+ // workers connect over 127.0.0.1 without proxy traces and are unaffected.
5756
5916
  const tunnelWorkerPathGuard = createTunnelWorkerPathGuard({
5757
5917
  pathPrefix: remotePath,
5758
- extraMarkerHeaders: config.tunnelMarkerHeaders
5759
- || process.env.COPILOT_TUNNEL_MARKER_HEADERS
5760
- || [],
5918
+ localRequestCheck,
5761
5919
  logger: console,
5762
5920
  });
5763
5921
  httpServer.prependListener('upgrade', (req, socket, _head) => {
@@ -5769,8 +5927,27 @@ httpServer.prependListener('upgrade', (req, socket, _head) => {
5769
5927
  // High-volume stream chunks are emitted volatile and deliberately excluded from
5770
5928
  // the replay buffer; see the io.volatile.emit('relay_stream') call site.
5771
5929
  const SOCKET_RECOVERY_WINDOW_MS = 10 * 60 * 1000;
5930
+ // The browser app opens its socket on the page's own origin (same host, the
5931
+ // `remotePath` socket.io path), so a handshake from another origin is refused:
5932
+ // a page elsewhere must not ride the relay cookie into a live socket. The
5933
+ // host compared is the one the client addressed (X-Forwarded-Host only from a
5934
+ // trusted proxy); config.allowedOrigins lists extra origins for a proxy that
5935
+ // rewrites the Host header. Clients that send no Origin (Node) pass.
5936
+ const socketAllowedOrigins = Array.isArray(config.allowedOrigins) ? config.allowedOrigins : [];
5937
+ const refusedSocketOrigins = new Set();
5772
5938
  const io = new Server(httpServer, {
5773
- cors: { origin: '*' },
5939
+ allowRequest: (req, callback) => {
5940
+ if (isSameOriginRequest(expressRequestView(app, req), { allowedOrigins: socketAllowedOrigins })) {
5941
+ callback(null, true);
5942
+ return;
5943
+ }
5944
+ const origin = String(req.headers?.origin || '').slice(0, 200);
5945
+ if (refusedSocketOrigins.size < 32 && !refusedSocketOrigins.has(origin)) {
5946
+ refusedSocketOrigins.add(origin);
5947
+ console.warn(`[socket] Refused a handshake from origin ${origin} (host ${String(req.headers?.host || '').slice(0, 200)}); list it in allowedOrigins if a proxy rewrites the Host header`);
5948
+ }
5949
+ callback('Origin not allowed', false);
5950
+ },
5774
5951
  path: socketIoPath(),
5775
5952
  connectionStateRecovery: {
5776
5953
  maxDisconnectionDuration: SOCKET_RECOVERY_WINDOW_MS,
@@ -5958,6 +6135,9 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
5958
6135
  if (runtimeState.relayPaused) {
5959
6136
  return { message: null, paused: true, reason: 'relay_paused' };
5960
6137
  }
6138
+ if (isRelayShutdownQueued(runtimeState)) {
6139
+ return relayShutdownGateReply(runtimeState);
6140
+ }
5961
6141
  if (runtimeState.tunnelState?.blocking) {
5962
6142
  return {
5963
6143
  message: null,
@@ -6172,11 +6352,36 @@ async function isWorkerSessionProcessAlive(sessionId, hintPid = null) {
6172
6352
  return false;
6173
6353
  }
6174
6354
 
6355
+ // Runs a dead worker can no longer end: marked failed, each with the full
6356
+ // header and its own turn's messageId, so an open page settles the row
6357
+ // without a reload (the browser drops a status event it cannot place).
6358
+ function closeStrandedSubagentRuns(runs, queueMessageId = null) {
6359
+ if (!runs.length) return;
6360
+ const nowIso = new Date().toISOString();
6361
+ for (const run of runs) {
6362
+ stmts.updateSubagentRunStatus?.run?.('failed', nowIso, nowIso, run.id);
6363
+ const closed = stmts.getSubagentRun?.get?.(run.id) || { ...run, status: 'failed', completed_at: nowIso };
6364
+ const header = mapSubagentRunHeader(closed, { stoppable: false });
6365
+ if (header) {
6366
+ io.emit('subagent_status', {
6367
+ ...header,
6368
+ timestamp: nowIso,
6369
+ queueMessageId: queueMessageId || header.messageId,
6370
+ reconciled: true,
6371
+ });
6372
+ }
6373
+ }
6374
+ }
6375
+
6175
6376
  function recoverDeadWorkerProcessingRows(sessionId, reason) {
6176
6377
  const sid = String(sessionId || '').trim();
6177
6378
  if (!sid) return 0;
6379
+ // A background agent outlives its turn, so its run is closed with the
6380
+ // worker rather than with a processing row (its turn is usually done).
6381
+ closeStrandedSubagentRuns(stmts.listRunningBackgroundSubagentRunsForSession?.all?.(sid, sid) || []);
6178
6382
  const rows = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
6179
6383
  if (!rows.length) return 0;
6384
+ noteWorkerDeathForEvents(sid, rows.map((row) => row.conversation_id), reason);
6180
6385
  let recovered = 0;
6181
6386
  const requeueAt = addMsIso(2_000);
6182
6387
  for (const row of rows) {
@@ -6196,22 +6401,7 @@ function recoverDeadWorkerProcessingRows(sessionId, reason) {
6196
6401
  }
6197
6402
  // The dead worker's subagent runs can never be terminated by the replay
6198
6403
  // (fresh worker, fresh call ids) — close them out now.
6199
- const strandedRuns = stmts.listRunningSubagentRunsByQueueMessage?.all?.(row.id) || [];
6200
- if (strandedRuns.length) {
6201
- const nowIso = new Date().toISOString();
6202
- stmts.closeRunningSubagentRunsByQueueMessage?.run?.('failed', nowIso, nowIso, row.id);
6203
- for (const run of strandedRuns) {
6204
- io.emit('subagent_status', {
6205
- conversationId: run.conversation_id,
6206
- subagentRunId: run.id,
6207
- parentSubagentId: run.parent_subagent_id || null,
6208
- displayName: run.display_name || null,
6209
- status: 'failed',
6210
- queueMessageId: row.id,
6211
- reconciled: true,
6212
- });
6213
- }
6214
- }
6404
+ closeStrandedSubagentRuns(stmts.listRunningSubagentRunsByQueueMessage?.all?.(row.id) || [], row.id);
6215
6405
  stmts.recoverProcessingRowKeepOwner?.run?.(requeueAt, row.id);
6216
6406
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
6217
6407
  recovered += 1;
@@ -6231,18 +6421,27 @@ function handleWorkerSocketClosed({ sessionId, pid = null } = {}) {
6231
6421
  if (runtimeShutdownStarted) return;
6232
6422
  const sid = String(sessionId || '').trim();
6233
6423
  if (!sid) return;
6424
+ detachedWorkerWatch.noteSocketEnded(sid);
6234
6425
  const owned = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
6235
6426
  // A dead worker's steering snapshot must not keep holding the composer even
6236
6427
  // when it owned no rows (the hold can outlive them, e.g. a continuation's).
6237
6428
  const heldSteering = Boolean(sessionWorkerRegistry?.getWorker?.(sid)?.steering);
6238
- if (!owned.length && !heldSteering) return;
6429
+ // Background agents run between turns too: a worker that dies then owns no
6430
+ // row, but its agents' runs still read "running".
6431
+ const backgroundRuns = () => stmts.listRunningBackgroundSubagentRunsForSession?.all?.(sid, sid) || [];
6432
+ if (!owned.length && !heldSteering && !backgroundRuns().length) return;
6239
6433
  const timer = setTimeout(async () => {
6240
6434
  try {
6241
6435
  if (runtimeShutdownStarted) return;
6242
6436
  if (await isWorkerSessionProcessAlive(sid, pid)) return; // reconnect or socket flap
6243
6437
  sessionWorkerRegistry?.clearSteering?.(sid);
6244
6438
  const stillOwned = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
6245
- if (!stillOwned.length) return;
6439
+ if (!stillOwned.length) {
6440
+ const stranded = backgroundRuns();
6441
+ closeStrandedSubagentRuns(stranded);
6442
+ if (stranded.length) noteWorkerDeathForEvents(sid, stranded.map((run) => run.conversation_id), 'socket-close');
6443
+ return;
6444
+ }
6246
6445
  console.warn(`${runtimeLogPrefix()}WORKER DIED session=${sid.slice(0, 8)} rows=${stillOwned.length} trigger=socket-close — recovering owned turns`);
6247
6446
  recoverDeadWorkerProcessingRows(sid, 'socket-close');
6248
6447
  } catch (error) {
@@ -6280,6 +6479,64 @@ async function sweepDeadWorkerProcessingRowsOnce() {
6280
6479
  return recovered;
6281
6480
  }
6282
6481
 
6482
+ // A worker alive without its socket while a message waits: replaced after a
6483
+ // grace time (see the service). Stops the way the kill route does, but only
6484
+ // names the registry's pid when the process list confirms it is the
6485
+ // session's worker, then spawns the replacement.
6486
+ async function replaceDetachedSessionWorker(sid) {
6487
+ const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
6488
+ const found = process.platform === 'win32'
6489
+ ? await sessionWorkerProcessInspector.findWindowsProcessTreeForSessionAsync(sid, { fresh: true })
6490
+ : await sessionWorkerProcessInspector.findProcessesForSessionAsync(sid, { fresh: true });
6491
+ const foundPids = new Set((found || []).map((entry) => Number(entry?.processId)));
6492
+ const stopped = await stopSessionWorkerProcesses({
6493
+ sdkSessionId: sid,
6494
+ worker: worker && foundPids.has(Number(worker.pid)) ? worker : null,
6495
+ processInspector: sessionWorkerProcessInspector,
6496
+ isPidAliveImpl: isProcessAlive,
6497
+ killTmuxSessionImpl: killTmuxSession,
6498
+ });
6499
+ if (!stopped.ok) return { ok: false, error: stopped.error || 'worker-survived-stop' };
6500
+ sessionWorkerRegistry?.removeWorker?.(sid);
6501
+ sessionWorkerSupervisor?.clearRestartSchedule?.(sid);
6502
+ sessionWorkerSupervisor?.resetHealth?.(sid, { clearFailureCount: false });
6503
+ const launched = await sessionWorkerSupervisor?.ensureWorker?.(sid);
6504
+ return { ok: launched?.ok === true, error: launched?.error || null };
6505
+ }
6506
+
6507
+ function sessionHasBackgroundWork(sid) {
6508
+ if ((stmts.listRunningBackgroundSubagentRunsForSession?.all?.(sid, sid) || []).length) return true;
6509
+ if (backgroundWorkView.hasUnreportedWork(sid)) return true;
6510
+ const conversationId = resolveConversationRecord({ conversationId: sid, sdkSessionId: sid })?.id || '';
6511
+ if (backgroundTaskStore.get(sid).length || (conversationId && backgroundTaskStore.get(conversationId).length)) return true;
6512
+ return backgroundWorkView.backgroundBlockers()
6513
+ .some((blocker) => blocker.conversationId === sid || (conversationId && blocker.conversationId === conversationId));
6514
+ }
6515
+
6516
+ const detachedWorkerWatch = createDetachedWorkerWatch({
6517
+ isEnabled: () => featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true
6518
+ && !runtimeShutdownStarted
6519
+ && !pendingRelayShutdownRequest,
6520
+ listWaitingSessionIds: () => (stmts.listPendingWorkerOwnerSessionIds?.all?.(25) || [])
6521
+ .map((row) => row?.sdk_session_id),
6522
+ hasWorkerSocket: (sid) => sessionWorkerWebSocketService.hasWorkerSocket(sid) === true,
6523
+ hasRunningTurn: (sid) => (stmts.listProcessingRowsForOwner?.all?.(sid) || []).length > 0,
6524
+ hasBackgroundWork: sessionHasBackgroundWork,
6525
+ isLaunchBlocked: (sid) => sessionWorkerSupervisor?.isKillBlocked?.(sid) === true,
6526
+ getLaunchState: (sid) => {
6527
+ const lifecycle = sessionWorkerSupervisor?.getLifecycleState?.(sid) || null;
6528
+ if (!lifecycle) return null;
6529
+ return {
6530
+ launchMode: lifecycle.launchMode,
6531
+ launchAtMs: lifecycle.launchAt ? Date.parse(lifecycle.launchAt) : null,
6532
+ awaitingHeartbeat: lifecycle.awaitingHeartbeat === true,
6533
+ };
6534
+ },
6535
+ isWorkerProcessAlive: async (sid) => Boolean(await sessionWorkerProcessInspector?.findProcessForSessionAsync?.(sid)),
6536
+ replaceWorker: replaceDetachedSessionWorker,
6537
+ log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
6538
+ });
6539
+
6283
6540
  const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
6284
6541
  WebSocketServerImpl: WebSocketServer,
6285
6542
  httpServer,
@@ -6414,7 +6671,27 @@ app.get(['/shared/:token', '/shared/:token/'], (req, res, next) => {
6414
6671
  try {
6415
6672
  const rawToken = String(req.params?.token || '').trim().toLowerCase();
6416
6673
  const shareToken = /^[a-f0-9]{32,128}$/.test(rawToken) ? rawToken : '';
6674
+ res.setHeader('Cache-Control', 'no-store');
6675
+ res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
6676
+ // An unknown, revoked or renewed link is a plain 404: no app shell, and
6677
+ // nothing that tells it apart from a link that never existed.
6678
+ if (!shareToken || !shareRepository.findLiveShare(shareToken)) {
6679
+ res.status(404).type('text/plain').send('Not Found');
6680
+ return;
6681
+ }
6417
6682
  const html = renderIndexHtmlWithPwaVersion({ sharedToken: shareToken });
6683
+ res.type('html').send(html);
6684
+ } catch (error) {
6685
+ next(error);
6686
+ }
6687
+ });
6688
+ // "Preview as shared": the shared reader for the owner. The page holds
6689
+ // nothing of the conversation; its data comes from the owner-only preview route.
6690
+ app.get(['/share-preview/:conversationId', '/share-preview/:conversationId/'], (req, res, next) => {
6691
+ try {
6692
+ const conversationId = String(req.params?.conversationId || '').trim();
6693
+ const sharePreviewConversationId = /^[A-Za-z0-9_.:-]{1,200}$/.test(conversationId) ? conversationId : '';
6694
+ const html = renderIndexHtmlWithPwaVersion({ sharePreviewConversationId });
6418
6695
  res.setHeader('Cache-Control', 'no-store');
6419
6696
  res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
6420
6697
  res.type('html').send(html);
@@ -6437,6 +6714,9 @@ const runtimeState = {
6437
6714
  set relayPaused(value) { relayPaused = value; },
6438
6715
  get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
6439
6716
  get relayShutdown() { return getRelayShutdownState(); },
6717
+ // A shutdown or restart waits for the processing turns: no pending row
6718
+ // goes out meanwhile (see requestRelayShutdown).
6719
+ get relayShutdownQueued() { return !!pendingRelayShutdownRequest && !runtimeShutdownStarted; },
6440
6720
  get hostSuspend() { return hostSuspendService.getState(); },
6441
6721
  get usageLimit() {
6442
6722
  return { account: usageLimitPauseService.getAccountState(), pauses: usageLimitPauseService.listPauses() };
@@ -6490,6 +6770,13 @@ const remoteRelayRegistry = createRemoteRelayRegistry({
6490
6770
  platform: process.platform,
6491
6771
  version: RUNNING_VERSION,
6492
6772
  emit: (event, payload) => io.emit(event, payload),
6773
+ onRemove: (entry) => { void relayEventLinkService?.noteRelayRemoved(entry); },
6774
+ logger: console,
6775
+ });
6776
+ relayEventLinkService = createRelayEventLinkService({
6777
+ relayEvents: relayEventService,
6778
+ registry: remoteRelayRegistry,
6779
+ client: remoteRelayClient,
6493
6780
  logger: console,
6494
6781
  });
6495
6782
  const remoteRelayPairing = createRemoteRelayPairing({
@@ -6520,7 +6807,10 @@ const remoteRelayCallerContext = createRemoteRelayCallerContext({
6520
6807
  normalizeRelayMode,
6521
6808
  defaultRelayMode: DEFAULT_RELAY_MODE,
6522
6809
  emit: (event, payload) => io.emit(event, payload),
6523
- notifyQuestion: (question) => pushDispatchService.notifyQuestion?.(question),
6810
+ notifyQuestion: (question) => {
6811
+ try { relayEventService?.noteQuestion(question); } catch {}
6812
+ return pushDispatchService.notifyQuestion?.(question);
6813
+ },
6524
6814
  uuid: uuidv4,
6525
6815
  logger: console,
6526
6816
  });
@@ -6541,11 +6831,14 @@ const remoteRelayDispatcher = createRemoteRelayDispatcher({
6541
6831
  requestApproval: (request) => remoteRelayCallerContext.requestApproval(request),
6542
6832
  emitStatusEvent: ({ type, ...details }) => statusEventService.recordEvent(type, details),
6543
6833
  resolveConversationId: (id) => resolveConversationRecord({ conversationId: id, sdkSessionId: id })?.id || id,
6834
+ relayEvents: relayEventService,
6835
+ relayEventLink: relayEventLinkService,
6544
6836
  logger: console,
6545
6837
  });
6546
6838
 
6547
6839
  const sharedRouteDeps = {
6548
6840
  auth,
6841
+ isTrulyLocalRequest: localRequestCheck.isTrulyLocal,
6549
6842
  gitChangesService,
6550
6843
  gitRemoteService,
6551
6844
  currentWorkspaceRootPath,
@@ -6665,9 +6958,12 @@ const sharedRouteDeps = {
6665
6958
  inFlightStateForConversation,
6666
6959
  resolveLiveTurnQueueRow,
6667
6960
  backgroundTaskStore,
6961
+ backgroundWorkView,
6668
6962
  // A closure, not the registry itself: this deps object is built before the
6669
6963
  // preview lane is constructed further down the file.
6670
6964
  listConversationPreviews: (conversationId) => previewRegistry.listForConversation(conversationId),
6965
+ closeConversationPreviews: (conversationId) => previewRegistry.closeForConversation(conversationId),
6966
+ listKnownPreviewTokens: () => previewRegistry.knownTokens(),
6671
6967
  sendWorkerControl: (sessionId, control) => sessionWorkerWebSocketService.sendControlToSession(sessionId, control),
6672
6968
  emitToClientsExceptSessionId,
6673
6969
  relayBridgeOwnerService,
@@ -6729,6 +7025,11 @@ const sharedRouteDeps = {
6729
7025
  resolveCursorSessionRoot: cursorSessionRootResolver.resolveCursorSessionRoot,
6730
7026
  getTurnCeilingMinutes,
6731
7027
  setTurnCeilingMinutes,
7028
+ getSubagentRecordRetentionDays,
7029
+ setSubagentRecordRetentionDays,
7030
+ getSharePathPrivacyMode,
7031
+ setSharePathPrivacyMode,
7032
+ sharePathKnownRoots,
6732
7033
  getBackgroundTaskTimeoutMinutes,
6733
7034
  setBackgroundTaskTimeoutMinutes,
6734
7035
  getFeatureFlagsSettingsState,
@@ -6741,6 +7042,9 @@ const sharedRouteDeps = {
6741
7042
  hostSuspendService,
6742
7043
  collectHostActivity,
6743
7044
  usageLimitPauseService,
7045
+ relayEventService,
7046
+ sessionActivityTracker,
7047
+ resolveConversationRecord,
6744
7048
  markSharedViewerPresence,
6745
7049
  getSharedWatcherCount,
6746
7050
  statusEventService,
@@ -6756,9 +7060,12 @@ const sharedRouteDeps = {
6756
7060
  windowsBootAutostartService,
6757
7061
  pushDispatchService,
6758
7062
  remoteRelayInbound,
7063
+ conversationGroups,
7064
+ shareRepository,
6759
7065
  };
6760
7066
  registerMessagesRoutes(app, sharedRouteDeps);
6761
7067
  registerSessionsRoutes(app, sharedRouteDeps);
7068
+ registerConversationGroupRoutes(app, sharedRouteDeps);
6762
7069
  registerAskUserRoutes(app, sharedRouteDeps);
6763
7070
  registerRelayBoardRoutes(app, sharedRouteDeps);
6764
7071
  registerCacheRoutes(app, sharedRouteDeps);
@@ -6774,6 +7081,36 @@ registerRemoteRelayRoutes(app, {
6774
7081
  registry: remoteRelayRegistry,
6775
7082
  pairing: remoteRelayPairing,
6776
7083
  dispatcher: remoteRelayDispatcher,
7084
+ remoteRelayInbound,
7085
+ });
7086
+ registerRelayEventRoutes(app, {
7087
+ auth,
7088
+ relayEventService,
7089
+ remoteRelayInbound,
7090
+ });
7091
+ registerAboutRoutes(app, {
7092
+ auth,
7093
+ aboutService: createAboutService({
7094
+ db,
7095
+ dbPath: DB_PATH,
7096
+ version: RUNNING_VERSION,
7097
+ commit: BUILD_COMMIT,
7098
+ installMethod: INSTALL_METHOD,
7099
+ startedAt: RELAY_STARTED_AT,
7100
+ packageRoot: PACKAGE_ROOT,
7101
+ getProviderSettings: () => ({
7102
+ github: { enabled: true, engine: getCopilotEngine() },
7103
+ openai: { enabled: getOpenAIProviderSettings()?.enabled === true },
7104
+ claude: { enabled: getClaudeProviderSettings()?.enabled === true },
7105
+ 'claude-cloud': { enabled: getClaudeCloudProviderSettings()?.enabled === true },
7106
+ cursor: { enabled: getCursorProviderSettings()?.enabled === true },
7107
+ grok: { enabled: getGrokProviderSettings()?.enabled === true },
7108
+ }),
7109
+ getCachedCliProviders: () => cliInstallService.getCachedStatusSnapshot()?.providers || {},
7110
+ isRelayCliOnline: () => runtimeState.cliOnline === true,
7111
+ listWorkers: () => sessionWorkerRegistry.listWorkers(),
7112
+ getUpdateState: () => buildUpdateStatePayload(),
7113
+ }),
6777
7114
  });
6778
7115
  function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
6779
7116
  cliLastSeen = null;
@@ -6817,9 +7154,22 @@ if (managedOwnerPid) {
6817
7154
  // The window is measured from the last heartbeat that named the message, not
6818
7155
  // from when the turn began, so a turn stays alive for as long as its worker
6819
7156
  // keeps reporting it — however many minutes of tool calls and subagents that
6820
- // takes. The separate, user-configurable ceiling is the only elapsed-time cap,
6821
- // and both exempt a turn that is waiting on an unanswered question.
7157
+ // takes. The separate, user-configurable ceiling is the only elapsed-time cap;
7158
+ // it counts from the last answered question rather than from the start, and
7159
+ // both exempt a turn that is waiting on an unanswered question (continuation
7160
+ // rows included — an interview hangs its cards on one).
7161
+ //
7162
+ // After a wall-clock jump (the host slept, the event loop was blocked) every
7163
+ // heartbeat looks old at once although the workers may be alive: the guard
7164
+ // skips recovery for a full inactivity window, so a live worker can report.
7165
+ const STALE_RECOVERY_INTERVAL_MS = 15_000;
7166
+ const staleRecoveryClockGuard = createStaleRecoveryClockGuard({
7167
+ intervalMs: STALE_RECOVERY_INTERVAL_MS,
7168
+ graceMs: Math.max(processingTimeoutMs, 60_000),
7169
+ log: (line) => console.log(`${runtimeLogPrefix()}${line}`),
7170
+ });
6822
7171
  function recoverStaleMessages() {
7172
+ if (staleRecoveryClockGuard.tick().skip) return;
6823
7173
  const staleWindowMs = cliOnline
6824
7174
  ? processingTimeoutMs
6825
7175
  : Math.min(processingTimeoutMs, OFFLINE_STALE_RECOVER_MS);
@@ -6835,7 +7185,7 @@ function recoverStaleMessages() {
6835
7185
  );
6836
7186
  }
6837
7187
  }
6838
- runtimeTimers.staleRecovery = setInterval(recoverStaleMessages, 15_000);
7188
+ runtimeTimers.staleRecovery = setInterval(recoverStaleMessages, STALE_RECOVERY_INTERVAL_MS);
6839
7189
  recoverStaleMessages(); // run immediately on startup
6840
7190
 
6841
7191
  function expirePendingQuestions() {
@@ -6865,6 +7215,40 @@ if (sweptUploads > 0) {
6865
7215
  }
6866
7216
  runtimeTimers.uploadSweep = setInterval(sweepUnreferencedUploadBlobs, 6 * 60 * 60 * 1000);
6867
7217
  if (typeof runtimeTimers.uploadSweep.unref === 'function') runtimeTimers.uploadSweep.unref();
7218
+ // Old sub-agent records lose their tool inputs and output: shortly after
7219
+ // start (not in the boot path), then once a day.
7220
+ subagentRecordRetention = createSubagentRecordRetention({
7221
+ db,
7222
+ getRetentionDays: getSubagentRecordRetentionDays,
7223
+ isStopped: () => runtimeShutdownStarted,
7224
+ log: (line) => console.log(`${runtimeLogPrefix()}${line}`),
7225
+ warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
7226
+ });
7227
+ subagentRecordRetention.scheduleSweep(30_000);
7228
+ runtimeTimers.subagentRecordRetention = setInterval(() => { void subagentRecordRetention.sweep(); }, 24 * 60 * 60 * 1000);
7229
+ if (typeof runtimeTimers.subagentRecordRetention.unref === 'function') runtimeTimers.subagentRecordRetention.unref();
7230
+ // Sub-agent runs left `running` by a worker that is gone are closed: once
7231
+ // the previous runtime's workers have had time to reconnect after boot, then
7232
+ // periodically. A live worker's runs are never touched.
7233
+ const staleSubagentRunSweep = createStaleSubagentRunSweep({
7234
+ db,
7235
+ isConversationWorkerAlive: (conversationId) => {
7236
+ if (hostActivityWorkerAlive(conversationId)) return true;
7237
+ const sdkSessionId = String(stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.sdk_session_id || '').trim();
7238
+ return !!sdkSessionId && hostActivityWorkerAlive(sdkSessionId);
7239
+ },
7240
+ log: (line) => console.log(`${runtimeLogPrefix()}${line}`),
7241
+ warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
7242
+ });
7243
+ runtimeTimers.staleSubagentRunsBoot = setTimeout(() => {
7244
+ runtimeTimers.staleSubagentRunsBoot = null;
7245
+ if (!runtimeShutdownStarted) staleSubagentRunSweep.sweep();
7246
+ }, STALE_SUBAGENT_RUN_BOOT_DELAY_MS);
7247
+ if (typeof runtimeTimers.staleSubagentRunsBoot.unref === 'function') runtimeTimers.staleSubagentRunsBoot.unref();
7248
+ runtimeTimers.staleSubagentRuns = setInterval(() => {
7249
+ if (!runtimeShutdownStarted) staleSubagentRunSweep.sweep();
7250
+ }, STALE_SUBAGENT_RUN_SWEEP_INTERVAL_MS);
7251
+ if (typeof runtimeTimers.staleSubagentRuns.unref === 'function') runtimeTimers.staleSubagentRuns.unref();
6868
7252
  // Arms the 12h update poller only when the user opted in; otherwise silent.
6869
7253
  updateCheckService?.startIfEnabled();
6870
7254
  const runtimeBindingsBootstrapped = bootstrapRuntimeSessionBindings();
@@ -6878,38 +7262,21 @@ function runtimeLogPrefix() {
6878
7262
  }
6879
7263
 
6880
7264
  // ─── Auth Middleware ──────────────────────────────────────────────────────────
6881
- // Constant-time comparison so the auth token can't be recovered through response
6882
- // timing. The token length is fixed (a UUID), so the length short-circuit leaks
6883
- // nothing secret; any empty/missing value is a non-match.
6884
- function tokensMatch(presented, expected) {
6885
- const a = Buffer.from(String(presented || ''), 'utf8');
6886
- const b = Buffer.from(String(expected || ''), 'utf8');
6887
- if (a.length === 0 || b.length === 0 || a.length !== b.length) return false;
6888
- return crypto.timingSafeEqual(a, b);
6889
- }
7265
+ // Token check, wrong-token lockout and the Socket.IO handshake gate live in
7266
+ // services/relay-auth.mjs. `auth` stays a hoisted function: the route deps
7267
+ // above hand it out before this line runs.
7268
+ const relayAuth = createRelayAuth({
7269
+ app,
7270
+ getAuthToken: () => config.authToken,
7271
+ cookieName: AUTH_COOKIE,
7272
+ cookiePath: COOKIE_PATH,
7273
+ parseCookies,
7274
+ appendSetCookie,
7275
+ localRequestCheck,
7276
+ logger: console,
7277
+ });
6890
7278
  function auth(req, res, next) {
6891
- const cookies = parseCookies(req.headers.cookie);
6892
- // req.secure is derived through the configured `trust proxy` boundary, so a
6893
- // direct (untrusted) client can no longer force a non-Secure cookie by
6894
- // omitting X-Forwarded-Proto.
6895
- const secureAttr = req.secure ? '; Secure' : '';
6896
- // Note: the token is intentionally NOT read from req.body — a request body is
6897
- // an easy CSRF/logging vector and has no legitimate use here. Header, query
6898
- // (first-load bootstrap only), and the HttpOnly cookie remain.
6899
- const token =
6900
- (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '') ||
6901
- req.query.token ||
6902
- cookies[AUTH_COOKIE];
6903
- if (tokensMatch(token, config.authToken)) {
6904
- if (res && !tokensMatch(cookies[AUTH_COOKIE], config.authToken)) {
6905
- appendSetCookie(res, `${AUTH_COOKIE}=${encodeURIComponent(config.authToken)}; Path=${COOKIE_PATH}; Max-Age=2592000; SameSite=Lax; HttpOnly${secureAttr}`);
6906
- }
6907
- return next();
6908
- }
6909
- if (res && cookies[AUTH_COOKIE]) {
6910
- appendSetCookie(res, `${AUTH_COOKIE}=; Path=${COOKIE_PATH}; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax; HttpOnly${secureAttr}`);
6911
- }
6912
- res.status(401).json({ error: 'Unauthorized' });
7279
+ return relayAuth.auth(req, res, next);
6913
7280
  }
6914
7281
 
6915
7282
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -7067,8 +7434,15 @@ function touchCli() {
7067
7434
  }
7068
7435
  sessionWorkerWebSocketService.start();
7069
7436
  void primePendingSessionWorkers('startup');
7437
+ // Undelivered events get their timers back; stalled, all_idle and the
7438
+ // heartbeat are swept from here on.
7439
+ relayEventService.start();
7440
+ relayEventLinkService.start();
7070
7441
  runtimeTimers.pendingWorkerPrime = setInterval(() => {
7071
7442
  void primePendingSessionWorkers('maintenance');
7443
+ detachedWorkerWatch.sweep().catch((error) => {
7444
+ console.warn(`${runtimeLogPrefix()}WORKER DETACHED sweep failed: ${error?.message || error}`);
7445
+ });
7072
7446
  }, 5_000);
7073
7447
  if (typeof runtimeTimers.pendingWorkerPrime.unref === 'function') runtimeTimers.pendingWorkerPrime.unref();
7074
7448
 
@@ -7109,14 +7483,13 @@ function sanitizeRelayQuestionContext(rawContext) {
7109
7483
 
7110
7484
 
7111
7485
  // ─── Socket.io Auth ───────────────────────────────────────────────────────────
7112
- function socketAuthToken(socket) {
7113
- const cookies = parseCookies(socket.request.headers.cookie);
7114
- return socket.handshake.auth?.token || socket.handshake.query?.token || cookies[AUTH_COOKIE];
7115
- }
7116
- io.use((socket, next) => {
7117
- if (tokensMatch(socketAuthToken(socket), config.authToken)) return next();
7118
- next(new Error('Unauthorized'));
7486
+ // A sub-agent's record goes live only to the pages whose viewer shows it.
7487
+ const subagentViewerRooms = createSubagentViewerRooms({
7488
+ getRun: (runId) => stmts.getSubagentRun?.get?.(runId) || null,
7119
7489
  });
7490
+ // The handshake presents the token as `auth.token` or the cookie; a query
7491
+ // `token` is not read (see services/relay-auth.mjs).
7492
+ io.use(relayAuth.socketMiddleware);
7120
7493
 
7121
7494
  io.engine.on('initial_headers', (headers, req) => {
7122
7495
  const sessionId = ensureSessionId(req);
@@ -7128,7 +7501,7 @@ io.on('connection', (socket) => {
7128
7501
  // socket bypasses the io.use() token gate above. Re-validate here and drop it
7129
7502
  // on mismatch so a recovered session still requires the auth token (and a
7130
7503
  // rotated token takes effect immediately).
7131
- if (socket.recovered && !tokensMatch(socketAuthToken(socket), config.authToken)) {
7504
+ if (socket.recovered && !relayAuth.socketTokenValid(socket)) {
7132
7505
  socket.disconnect(true);
7133
7506
  return;
7134
7507
  }
@@ -7150,6 +7523,7 @@ io.on('connection', (socket) => {
7150
7523
  // deviceVisible flag restored by connectionStateRecovery.
7151
7524
  activeDeviceTracker.registerSocket(socket);
7152
7525
  tmuxInspectorSocketService.registerSocket(socket);
7526
+ subagentViewerRooms.registerSocket(socket);
7153
7527
  });
7154
7528
 
7155
7529
  // ─── SSH Reverse Tunnel ────────────────────────────────────────────────────────
@@ -7198,11 +7572,42 @@ const previewsConfig = normalizePreviewsConfig(config.previews || {}, {
7198
7572
  for (const previewConfigError of previewsConfig.errors) {
7199
7573
  console.warn(`${runtimeLogPrefix()}PREVIEW LANE disabled: ${previewConfigError}`);
7200
7574
  }
7575
+ // Static previews are jailed to the conversation's workspace root; a preview
7576
+ // registered without a conversation falls back to the server-wide root.
7577
+ const resolvePreviewWorkspaceRoot = (conversationId) => {
7578
+ if (String(conversationId || '').trim()) {
7579
+ const state = resolveConversationWorkspaceState({ conversationId });
7580
+ const rootPath = String(state?.currentWorkspaceRootPath || '').trim();
7581
+ if (rootPath) return rootPath;
7582
+ }
7583
+ return currentWorkspaceRootPath();
7584
+ };
7585
+ // Persisted in the previews table and restored here, each row checked again
7586
+ // with the registration rules (see createPreviewRegistry).
7201
7587
  const previewRegistry = createPreviewRegistry({
7202
7588
  config: previewsConfig,
7203
7589
  onChange: ({ previews }) => {
7204
7590
  try { io.emit('previews', { previews }); } catch {}
7205
7591
  },
7592
+ store: createSqlitePreviewStore(db, { logger: console }),
7593
+ hostBootId: readHostBootId(),
7594
+ isSameHostBoot,
7595
+ checkConversation: (conversationId) => {
7596
+ const row = stmts.getConvAnyStatus.get(conversationId);
7597
+ return !!row && row.status !== 'deleted';
7598
+ },
7599
+ // At boot no worker has reported, so the configured root comes first while
7600
+ // the preview may have been registered under the worker's runtime root:
7601
+ // either of the conversation's roots keeps it.
7602
+ checkStaticRoot: (rootDir, conversationId) => {
7603
+ const state = String(conversationId || '').trim()
7604
+ ? resolveConversationWorkspaceState({ conversationId }) : null;
7605
+ return validateStaticRootInAny(rootDir, [
7606
+ resolvePreviewWorkspaceRoot(conversationId),
7607
+ state?.runtimeWorkspaceRootPath,
7608
+ state?.configuredWorkspaceRootPath,
7609
+ ]);
7610
+ },
7206
7611
  });
7207
7612
  const previewProxyServer = createPreviewProxyServer({
7208
7613
  registry: previewRegistry,
@@ -7226,16 +7631,7 @@ registerPreviewRoutes(app, {
7226
7631
  auth,
7227
7632
  previewRegistry,
7228
7633
  previewHealthProbe,
7229
- // Static previews are jailed to the conversation's workspace root; a preview
7230
- // registered without a conversation falls back to the server-wide root.
7231
- resolvePreviewWorkspaceRoot: (conversationId) => {
7232
- if (String(conversationId || '').trim()) {
7233
- const state = resolveConversationWorkspaceState({ conversationId });
7234
- const rootPath = String(state?.currentWorkspaceRootPath || '').trim();
7235
- if (rootPath) return rootPath;
7236
- }
7237
- return currentWorkspaceRootPath();
7238
- },
7634
+ resolvePreviewWorkspaceRoot,
7239
7635
  // A preview published while a turn is processing gets pinned to that turn's
7240
7636
  // transcript (snapshot, keyed on the queue id and linked to the response at
7241
7637
  // finalize — the relay_activity two-step). No in-flight turn → no card.
@@ -7259,6 +7655,7 @@ function clearRuntimeTimers() {
7259
7655
  if (!timer) continue;
7260
7656
  try { clearInterval(timer); } catch {}
7261
7657
  }
7658
+ subagentRecordRetention?.stop();
7262
7659
  runtimeTimers.cliStatus = null;
7263
7660
  runtimeTimers.ownerWatchdog = null;
7264
7661
  runtimeTimers.staleRecovery = null;
@@ -7306,15 +7703,17 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
7306
7703
  console.warn(`${runtimeLogPrefix()}Grok auth service shutdown failed: ${error?.message || error}`);
7307
7704
  }
7308
7705
  sessionWorkerWebSocketService.stop();
7706
+ try { relayEventService.stop(); } catch {}
7707
+ try { relayEventLinkService.stop(); } catch {}
7309
7708
  tmuxInspectorSocketService.stop();
7310
7709
  stopWorkspaceFileWatcher();
7311
7710
  sshTunnelManager.stop();
7312
7711
  cloudflaredTunnelManager.stop();
7313
7712
  previewHealthProbe.stop();
7314
7713
  try { remoteRelayRegistry.stopHealthLoop(); } catch {}
7315
- // Previews are in-memory by design: a restart is the only implicit cleanup,
7316
- // so every public link dies with the process.
7317
- previewRegistry.clear();
7714
+ // Previews persist across the restart (the next runtime restores them); only
7715
+ // the throttled last-use times still need writing.
7716
+ previewRegistry.flushHits();
7318
7717
  void previewProxyServer.stop().catch(() => {});
7319
7718
  try { relaySingletonGuard.release(); } catch (error) {
7320
7719
  console.warn(`${runtimeLogPrefix()}Failed to release singleton lock: ${error?.message || error}`);