@oar-sh/oar 0.9.13 → 0.9.14

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 (72) 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 +302 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +155 -94
  7. package/package.json +5 -5
  8. package/server/README.md +296 -149
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +39 -13
  11. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  12. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  13. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  14. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  15. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  16. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  17. package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
  18. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  19. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  20. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  21. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  22. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  24. package/server/public/app/api-client.js +3 -1
  25. package/server/public/app/ask-user-view.js +29 -4
  26. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  27. package/server/public/app/question-answer-rule.mjs +15 -0
  28. package/server/public/app/question-multi-select.mjs +9 -2
  29. package/server/public/app/relay-event-view.mjs +5 -1
  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 +4 -2
  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 +6 -1
  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 +329 -31
  61. package/server/services/windows-boot-autostart-service.mjs +188 -60
  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/server/uploads/image_input.md +0 -540
package/bin/oar.js CHANGED
@@ -11,8 +11,17 @@ import net from 'net';
11
11
 
12
12
  import {
13
13
  migrateStateToOarRoot,
14
- resolveOarRoot,
15
14
  } from '../server/services/oar-state-migration-service.mjs';
15
+ import { resolveDataDir, resolveUploadDir } from '../server/services/data-dir-resolution.mjs';
16
+ import { TAKEOVER_PROGRESS_FILE } from '../server/services/earlier-database-service.mjs';
17
+ import { defaultIsProcessAlive, lockHolderMayRun, processIdentity } from '../server/services/relay-singleton-guard.mjs';
18
+ import {
19
+ getLauncherLogDir,
20
+ getLegacyManagedConfigDir,
21
+ isGitCheckout,
22
+ resolveRelayStateDirs,
23
+ resolveStateLayout,
24
+ } from '../server/services/oar-state-layout.mjs';
16
25
  import {
17
26
  buildDefaultConfig, buildServicePath, buildSystemdUnit, describeRelayAddress, DOCTOR_PROBES, generateAuthToken,
18
27
  NPM_ALLOW_SCRIPTS_ARG, parseCliArgs, primaryLanAddress, renderDoctorReport, resolveNpmInvocation, usageText,
@@ -56,20 +65,42 @@ function readRelayLock(lockPath) {
56
65
  pid: Number.parseInt(String(parsed?.pid ?? ''), 10),
57
66
  startedAt: typeof parsed?.startedAt === 'string' ? parsed.startedAt : null,
58
67
  token: typeof parsed?.token === 'string' ? parsed.token : '',
68
+ format: Number(parsed?.format) || 0,
59
69
  };
60
70
  } catch {
61
71
  return null;
62
72
  }
63
73
  }
64
74
 
75
+ // What a relay that holds its lock and does not answer yet is bringing over
76
+ // (earlier-database-service), or null: the progress file names the lock's
77
+ // pid, and that pid is the lock's holder still (lockHolderMayRun: a pid
78
+ // reused after a crash is not).
79
+ function readTakeover(lockPath, isProcessAliveImpl = isProcessAlive) {
80
+ const progress = readJsonFile(path.join(path.dirname(lockPath), TAKEOVER_PROGRESS_FILE));
81
+ const lock = readJsonFile(lockPath);
82
+ const pid = Number.parseInt(String(progress?.pid ?? ''), 10);
83
+ return Number.isInteger(pid) && Number(lock?.pid) === pid && lockHolderMayRun({ lock, isProcessAlive: isProcessAliveImpl }) ? progress : null;
84
+ }
85
+
86
+ // The lock a relay that did not come up found held, and whether its holder
87
+ // is known to be a relay (its recorded identity matches): only when it is
88
+ // not may the user delete the lock. Null when no live process holds it.
89
+ function heldLock(lockPath, isProcessAliveImpl = isProcessAlive) {
90
+ const lock = readJsonFile(lockPath);
91
+ const pid = Number.parseInt(String(lock?.pid ?? ''), 10);
92
+ if (!Number.isInteger(pid) || !lockHolderMayRun({ lock, isProcessAlive: isProcessAliveImpl })) return null;
93
+ return { pid, path: lockPath, startedAt: lock.startedAt || 'unknown', known: !!lock.holder && !!processIdentity(pid) };
94
+ }
95
+
96
+ function takeoverLine(takeover, logDir) {
97
+ const what = takeover ? ` (${takeover.from}, ${Math.max(1, Math.round(Number(takeover.bytes || 0) / 1048576))} MB)` : '';
98
+ return `[oar] The relay${takeover ? ` (process ${takeover.pid})` : ''} is bringing over the database it ran on until now${what} and is still running; it answers once that is done. Its log: ${path.join(String(logDir || ''), 'server.log')}`;
99
+ }
100
+
101
+ // As the server tells it: a process this user may not signal (EPERM) lives.
65
102
  function isProcessAlive(pid) {
66
- if (!Number.isInteger(pid) || pid <= 0) return false;
67
- try {
68
- process.kill(pid, 0);
69
- return true;
70
- } catch {
71
- return false;
72
- }
103
+ return defaultIsProcessAlive(pid);
73
104
  }
