@oar-sh/oar 0.9.19 → 0.9.21

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 (190) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +529 -0
  4. package/README.md +14 -14
  5. package/bin/oar.js +392 -98
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
  10. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  11. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  12. package/server/claude-worker/claude-session-process.mjs +10 -0
  13. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  14. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  15. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  16. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  17. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  18. package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
  19. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  20. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  21. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  22. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  23. package/server/db-schema.mjs +18 -0
  24. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  25. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  26. package/server/grok-worker/grok-session-worker.mjs +12 -1
  27. package/server/grok-worker/grok-turn-runner.mjs +47 -6
  28. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  29. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  30. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  31. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  32. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  33. package/server/migrations/0005-remote-relays.mjs +5 -7
  34. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  35. package/server/migrations/0007-claude-cloud.mjs +5 -7
  36. package/server/migrations/0008-message-pins.mjs +5 -7
  37. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  38. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  39. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  40. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  41. package/server/migrations/0013-previews.mjs +5 -7
  42. package/server/migrations/0014-conversation-groups.mjs +5 -7
  43. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  44. package/server/migrations/0016-share-access-log.mjs +5 -7
  45. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  46. package/server/migrations/0018-background-task-rows.mjs +5 -7
  47. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  48. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  49. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  50. package/server/migrations/0022-relay-schedules.mjs +5 -7
  51. package/server/migrations/0023-relay-asks.mjs +5 -7
  52. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  53. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  54. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  55. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  56. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  57. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  58. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  59. package/server/migrations/0031-queue-provider.mjs +5 -7
  60. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  61. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  62. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  63. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  64. package/server/migrations/0036-token-usage.mjs +267 -0
  65. package/server/public/app/about-ui.js +1 -1
  66. package/server/public/app/about-view.mjs +106 -3
  67. package/server/public/app/action-confirmations.js +1 -1
  68. package/server/public/app/api-client.js +6 -3
  69. package/server/public/app/ask-user-view.js +20 -6
  70. package/server/public/app/attachments-view.js +1 -1
  71. package/server/public/app/back-guard.mjs +31 -5
  72. package/server/public/app/bootstrap.js +611 -70
  73. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  74. package/server/public/app/chat-header-meta.mjs +27 -0
  75. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  76. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  77. package/server/public/app/composer-control-state.mjs +12 -2
  78. package/server/public/app/composer-settings-label.mjs +90 -0
  79. package/server/public/app/composer-settings-ui.js +442 -0
  80. package/server/public/app/context-menu.mjs +36 -5
  81. package/server/public/app/context-usage-view.mjs +16 -0
  82. package/server/public/app/conversation-deep-link.mjs +15 -11
  83. package/server/public/app/conversation-group-drag.mjs +12 -2
  84. package/server/public/app/conversation-group-order.mjs +9 -2
  85. package/server/public/app/conversation-group-state.mjs +47 -10
  86. package/server/public/app/conversation-origin-ui.js +17 -1
  87. package/server/public/app/conversation-view.js +20 -2
  88. package/server/public/app/cwd-menu-placement.mjs +18 -6
  89. package/server/public/app/cwd-picker.js +36 -6
  90. package/server/public/app/emoji-view.js +1 -1
  91. package/server/public/app/font-scaling.js +26 -0
  92. package/server/public/app/header-priority-actions.mjs +234 -0
  93. package/server/public/app/hidden-messages-view.mjs +4 -0
  94. package/server/public/app/inbox-ui.js +50 -2
  95. package/server/public/app/inbox-view.mjs +141 -5
  96. package/server/public/app/journal-view.js +311 -58
  97. package/server/public/app/phone-landscape-ui.js +225 -0
  98. package/server/public/app/phone-landscape.mjs +99 -0
  99. package/server/public/app/phone-typing-state.mjs +104 -0
  100. package/server/public/app/pinned-messages-view.mjs +5 -0
  101. package/server/public/app/plan-board-shared.mjs +49 -0
  102. package/server/public/app/plan-usage-view.mjs +1 -1
  103. package/server/public/app/push-settings.js +2 -2
  104. package/server/public/app/pwa-install.js +24 -3
  105. package/server/public/app/relay-board-inline.mjs +4 -1
  106. package/server/public/app/relay-board-view.js +38 -10
  107. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  108. package/server/public/app/relay-event-view.mjs +42 -0
  109. package/server/public/app/relay-restart-ui.js +2 -2
  110. package/server/public/app/relay-schedule-view.mjs +32 -6
  111. package/server/public/app/relay-schedules-ui.js +69 -20
  112. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  113. package/server/public/app/session-history-ui.js +250 -0
  114. package/server/public/app/session-history.mjs +599 -0
  115. package/server/public/app/settings-modal.js +61 -6
  116. package/server/public/app/settings-tabs.js +12 -0
  117. package/server/public/app/share-state.js +1 -1
  118. package/server/public/app/shared-conversations-settings.js +2 -2
  119. package/server/public/app/socket-handlers.js +15 -4
  120. package/server/public/app/status-view.mjs +18 -2
  121. package/server/public/app/store.js +87 -16
  122. package/server/public/app/subagent-viewer.mjs +27 -2
  123. package/server/public/app/tab-strip-fade.mjs +101 -0
  124. package/server/public/app/tap-actions.js +66 -0
  125. package/server/public/app/usage-limit-ui.js +63 -2
  126. package/server/public/app/viewer-history.mjs +13 -2
  127. package/server/public/index.html +2258 -1585
  128. package/server/public/sw.js +8 -0
  129. package/server/relay-heap-guard.mjs +14 -1
  130. package/server/relay-self-restart.mjs +83 -2
  131. package/server/relay-tools.md +5 -3
  132. package/server/repositories/message-repository.mjs +54 -1
  133. package/server/repositories/remote-relay-repository.mjs +10 -0
  134. package/server/repositories/session-repository.mjs +25 -1
  135. package/server/routes/claude-cloud-routes.mjs +19 -0
  136. package/server/routes/messages-routes.mjs +144 -1
  137. package/server/routes/relay-ask-routes.mjs +138 -8
  138. package/server/routes/relay-board-routes.mjs +55 -40
  139. package/server/routes/sessions-routes.mjs +144 -14
  140. package/server/server-runtime.mjs +322 -51
  141. package/server/services/about-service.mjs +81 -53
  142. package/server/services/cache-rebuild-service.mjs +7 -0
  143. package/server/services/claude-billing-swap-service.mjs +7 -20
  144. package/server/services/claude-cloud-session-service.mjs +47 -5
  145. package/server/services/conversation-preferences-service.mjs +31 -0
  146. package/server/services/database-file.mjs +413 -0
  147. package/server/services/db-backup-service.mjs +809 -0
  148. package/server/services/deepseek-usage-import.mjs +6 -9
  149. package/server/services/delete-archive-service.mjs +3 -0
  150. package/server/services/earlier-database-service.mjs +69 -126
  151. package/server/services/oar-cli-helpers.mjs +214 -12
  152. package/server/services/oar-cli-menu.mjs +1 -0
  153. package/server/services/oar-state-migration-service.mjs +33 -13
  154. package/server/services/package-data-rescue.mjs +4 -2
  155. package/server/services/plan-usage-claude-api.mjs +14 -15
  156. package/server/services/plan-usage-contract.mjs +6 -2
  157. package/server/services/plan-usage-deepseek.mjs +1 -1
  158. package/server/services/plan-usage-tokens.mjs +248 -0
  159. package/server/services/push-dispatch-service.mjs +134 -9
  160. package/server/services/relay-cli-launcher-service.mjs +3 -0
  161. package/server/services/relay-event-service.mjs +30 -0
  162. package/server/services/relay-question-context.mjs +11 -0
  163. package/server/services/relay-restart-picture.mjs +67 -22
  164. package/server/services/remote-inbox-watch-service.mjs +204 -0
  165. package/server/services/remote-relay-caller-context.mjs +80 -7
  166. package/server/services/remote-relay-dispatcher.mjs +101 -19
  167. package/server/services/restart-resume-service.mjs +5 -2
  168. package/server/services/session-activity-tracker.mjs +1 -1
  169. package/server/services/session-launch-mutex-service.mjs +23 -0
  170. package/server/services/session-worker-launch-service.mjs +12 -3
  171. package/server/services/session-worker-registry-service.mjs +36 -0
  172. package/server/services/session-worker-stop-service.mjs +27 -6
  173. package/server/services/session-worker-websocket-service.mjs +40 -16
  174. package/server/services/token-usage-service.mjs +430 -0
  175. package/server/services/update-install-service.mjs +13 -0
  176. package/server/services/usage-limit-pause-service.mjs +8 -1
  177. package/server/services/worker-recycle-service.mjs +772 -0
  178. package/server/services/worker-replacement.mjs +197 -0
  179. package/server/services/worker-scope.mjs +77 -0
  180. package/shared/build-id.mjs +128 -0
  181. package/shared/plan-board-actions.mjs +130 -0
  182. package/shared/plan-only.mjs +43 -0
  183. package/shared/relay-asks.mjs +3 -0
  184. package/shared/relay-events.mjs +38 -4
  185. package/shared/remote-relay-contract.mjs +66 -11
  186. package/shared/turn-token-usage.mjs +144 -0
  187. package/shared/worker-runtime/heartbeat.mjs +5 -0
  188. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  189. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  190. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -28,6 +28,8 @@ import { createDetachedWorkerWatch } from './services/detached-worker-watch-serv
