@oar-sh/oar 0.9.13 → 0.9.15

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 (74) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +396 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +677 -103
  7. package/package.json +7 -5
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  9. package/server/claude-worker/claude-session-process.mjs +39 -13
  10. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  12. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  13. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  14. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
  17. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  18. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  19. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  20. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  21. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  22. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  23. package/server/public/app/api-client.js +3 -1
  24. package/server/public/app/ask-user-view.js +29 -4
  25. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  26. package/server/public/app/question-answer-rule.mjs +15 -0
  27. package/server/public/app/question-multi-select.mjs +9 -2
  28. package/server/public/app/relay-event-view.mjs +5 -1
  29. package/server/public/app/settings-modal.js +17 -3
  30. package/server/public/index.html +9 -5
  31. package/server/relay-exit-codes.mjs +6 -0
  32. package/server/relay-self-restart.mjs +6 -1
  33. package/server/relay-tools.md +14 -11
  34. package/server/repositories/message-repository.mjs +29 -28
  35. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  36. package/server/routes/ask-user-routes.mjs +41 -7
  37. package/server/routes/messages-routes.mjs +65 -10
  38. package/server/routes/preview-routes.mjs +73 -7
  39. package/server/routes/sessions-routes.mjs +49 -16
  40. package/server/server-runtime.mjs +156 -94
  41. package/server/services/cursor-session-root-service.mjs +70 -6
  42. package/server/services/data-dir-resolution.mjs +208 -19
  43. package/server/services/earlier-database-service.mjs +631 -0
  44. package/server/services/oar-cli-helpers.mjs +18 -5
  45. package/server/services/oar-state-layout.mjs +107 -0
  46. package/server/services/oar-state-migration-service.mjs +387 -96
  47. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  48. package/server/services/package-data-rescue.mjs +137 -0
  49. package/server/services/preview-registry-service.mjs +3 -2
  50. package/server/services/push-dispatch-service.mjs +33 -6
  51. package/server/services/relay-event-service.mjs +183 -5
  52. package/server/services/relay-restart-queue.mjs +10 -3
  53. package/server/services/relay-singleton-guard.mjs +300 -36
  54. package/server/services/remote-relay-caller-context.mjs +7 -2
  55. package/server/services/remote-relay-dispatcher.mjs +30 -9
  56. package/server/services/session-activity-tracker.mjs +52 -15
  57. package/server/services/session-worker-launch-service.mjs +31 -1
  58. package/server/services/stale-turn-recovery.mjs +99 -0
  59. package/server/services/subagent-timeline-service.mjs +4 -1
  60. package/server/services/windows-autostart-service.mjs +415 -45
  61. package/server/services/windows-boot-autostart-service.mjs +962 -185
  62. package/shared/preview-instructions.mjs +17 -12
  63. package/shared/preview-tool-core.mjs +32 -14
  64. package/shared/question-answer-rule.mjs +63 -0
  65. package/shared/relay-events.mjs +54 -4
  66. package/shared/remote-relay-contract.mjs +20 -10
  67. package/shared/subagent-timeline.mjs +4 -0
  68. package/shared/windows-system-tools.mjs +25 -0
  69. package/shared/worker-runtime/config-loader.mjs +54 -8
  70. package/shared/worker-runtime/prompt-context.mjs +3 -1
  71. package/shared/worker-runtime/working-directory.mjs +65 -0
  72. package/.github/extensions/web-relay/README.md +0 -190
  73. package/server/README.md +0 -2187
  74. package/server/uploads/image_input.md +0 -540
@@ -9,6 +9,7 @@ import { randomUUID } from 'crypto';
9
9
  import { createSdkSessionSyncService } from '../services/sdk-session-sync-service.mjs';
10
10
  import { serveFileWithRangeSupport } from '../services/file-serving.mjs';
11
11
  import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