74
105
 
75
106
  function readJsonFile(filePath) {
@@ -91,45 +122,66 @@ function openAppendFileDescriptor(filePath) {
91
122
  return fs.openSync(filePath, 'a');
92
123
  }
93
124
 
94
- // Pre-OAR managed config location — kept only as a migration source and as the
95
- // config home for git checkouts, where nothing may change underfoot.
96
- function getLegacyManagedConfigDir(env = process.env) {
97
- if (process.platform === 'win32') {
98
- return path.join(
99
- env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
100
- 'copilot-remote',
101
- );
102
- }
103
- return path.join(
104
- env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
105
- 'copilot-remote',
106
- );
107
- }
108
-
109
- function isGitCheckout(packageRoot) {
110
- try { return fs.existsSync(path.join(packageRoot, '.git')); } catch { return false; }
111
- }
112
-
113
125
  /**
114
- * Where launcher-managed state lives. A git checkout keeps every pre-OAR
115
- * default (repo-local server/data, legacy managed config) so development and
116
- * the two existing machines change nothing. A global install gets the OAR
117
- * state root: config, data, logs all under ~/.oar (APPDATA\oar on Windows),
118
- * which survives `npm i -g` updates.
126
+ * Brings pre-OAR state into a global install's state root. Without
127
+ * --migrate-from it runs quietly, once; with it, it runs unless that source
128
+ * was migrated already, never overwrites, and says what it did or why it did
129
+ * nothing. Database and uploads go where the relay `oar` starts will read
130
+ * them, resolved as the relay resolves them. Returns null to go on, or the
131
+ * result to stop with. A refusal stops only `oar setup` (`strict`): `oar
132
+ * copilot` warns and starts the session on the state root as it is.
119
133
  */
120
- function resolveStateLayout({ packageRoot, env = process.env } = {}) {
121
- if (isGitCheckout(packageRoot)) {
122
- return { checkout: true, root: null, configDir: getLegacyManagedConfigDir(env), dataDir: null };
134
+ async function migrateIntoStateRoot({
135
+ layout, env, packageRoot, migrateFrom = '', logger, isProcessAliveImpl = isProcessAlive, strict = true,
136
+ }) {
137
+ const serverDir = path.join(packageRoot, 'server');
138
+ const relayEnv = { COPILOT_WEB_RELAY_DATA_DIR: resolveRelayStateDirs({ env, layout }).dataDir };
139
+ const dataDir = resolveDataDir({ env: relayEnv, serverDir });
140
+ const source = migrateFrom ? path.resolve(migrateFrom) : '';
141
+ let migration;
142
+ try {
143
+ migration = await migrateStateToOarRoot({
144
+ targetRoot: layout.root,
145
+ targetDataDir: dataDir,
146
+ targetUploadDir: resolveUploadDir({ dataDir, env: relayEnv, serverDir }),
147
+ repoServerDir: source ? path.join(source, 'server') : null,
148
+ managedConfigDir: getLegacyManagedConfigDir(env),
149
+ isProcessAliveImpl,
150
+ });
151
+ } catch (error) {
152
+ logger.error?.(`[oar] State migration failed, nothing was changed at the source: ${error?.message || error}`);
153
+ return { code: 1, reason: 'migration-failed', error };
154
+ }
155
+ const refused = (message, reason) => {
156
+ logger.error?.(`[oar] ${message}`);
157
+ if (strict) return { code: 1, reason };
158
+ logger.error?.('[oar] Nothing was migrated; starting on the state root as it is.');
159
+ return null;
160
+ };
161
+ switch (migration.status) {
162
+ case 'blocked-live-relay': return refused(migration.error, 'migration-blocked');
163
+ case 'refused-relay-running': return refused(migration.error, 'migration-relay-running');
164
+ case 'refused-existing-database': return refused(migration.error, 'migration-refused');
165
+ case 'nothing-to-migrate':
166
+ return source ? refused(`Nothing to migrate from ${source}: ${migration.reason}.`, 'migration-nothing') : null;
167
+ case 'already-migrated':
168
+ if (source) logger.log?.(`[oar] ${layout.root} is already migrated from ${source}; nothing to do.`);
169
+ return null;
170
+ case 'migrated':
171
+ if (migration.movedAsideTo) {
172
+ logger.log?.(`[oar] The state root's database had no conversations yet (a fresh one); moved it aside to ${migration.movedAsideTo}.`);
173
+ }
174
+ logger.log?.(`[oar] Migrated ${source || 'earlier relay state'} into ${layout.root} (${migration.copied} items, sources untouched; receipt in backups/MIGRATION.md).`);
175
+ for (const kept of migration.kept || []) {
176
+ logger.log?.(`[oar] Kept the existing ${kept}; the one from the source was not copied.`);
177
+ }
178
+ for (const link of migration.skipped || []) {
179
+ logger.log?.(`[oar] Skipped the link ${link}: what it points to was not copied.`);
180
+ }
181
+ return null;
182
+ default:
183
+ return null;
123
184
  }
124
- const root = resolveOarRoot(env);
125
- return { checkout: false, root, configDir: root, dataDir: path.join(root, 'data') };
126
- }
127
-
128
- function getLauncherLogDir(env = process.env, layout = null) {
129
- const envLogDir = String(env.COPILOT_WEB_RELAY_LOG_DIR || '').trim();
130
- if (envLogDir) return envLogDir;
131
- if (layout && !layout.checkout) return path.join(layout.root, 'logs');
132
- return path.join(getLegacyManagedConfigDir(env), 'logs');
133
185
  }
134
186
 
135
187
  function getCopilotHomeDir(env = process.env) {
@@ -278,6 +330,7 @@ export async function launchRelay({
278
330
  fetchImpl = globalThis.fetch,
279
331
  isProcessAliveImpl = isProcessAlive,
280
332
  lockGraceMs = 5000,
333
+ readyWaitMs = 20_000,
281
334
  env = process.env,
282
335
  logger = console,
283
336
  } = {}) {
@@ -328,22 +381,11 @@ export async function launchRelay({
328
381
 
329
382
  if (!layout.checkout) {
330
383
  // Global install: state lives in the OAR root. Migrate pre-OAR state in
331
- // exactly once (marker-guarded); a live source relay blocks the migration.
332
- try {
333
- const migration = await migrateStateToOarRoot({
334
- targetRoot: layout.root,
335
- repoServerDir: migrateFromRoot ? path.join(path.resolve(migrateFromRoot), 'server') : null,
336
- managedConfigDir: getLegacyManagedConfigDir(env),
337
- logger,
338
- });
339
- if (migration.status === 'blocked-live-relay') {
340
- logger.error?.(`[oar] ${migration.error}`);
341
- return { code: 1, reason: 'migration-blocked' };
342
- }
343
- } catch (error) {
344
- logger.error?.(`[oar] State migration failed, nothing was changed at the source: ${error?.message || error}`);
345
- return { code: 1, reason: 'migration-failed', error };
346
- }
384
+ // (once, or as --migrate-from asks); a live source relay blocks it.
385
+ const stop = await migrateIntoStateRoot({
386
+ layout, env, packageRoot, migrateFrom: migrateFromRoot, logger, isProcessAliveImpl, strict: false,
387
+ });
388
+ if (stop) return stop;
347
389
  }
348
390
 
349
391
  const lockPath = layout.checkout
@@ -372,14 +414,21 @@ export async function launchRelay({
372
414
  ? await probeRelayPort(probe)
373
415
  : await probeRelayBehindLock({ ...probe, lockPath, port, isProcessAliveImpl, lockGraceMs, logger });
374
416
  if (answer === 'taken') {
375
- logger.error?.(`[oar] Port ${port} is in use by another program (or by a relay with another token).`);
417
+ logger.error?.(`[oar] Port ${port} is in use by another program, or by a relay with another token.`);
376
418
  logger.error?.('[oar] Stop that program, or pick another port: oar copilot --port <port>');
377
419
  return { code: 1, reason: 'port-taken' };
378
420
  }
421
+ if (answer === 'bringing-over') {
422
+ logger.log(takeoverLine(readTakeover(lockPath, isProcessAliveImpl), getLauncherLogDir(env, layout)));
423
+ return { code: 1, reason: 'bringing-over' };
424
+ }
379
425
  if (runtimeConfig.managed || !fs.existsSync(runtimeConfig.configPath)) {
380
426
  writeJsonFile(runtimeConfig.configPath, runtimeConfig.config);
381
427
  }
382
428
 
429
+ // Checkouts keep the repo-local server/data default; global installs point
430
+ // the server at the OAR root so updates cannot wipe the database.
431
+ const stateDirs = resolveRelayStateDirs({ env, layout });
383
432
  const baseEnv = {
384
433
  ...env,
385
434
  COPILOT_WORKSPACE_ROOT: cwd,
@@ -387,11 +436,7 @@ export async function launchRelay({
387
436
  COPILOT_WEB_RELAY_SERVER_DIR: serverDir,
388
437
  COPILOT_WEB_RELAY_CONFIG: runtimeConfig.configPath,
389
438
  COPILOT_WEB_RELAY_LOG_DIR: getLauncherLogDir(env, layout),
390
- // Checkouts keep the repo-local server/data default; global installs point
391
- // the server at the OAR root so updates cannot wipe the database.
392
- ...(layout.checkout ? {} : {
393
- COPILOT_WEB_RELAY_DATA_DIR: String(env.COPILOT_WEB_RELAY_DATA_DIR || '').trim() || layout.dataDir,
394
- }),
439
+ ...(stateDirs.dataDir ? { COPILOT_WEB_RELAY_DATA_DIR: stateDirs.dataDir } : {}),
395
440
  GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS: String(env.GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS || 'true'),
396
441
  };
397
442
 
@@ -429,7 +474,7 @@ export async function launchRelay({
429
474
  return { code: 1, reason: 'spawn-invalid-child' };
430
475
  }
431
476
 
432
- const readyDeadlineMs = 20_000;
477
+ const readyDeadlineMs = readyWaitMs;
433
478
  const ready = await new Promise((resolve) => {
434
479
  const deadline = Date.now() + readyDeadlineMs;
435
480
  const probe = async () => {
@@ -457,6 +502,14 @@ export async function launchRelay({
457
502
  probe();
458
503
  });
459
504
 
505
+ const takeover = ready ? null : readTakeover(lockPath, isProcessAliveImpl);
506
+ if (takeover) {
507
+ // Left running: a kill would cut the copy short for nothing.
508
+ logger.log(takeoverLine(takeover, logDir));
509
+ try { fs.closeSync(serverOutFd); } catch {}
510
+ try { fs.closeSync(serverErrFd); } catch {}
511
+ return { code: 1, reason: 'bringing-over' };
512
+ }
460
513
  if (!ready) {
461
514
  logger.error?.(`[oar] Relay did not become ready at ${statusUrl}`);
462
515
  try {
@@ -667,7 +720,11 @@ async function probeRelayBehindLock({
667
720
  await new Promise((resolve) => setTimeout(resolve, 300));
668
721
  answer = await probeRelayPort({ statusUrl, token, fetchImpl });
669
722
  }
670
- if (answer === 'none' && lockedByLiveProcess()) {
723
+ if (answer === 'none' && readTakeover(lockPath, isProcessAliveImpl)) return 'bringing-over';
724
+ // A lock this version wrote is left to the server, which tells a reused
725
+ // process id itself (lockHolderMayRun): removed here, a second relay
726
+ // started beside one still bringing over its database.
727
+ if (answer === 'none' && lockedByLiveProcess() && !readRelayLock(lockPath)?.format) {
671
728
  // Nothing listens: the lock is left over from a relay that did not
672
729
  // stop cleanly. The server refuses to start beside a lock whose process
673
730
  // id is alive, so it goes first.
@@ -697,6 +754,7 @@ export async function ensureRelayRunning({
697
754
  fetchImpl = globalThis.fetch,
698
755
  isProcessAliveImpl = isProcessAlive,
699
756
  lockGraceMs = 5000,
757
+ readyWaitMs = 20_000,
700
758
  homeDir = os.homedir(),
701
759
  platform = process.platform,
702
760
  logger = console,
@@ -710,8 +768,9 @@ export async function ensureRelayRunning({
710
768
  statusUrl, token, lockPath, port, fetchImpl, isProcessAliveImpl, lockGraceMs, logger,
711
769
  });
712
770
  if (answer === 'oar') return { ok: true, how: 'already', logDir };
771
+ if (answer === 'bringing-over') return { ok: false, how: 'bringing-over', logDir, takeover: readTakeover(lockPath, isProcessAliveImpl) };
713
772
  if (answer === 'taken') {
714
- logger.error?.(`[oar] Port ${port} is in use by another program (or by a relay with another token).`);
773
+ logger.error?.(`[oar] Port ${port} is in use by another program, or by a relay with another token.`);
715
774
  logger.error?.('[oar] Stop that program, or pick another port: oar setup --port <port>');
716
775
  return { ok: false, how: 'port-taken', logDir };
717
776
  }
@@ -739,7 +798,7 @@ export async function ensureRelayRunning({
739
798
  COPILOT_WEB_RELAY_SERVER_DIR: serverDir,
740
799
  COPILOT_WEB_RELAY_CONFIG: configPath,
741
800
  COPILOT_WEB_RELAY_LOG_DIR: logDir,
742
- COPILOT_WEB_RELAY_DATA_DIR: String(env.COPILOT_WEB_RELAY_DATA_DIR || '').trim() || layout.dataDir,
801
+ COPILOT_WEB_RELAY_DATA_DIR: resolveRelayStateDirs({ env, layout }).dataDir,
743
802
  },
744
803
  detached: true,
745
804
  stdio: ['ignore', outFd, errFd],
@@ -753,8 +812,10 @@ export async function ensureRelayRunning({
753
812
  try { fs.closeSync(outFd); } catch {}
754
813
  try { fs.closeSync(errFd); } catch {}
755
814
  }
756
- const ok = await waitForRelay({ statusUrl, token, fetchImpl, stillStarting: () => child.exitCode === null });
757
- return { ok, how: 'detached', logDir };
815
+ const ok = await waitForRelay({ statusUrl, token, fetchImpl, deadlineMs: readyWaitMs, stillStarting: () => child.exitCode === null });
816
+ const takeover = ok ? null : readTakeover(lockPath, isProcessAliveImpl);
817
+ if (takeover) return { ok: false, how: 'bringing-over', logDir, takeover };
818
+ return { ok, how: 'detached', logDir, ...(ok ? {} : { lock: heldLock(lockPath, isProcessAliveImpl) }) };
758
819
  }
759
820
 
760
821
  /**
@@ -916,7 +977,7 @@ async function restartRelay(ctx, paths, { old, viaService }) {
916
977
  logger.error('[oar] The relay came back on its earlier port and token: it was started with fixed ones (oar copilot). End that session, then run: oar start');
917
978
  return { ok: false, reason: 'fixed-settings' };
918
979
  }
919
- logger.error(`[oar] The relay did not come back — see ${path.join(paths.logDir, 'server-err.log')}`);
980
+ logger.error(`[oar] The relay did not come back — see ${viaService ? 'journalctl --user -u oar' : path.join(paths.logDir, 'server-err.log')}`);
920
981
  return { ok: false, reason: 'server-not-ready' };
921
982
  }
922
983
 
@@ -987,6 +1048,7 @@ function startRelay(ctx, paths) {
987
1048
  fetchImpl: ctx.fetchImpl,
988
1049
  isProcessAliveImpl: ctx.isProcessAliveImpl,
989
1050
  lockGraceMs: ctx.lockGraceMs,
1051
+ readyWaitMs: ctx.readyWaitMs,
990
1052
  homeDir: ctx.homeDir,
991
1053
  platform: ctx.platform,
992
1054
  logger: ctx.logger,
@@ -1028,12 +1090,18 @@ function warnWindowsPort(ctx, port, windowsHolder = createWindowsPortCheck(ctx))
1028
1090
  function reportRelayStart(result, logger) {
1029
1091
  // A port held by something else was already explained.
1030
1092
  if (result.how === 'port-taken') return;
1093
+ if (result.how === 'bringing-over') {
1094
+ logger.log(takeoverLine(result.takeover, result.logDir));
1095
+ return;
1096
+ }
1031
1097
  if (!result.ok) {
1032
- logger.error?.(`[oar] The relay did not come up — see ${path.join(result.logDir, 'server-err.log')}`);
1098
+ logger.error?.(`[oar] The relay did not come up — see ${result.how === 'service' ? 'journalctl --user -u oar' : path.join(result.logDir, 'server-err.log')}`);
1099
+ if (result.lock?.known) logger.error?.(`[oar] The lock ${result.lock.path} is held by relay process ${result.lock.pid} (started ${result.lock.startedAt}): oar stop, or end process ${result.lock.pid}.`);
1100
+ else if (result.lock) logger.error?.(`[oar] The lock ${result.lock.path} names process ${result.lock.pid}, which cannot be told to be a relay: if no relay is running, end process ${result.lock.pid} or delete the lock.`);
1033
1101
  return;
1034
1102
  }
1035
1103
  if (result.how === 'already') {
1036
- logger.log('[oar] The relay is already running. To load a new version, restart it: oar restart');
1104
+ logger.log('[oar] The relay is already running. It keeps the version and settings it started with until you restart it: oar restart');
1037
1105
  } else if (result.how === 'service') {
1038
1106
  logger.log('[oar] The relay is running as a systemd user service (systemctl --user status oar).');
1039
1107
  } else {
@@ -1048,7 +1116,7 @@ function requireConfig(ctx, paths) {
1048
1116
  }
1049
1117
 
1050
1118
  function reportPortTaken(ctx, paths, port) {
1051
- ctx.logger.error(`[oar] Port ${port} is held by another program, or by a relay with another token.`);
1119
+ ctx.logger.error(`[oar] Port ${port} is in use by another program, or by a relay with another token.`);
1052
1120
  // A relay that was started before its token was changed in the config
1053
1121
  // cannot be asked to restart: only its lock tells that it is there.
1054
1122
  const pid = readRelayLock(paths.lockPath)?.pid;
@@ -1077,6 +1145,7 @@ export async function runStart(options = {}) {
1077
1145
  reportRelayStart(result, logger);
1078
1146
  if (result.ok) await showAddress(ctx, paths);
1079
1147
  warnWindowsPort(ctx, endpointOf(paths.config).port);
1148
+ if (result.how === 'bringing-over') return { code: 0, reason: 'bringing-over' };
1080
1149
  return { code: result.ok ? 0 : 1, reason: result.ok ? 'started' : 'server-not-ready' };
1081
1150
  }
1082
1151
 
@@ -1092,7 +1161,7 @@ export async function runStop(options = {}) {
1092
1161
  logger.error('[oar] systemctl --user stop oar failed — see: systemctl --user status oar');
1093
1162
  return { code: 1, reason: 'service' };
1094
1163
  }
1095
- logger.log('[oar] The relay service is stopped. It starts again at the next login; to take it out: oar service remove');
1164
+ logger.log('[oar] The relay service is stopped. It starts again at the next boot (without lingering: at the next login); to take it out: oar service remove');
1096
1165
  return { code: 0, reason: 'stopped' };
1097
1166
  }
1098
1167
  const relay = await findRelay(ctx, paths);
@@ -1172,7 +1241,7 @@ export async function runStatus(options = {}) {
1172
1241
  const access = status.access === 'lan' ? 'LAN access' : 'this machine only';
1173
1242
  if (status.relay === 'running') logger.log(`[oar] The relay is running on port ${status.port} (${access}): ${status.url}`);
1174
1243
  else if (status.relay === 'stopped') logger.log(`[oar] The relay is stopped (port ${status.port}, ${access}). Start it with: oar start`);
1175
- else logger.log(`[oar] Port ${status.port} is held by another program, or by a relay with another token.`);
1244
+ else logger.log(`[oar] Port ${status.port} is in use by another program, or by a relay with another token.`);
1176
1245
  if (status.service !== 'unavailable') {
1177
1246
  logger.log(`[oar] Service: ${{ active: 'installed, running', installed: 'installed', none: 'not installed' }[status.service]}`);
1178
1247
  }
@@ -1205,7 +1274,7 @@ async function runWindowsService(ctx, paths, action) {
1205
1274
  return { code: 0, reason: 'service-status' };
1206
1275
  }
1207
1276
  if (boot) {
1208
- logger.error('[oar] The relay starts at boot through a scheduled task. Change that in the web UI: Settings → Autostart.');
1277
+ logger.error('[oar] The relay starts at boot through a scheduled task. Change that in the web UI: Settings → General → Autostart (Windows).');
1209
1278
  return { code: 1, reason: 'boot-task' };
1210
1279
  }
1211
1280
  if (action === 'install' && requireConfig(ctx, paths)) return { code: 1, reason: 'no-config' };
@@ -1261,7 +1330,7 @@ export async function runService(options = {}) {
1261
1330
  // A relay started by hand holds the port and the lock: it makes way first.
1262
1331
  if ((await runStop({ ...options, argv: [] })).reason !== 'stopped') {
1263
1332
  runQuiet('systemctl', ['--user', 'enable', 'oar'], ctx);
1264
- logger.log('[oar] The service is installed and takes over at the next login, or once the relay has stopped: oar start');
1333
+ logger.log('[oar] The service is installed and takes over at the next boot (without lingering: at the next login), or once the relay has stopped: oar start');
1265
1334
  return { code: 0, reason: 'service-install' };
1266
1335
  }
1267
1336
  }
@@ -1310,21 +1379,13 @@ export async function runSetup(options = {}) {
1310
1379
  let paths = installPaths(ctx);
1311
1380
  const { layout } = paths;
1312
1381
  if (!layout.checkout) {
1313
- const migrateFromRoot = String(optionValue(argv, '--migrate-from') || '').trim();
1314
- const migration = await migrateStateToOarRoot({
1315
- targetRoot: layout.root,
1316
- repoServerDir: migrateFromRoot ? path.join(path.resolve(migrateFromRoot), 'server') : null,
1317
- managedConfigDir: getLegacyManagedConfigDir(env),
1318
- logger,
1382
+ const migrateFrom = String(optionValue(argv, '--migrate-from') || '').trim();
1383
+ const stop = await migrateIntoStateRoot({
1384
+ layout, env, packageRoot: ctx.packageRoot, migrateFrom, logger, isProcessAliveImpl: ctx.isProcessAliveImpl,
1319
1385
  });
1320
- if (migration.status === 'blocked-live-relay') {
1321
- logger.error?.(`[oar] ${migration.error}`);
1322
- return { code: 1, reason: 'migration-blocked' };
1323
- }
1324
- if (migration.status === 'migrated') {
1325
- logger.log(`[oar] Migrated existing relay state into ${layout.root} (sources untouched).`);
1326
- paths = installPaths(ctx);
1327
- }
1386
+ if (stop) return stop;
1387
+ // A migration may have brought a config over.
1388
+ paths = installPaths(ctx);
1328
1389
  }
1329
1390
 
1330
1391
  const existing = paths.config;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.13",
3
+ "version": "0.9.14",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -27,7 +27,6 @@
27
27
  "!server/config.json",
28
28
  "!server/uploads/",
29
29
  "server/uploads/README.md",
30
- "server/uploads/image_input.md",
31
30
  "shared/",
32
31
  "!shared/**/*.test.mjs",
33
32
  "!shared/**/*-test-harness.mjs",
@@ -39,6 +38,7 @@
39
38
  "LICENSE"
40
39
  ],
41
40
  "scripts": {
41
+ "preinstall": "node server/services/package-data-rescue-preinstall.mjs",
42
42
  "start": "node server/server.js",
43
43
  "copilot:relay": "gh copilot -- --allow-all",
44
44
  "test": "node --import ./scripts/node-test-keep-alive.mjs --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\" \"scripts/**/*.test.mjs\"",
@@ -50,9 +50,9 @@
50
50
  "test:e2e:raw": "node ./node_modules/@playwright/test/cli.js test --config tests/playwright.config.mjs"
51
51
  },
52
52
  "dependencies": {
53
- "@anthropic-ai/claude-agent-sdk": "0.3.292",
54
- "@cursor/sdk": "1.0.36",
55
- "@github/copilot-sdk": "1.0.17",
53
+ "@anthropic-ai/claude-agent-sdk": "0.3.304",
54
+ "@cursor/sdk": "1.0.38",
55
+ "@github/copilot-sdk": "1.0.23",
56
56
  "better-sqlite3": "12.11.1",
57
57
  "express": "4.22.3",
58
58
  "qrcode-terminal": "0.12.0",