28
28
  import { applyCdnNoStoreHeaders } from './services/file-serving.mjs';
29
29
  import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
30
30
  import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
31
+ import { PLAN_BOARD_APPROVE_ONLY, planBoardShownActions } from '../shared/plan-board-actions.mjs';
32
+ import { carryPlanOnlyToCompacted, readPlanOnly } from './services/conversation-preferences-service.mjs';
31
33
  import { sanitizeRelayQuestionContext as sanitizeRelayQuestionContextWith } from './services/relay-question-context.mjs';
32
34
  import { isRealPathWithinRoot } from './services/workspace-symlink-guard.mjs';
33
35
  import { applySchema } from './db-schema.mjs';
@@ -73,7 +75,11 @@ import { createDeepSeekUsageImportService } from './services/deepseek-usage-impo
73
75
  import { createClaudeApiSettingsService } from './services/claude-api-settings-service.mjs';
74
76
  import { createClaudeBillingSwitchService, normalizeClaudeBilling } from './services/claude-billing-switch-service.mjs';
75
77
  import { createClaudeBillingSwapService } from './services/claude-billing-swap-service.mjs';
78
+ import { createWorkerReplacement, createWorkerReplacementFence } from './services/worker-replacement.mjs';
79
+ import { sessionLaunchMutex } from './services/session-launch-mutex-service.mjs';
80
+ import { createWorkerRecycleService, sweepArchivedWorkers } from './services/worker-recycle-service.mjs';
76
81
  import { createClaudeApiUsageService } from './services/claude-api-usage-service.mjs';
82
+ import { createTokenUsageService } from './services/token-usage-service.mjs';
77
83
  import { createClaudeApiCapWatch, createClaudeApiPlanUsage, createClaudeApiStopNoteLedger } from './services/plan-usage-claude-api.mjs';
78
84
  import { DEEPSEEK_DEFAULT_AUTO_COMPACT_WINDOW, DEFAULT_DEEPSEEK_MODEL, deepseekModelLabel } from '../shared/deepseek-models.mjs';
79
85
  import { createClaudeCloudSessionService } from './services/claude-cloud-session-service.mjs';
@@ -101,6 +107,7 @@ import { createRelayEventService } from './services/relay-event-service.mjs';
101
107
  import { createRelayScheduleService } from './services/relay-schedule-service.mjs';
102
108
  import { createRelayAskService } from './services/relay-ask-service.mjs';
103
109
  import { createRelayAskLinkService } from './services/relay-ask-link-service.mjs';
110
+ import { createRemoteInboxWatchService } from './services/remote-inbox-watch-service.mjs';
104
111
  import { createRelayEventLinkService } from './services/relay-event-link-service.mjs';
105
112
  import { buildClaudeAttributionSettings, normalizeClaudeAttributionMode, resolveClaudeAttributionMode, DEFAULT_CLAUDE_ATTRIBUTION_MODE } from '../shared/claude-attribution.mjs';