12
+ import { describeWindowsAutostart, handMadeLines, switchBlockers } from '../services/windows-boot-autostart-service.mjs';
12
13
  import {
13
14
  buildConversationHistoryPageInfo,
14
15
  normalizeConversationHistoryLimit,
@@ -4032,8 +4033,10 @@ export function registerSessionsRoutes(app, deps) {
4032
4033
  // then nothing is shared.
4033
4034
  function collectSharePreviewTokens(conversationId) {
4034
4035
  const tokens = new Set();
4035
- // Every preview of the relay, not only this conversation's: the preview
4036
- // tool's list covers them all, and a reply may quote any of its tokens.
4036
+ // Every preview of the relay, not only this conversation's: a reply may
4037
+ // quote any token its agent got hold of — one the user pasted from
4038
+ // Settings → Previews, or a relay-wide list fetched by hand with the relay
4039
+ // token (the preview tool's own list is scoped to its conversation).
4037
4040
  try {
4038
4041
  for (const knownToken of listKnownPreviewTokens?.() || []) tokens.add(String(knownToken));
4039
4042
  } catch {
@@ -7297,6 +7300,7 @@ export function registerSessionsRoutes(app, deps) {
7297
7300
  legacyTaskPresent: boot.legacyTaskPresent,
7298
7301
  customised: boot.customised === true,
7299
7302
  customisations: Array.isArray(boot.customisations) ? boot.customisations : [],
7303
+ handMade: Array.isArray(boot.handMade) ? boot.handMade : [],
7300
7304
  launcherBackupPath: boot.launcherBackupPath || null,
7301
7305
  readError: boot.readError || null,
7302
7306
  pendingElevation: boot.pendingElevation || null,
@@ -7304,6 +7308,9 @@ export function registerSessionsRoutes(app, deps) {
7304
7308
  manualCommand: boot.manualCommand || null,
7305
7309
  } : null,
7306
7310
  signinEnabled: signin.enabled,
7311
+ // The Startup entries OAR did not make, which it leaves as they are.
7312
+ signinHandMade: (windowsAutostartService.listEntries?.() || []).filter((entry) => !entry.generated)
7313
+ .map(({ path: entryPath, otherInstall }) => ({ path: entryPath, otherInstall: otherInstall || null })),
7307
7314
  };
7308
7315
  }
7309
7316
 
@@ -7340,9 +7347,31 @@ export function registerSessionsRoutes(app, deps) {
7340
7347
  if (!windowsAutostartService.getState().supported) {
7341
7348
  return res.status(400).json({ error: 'Windows autostart is only available on Windows' });
7342
7349
  }
7350
+ if (mode === 'boot' && !windowsBootAutostartService) {
7351
+ return res.status(500).json({ error: 'Windows boot autostart is unavailable' });
7352
+ }
7353
+ // The rules of `oar autostart`: only entries OAR generated are deleted
7354
+ // or written over. A hand-made one (a Startup entry, our task or its
7355
+ // launcher changed by hand, a task someone else made) that would stay
7356
+ // beside the new mode, or be replaced by it, stops the switch with the
7357
+ // same message; replacing it is `oar autostart <mode> --replace`.
7358
+ const boot = windowsBootAutostartService ? await windowsBootAutostartService.getState() : null;
7359
+ const now = describeWindowsAutostart({
7360
+ signinEntries: windowsAutostartService.listEntries?.() || [],
7361
+ boot: boot?.supported ? boot : {},
7362
+ });
7363
+ if (now.mode === 'unknown') {
7364
+ return res.status(409).json({ error: 'The boot task could not be read, so nothing was changed; the relay log has the details.' });
7365
+ }
7366
+ const blocking = switchBlockers(now, mode);
7367
+ if (blocking.length) return res.status(409).json({ error: handMadeLines(blocking, mode).join(' ') });
7343
7368
  if (mode === 'boot') {
7344
- if (!windowsBootAutostartService) {
7345
- return res.status(500).json({ error: 'Windows boot autostart is unavailable' });
7369
+ // A generated launcher that is missing or of an earlier version is
7370
+ // regenerated by enabling again; a hand-made one stays.
7371
+ const regenerate = boot.customised && !boot.handMade?.length;
7372
+ if (now.mode === 'boot' && !regenerate) {
7373
+ windowsAutostartService.setEnabled(false);
7374
+ return res.json({ ...(await windowsAutostartMergedState()), accepted: true });
7346
7375
  }
7347
7376
  // The sweep's completion also removes the Startup .cmd entries; the
7348
7377
  // sign-in service is not touched here so a declined UAC leaves the
@@ -7350,19 +7379,23 @@ export function registerSessionsRoutes(app, deps) {
7350
7379
  const result = await windowsBootAutostartService.requestEnable();
7351
7380
  return res.json({ ...(await windowsAutostartMergedState()), accepted: result.accepted });
7352
7381
  }
7353
- // Leaving boot mode needs its own elevated sweep before the sign-in
7354
- // half applies; a declined UAC must not leave both modes armed.
7355
- const boot = windowsBootAutostartService ? await windowsBootAutostartService.getState() : null;
7356
- let accepted = true;
7357
- if (boot?.supported && (boot.taskStatus === 'ready' || boot.taskStatus === 'foreign' || boot.legacyTaskPresent)) {
7358
- if (boot.taskStatus === 'foreign' && mode === 'signin') {
7359
- // Someone else's task under our name: never delete it as a side
7360
- // effect of picking sign-in mode.
7361
- windowsAutostartService.setEnabled(true);
7362
- return res.json({ ...(await windowsAutostartMergedState()), accepted: true });
7363
- }
7364
- accepted = (await windowsBootAutostartService.requestDisable()).accepted;
7382
+ // Leaving boot mode needs its own elevated sweep; the sign-in half
7383
+ // applies once the prompt was confirmed and the task is gone, so a
7384
+ // declined or failed one leaves the earlier mode as it was.
7385
+ if (now.mode === 'boot') {
7386
+ const result = await windowsBootAutostartService.requestDisable({
7387
+ onSettled: (ok) => {
7388
+ if (!ok) return;
7389
+ try {
7390
+ windowsAutostartService.setEnabled(mode === 'signin');
7391
+ } catch (error) {
7392
+ console.warn(`[autostart] the boot task is gone, but the sign-in entry could not be changed: ${error?.message || error}`);
7393
+ }
7394
+ },
7395
+ });
7396
+ return res.json({ ...(await windowsAutostartMergedState()), accepted: result.accepted });
7365
7397
  }
7398
+ const accepted = true;
7366
7399
  windowsAutostartService.setEnabled(mode === 'signin');
7367
7400
  return res.json({ ...(await windowsAutostartMergedState()), accepted });
7368
7401
  } catch (error) {
@@ -42,7 +42,7 @@ import { registerRelayBoardRoutes } from './routes/relay-board-routes.mjs';
42
42
  import { registerCacheRoutes } from './routes/cache-routes.mjs';
43
43
  import { registerGitRoutes } from './routes/git-routes.mjs';
44
44
  import { registerClaudeCloudRoutes } from './routes/claude-cloud-routes.mjs';
45
- import { registerPreviewRoutes } from './routes/preview-routes.mjs';
45
+ import { createPreviewScopeResolver, registerPreviewRoutes } from './routes/preview-routes.mjs';
46
46
  import { registerRemoteRelayRoutes } from './routes/remote-relay-routes.mjs';
47
47
  import { registerRelayEventRoutes } from './routes/relay-event-routes.mjs';
48
48
  import { createRemoteRelayRepository } from './repositories/remote-relay-repository.mjs';
@@ -124,7 +124,9 @@ import { createClaudeSessionRootResolver } from './services/claude-session-root-
124
124
  import { createCursorSessionRootResolver } from './services/cursor-session-root-service.mjs';
125
125
  import { createGitChangesService } from './services/git-changes-service.mjs';
126
126
  import { createRelaySingletonGuard } from './services/relay-singleton-guard.mjs';
127
- import { resolveDataDir } from './services/data-dir-resolution.mjs';
127
+ import { adoptEarlierDatabase, lostPackageDatabaseWarning, packageDatabaseEvidence } from './services/earlier-database-service.mjs';
128
+ import { ensureDataDirs, resolveDataDir, resolveUploadDir, resolveUploadReadDirs, uploadPathInDirs } from './services/data-dir-resolution.mjs';
129
+ import { mergeStrayMigratedUploads } from './services/oar-state-migration-service.mjs';
128
130
  import { createRelayRestartOrchestrator } from './services/relay-restart-orchestrator-service.mjs';
129
131
  import { createRelayBridgeOwnerService } from './services/relay-bridge-owner-service.mjs';
130
132
  import { createRelayCliLauncherService } from './services/relay-cli-launcher-service.mjs';
@@ -173,7 +175,7 @@ import {
173
175
  migrateLegacyConfigFeatures,
174
176
  resolveBootFeatureFlags,
175
177
  } from './features.mjs';
176
- import { RELAY_RESTART_EXIT_CODE } from './relay-exit-codes.mjs';
178
+ import { RELAY_RESTART_EXIT_CODE, RELAY_STOP_EXIT_CODE } from './relay-exit-codes.mjs';
177
179
  import { DEFAULT_QUESTION_TIMEOUT_MS, questionExpiresAt } from '../shared/question-timeout.mjs';
178
180
  import {
179
181
  parseTurnCeilingUpdate,
@@ -201,6 +203,7 @@ import {
201
203
  } from './services/relay-restart-queue.mjs';
202
204
  import { createRestartPictureCollector } from './services/relay-restart-picture.mjs';
203
205
  import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
206
+ import { createStaleTurnRecovery } from './services/stale-turn-recovery.mjs';
204
207
  import {
205
208
  STALE_SUBAGENT_RUN_BOOT_DELAY_MS,
206
209
  STALE_SUBAGENT_RUN_SWEEP_INTERVAL_MS,
@@ -248,7 +251,7 @@ import {
248
251
  import { createModelVariantCatalogService } from './services/model-variant-catalog-service.mjs';
249
252
  import { applyPwaManifestTheme, normalizePwaTheme } from './services/pwa-manifest-theme.mjs';
250
253
  import { mapSubagentRunHeader } from './services/subagent-timeline-service.mjs';
251
- import { describeExecError, describeSpawnEnvironment, windowsSystemToolPath } from '../shared/windows-system-tools.mjs';
254
+ import { describeExecError, describeSpawnEnvironment, normalizeTempEnv, windowsSystemToolPath } from '../shared/windows-system-tools.mjs';
252
255
 
253
256
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
254
257
  const REPO_ROOT = path.resolve(__dirname, '..');
@@ -279,9 +282,8 @@ const APP_CONFIG_PLACEHOLDER = /window\.__COPILOT_APP_CONFIG = \{ basePath: '[^'
279
282
  const PWA_VERSION_PLACEHOLDER = /const __PWA_VERSION = '[^']*';/;
280
283
  // The console goes to a terminal, a tmux pane or nowhere: keep a copy to read
281
284
  // after a restart or an incident.
282
- const relayConsoleLog = installRelayConsoleLog({
283
- logPath: resolveRelayConsoleLogPath({ env: process.env, serverDir: __dirname }),
284
- });
285
+ const relayConsoleLogPath = resolveRelayConsoleLogPath({ env: process.env, serverDir: __dirname });
286
+ const relayConsoleLog = installRelayConsoleLog({ logPath: relayConsoleLogPath });
285
287
  const ttyConsoleRuntime = await maybeStartTtyConsole({
286
288
  serverDir: __dirname,
287
289
  logsDir: path.join(__dirname, 'logs'),
@@ -289,6 +291,8 @@ const ttyConsoleRuntime = await maybeStartTtyConsole({
289
291
  });
290
292
  // The terminal console takes the console's methods over when it starts.
291
293
  relayConsoleLog?.rewrap();
294
+ // TEMP and TMP for this relay and everything it starts (normalizeTempEnv).
295
+ for (const change of normalizeTempEnv()) console.log(`[startup] ${change} (quotes taken off, or the folder is not there)`);
292
296
 
293
297
  // ─── Config ────────────────────────────────────────────────────────────────────
294
298
  const CONFIG_PATH = process.env.COPILOT_WEB_RELAY_CONFIG
@@ -296,26 +300,32 @@ const CONFIG_PATH = process.env.COPILOT_WEB_RELAY_CONFIG
296
300
  : path.join(__dirname, 'config.json');
297
301
  // env var > config.json `dataDir` > <server>/data — see data-dir-resolution.mjs
298
302
  // for why the config can carry it (a dev checkout pointing at a release
299
- // install's data, and autostart launchers that only pin the config path).
303
+ // install's data).
300
304
  const DATA_DIR = resolveDataDir({ env: process.env, configPath: CONFIG_PATH, serverDir: __dirname });
301
305
  const DB_PATH = path.join(DATA_DIR, 'copilot.db');
302
306
  const RELAY_LOCK_PATH = path.join(DATA_DIR, 'relay-server.lock');
303
- const UPLOAD_DIR = process.env.COPILOT_WEB_RELAY_DATA_DIR
304
- ? path.join(DATA_DIR, 'uploads')
305
- : path.join(__dirname, 'uploads');
307
+ // Uploads follow the data dir whenever the env var or config.json chose it.
308
+ const UPLOAD_DIR = resolveUploadDir({ dataDir: DATA_DIR, env: process.env, serverDir: __dirname });
309
+ // Uploads earlier versions left in <server>/uploads or <state root>/uploads
310
+ // are read in place; nothing there is written, moved or deleted.
311
+ const UPLOAD_READ_DIRS = resolveUploadReadDirs({ uploadDir: UPLOAD_DIR, dataDir: DATA_DIR, serverDir: __dirname });
306
312
  const INITIAL_WORKSPACE_ROOT = resolveStartupWorkspaceRoot(__dirname);
307
313
  const WORKSPACE_ROOT_LOCKED = true;
308
314
  const SESSION_COOKIE = 'copilot_session';
309
315
  const AUTH_COOKIE = 'copilot_auth';
310
316
 
311
- if (!fs.existsSync(DATA_DIR)) {
312
- fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
313
- }
317
+ ensureDataDirs({ dataDir: DATA_DIR, uploadDir: UPLOAD_DIR });
314
318
  if (process.platform !== 'win32') {
315
319
  fs.chmodSync(DATA_DIR, 0o700);
316
320
  }
317
- if (!fs.existsSync(UPLOAD_DIR)) {
318
- fs.mkdirSync(UPLOAD_DIR, { recursive: true });
321
+ // Earlier migrations put a checkout's uploads in <state root>/uploads, which
322
+ // nothing read: merge them in (never overwriting, never deleting the source).
323
+ mergeStrayMigratedUploads({ dataDir: DATA_DIR, uploadDir: UPLOAD_DIR, logger: console });
324
+ for (const dir of UPLOAD_READ_DIRS.slice(1)) {
325
+ try {
326
+ const count = fs.readdirSync(dir).filter((name) => isSha256(name)).length;
327
+ if (count) console.log(`[uploads] New uploads go to ${UPLOAD_DIR}; ${count} earlier upload${count === 1 ? '' : 's'} in ${dir} are read from there.`);
328
+ } catch {}
319
329
  }
320
330
 
321
331
  async function refreshOpenAIProviderModels() {
@@ -1097,18 +1107,20 @@ function setCursorProviderSettings(update = {}) {
1097
1107
  const CURSOR_MODEL_DISCOVERY_TIMEOUT_MS = 20_000;
1098
1108
 
1099
1109
  // Reasoning tiers for one discovered Cursor model, in composer vocabulary.
1100
- // Cursor exposes effort as a per-model parameter named 'effort' or 'reasoning'
1101
- // whose value list varies by model; 'extra-high' is Cursor's spelling of the
1102
- // composer's 'xhigh'. Values with no composer equivalent (e.g. 'minimal') are
1103
- // dropped rather than remapped onto a tier the model does not actually have.
1110
+ // Cursor exposes effort as a per-model parameter named 'effort', 'reasoning'
1111
+ // or 'reasoning_effort' whose value list varies by model; 'extra-high' is
1112
+ // Cursor's spelling of the composer's 'xhigh'. Values with no composer
1113
+ // equivalent (e.g. 'minimal') are dropped rather than remapped onto a tier the
1114
+ // model does not actually have.
1104
1115
  // Mirrors resolveCursorReasoningParams exactly, including its parameter
1105
- // precedence: it consults 'effort' first and only falls back to 'reasoning',
1106
- // so scanning both would advertise an off switch the worker never sends.
1116
+ // precedence: it consults 'effort' first, then 'reasoning', then
1117
+ // 'reasoning_effort', so scanning several would advertise an off switch the
1118
+ // worker never sends.
1107
1119
  function cursorEntrySupportsReasoningOff(entry) {
1108
1120
  const parameters = Array.isArray(entry?.parameters) ? entry.parameters : [];
1109
1121
  const byId = new Map(parameters.map((param) => [String(param?.id || '').trim().toLowerCase(), param]));
1110
1122
  if (byId.has('thinking')) return true;
1111
- const effortParam = byId.get('effort') || byId.get('reasoning');
1123
+ const effortParam = byId.get('effort') || byId.get('reasoning') || byId.get('reasoning_effort');
1112
1124
  const values = Array.isArray(effortParam?.values) ? effortParam.values : [];
1113
1125
  return values.some((value) => (
1114
1126
  String((value && typeof value === 'object' ? value.value : value) || '').trim().toLowerCase() === 'none'
@@ -1121,7 +1133,7 @@ function cursorEntryEffortLevels(entry) {
1121
1133
  // listing both parameters must not have its tiers read from one and its off
1122
1134
  // switch from the other.
1123
1135
  const byId = new Map(parameters.map((param) => [String(param?.id || '').trim().toLowerCase(), param]));
1124
- const effortParam = byId.get('effort') || byId.get('reasoning');
1136
+ const effortParam = byId.get('effort') || byId.get('reasoning') || byId.get('reasoning_effort');
1125
1137
  if (!effortParam) return [];
1126
1138
  const levels = [];
1127
1139
  for (const value of (Array.isArray(effortParam.values) ? effortParam.values : [])) {
@@ -1824,18 +1836,43 @@ function isProcessAlive(pid) {
1824
1836
  }
1825
1837
  }
1826
1838
 
1839
+ // The guard's own stale-lock rules (lockHolderMayRun; an EPERM is alive).
1827
1840
  const relaySingletonGuard = createRelaySingletonGuard({
1828
1841
  lockPath: RELAY_LOCK_PATH,
1829
1842
  pid: process.pid,
1830
1843
  token: config.authToken,
1831
- isProcessAlive,
1832
1844
  logger: console,
1833
1845
  });
1834
1846
  try {
1835
1847
  relaySingletonGuard.acquire();
1836
1848
  } catch (error) {
1849
+ // A held lock is not a crash: the supervisors do not retry 78.
1837
1850
  console.error(`[server] ${error?.message || String(error)}`);
1838
- process.exit(1);
1851
+ process.exit(error?.code === 'ERELAYLOCKED' ? RELAY_STOP_EXIT_CODE : 1);
1852
+ }
1853
+ // Before the database opens, under the lock (adoptEarlierDatabase); the
1854
+ // result also feeds lostPackageDatabaseWarning.
1855
+ let earlierDatabase;
1856
+ {
1857
+ try {
1858
+ earlierDatabase = adoptEarlierDatabase({
1859
+ dataDir: DATA_DIR,
1860
+ uploadDir: UPLOAD_DIR,
1861
+ serverDir: __dirname,
1862
+ configPath: CONFIG_PATH,
1863
+ packageRoot: REPO_ROOT,
1864
+ Database,
1865
+ authToken: config.authToken,
1866
+ logger: console,
1867
+ });
1868
+ } catch (error) {
1869
+ earlierDatabase = { status: 'refused', error: `[data] Not starting: the database this relay ran on until now could not be looked at (${error?.message || error}).` };
1870
+ }
1871
+ if (earlierDatabase.status === 'refused') {
1872
+ console.error(earlierDatabase.error);
1873
+ try { relaySingletonGuard.release(); } catch {}
1874
+ process.exit(RELAY_STOP_EXIT_CODE);
1875
+ }
1839
1876
  }
1840
1877
 
1841
1878
  const MAX_REQUEUE_RETRIES = Number.isFinite(Number(config.maxRequeueRetries))
@@ -2128,7 +2165,7 @@ function setSharePathPrivacyMode(value) {
2128
2165
  // checkout, data and upload dirs, the workspace roots it knows, and the OS
2129
2166
  // user name. The share routes add the conversation's own roots.
2130
2167
  function sharePathKnownRoots() {
2131
- const roots = [REPO_ROOT, DATA_DIR, UPLOAD_DIR, currentWorkspaceRootPath(), INITIAL_WORKSPACE_ROOT];
2168
+ const roots = [REPO_ROOT, DATA_DIR, ...UPLOAD_READ_DIRS, currentWorkspaceRootPath(), INITIAL_WORKSPACE_ROOT];
2132
2169
  const userNames = [];
2133
2170
  try { roots.push(os.homedir()); } catch {}
2134
2171
  try { userNames.push(os.userInfo().username); } catch {}
@@ -2679,6 +2716,7 @@ const configuredConversationSessionMode =
2679
2716
  updateModelCatalog({ models: [DEFAULT_MODEL], currentModel: DEFAULT_MODEL, defaultModel: DEFAULT_MODEL, source: 'bootstrap' });
2680
2717
 
2681
2718
  // ─── SQLite Setup ─────────────────────────────────────────────────────────────
2719
+ const databaseCreatedAtStart = !fs.existsSync(DB_PATH);
2682
2720
  const db = new Database(DB_PATH);
2683
2721
  if (process.platform !== 'win32') {
2684
2722
  fs.chmodSync(DB_PATH, 0o600);
@@ -3042,6 +3080,7 @@ function buildSessionWorkerLaunchEnvForSession(targetSessionId) {
3042
3080
  enabled: true,
3043
3081
  model: cursorModel,
3044
3082
  apiKey: cursorSettings.apiKey,
3083
+ dataDir: DATA_DIR,
3045
3084
  });
3046
3085
  }
3047
3086
  if (providerType === 'grok') {
@@ -3526,8 +3565,11 @@ function collectRestartPicture({ action = 'restart' } = {}) {
3526
3565
  const relayRestartQueue = createRelayRestartQueue({
3527
3566
  getCounts: () => queueCounts(),
3528
3567
  collectPicture: collectRestartPicture,
3529
- startShutdown: ({ action, reason }) => {
3568
+ startShutdown: ({ action, reason, requestedBy, requestedAt }) => {
3530
3569
  const exitCode = action === 'restart' ? RELAY_RESTART_EXIT_CODE : 0;
3570
+ // The next start tells the conversations that asked for relay_restarted
3571
+ // how this one ended (services/relay-event-service.mjs).
3572
+ try { relayEventService?.noteRelayExit({ action, reason, requestedBy, requestedAt }); } catch {}
3531
3573
  sessionWorkerWebSocketService.emitDraining(`api-${action}:${reason}`);
3532
3574
  void shutdownRuntime(`api-${action}:${reason}`, { exitCode });
3533
3575
  },
@@ -3554,7 +3596,9 @@ function relayShutdownStatePayload() {
3554
3596
  }
3555
3597
 
3556
3598
  function cancelRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
3557
- return relayRestartQueue.cancel({ requestedBy });
3599
+ const result = relayRestartQueue.cancel({ requestedBy });
3600
+ if (result?.cancelled) relayEventService?.noteRestartCancelled();
3601
+ return result;
3558
3602
  }
3559
3603
 
3560
3604
  function keepWaitingForRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
@@ -3562,7 +3606,13 @@ function keepWaitingForRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
3562
3606
  }
3563
3607
 
3564
3608
  function requestRelayShutdown(options = {}) {
3565
- return relayRestartQueue.request(options);
3609
+ // The loop guard of relay_restarted: a conversation whose turn answers the
3610
+ // last restart is not woken by this one. Noted first, since an immediate
3611
+ // request exits from inside the call.
3612
+ if (!runtimeShutdownStarted) relayEventService?.noteRestartRequested();
3613
+ const result = relayRestartQueue.request(options);
3614
+ if (!relayRestartQueue.isQueued() && !runtimeShutdownStarted) relayEventService?.noteRestartCancelled();
3615
+ return result;
3566
3616
  }
3567
3617
 
3568
3618
  function toNullableInt(value) {
@@ -4205,7 +4255,9 @@ function isSha256(value) {
4205
4255
  }
4206
4256
 
4207
4257
  function uploadPathForSha(sha256) {
4208
- return path.join(UPLOAD_DIR, sha256);
4258
+ let expectedSize = null;
4259
+ try { expectedSize = stmts.getUploadFile.get(String(sha256 || '').trim().toLowerCase())?.size_bytes ?? null; } catch {}
4260
+ return uploadPathInDirs(UPLOAD_READ_DIRS, sha256, { expectedSize });
4209
4261
  }
4210
4262
 
4211
4263
  function uploadContentUrlForSha(sha256) {
@@ -4886,7 +4938,10 @@ function persistUploadBuffer(buffer, options = {}) {
4886
4938
  if (!Buffer.isBuffer(buffer) || !buffer.length) throw new Error('Empty upload');
4887
4939
  if (buffer.length > MAX_UPLOAD_BYTES) throw new Error('Uploaded file too large');
4888
4940
  const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
4889
- const filePath = uploadPathForSha(sha256);
4941
+ // Always kept in the upload dir: the folders of earlier versions can go
4942
+ // (a package update, a clean checkout), so a new reference never relies on
4943
+ // them. An existing file is never overwritten.
4944
+ const filePath = path.join(UPLOAD_DIR, sha256);
4890
4945
  if (!fs.existsSync(filePath)) {
4891
4946
  const tmpPath = path.join(UPLOAD_DIR, `${sha256}.${process.pid}.${Date.now()}.tmp`);
4892
4947
  fs.writeFileSync(tmpPath, buffer);
@@ -4924,13 +4979,14 @@ function buildStoredAttachment({ sha256, name, type, size }) {
4924
4979
  if (!isSha256(normalizedSha)) return null;
4925
4980
  const normalizedName = String(name || '').trim().slice(0, 255) || `upload-${normalizedSha.slice(0, 12)}`;
4926
4981
  const normalizedType = String(type || '').trim().toLowerCase().slice(0, 127) || 'application/octet-stream';
4982
+ const filePath = uploadPathForSha(normalizedSha);
4927
4983
  return {
4928
4984
  name: normalizedName,
4929
4985
  type: normalizedType,
4930
4986
  size: Number(size || 0),
4931
4987
  sha256: normalizedSha,
4932
- path: uploadPathForSha(normalizedSha),
4933
- reference: `@${uploadPathForSha(normalizedSha)}`,
4988
+ path: filePath,
4989
+ reference: `@${filePath}`,
4934
4990
  contentUrl: uploadContentUrlForSha(normalizedSha),
4935
4991
  };
4936
4992
  }
@@ -5003,7 +5059,9 @@ function deleteOrphanedUploads(hashes) {
5003
5059
  const refs = Number(stmts.countUploadRefsBySha.get(sha256)?.cnt || 0);
5004
5060
  if (refs > 0) continue;
5005
5061
  stmts.deleteUploadFile.run(sha256);
5006
- const filePath = uploadPathForSha(sha256);
5062
+ // Only the upload dir is swept; the folders of earlier versions are
5063
+ // read-only to the relay.
5064
+ const filePath = path.join(UPLOAD_DIR, sha256);
5007
5065
  if (fs.existsSync(filePath)) {
5008
5066
  try { fs.unlinkSync(filePath); } catch {}
5009
5067
  }
@@ -5536,64 +5594,21 @@ function failConsumedRowTerminally(row, reason) {
5536
5594
  });
5537
5595
  }
5538
5596
 
5539
- function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso = null } = {}) {
5540
- const params = { inactiveBefore: cutoffIso, ceilingBefore: ceilingBeforeIso || null };
5541
- // Stale background-continuation turns are torn down, never requeued —
5542
- // replaying one would deliver the CLI's own continuation as a user prompt.
5543
- const staleContinuations = stmts.listStaleProcessingContinuations?.all?.(params) || [];
5544
- for (const row of staleContinuations) {
5545
- stmts.dropStaleContinuation?.run?.(row.id);
5546
- io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
5547
- noteRestartTurnOutcome(row, 'cut');
5548
- // A teardown is a turn ending with no reply; it must be visible in the
5549
- // console log (the only trace of a silent one was `ignored=already_failed`
5550
- // hours later, when the worker's reply arrived for the dead row).
5551
- const lastSeen = row.owner_last_claimed_at || row.processing_at || row.timestamp || null;
5552
- const reason = lastSeen && lastSeen < cutoffIso ? `inactive since ${lastSeen}` : `ceiling (started ${row.processing_at || row.timestamp})`;
5553
- console.warn(`${runtimeLogPrefix()}STALE CONTINUATION ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} dropped: ${reason}`);
5554
- }
5555
- const listedRows = stmts.listRecoverableProcessing.all(params);
5556
- for (const row of listedRows.filter((entry) => entry?.consumed_at)) {
5557
- failConsumedRowTerminally(row, 'stale-recovery');
5558
- noteRestartTurnOutcome(row, 'cut');
5559
- }
5560
- const allRows = listedRows.filter((entry) => !entry?.consumed_at);
5561
- if (!allRows.length) return [];
5562
- // Enforce the retry budget before requeueing: failing these rows first
5563
- // flips them out of 'processing' so the recovery UPDATE below skips them.
5564
- const overBudget = allRows.filter((row) => Number(row?.retry_count || 0) + 1 >= MAX_REQUEUE_RETRIES);
5565
- for (const row of overBudget) {
5566
- failRecoveredRowTerminally(row, 'stale-recovery');
5567
- noteRestartTurnOutcome(row, 'failed');
5568
- }
5569
- const rows = allRows.filter((row) => Number(row?.retry_count || 0) + 1 < MAX_REQUEUE_RETRIES);
5570
- if (!rows.length) return [];
5571
-
5572
- const tx = db.transaction(() => {
5573
- stmts.recoverProcessingBefore.run({ ...params, requeueAt: requeueAtIso });
5574
- const settleRecoveredAbortControls = db.prepare(`
5575
- UPDATE relay_control_requests
5576
- SET status = 'failed',
5577
- error = ?,
5578
- updated_at = ?,
5579
- completed_at = ?
5580
- WHERE queue_message_id = ?
5581
- AND type IN ('abort_turn', 'abort_subagent')
5582
- AND status IN ('pending', 'processing')
5583
- `);
5584
- const now = new Date().toISOString();
5585
- for (const row of rows) {
5586
- settleRecoveredAbortControls.run('queue-recovered', now, now, row.id);
5587
- }
5588
- });
5589
- tx();
5590
-
5591
- for (const row of rows) {
5592
- io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
5593
- noteRestartTurnOutcome(row, 'requeued');
5594
- }
5595
- io.emit('queue_updated', { recovered: rows.length });
5596
- return rows;
5597
+ // The stale-turn sweep itself lives in services/stale-turn-recovery.mjs so it
5598
+ // can be tested against a real database; io is created further down, hence
5599
+ // the late-bound emit.
5600
+ const staleTurnRecovery = createStaleTurnRecovery({
5601
+ db,
5602
+ stmts,
5603
+ emit: (event, payload) => io.emit(event, payload),
5604
+ maxRequeueRetries: MAX_REQUEUE_RETRIES,
5605
+ failRecoveredRowTerminally,
5606
+ failConsumedRowTerminally,
5607
+ noteRestartTurnOutcome,
5608
+ warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
5609
+ });
5610
+ function recoverProcessingOlderThan(cutoffIso, requeueAtIso, options) {
5611
+ return staleTurnRecovery.recoverProcessingOlderThan(cutoffIso, requeueAtIso, options);
5597
5612
  }
5598
5613
 
5599
5614
  // One `/api/usage` needs the same token for the quota read and the billing
@@ -5928,7 +5943,7 @@ const readContextFromSessionEvents = contextSnapshotService.readContextFromSessi
5928
5943
  // Claude-provider sessions live under the Agent SDK's project layout rather than
5929
5944
  // ~/.copilot/session-state, so their browsable session folder resolves separately.
5930
5945
  const claudeSessionRootResolver = createClaudeSessionRootResolver({ fs, path });
5931
- const cursorSessionRootResolver = createCursorSessionRootResolver({ fs, path, serverDir: __dirname });
5946
+ const cursorSessionRootResolver = createCursorSessionRootResolver({ fs, path, serverDir: __dirname, dataDir: DATA_DIR });
5932
5947
 
5933
5948
  // ─── Express + Socket.io Setup ────────────────────────────────────────────────
5934
5949
  const app = express();
@@ -6805,6 +6820,34 @@ const windowsBootAutostartService = createWindowsBootAutostartService({
6805
6820
  packageRoot: REPO_ROOT,
6806
6821
  configPath: CONFIG_PATH,
6807
6822
  });
6823
+ // Autostart entries written before they pinned the data and log folders start
6824
+ // the relay on a database inside the package directory, or before TEMP and
6825
+ // the data drive are there. Brought up to date once this relay holds the
6826
+ // lock and its port; edited ones stay.
6827
+ function refreshWindowsAutostartEntries() {
6828
+ // Entries that ran the relay on the database inside the package.
6829
+ const unpinnedLaunchers = [];
6830
+ const onEarlierForm = ({ path: launcher, pinnedData }) => { if (!pinnedData) unpinnedLaunchers.push(launcher); };
6831
+ for (const [name, refresh] of [
6832
+ ['sign-in entry', () => windowsAutostartService.refreshInstalledEntry({ onEarlierForm })],
6833
+ ['boot launcher', () => windowsBootAutostartService.refreshInstalledLauncher({ onEarlierForm })],
6834
+ ]) {
6835
+ try {
6836
+ if (refresh()) console.log(`[autostart] brought the ${name} up to date: it pins the data and log folders and waits for TEMP and the data drive`);
6837
+ } catch (error) {
6838
+ console.warn(`[autostart] could not bring the ${name} up to date: ${error?.message || error}`);
6839
+ }
6840
+ }
6841
+ const warning = lostPackageDatabaseWarning({
6842
+ unpinnedLaunchers,
6843
+ earlierDatabase,
6844
+ databaseCreated: databaseCreatedAtStart,
6845
+ evidence: unpinnedLaunchers.length > 0 && packageDatabaseEvidence({ serverDir: __dirname, ownDirs: [DATA_DIR, path.dirname(relayConsoleLogPath)] }),
6846
+ serverDir: __dirname,
6847
+ dbPath: DB_PATH,
6848
+ });
6849
+ if (warning) console.error(warning);
6850
+ }
6808
6851
 
6809
6852
  const gitChangesService = createGitChangesService();
6810
6853
  // GET /api/git/remote: where a folder lives on GitHub (New Chat, Claude Cloud).
@@ -7069,6 +7112,7 @@ const sharedRouteDeps = {
7069
7112
  fs,
7070
7113
  path,
7071
7114
  uploadsDir: UPLOAD_DIR,
7115
+ uploadReadDirs: UPLOAD_READ_DIRS,
7072
7116
  fetchUsageSummary,
7073
7117
  fetchCopilotBillingUsage,
7074
7118
  fetchGrokBillingUsage: createGrokBillingUsageFetcher(),
@@ -7776,6 +7820,14 @@ registerPreviewRoutes(app, {
7776
7820
  new Date().toISOString(),
7777
7821
  );
7778
7822
  },
7823
+ // A scope named by a conversation's id or its session id covers both.
7824
+ resolveConversationScope: createPreviewScopeResolver({
7825
+ getConversation: (id) => {
7826
+ const conv = stmts.getConvAnyStatus.get(id) || null;
7827
+ return conv && String(conv.status || '').trim().toLowerCase() !== 'deleted' ? conv : null;
7828
+ },
7829
+ getConversationBySdkSessionId: (id) => stmts.getConvBySdkSessionId?.get?.(id) || null,
7830
+ }),
7779
7831
  });
7780
7832
 
7781
7833
  function clearRuntimeTimers() {
@@ -7835,6 +7887,9 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
7835
7887
  console.warn(`${runtimeLogPrefix()}Grok auth service shutdown failed: ${error?.message || error}`);
7836
7888
  }
7837
7889
  sessionWorkerWebSocketService.stop();
7890
+ // A queued request wrote its own end already; this covers the rest
7891
+ // (signals, the owner gone).
7892
+ try { relayEventService.noteRelayExit({ action: runtimeShutdownExitCode === RELAY_RESTART_EXIT_CODE ? 'restart' : 'shutdown', reason }); } catch {}
7838
7893
  try { relayEventService.stop(); } catch {}
7839
7894
  try { relayEventLinkService.stop(); } catch {}
7840
7895
  tmuxInspectorSocketService.stop();
@@ -7965,12 +8020,19 @@ httpServer.on('error', (error) => {
7965
8020
  process.exit(1);
7966
8021
  });
7967
8022
  httpServer.listen(config.port, listenHost, () => {
8023
+ // Conversations that asked for relay_restarted hear that the relay is back:
8024
+ // once it listens, so a start that fails to bind does not take the record
8025
+ // of how the run before ended.
8026
+ try { relayEventService.noteRelayStarted(); } catch (error) {
8027
+ console.warn(`${runtimeLogPrefix()}relay_restarted not recorded: ${error?.message || error}`);
8028
+ }
7968
8029
  const readyBanner = buildRelayReadyBannerData();
7969
8030
  console.log('');
7970
8031
  for (const line of (readyBanner.lines || [])) {
7971
8032
  console.log(line);
7972
8033
  }
7973
8034
  console.log('\nCLI status: waiting for first heartbeat...\n');
8035
+ refreshWindowsAutostartEntries();
7974
8036
 
7975
8037
  // Start SSH tunnel after server is listening
7976
8038
  sshTunnelManager.start();