106
113
  import {
@@ -115,7 +122,8 @@ import { createActiveDeviceTracker } from './services/push-active-device-service
115
122
  import { registerPushRoutes } from './routes/push-routes.mjs';
116
123
  import { registerAboutRoutes } from './routes/about-routes.mjs';
117
124
  import { registerRelayRestartRoutes } from './routes/relay-restart-routes.mjs';
118
- import { createAboutService, resolveBuildCommit } from './services/about-service.mjs';
125
+ import { createAboutService } from './services/about-service.mjs';
126
+ import { resolveBuildId } from '../shared/build-id.mjs';
119
127
  import { createImageOperationService } from './services/image-operation-service.mjs';
120
128
  import {
121
129
  normalizeDriveAbsolutePath as _normalizeDriveAbsolutePath,
@@ -142,7 +150,10 @@ import { createCursorSessionRootResolver } from './services/cursor-session-root-
142
150
  import { createGitChangesService } from './services/git-changes-service.mjs';
143
151
  import { createRelaySingletonGuard } from './services/relay-singleton-guard.mjs';
144
152
  import { startRelayMemoryLog } from './relay-heap-guard.mjs';
145
- import { adoptEarlierDatabase, lostPackageDatabaseWarning, packageDatabaseEvidence } from './services/earlier-database-service.mjs';
153
+ import { TAKEOVER_PROGRESS_FILE, adoptEarlierDatabase, lostPackageDatabaseWarning, packageDatabaseEvidence } from './services/earlier-database-service.mjs';
154
+ import { checkDatabaseFile, resolveDatabaseFile, settleDatabaseFile } from './services/database-file.mjs';
155
+ import { describeBackups, describeNextBackup, maybeBackupBeforeStart, writeDbLastRun } from './services/db-backup-service.mjs';
156
+ import { resolveStateLayout } from './services/oar-state-layout.mjs';
146
157
  import { ensureDataDirs, resolveDataDir, resolveUploadDir, resolveUploadReadDirs, uploadPathInDirs } from './services/data-dir-resolution.mjs';
147
158
  import { mergeStrayMigratedUploads } from './services/oar-state-migration-service.mjs';
148
159
  import { createRelayRestartOrchestrator } from './services/relay-restart-orchestrator-service.mjs';
@@ -219,7 +230,8 @@ import {
219
230
  resolveRelayRestartMaxWaitOverrideMs,
220
231
  RELAY_RESTART_MAX_WAIT_SETTING_KEY,
221
232
  } from './services/relay-restart-queue.mjs';
222
- import { createRestartPictureCollector } from './services/relay-restart-picture.mjs';
233
+ import { createRestartPictureCollector, detectRelayHosting } from './services/relay-restart-picture.mjs';
234
+ import { createWorkerScope } from './services/worker-scope.mjs';
223
235
  import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
224
236
  import { createStaleTurnRecovery } from './services/stale-turn-recovery.mjs';
225
237
  import {
@@ -290,7 +302,12 @@ const INSTALL_METHOD = fs.existsSync(path.join(PACKAGE_ROOT, '.git')) ? 'git-che
290
302
  // When this relay process started, and the commit its code came from (read
291
303
  // once: a checkout moved on later still runs the code it started with).
292
304
  const RELAY_STARTED_AT = new Date(Date.now() - process.uptime() * 1000).toISOString();
293
- const BUILD_COMMIT = resolveBuildCommit(PACKAGE_ROOT);
305
+ // The build id (version plus commit) names that code for the session workers:
306
+ // a worker that states another one started on older code. Read in the same
307
+ // pass as the commit so the two cannot disagree.
308
+ const RELAY_BUILD = resolveBuildId(PACKAGE_ROOT);
309
+ const BUILD_COMMIT = RELAY_BUILD.commit;
310
+ const BUILD_ID = RELAY_BUILD.id;
294
311
  const PUBLIC_DIR = path.join(__dirname, 'public');
295
312
  const INDEX_HTML_PATH = path.join(PUBLIC_DIR, 'index.html');
296
313
  const PWA_MANIFEST_PATH = path.join(PUBLIC_DIR, 'manifest.webmanifest');
@@ -320,7 +337,9 @@ const CONFIG_PATH = process.env.COPILOT_WEB_RELAY_CONFIG
320
337
  // for why the config can carry it (a dev checkout pointing at a release
321
338
  // install's data).
322
339
  const DATA_DIR = resolveDataDir({ env: process.env, configPath: CONFIG_PATH, serverDir: __dirname });
323
- const DB_PATH = path.join(DATA_DIR, 'copilot.db');
340
+ // DB_PATH (oar.db, or the name of installs from before 0.9.20) is
341
+ // resolved under the lock, once an earlier database may have been brought
342
+ // over: see resolveDatabaseFile below.
324
343
  const RELAY_LOCK_PATH = path.join(DATA_DIR, 'relay-server.lock');
325
344
  // Uploads follow the data dir whenever the env var or config.json chose it.
326
345
  const UPLOAD_DIR = resolveUploadDir({ dataDir: DATA_DIR, env: process.env, serverDir: __dirname });
@@ -1441,7 +1460,16 @@ async function refreshGrokProviderModels() {
1441
1460
  }
1442
1461
  }
1443
1462
 
1444
- const DEFAULT_CONFIG = { authToken: '', port: 3333, pollIntervalMs: 3000, conversationSessionMode: 'isolated', localhostOnly: true };
1463
+ const DEFAULT_CONFIG = {
1464
+ authToken: '',
1465
+ port: 3333,
1466
+ pollIntervalMs: 3000,
1467
+ conversationSessionMode: 'isolated',
1468
+ localhostOnly: true,
1469
+ // A copy of the database at the first start of each new version, the last
1470
+ // `keep` kept (db-backup-service.mjs); `dir` (absolute) moves the folder.
1471
+ dbBackups: { enabled: true, keep: 2 },
1472
+ };
1445
1473
  // How long a turn may go without any sign of life from its worker before the
1446
1474
  // relay assumes the worker died. This is an inactivity window, not a cap on turn
1447
1475
  // length — see recoverStaleMessages.
@@ -1868,8 +1896,28 @@ try {
1868
1896
  console.error(`[server] ${error?.message || String(error)}`);
1869
1897
  process.exit(error?.code === 'ERELAYLOCKED' ? RELAY_STOP_EXIT_CODE : 1);
1870
1898
  }
1871
- // Before the database opens, under the lock (adoptEarlierDatabase); the
1872
- // result also feeds lostPackageDatabaseWarning.
1899
+ // Before the database opens, under the lock: both names there are settled
1900
+ // first (the earlier name's database, not newer, is set aside, else the
1901
+ // relay stops), then adoptEarlierDatabase, whose result also feeds
1902
+ // lostPackageDatabaseWarning.
1903
+ {
1904
+ let settled;
1905
+ try {
1906
+ settled = settleDatabaseFile({
1907
+ dataDir: DATA_DIR,
1908
+ Database,
1909
+ progressFile: path.join(DATA_DIR, TAKEOVER_PROGRESS_FILE),
1910
+ logger: console,
1911
+ });
1912
+ } catch (error) {
1913
+ settled = { status: 'refused', error: `[data] Not starting: the database files in ${DATA_DIR} could not be looked at (${error?.message || error}).` };
1914
+ }
1915
+ if (settled.status === 'refused') {
1916
+ console.error(settled.error);
1917
+ try { relaySingletonGuard.release(); } catch {}
1918
+ process.exit(RELAY_STOP_EXIT_CODE);
1919
+ }
1920
+ }
1873
1921
  let earlierDatabase;
1874
1922
  {
1875
1923
  try {
@@ -1892,6 +1940,21 @@ let earlierDatabase;
1892
1940
  process.exit(RELAY_STOP_EXIT_CODE);
1893
1941
  }
1894
1942
  }
1943
+ // Resolved after the adoption, which may have placed the database: `created`
1944
+ // only when neither name is there now.
1945
+ const databaseFile = resolveDatabaseFile(DATA_DIR);
1946
+ const DB_PATH = databaseFile.path;
1947
+ {
1948
+ // A dangling link, or an oar.db-wal/-shm without oar.db (a later rename to
1949
+ // oar.db would take its pages in), stops the start; other strays are said.
1950
+ const check = checkDatabaseFile(databaseFile);
1951
+ for (const warning of check.warnings) console.warn(warning);
1952
+ if (check.refusal) {
1953
+ console.error(check.refusal);
1954
+ try { relaySingletonGuard.release(); } catch {}
1955
+ process.exit(RELAY_STOP_EXIT_CODE);
1956
+ }
1957
+ }
1895
1958
 
1896
1959
  const MAX_REQUEUE_RETRIES = Number.isFinite(Number(config.maxRequeueRetries))
1897
1960
  ? Math.max(1, Number(config.maxRequeueRetries))
@@ -1952,6 +2015,8 @@ const runtimeTimers = {
1952
2015
  claudeApiCapClock: null,
1953
2016
  memoryLog: null,
1954
2017
  memoryWatch: null,
2018
+ // Once, 30 s after the relay listens: archived sessions' workers stopped.
2019
+ archivedWorkerSweep: null,
1955
2020
  };
1956
2021
 
1957
2022
  function normalizeRemotePath(value) {
@@ -2737,7 +2802,7 @@ const configuredConversationSessionMode =
2737
2802
  updateModelCatalog({ models: [DEFAULT_MODEL], currentModel: DEFAULT_MODEL, defaultModel: DEFAULT_MODEL, source: 'bootstrap' });
2738
2803
 
2739
2804
  // ─── SQLite Setup ─────────────────────────────────────────────────────────────
2740
- const databaseCreatedAtStart = !fs.existsSync(DB_PATH);
2805
+ const databaseCreatedAtStart = databaseFile.created;
2741
2806
  const db = new Database(DB_PATH);
2742
2807
  if (process.platform !== 'win32') {
2743
2808
  fs.chmodSync(DB_PATH, 0o600);
@@ -2750,11 +2815,39 @@ if (process.platform !== 'win32') {
2750
2815
  }
2751
2816
  }
2752
2817
 
2818
+ // Where the state root keeps backups (npm), or a checkout (server/backups).
2819
+ const STATE_LAYOUT = resolveStateLayout({ packageRoot: REPO_ROOT });
2820
+ // A copy of the database before this version's migrations, when another
2821
+ // version (on a checkout: another commit) ran on it last; before applySchema
2822
+ // and before reconcileUpdateAttempt, which removes the in-app update's
2823
+ // record that names the version before. Never stops the start.
2824
+ let dbBackupAtStart = null;
2825
+ try {
2826
+ dbBackupAtStart = await maybeBackupBeforeStart({
2827
+ db,
2828
+ Database,
2829
+ dbPath: DB_PATH,
2830
+ dataDir: DATA_DIR,
2831
+ databaseCreated: databaseCreatedAtStart,
2832
+ layout: STATE_LAYOUT,
2833
+ serverDir: __dirname,
2834
+ config,
2835
+ runningVersion: RUNNING_VERSION,
2836
+ runningCommit: BUILD_COMMIT,
2837
+ logger: console,
2838
+ });
2839
+ } catch (error) {
2840
+ console.warn(`[backup] failed: ${error?.message || error}; this start goes on without a backup of the version before.`);
2841
+ }
2842
+
2753
2843
  // Base DDL plus the column-backfill/upgrade migrations, extracted verbatim to
2754
2844
  // db-schema.mjs so tests can build the exact production schema. Runs the
2755
2845
  // CREATE TABLE IF NOT EXISTS block, the FTS setup, and every ALTER TABLE
2756
2846
  // upgrade in the original order.
2757
2847
  applySchema(db);
2848
+ // A database that had no app_settings table before applySchema (a new one):
2849
+ // the version that runs on it now.
2850
+ if (dbBackupAtStart?.deferredLastRun) writeDbLastRun(db, dbBackupAtStart.deferredLastRun);
2758
2851
 
2759
2852
  // ─── Prepared Statements ──────────────────────────────────────────────────────
2760
2853
  // Owns its own schema (plan-usage tables) and the derived Cursor spend
@@ -3245,11 +3338,16 @@ function buildSessionWorkerLaunchEnvForSession(targetSessionId) {
3245
3338
  if (!copilotSdkEngine) return openAiEnv;
3246
3339
  return applyCopilotSdkProviderEnvironment(openAiEnv, { enabled: true, model });
3247
3340
  }
3341
+ // Under a systemd user service, session workers start in scopes of their
3342
+ // own (worker-scope.mjs); asked once, at the first worker start.
3343
+ const workerScope = createWorkerScope({ hosting: detectRelayHosting(), log: (message) => console.log(`${runtimeLogPrefix()}${message}`) });
3248
3344
  const relayCliLauncherService = createRelayCliLauncherService({
3249
3345
  cwd: (targetSessionId) => resolveLaunchWorkspaceRootForSession(targetSessionId),
3250
3346
  env: sessionWorkerLaunchEnv,
3251
3347
  log: (message) => console.log(`${runtimeLogPrefix()}${message}`),
3348
+ workerScope,
3252
3349
  });
3350
+
3253
3351
  async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true } = {}) {
3254
3352
  const normalizedTargetSessionId = String(targetSessionId || '').trim();
3255
3353
  if (!normalizedTargetSessionId) {
@@ -3281,6 +3379,7 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
3281
3379
  const resolvedWorkspaceRoot = resolveLaunchWorkspaceRootForSession(normalizedTargetSessionId);
3282
3380
  const workerLaunchEnv = buildSessionWorkerLaunchEnvForSession(normalizedTargetSessionId);
3283
3381
  const launched = await launchSessionCli({
3382
+ workerScope,
3284
3383
  targetSessionId: normalizedTargetSessionId,
3285
3384
  cwd: resolvedWorkspaceRoot,
3286
3385
  env: workerLaunchEnv,
@@ -3359,6 +3458,7 @@ const updateCheckService = updateCheckKilled ? null : createUpdateCheckService({
3359
3458
  });
3360
3459
  const updateInstallService = createUpdateInstallService({
3361
3460
  runningVersion: RUNNING_VERSION,
3461
+ describeNextBackup: () => describeNextBackup({ layout: STATE_LAYOUT, serverDir: __dirname, dataDir: DATA_DIR, dbPath: DB_PATH, config }),
3362
3462
  installMethod: INSTALL_METHOD,
3363
3463
  packageRoot: PACKAGE_ROOT,
3364
3464
  readSetting: readAppSettingValue,
@@ -3676,6 +3776,7 @@ function collectRestartPicture({ action = 'restart' } = {}) {
3676
3776
  restartPictureCollector = createRestartPictureCollector({
3677
3777
  db,
3678
3778
  backgroundWorkView,
3779
+ workerScopes: () => workerScope.inUse(),
3679
3780
  listHeldTasks: () => backgroundTaskStore.listRestored().map(({ key, task }) => ({
3680
3781
  conversationId: resolveConversationRecord({ conversationId: key, sdkSessionId: key })?.id || key,
3681
3782
  description: task?.description || task?.taskType || '',
@@ -4055,19 +4156,28 @@ function parseBoardContext(rawContext) {
4055
4156
 
4056
4157
  function formatRelayBoardRow(row) {
4057
4158
  if (!row) return null;
4159
+ const boardType = String(row.board_type || '').trim().toLowerCase() || 'generic';
4160
+ const status = String(row.status || 'pending').trim().toLowerCase() || 'pending';
4161
+ const planOnly = readPlanOnly(stmts.getConvAnyStatus.get(row.conversation_id));
4058
4162
  return {
4059
4163
  id: row.id,
4060
4164
  queueId: row.queue_id,
4061
4165
  conversationId: row.conversation_id,
4062
4166
  messageId: row.message_id,
4063
- boardType: String(row.board_type || '').trim().toLowerCase() || 'generic',
4167
+ boardType,
4064
4168
  mode: normalizeRelayMode(row.relay_mode) || DEFAULT_RELAY_MODE,
4065
4169
  title: String(row.title || '').trim(),
4066
4170
  body: String(row.body || '').trim(),
4067
- actions: parseBoardActions(row.actions_json),
4068
- recommendedAction: String(row.recommended_action || '').trim() || null,
4171
+ // A pending plan card stored before "Approve plan" existed shows it too.
4172
+ actions: planBoardShownActions({ boardType, status }, parseBoardActions(row.actions_json)),
4173
+ // On a plan-only session a pending plan card recommends "Approve plan".
4174
+ recommendedAction: (planOnly && status === 'pending' && boardType === 'plan_ready')
4175
+ ? PLAN_BOARD_APPROVE_ONLY
4176
+ : (String(row.recommended_action || '').trim() || null),
4177
+ // The UI asks before an action that leaves plan mode on such a session.
4178
+ planOnly,
4069
4179
  context: parseBoardContext(row.context_json),
4070
- status: String(row.status || 'pending').trim().toLowerCase() || 'pending',
4180
+ status,
4071
4181
  selectedAction: String(row.selected_action || '').trim() || null,
4072
4182
  actedAt: row.acted_at || null,
4073
4183
  createdAt: row.created_at,
@@ -5249,6 +5359,8 @@ function createCompactedConversation(sourceConversationId) {
5249
5359
  // The new conversation stays in the old one's session group, and heads
5250
5360
  // it if the old one did; the archived source stays a member.
5251
5361
  conversationGroups.carryToCompacted(sourceConversationId, targetConversationId, now);
5362
+ // A plan-only conversation's lock goes along, with its saved mode.
5363
+ carryPlanOnlyToCompacted({ db, stmts, source, targetConversationId, updatedAt: now });
5252
5364
  // A Claude chat keeps its billing (the subscription or the API key, a
5253
5365
  // pending swap's included): the new chat's first worker launches on it,
5254
5366
  // and it is never guessed again from a Claude model id. Other providers
@@ -5550,6 +5662,9 @@ const usageLimitPauseService = createUsageLimitPauseService({
5550
5662
  // workers' reports (services/claude-api-usage-service.mjs); the Claude API
5551
5663
  // card and its cap read it.
5552
5664
  const claudeApiUsageService = createClaudeApiUsageService({ db, logger: console });
5665
+ // Tokens per turn and per day for every provider, booked from the
5666
+ // workers' reports; the Tokens sections in Check Usage and About read it.
5667
+ const tokenUsageService = createTokenUsageService({ db, logger: console });
5553
5668
  // This month's spend on the key against the cap, and the Check Usage card
5554
5669
  // (services/plan-usage-claude-api.mjs), over the same local days.
5555
5670
  const claudeApiPlanUsage = createClaudeApiPlanUsage({
@@ -6446,6 +6561,23 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6446
6561
  restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
6447
6562
  };
6448
6563
  }
6564
+ // The process asking is being replaced (the worker recycle passed its holds
6565
+ // for it): no work, as for a killed session; the worker stays ready, and
6566
+ // what waits goes to the new one. Unlike the killed marker above, this
6567
+ // survives the marker's grace and every clearRestartSchedule.
6568
+ if (workerReplacementFence.isHeld(requesterSessionId, pid)) {
6569
+ return {
6570
+ message: null,
6571
+ routing: {
6572
+ enabled: true,
6573
+ requesterSessionId,
6574
+ blockedReason: 'worker-replacement',
6575
+ lifecycle: sessionWorkerSupervisor?.getLifecycleState?.(requesterSessionId) || null,
6576
+ fallbackRestart: null,
6577
+ },
6578
+ restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
6579
+ };
6580
+ }
6449
6581
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
6450
6582
  if (runtimeState.relayPaused) {
6451
6583
  return { message: null, paused: true, reason: 'relay_paused' };
@@ -6525,6 +6657,9 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
6525
6657
  workerKind: workerKind || sessionWorkerRegistry?.getWorker?.(requesterSessionId)?.kind || null,
6526
6658
  claudeApiClaimCheck,
6527
6659
  onBillingFence: handleClaudeBillingFence,
6660
+ // The fence check above, asked again before every claim: ensureWorker
6661
+ // and a transient error's backoff both wait.
6662
+ isReplacementFenced: () => workerReplacementFence.isHeld(requesterSessionId, pid),
6528
6663
  });
6529
6664
  if (!dequeueResult?.message) {
6530
6665
  return {
@@ -6838,48 +6973,38 @@ async function sweepDeadWorkerProcessingRowsOnce() {
6838
6973
  // claude-billing-swap-service.mjs): a Claude chat's next row of the other
6839
6974
  // billing is refused to the worker it has, which is replaced, once idle, by
6840
6975
  // one of the row's kind. The old one is stopped like the kill route does, and
6841
- // the new one launched only once every process of the session is gone.
6976
+ // the new one launched only once every process of the session is gone
6977
+ // (services/worker-replacement.mjs, which the worker recycle below shares).
6978
+ // The two get one instance each so a failed stop's warning names the caller.
6979
+ // They share one replacement fence, the one the deliveries check (the socket
6980
+ // delivery above and /api/pending): a process being replaced is handed no
6981
+ // work. The worker recycle raises it; the billing swap does not, since its
6982
+ // own delivery fence already keeps the other billing's rows from the old
6983
+ // worker.
6984
+ const workerReplacementFence = createWorkerReplacementFence();
6985
+ function createRuntimeWorkerReplacement(tag) {
6986
+ return createWorkerReplacement({
6987
+ registry: sessionWorkerRegistry,
6988
+ supervisor: sessionWorkerSupervisor,
6989
+ processInspector: sessionWorkerProcessInspector,
6990
+ stopSessionWorkerProcesses,
6991
+ isProcessAlive,
6992
+ killTmuxSession,
6993
+ logger: console,
6994
+ logPrefix: () => `${runtimeLogPrefix()}${tag} `,
6995
+ fence: workerReplacementFence,
6996
+ });
6997
+ }
6998
+ // A stop that fails backs off (the swap service retries it) and clears the
6999
+ // killed marker, so the session's worker, alive or relaunched, keeps asking
7000
+ // for work meanwhile. The fence still keeps the other billing's rows from it.
7001
+ const billingSwapWorkerReplacement = createRuntimeWorkerReplacement('BILLING SWAP');
6842
7002
  const claudeBillingSwapService = createClaudeBillingSwapService({
6843
7003
  getWorker: (sid) => sessionWorkerRegistry?.getWorker?.(sid) || null,
6844
7004
  // A row the session claimed and has not finished: never stopped under it.
6845
7005
  hasRunningTurn: (sid) => (stmts.listProcessingRowsForOwner?.all?.(sid) || []).length > 0,
6846
- stopWorker: async (sid) => {
6847
- const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
6848
- // Nothing may start the session while it is being stopped: a launch then
6849
- // would adopt the old process, kind and all.
6850
- sessionWorkerSupervisor?.markKilled?.(sid);
6851
- // A stop that fails backs off (the swap service retries it): the killed
6852
- // marker goes with it, so the session's worker, alive or relaunched,
6853
- // keeps asking for work meanwhile. The fence still keeps the other
6854
- // billing's rows from it.
6855
- const backOff = (error, remainingPids = []) => {
6856
- sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
6857
- console.warn(`${runtimeLogPrefix()}BILLING SWAP STOP FAILED session=${sid.slice(0, 8)} ${error}${remainingPids.length ? ` remaining=${remainingPids.join(',')}` : ''} — kill marker cleared, retrying with a backoff`);
6858
- return { ok: false, error, remainingPids };
6859
- };
6860
- let stopped = null;
6861
- try {
6862
- await sessionWorkerSupervisor?.cancelPendingStart?.(sid, { wait: true });
6863
- stopped = await stopSessionWorkerProcesses({
6864
- sdkSessionId: sid,
6865
- worker,
6866
- processInspector: sessionWorkerProcessInspector,
6867
- isPidAliveImpl: isProcessAlive,
6868
- killTmuxSessionImpl: killTmuxSession,
6869
- });
6870
- } catch (error) {
6871
- return backOff(String(error?.message || error || 'worker-stop-failed'));
6872
- }
6873
- if (!stopped.ok) return backOff(stopped.error || 'worker-survived-stop', stopped.remainingPids || []);
6874
- sessionWorkerRegistry?.removeWorker?.(sid);
6875
- sessionWorkerSupervisor?.resetHealth?.(sid, { clearFailureCount: false });
6876
- return { ok: true };
6877
- },
6878
- launchWorker: async (sid) => {
6879
- sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
6880
- const launched = await sessionWorkerSupervisor?.ensureWorker?.(sid, { allowProcessReuse: false });
6881
- return { ok: launched?.ok === true, error: launched?.error || null };
6882
- },
7006
+ stopWorker: billingSwapWorkerReplacement.stopWorker,
7007
+ launchWorker: billingSwapWorkerReplacement.launchWorker,
6883
7008
  onStuck: ({ conversationId, rowBilling, attempts, reason }) => {
6884
7009
  if (!conversationId) return;
6885
7010
  const at = new Date().toISOString();
@@ -6911,6 +7036,95 @@ const claudeBillingSwapService = createClaudeBillingSwapService({
6911
7036
  logger: console,
6912
7037
  });
6913
7038
 
7039
+ // Whether a worker's session is one the relay keeps no worker for: its
7040
+ // conversation is archived (or deleted), or there is no conversation behind
7041
+ // the session at all. A conversation in use found either way (the runtime
7042
+ // session's, or the one naming the session id) keeps its worker: a session
7043
+ // is never stopped for good on a missing link alone.
7044
+ function isWorkerSessionArchived(sid) {
7045
+ const runtime = stmts.getRuntimeSessionBySdkSessionId?.get?.(sid) || null;
7046
+ const viaRuntime = runtime?.conversation_id ? (stmts.getConvAnyStatus?.get?.(runtime.conversation_id) || null) : null;
7047
+ const viaSessionId = stmts.getConvBySdkSessionId?.get?.(sid) || null;
7048
+ const inUse = (conversation) => Boolean(conversation)
7049
+ && Number(conversation.archived || 0) !== 1
7050
+ && String(conversation.status || '').trim().toLowerCase() !== 'deleted';
7051
+ return !inUse(viaRuntime) && !inUse(viaSessionId);
7052
+ }
7053
+
7054
+ // The worker recycle (services/worker-recycle-service.mjs): a worker that
7055
+ // runs older code than this relay (another build id, or none stated) is
7056
+ // replaced between turns by one on this relay's code; an archived session's
7057
+ // is stopped for good. Its own replacement instance, so a failed stop says
7058
+ // WORKER RECYCLE rather than BILLING SWAP.
7059
+ const recycleWorkerReplacement = createRuntimeWorkerReplacement('WORKER RECYCLE');
7060
+ const workerRecycleService = createWorkerRecycleService({
7061
+ relayBuildId: BUILD_ID,
7062
+ listWorkers: () => sessionWorkerRegistry.listWorkers(),
7063
+ getWorker: (sid) => sessionWorkerRegistry.getWorker(sid),
7064
+ // A row the session claimed and has not finished: never stopped under it.
7065
+ hasRunningTurn: (sid) => (stmts.listProcessingRowsForOwner?.all?.(sid) || []).length > 0,
7066
+ // From the supervisor's lifecycle, not its firstObservedHeartbeatAt: that
7067
+ // is set only for a worker it launched itself, and the workers that
7068
+ // reconnect after a relay restart (the ones most likely stale) never get it.
7069
+ hasHeartbeated: (sid) => {
7070
+ const lc = sessionWorkerSupervisor.getLifecycleState(sid);
7071
+ return !!lc?.lastHeartbeatAt && !lc?.awaitingHeartbeat;
7072
+ },
7073
+ isArchived: (sid) => isWorkerSessionArchived(sid),
7074
+ // The session's last delivery or turn end, for a worker that does not say
7075
+ // whether its CLI child runs (message-repository getOwnerLastActivityAt).
7076
+ quietSince: (sid) => {
7077
+ const at = Date.parse(stmts.getOwnerLastActivityAt?.get?.(sid)?.last_activity_at || '');
7078
+ return Number.isFinite(at) ? at : null;
7079
+ },
7080
+ stopWorker: recycleWorkerReplacement.stopWorker,
7081
+ launchWorker: recycleWorkerReplacement.launchWorker,
7082
+ fence: recycleWorkerReplacement.fence,
7083
+ // The stop and the launch under the per-session launch mutex the Change
7084
+ // CWD relaunch and the launch routes take (sessions-routes.mjs).
7085
+ runExclusive: (sid, fn) => sessionLaunchMutex.runExclusive(sid, fn),
7086
+ logger: console,
7087
+ });
7088
+
7089
+ // Once at start, after the restart's own worker reconnects settled: what
7090
+ // still runs for an archived conversation is stopped and stays stopped.
7091
+ // Through the recycle's replacement, one session at a time; never throws.
7092
+ async function sweepArchivedSessionWorkersAtStart() {
7093
+ if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED !== true) return null;
7094
+ if (runtimeShutdownStarted) return null;
7095
+ // The first lookup reads the process list afresh; the rest share it for the
7096
+ // inspector's cache moment, so a long archive costs one read, not one each.
7097
+ let freshRead = true;
7098
+ return sweepArchivedWorkers({
7099
+ listArchivedSessionIds: () => (stmts.listArchivedRuntimeSessionIds?.all?.() || []).map((row) => row?.sdk_session_id),
7100
+ hasLiveProcess: async (sid) => {
7101
+ if (runtimeShutdownStarted) return false;
7102
+ const pid = Number(sessionWorkerRegistry?.getWorker?.(sid)?.pid);
7103
+ if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return true;
7104
+ const fresh = freshRead;
7105
+ freshRead = false;
7106
+ return Boolean(await sessionWorkerProcessInspector?.findProcessForSessionAsync?.(sid, { fresh }));
7107
+ },
7108
+ isArchived: (sid) => isWorkerSessionArchived(sid),
7109
+ // The process the fence keeps deliveries from during the stop: the
7110
+ // registry's live pid, else the one the process list shows, else none
7111
+ // (every process of the session is fenced then).
7112
+ workerPid: async (sid) => {
7113
+ const pid = Number(sessionWorkerRegistry?.getWorker?.(sid)?.pid);
7114
+ if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return pid;
7115
+ const found = await sessionWorkerProcessInspector?.findProcessForSessionAsync?.(sid);
7116
+ return Number(found?.processId) || null;
7117
+ },
7118
+ stopWorker: recycleWorkerReplacement.stopWorker,
7119
+ // Guarded as the recycle's archived stop is: a message that unarchives
7120
+ // the session meanwhile must not start a turn on the process being
7121
+ // stopped.
7122
+ fence: workerReplacementFence,
7123
+ runExclusive: (sid, fn) => sessionLaunchMutex.runExclusive(sid, fn),
7124
+ logger: console,
7125
+ });
7126
+ }
7127
+
6914
7128
  // The Claude API monthly cap (plan decision 12): `{ reached, error? }`. The
6915
7129
  // one seam the claim check below, the billing switch, an agent's send and a
6916
7130
  // new Claude API chat ask: this month's priced estimate in
@@ -7083,6 +7297,16 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
7083
7297
  const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
7084
7298
  if (worker && worker.kind !== kind) sessionWorkerRegistry?.upsertWorker?.({ ...worker, kind });
7085
7299
  },
7300
+ // The build id a worker says it started on: the registry keeps it so a
7301
+ // worker on older code can be told from one on the relay's.
7302
+ onWorkerBuildId: (sessionId, buildId) => {
7303
+ const sid = String(sessionId || '').trim();
7304
+ if (!sid) return;
7305
+ const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
7306
+ if (worker && worker.buildId !== buildId) sessionWorkerRegistry?.upsertWorker?.({ ...worker, buildId });
7307
+ },
7308
+ // Sent in server.hello, so a worker on other code can say so in its log.
7309
+ buildId: BUILD_ID,
7086
7310
  isWorkerProcessAlive: (pid) => isProcessAlive(Number(pid)),
7087
7311
  pathPrefix: remotePath,
7088
7312
  pollIntervalMs: 500,
@@ -7094,6 +7318,10 @@ runtimeTimers.deadWorkerSweep = setInterval(() => {
7094
7318
  });
7095
7319
  }, 30_000);
7096
7320
  if (typeof runtimeTimers.deadWorkerSweep.unref === 'function') runtimeTimers.deadWorkerSweep.unref();
7321
+ // Workers on older code are recycled only where the relay launches them;
7322
+ // with routing off the service never starts. Its timer is its own (unref'd)
7323
+ // and stops with the runtime timers.
7324
+ if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true) workerRecycleService.start();
7097
7325
  const tmuxInspectorAccessPolicy = createTmuxInspectorAccessPolicy({
7098
7326
  sessionWorkerRegistry,
7099
7327
  sessionWorkerSupervisor,
@@ -7253,6 +7481,13 @@ let relayPaused = false;
7253
7481
  const relayBridgeOwnerService = createRelayBridgeOwnerService();
7254
7482
 
7255
7483
  const runtimeState = {
7484
+ // The relay's own build id, read once at start; null when package.json
7485
+ // names no version.
7486
+ buildId: BUILD_ID,
7487
+ // The worker recycle's status() (the relay's build id, the stale count,
7488
+ // the recycles in flight and done, who waits on what): a function, so a
7489
+ // route reads it when asked and needs no wiring of its own.
7490
+ workerRecycle: () => workerRecycleService.status(),
7256
7491
  get cliOnline() { return cliOnline; },
7257
7492
  set cliOnline(value) { cliOnline = value; },
7258
7493
  get relayPaused() { return relayPaused; },
@@ -7362,6 +7597,16 @@ const relayAskLinkService = createRelayAskLinkService({
7362
7597
  client: remoteRelayClient,
7363
7598
  logger: console,
7364
7599
  });
7600
+ // Paired relays' cards a turn waits on: the badge here stays current, and
7601
+ // one relay pushes a card its own relay reached no device for.
7602
+ const remoteInboxWatchService = createRemoteInboxWatchService({
7603
+ registry: remoteRelayRegistry,
7604
+ client: remoteRelayClient,
7605
+ push: pushDispatchService,
7606
+ hasSockets: () => io.of('/').sockets.size > 0,
7607
+ emit: (event) => io.emit(event),
7608
+ logger: console,
7609
+ });
7365
7610
  const remoteRelayPairing = createRemoteRelayPairing({
7366
7611
  registry: remoteRelayRegistry,
7367
7612
  client: remoteRelayClient,
@@ -7579,6 +7824,8 @@ const sharedRouteDeps = {
7579
7824
  sessionWorkerSupervisor,
7580
7825
  sessionWorkerRegistry,
7581
7826
  sessionWorkerProcessInspector,
7827
+ // /api/pending refuses work to a process the worker recycle is replacing.
7828
+ workerReplacementFence,
7582
7829
  buildContextResponseText,
7583
7830
  readContextFromSessionEvents,
7584
7831
  sessionHistoryRefreshService,
@@ -7647,6 +7894,7 @@ const sharedRouteDeps = {
7647
7894
  usageLimitPauseService,
7648
7895
  claudeBillingSwitchService,
7649
7896
  claudeApiUsageService,
7897
+ tokenUsageService,
7650
7898
  claudeApiClaimCheck,
7651
7899
  onClaudeBillingFence: handleClaudeBillingFence,
7652
7900
  claudeApiPlanUsage,
@@ -7719,11 +7967,13 @@ registerRelayAskRoutes(app, {
7719
7967
  db,
7720
7968
  stmts,
7721
7969
  formatQuestionRow,
7970
+ formatRelayBoardRow,
7722
7971
  relayAskService,
7723
7972
  relayAskLinkService,
7724
7973
  remoteRelayInbound,
7725
7974
  remoteRelayRegistry,
7726
7975
  remoteRelayClient,
7976
+ questionNotice: (questionId) => pushDispatchService.questionNotice(questionId),
7727
7977
  });
7728
7978
  registerRelayRestartRoutes(app, {
7729
7979
  auth,
@@ -7742,6 +7992,9 @@ registerAboutRoutes(app, {
7742
7992
  dbPath: DB_PATH,
7743
7993
  version: RUNNING_VERSION,
7744
7994
  commit: BUILD_COMMIT,
7995
+ // The workers' build ids are counted against this one (the About modal's
7996
+ // "Workers: 14, 3 on an older build").
7997
+ buildId: BUILD_ID,
7745
7998
  installMethod: INSTALL_METHOD,
7746
7999
  startedAt: RELAY_STARTED_AT,
7747
8000
  packageRoot: PACKAGE_ROOT,
@@ -7758,6 +8011,7 @@ registerAboutRoutes(app, {
7758
8011
  isRelayCliOnline: () => runtimeState.cliOnline === true,
7759
8012
  listWorkers: () => sessionWorkerRegistry.listWorkers(),
7760
8013
  getUpdateState: () => buildUpdateStatePayload(),
8014
+ describeBackups: () => describeBackups({ layout: STATE_LAYOUT, serverDir: __dirname, dataDir: DATA_DIR, config, sizes: false }),
7761
8015
  }),
7762
8016
  });
7763
8017
  function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
@@ -8109,6 +8363,8 @@ relayEventLinkService.start();
8109
8363
  relayScheduleService.start();
8110
8364
  // Asks on paired relays still open there: look for their answers again.
8111
8365
  relayAskLinkService.start();
8366
+ // Paired relays' Inboxes, every ~20 s.
8367
+ remoteInboxWatchService.start();
8112
8368
  runtimeTimers.pendingWorkerPrime = setInterval(() => {
8113
8369
  void primePendingSessionWorkers('maintenance');
8114
8370
  detachedWorkerWatch.sweep().catch((error) => {
@@ -8374,6 +8630,7 @@ function clearRuntimeTimers() {
8374
8630
  try { clearTimeout(timer); } catch {}
8375
8631
  }
8376
8632
  subagentRecordRetention?.stop();
8633
+ try { workerRecycleService.stop(); } catch {}
8377
8634
  try { taskHealthPoller.stop(); } catch {}
8378
8635
  try { taskOutputRooms.stop(); } catch {}
8379
8636
  runtimeTimers.cliStatus = null;
@@ -8383,6 +8640,7 @@ function clearRuntimeTimers() {
8383
8640
  runtimeTimers.pendingWorkerPrime = null;
8384
8641
  runtimeTimers.sharedViewerPrune = null;
8385
8642
  runtimeTimers.restoredTaskSettle = null;
8643
+ runtimeTimers.archivedWorkerSweep = null;
8386
8644
  try { relayRestartQueue.dispose(); } catch {}
8387
8645
  }
8388
8646
 
@@ -8431,6 +8689,7 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
8431
8689
  try { relayEventLinkService.stop(); } catch {}
8432
8690
  try { relayScheduleService.stop(); } catch {}
8433
8691
  try { relayAskLinkService.stop(); } catch {}
8692
+ try { remoteInboxWatchService.stop(); } catch {}
8434
8693
  tmuxInspectorSocketService.stop();
8435
8694
  stopWorkspaceFileWatcher();
8436
8695
  sshTunnelManager.stop();
@@ -8593,4 +8852,16 @@ httpServer.listen(config.port, listenHost, () => {
8593
8852
  // Deferred and unref'd (a few seconds behind the startup import's own CLI
8594
8853
  // spawn); a relay without an installed Copilot CLI skips it entirely.
8595
8854
  copilotModelDiscoveryService.scheduleStartupRefresh();
8855
+ // Archived conversations' workers: stopped once, 30 s in, so the workers
8856
+ // that reconnect after the restart and the first prime have settled. With
8857
+ // routing off the relay launches no workers, so there is nothing to sweep.
8858
+ if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true) {
8859
+ runtimeTimers.archivedWorkerSweep = setTimeout(() => {
8860
+ runtimeTimers.archivedWorkerSweep = null;
8861
+ sweepArchivedSessionWorkersAtStart().catch((error) => {
8862
+ console.warn(`${runtimeLogPrefix()}WORKER RECYCLE archived sweep failed: ${error?.message || error}`);
8863
+ });
8864
+ }, 30_000);
8865
+ runtimeTimers.archivedWorkerSweep.unref?.();
8866
+ }
8596
8867
  });