@oar-sh/oar 0.9.19 → 0.9.20

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 (119) 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 +317 -0
  4. package/README.md +10 -10
  5. package/bin/oar.js +391 -97
  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 +27 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  10. package/server/claude-worker/claude-session-process.mjs +3 -0
  11. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  12. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  13. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  14. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  15. package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
  16. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  17. package/server/db-schema.mjs +18 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  19. package/server/grok-worker/grok-turn-runner.mjs +44 -6
  20. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  21. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  22. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  23. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  24. package/server/migrations/0005-remote-relays.mjs +5 -7
  25. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  26. package/server/migrations/0007-claude-cloud.mjs +5 -7
  27. package/server/migrations/0008-message-pins.mjs +5 -7
  28. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  29. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  30. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  31. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  32. package/server/migrations/0013-previews.mjs +5 -7
  33. package/server/migrations/0014-conversation-groups.mjs +5 -7
  34. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  35. package/server/migrations/0016-share-access-log.mjs +5 -7
  36. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  37. package/server/migrations/0018-background-task-rows.mjs +5 -7
  38. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  39. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  40. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  41. package/server/migrations/0022-relay-schedules.mjs +5 -7
  42. package/server/migrations/0023-relay-asks.mjs +5 -7
  43. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  44. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  45. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  46. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  47. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  48. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  49. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  50. package/server/migrations/0031-queue-provider.mjs +5 -7
  51. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  52. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  53. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  54. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  55. package/server/migrations/0036-token-usage.mjs +267 -0
  56. package/server/public/app/about-view.mjs +87 -3
  57. package/server/public/app/api-client.js +2 -1
  58. package/server/public/app/bootstrap.js +55 -2
  59. package/server/public/app/composer-control-state.mjs +12 -2
  60. package/server/public/app/context-usage-view.mjs +16 -0
  61. package/server/public/app/conversation-view.js +17 -2
  62. package/server/public/app/inbox-ui.js +32 -0
  63. package/server/public/app/inbox-view.mjs +141 -5
  64. package/server/public/app/plan-board-shared.mjs +49 -0
  65. package/server/public/app/plan-usage-view.mjs +1 -1
  66. package/server/public/app/relay-board-inline.mjs +4 -1
  67. package/server/public/app/relay-board-view.js +38 -10
  68. package/server/public/app/relay-event-view.mjs +4 -0
  69. package/server/public/app/socket-handlers.js +10 -2
  70. package/server/public/index.html +14 -0
  71. package/server/public/sw.js +8 -0
  72. package/server/relay-heap-guard.mjs +14 -1
  73. package/server/relay-self-restart.mjs +83 -2
  74. package/server/relay-tools.md +4 -3
  75. package/server/repositories/message-repository.mjs +31 -1
  76. package/server/repositories/remote-relay-repository.mjs +10 -0
  77. package/server/repositories/session-repository.mjs +6 -1
  78. package/server/routes/messages-routes.mjs +87 -0
  79. package/server/routes/relay-ask-routes.mjs +138 -8
  80. package/server/routes/relay-board-routes.mjs +55 -40
  81. package/server/routes/sessions-routes.mjs +103 -7
  82. package/server/server-runtime.mjs +132 -11
  83. package/server/services/about-service.mjs +59 -0
  84. package/server/services/cache-rebuild-service.mjs +7 -0
  85. package/server/services/conversation-preferences-service.mjs +31 -0
  86. package/server/services/database-file.mjs +413 -0
  87. package/server/services/db-backup-service.mjs +809 -0
  88. package/server/services/deepseek-usage-import.mjs +6 -9
  89. package/server/services/delete-archive-service.mjs +3 -0
  90. package/server/services/earlier-database-service.mjs +69 -126
  91. package/server/services/oar-cli-helpers.mjs +214 -12
  92. package/server/services/oar-cli-menu.mjs +1 -0
  93. package/server/services/oar-state-migration-service.mjs +33 -13
  94. package/server/services/package-data-rescue.mjs +4 -2
  95. package/server/services/plan-usage-claude-api.mjs +14 -15
  96. package/server/services/plan-usage-contract.mjs +6 -2
  97. package/server/services/plan-usage-deepseek.mjs +1 -1
  98. package/server/services/plan-usage-tokens.mjs +248 -0
  99. package/server/services/push-dispatch-service.mjs +134 -9
  100. package/server/services/relay-cli-launcher-service.mjs +3 -0
  101. package/server/services/relay-event-service.mjs +30 -0
  102. package/server/services/relay-question-context.mjs +11 -0
  103. package/server/services/relay-restart-picture.mjs +67 -22
  104. package/server/services/remote-inbox-watch-service.mjs +204 -0
  105. package/server/services/remote-relay-caller-context.mjs +80 -7
  106. package/server/services/remote-relay-dispatcher.mjs +40 -5
  107. package/server/services/restart-resume-service.mjs +5 -2
  108. package/server/services/session-worker-launch-service.mjs +12 -3
  109. package/server/services/session-worker-stop-service.mjs +27 -6
  110. package/server/services/token-usage-service.mjs +430 -0
  111. package/server/services/update-install-service.mjs +13 -0
  112. package/server/services/usage-limit-pause-service.mjs +8 -1
  113. package/server/services/worker-scope.mjs +77 -0
  114. package/shared/plan-board-actions.mjs +130 -0
  115. package/shared/plan-only.mjs +43 -0
  116. package/shared/relay-asks.mjs +3 -0
  117. package/shared/relay-events.mjs +38 -4
  118. package/shared/remote-relay-contract.mjs +66 -11
  119. package/shared/turn-token-usage.mjs +144 -0
@@ -7,6 +7,7 @@ import { randomBytes } from 'node:crypto';
7
7
  import fs from 'node:fs';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
+ import { formatMegabytes } from './db-backup-service.mjs';
10
11
 
11
12
  export function generateAuthToken() {
12
13
  return randomBytes(32).toString('base64url');
@@ -66,21 +67,22 @@ export function describeRelayAddress({ config, lanAddress = null, wslNat = false
66
67
  return { url, lines, qrUrl, tunnelUrl };
67
68
  }
68
69
 
69
- const VALUE_OPTIONS = ['--port', '--migrate-from', '--to', '--autostart'];
70
+ const VALUE_OPTIONS = ['--port', '--migrate-from', '--to', '--autostart', '--reason'];
70
71
  const LAUNCHER_FLAGS = ['--install-extension', '--no-install-extension', '--migrate-from'];
71
72
  const COMMAND_OPTIONS = { '--setup': 'setup', '--start': 'start', '--stop': 'stop', '--status': 'status' };
72
73
  const CLI_COMMANDS = {
73
74
  menu: {},
74
75
  start: {},
75
- stop: {},
76
- restart: {},
76
+ stop: { flags: ['--now'] },
77
+ restart: { flags: ['--now'] },
77
78
  status: {},
78
79
  url: {},
79
80
  doctor: {},
81
+ backup: { values: ['--reason'] },
80
82
  help: {},
81
83
  version: {},
82
84
  setup: { flags: ['--lan', '--local', '--new-token', '--defaults', '--start'], values: ['--port', '--migrate-from', '--autostart'] },
83
- service: { words: ['install', 'remove', 'status'] },
85
+ service: { words: ['install', 'remove', 'status'], flags: ['--replace'] },
84
86
  autostart: { words: ['signin', 'boot', 'off', 'status'], flags: ['--replace'] },
85
87
  uninstall: { flags: ['--purge'] },
86
88
  update: { flags: ['--beta'], values: ['--to'] },
@@ -93,8 +95,8 @@ export function usageText() {
93
95
  '',
94
96
  ' oar Menu in a terminal; status and this text elsewhere',
95
97
  ' oar start Start the relay in the background',
96
- ' oar stop Stop the relay (waits for running turns)',
97
- ' oar restart Restart the relay (waits for running turns)',
98
+ ' oar stop [--now] Stop the relay (waits for running turns; --now does not)',
99
+ ' oar restart [--now] Restart the relay (waits for running turns; --now does not)',
98
100
  ' oar status Is the relay running, on which port, as a service',
99
101
  ' oar url Relay URL, with a QR code when a phone can reach it',
100
102
  ' oar setup Auth token, access, port and autostart; asks, or takes',
@@ -103,13 +105,16 @@ export function usageText() {
103
105
  ' oar autostart signin | boot | off | status [--replace]',
104
106
  ' Start the relay at sign-in or at boot (Windows: boot asks for an admin once);',
105
107
  ' --replace replaces hand-made entries, keeping a copy',
106
- ' oar service install | remove | status',
107
- ' Start the relay at login',
108
+ ' oar service install | remove | status [--replace]',
109
+ ' Start the relay at boot and keep it running (Linux: a systemd user service);',
110
+ ' --replace replaces a hand-made unit, keeping a copy',
108
111
  ' oar uninstall [--purge]',
109
112
  ' Remove autostart and the package; the data stays (--purge deletes it)',
110
113
  ' oar copilot [--port <port>] [--install-extension] [--no-install-extension] [-- gh copilot args...]',
111
114
  ' Relay plus a Copilot terminal session',
112
115
  ' oar doctor Check the install and the provider CLIs',
116
+ ' oar backup [--reason <text>]',
117
+ ' A copy of the database into the backup folder (safe while the relay runs)',
113
118
  ' oar update [--beta] [--to <version>]',
114
119
  ' oar help This text',
115
120
  ' oar --version',
@@ -178,15 +183,75 @@ export function parseCliArgs(argv = []) {
178
183
  return { command, args: [...args, ...forwarded] };
179
184
  }
180
185
 
186
+ // The first systemd version with RestartSteps= and RestartMaxDelaySec=.
187
+ const SYSTEMD_BACKOFF_VERSION = 254;
188
+ const UNIT_MARKER = /^# Generated by `oar service install` for (.*)\.$/m;
189
+
190
+ /** The version `systemctl --version` prints first (`systemd 257 (257.9-1)`), or null. */
191
+ export function parseSystemdVersion(text) {
192
+ const version = Number(String(text || '').match(/^systemd (\d+)/m)?.[1]);
193
+ return Number.isInteger(version) && version > 0 ? version : null;
194
+ }
195
+
181
196
  /**
182
197
  * A systemd *user* unit for the relay server. The launcher is not used here —
183
- * the service runs server.js directly with the same env the launcher would set,
184
- * so a `gh copilot` session is not tied to the unit's lifetime.
198
+ * the service runs server.js directly, so a `gh copilot` session is not tied
199
+ * to the unit's lifetime. A global install pins its state paths; a git
200
+ * checkout (`checkout`) runs in the checkout and finds its config, data and
201
+ * logs as `npm start` does. Crashes are restarted with a backoff that never
202
+ * gives up; a systemd older than 254 (or one whose version is unknown) has no
203
+ * backoff, and gets a longer fixed delay instead.
185
204
  */
186
- export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, logDir, pathEnv = '' }) {
187
- // systemd units are Linux-only, so the path inside the unit is always
205
+ export function buildSystemdUnit({
206
+ nodeBin, packageRoot, configPath, dataDir, logDir, pathEnv = '', checkout = false, systemdVersion = null,
207
+ }) {
208
+ // systemd units are Linux-only, so the paths inside the unit are always
188
209
  // posix-joined — host-platform path.join would write backslashes when this
189
210
  // template is exercised on Windows (tests; the CLI never writes it there).
211
+ const backoff = Number(systemdVersion) >= SYSTEMD_BACKOFF_VERSION;
212
+ return [
213
+ `# Generated by \`oar service install\` for ${packageRoot}.`,
214
+ '# OAR rewrites this file; put settings of your own in oar.service.d/*.conf next to it.',
215
+ '[Unit]',
216
+ 'Description=OAR — Open Agent Relay',
217
+ 'After=network-online.target',
218
+ 'StartLimitIntervalSec=0',
219
+ '',
220
+ '[Service]',
221
+ `ExecStart=${systemdExecWord(nodeBin)} ${systemdExecWord(path.posix.join(packageRoot, 'server', 'server.js'))}`,
222
+ ...(checkout
223
+ ? [`WorkingDirectory=${String(packageRoot).replace(/%/g, '%%')}`]
224
+ : [
225
+ systemdEnvironmentLine('COPILOT_WEB_RELAY_CONFIG', configPath),
226
+ systemdEnvironmentLine('COPILOT_WEB_RELAY_DATA_DIR', dataDir),
227
+ systemdEnvironmentLine('COPILOT_WEB_RELAY_LOG_DIR', logDir),
228
+ ]),
229
+ // A user service starts with systemd's bare PATH: without this line the
230
+ // relay's sessions find neither the provider CLIs in ~/.local/bin nor, on
231
+ // an install with a private Node, `node` and `npm` themselves.
232
+ ...(pathEnv ? [`Environment="PATH=${escapeSystemdValue(pathEnv)}"`] : []),
233
+ // The supervisor alone gets the SIGTERM of a stop, and stops its runtime
234
+ // itself; whatever is left then gets SIGKILL. Session workers run in
235
+ // scopes of their own and are not part of the unit.
236
+ 'KillMode=mixed',
237
+ 'Restart=on-failure',
238
+ // 78 (RELAY_STOP_EXIT_CODE): the relay stopped on purpose, its database
239
+ // or data folder needs a decision; restarting it cannot help.
240
+ '# 78: stopped on purpose (database or data folder needs a decision).',
241
+ 'RestartPreventExitStatus=78',
242
+ ...(backoff ? ['RestartSec=2', 'RestartSteps=8', 'RestartMaxDelaySec=300'] : ['RestartSec=10']),
243
+ '',
244
+ '[Install]',
245
+ 'WantedBy=default.target',
246
+ '',
247
+ ].join('\n');
248
+ }
249
+
250
+ /**
251
+ * The unit 0.9.19 wrote, frozen: a unit with this text is OAR's own, of an
252
+ * earlier version (classifySystemdUnit). Never change it.
253
+ */
254
+ export function buildSystemdUnit0919({ nodeBin, packageRoot, configPath, dataDir, logDir, pathEnv = '' }) {
190
255
  return [
191
256
  '[Unit]',
192
257
  'Description=OAR — Open Agent Relay',
@@ -219,6 +284,102 @@ function escapeSystemdValue(value) {
219
284
  return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%');
220
285
  }
221
286
 
287
+ // Characters that end or change a word of an unquoted systemd value.
288
+ const SYSTEMD_NEEDS_QUOTES = /[\s"'\\]/;
289
+
290
+ // One word of ExecStart=: quoted when it has to be; `%` and `$` (a specifier,
291
+ // a variable) are doubled either way.
292
+ function systemdExecWord(value) {
293
+ const text = String(value).replace(/\$/g, '$$$$');
294
+ return SYSTEMD_NEEDS_QUOTES.test(text) ? `"${escapeSystemdValue(text)}"` : text.replace(/%/g, '%%');
295
+ }
296
+
297
+ // `Environment=KEY=value`, quoted only when the value has to be: a path
298
+ // without a space is written as 0.9.19 wrote it.
299
+ function systemdEnvironmentLine(name, value) {
300
+ const text = String(value);
301
+ return SYSTEMD_NEEDS_QUOTES.test(text)
302
+ ? `Environment="${name}=${escapeSystemdValue(text)}"`
303
+ : `Environment=${name}=${text.replace(/%/g, '%%')}`;
304
+ }
305
+
306
+ // The first word of a unit's ExecStart= line: `written` as it stands, `value`
307
+ // unquoted and unescaped as systemdExecWord wrote it.
308
+ function readExecStartNode(text) {
309
+ const line = String(text || '').match(/^ExecStart=(.*)$/m)?.[1] || '';
310
+ if (line.startsWith('"')) {
311
+ const written = line.match(/^"((?:[^"\\]|\\.)*)"/)?.[0] || '';
312
+ const value = written.slice(1, -1).replace(/\\(.)/g, '$1').replace(/%%/g, '%').replace(/\$\$/g, '$');
313
+ return { written, value };
314
+ }
315
+ const written = line.split(' ')[0];
316
+ return { written, value: written.replace(/%%/g, '%').replace(/\$\$/g, '$') };
317
+ }
318
+
319
+ // The words of a unit's ExecStart= line, unquoted and unescaped.
320
+ function readExecStartWords(text) {
321
+ const line = String(text || '').match(/^ExecStart=(.*)$/m)?.[1] || '';
322
+ const words = [];
323
+ for (const match of line.matchAll(/"((?:[^"\\]|\\.)*)"|(\S+)/g)) {
324
+ const word = match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2];
325
+ words.push(word.replace(/%%/g, '%').replace(/\$\$/g, '$'));
326
+ }
327
+ return words;
328
+ }
329
+
330
+ /**
331
+ * Whether a unit without OAR's marker runs the relay of the checkout at
332
+ * `root`: its ExecStart names `<root>/server/server.js`. A unit that only
333
+ * runs in `<root>` (WorkingDirectory) does not count: it may start anything
334
+ * there. A hand-made unit of another install is not a checkout's service.
335
+ */
336
+ export function systemdUnitRunsCheckout(text, root) {
337
+ const resolve = (value) => path.posix.resolve(String(value));
338
+ const server = path.posix.join(resolve(root), 'server', 'server.js');
339
+ return readExecStartWords(text).some((word) => word.startsWith('/') && resolve(word) === server);
340
+ }
341
+
342
+ /** The install a unit's marker names, or ''. */
343
+ export function systemdUnitMarkerRoot(text) {
344
+ return String(text || '').match(UNIT_MARKER)?.[1] || '';
345
+ }
346
+
347
+ /**
348
+ * Whose a unit is, for this install (`{ nodeBin, packageRoot, configPath,
349
+ * dataDir, logDir, checkout }`): 'generated' (the text this version writes for
350
+ * it, with the Node of its own ExecStart and for either systemd variant),
351
+ * 'generated-earlier' (the text 0.9.19 wrote for it), 'other-install' (OAR's
352
+ * marker for another install path), or 'hand-made' (anything else, a
353
+ * generated unit edited by hand too). The PATH line does not count: it is the
354
+ * PATH of the shell that ran the install.
355
+ */
356
+ export function classifySystemdUnit({ text, install }) {
357
+ const strip = (unit) => String(unit).replace(/\r\n/g, '\n').split('\n')
358
+ .filter((line) => !line.startsWith('Environment="PATH=')).join('\n').trim();
359
+ const given = strip(text);
360
+ const node = readExecStartNode(text);
361
+ const args = { ...install, nodeBin: node.value || install.nodeBin };
362
+ if ([null, SYSTEMD_BACKOFF_VERSION].some((systemdVersion) => strip(buildSystemdUnit({ ...args, systemdVersion })) === given)) {
363
+ return 'generated';
364
+ }
365
+ if (!install.checkout && strip(buildSystemdUnit0919({ ...install, nodeBin: node.written || install.nodeBin })) === given) {
366
+ return 'generated-earlier';
367
+ }
368
+ const root = systemdUnitMarkerRoot(text);
369
+ if (root && path.posix.resolve(root) !== path.posix.resolve(String(install.packageRoot))) return 'other-install';
370
+ return 'hand-made';
371
+ }
372
+
373
+ /** The drop-ins next to the unit (`oar.service.d/*.conf`), sorted; OAR never writes or deletes them. */
374
+ export function listSystemdDropIns(homeDir, { fsImpl = fs } = {}) {
375
+ const dir = path.join(homeDir, '.config', 'systemd', 'user', 'oar.service.d');
376
+ try {
377
+ return fsImpl.readdirSync(dir).filter((name) => name.endsWith('.conf')).sort().map((name) => path.join(dir, name));
378
+ } catch {
379
+ return [];
380
+ }
381
+ }
382
+
222
383
  /**
223
384
  * The PATH a relay service gets: the PATH `oar setup` ran with (the user's
224
385
  * shell, so their tools), with two directories added when they are not on it:
@@ -287,6 +448,42 @@ export const DOCTOR_PROBES = Object.freeze([
287
448
  { id: 'grok', binary: 'grok', args: ['--version'] },
288
449
  ]);
289
450
 
451
+
452
+ function doctorAge(at, now) {
453
+ const ms = now - Date.parse(String(at || ''));
454
+ if (!Number.isFinite(ms)) return 'unknown age';
455
+ const hours = Math.floor(ms / 3_600_000);
456
+ return hours < 1 ? 'less than an hour ago' : (hours < 48 ? `${hours} h ago` : `${Math.floor(hours / 24)} days ago`);
457
+ }
458
+
459
+ // The backup lines of `oar doctor` (db-backup-service.mjs describeBackups).
460
+ function renderBackupLines(backups, now) {
461
+ const pad = ' ';
462
+ const lines = [` backups : ${backups.dir || '(no folder)'}${backups.problem ? ` (${backups.problem})` : ''}`];
463
+ lines.push(`${pad}at start: ${backups.enabled ? 'on' : 'off (dbBackups.enabled is false)'}, keep ${backups.keep}`);
464
+ for (const warning of backups.warnings || []) lines.push(`${pad}warning: ${warning}`);
465
+ const last = backups.lastResult;
466
+ if (last) {
467
+ const what = last.status === 'written' ? `written ${last.file} (${formatMegabytes(last.bytes)})` : `${last.status}: ${last.reason || ''}`;
468
+ lines.push(`${pad}last: ${what}, ${doctorAge(last.at, now)}`);
469
+ }
470
+ if (backups.own?.length) {
471
+ lines.push(`${pad}this relay's (${backups.own.length} of ${backups.keep}):`);
472
+ for (const entry of backups.own) lines.push(`${pad} ${entry.file} ${formatMegabytes(entry.bytes)}, ${doctorAge(entry.at, now)}`);
473
+ } else {
474
+ lines.push(`${pad}this relay's: none yet`);
475
+ }
476
+ if (backups.changed?.length) {
477
+ lines.push(`${pad}changed since written, not rotated:`);
478
+ for (const entry of backups.changed) lines.push(`${pad} ${entry.file} ${formatMegabytes(entry.bytes)}`);
479
+ }
480
+ if (backups.others?.length) {
481
+ lines.push(`${pad}other files here (not rotated):`);
482
+ for (const entry of backups.others) lines.push(`${pad} ${entry.name}${entry.folder ? '/' : ''} ${formatMegabytes(entry.bytes)}`);
483
+ }
484
+ return lines;
485
+ }
486
+
290
487
  export function renderDoctorReport({
291
488
  version,
292
489
  nodeVersion,
@@ -296,6 +493,9 @@ export function renderDoctorReport({
296
493
  config,
297
494
  dbPath,
298
495
  dbSizeBytes,
496
+ databaseNotes = [],
497
+ backups = null,
498
+ now = Date.now(),
299
499
  probes = [],
300
500
  warnings = [],
301
501
  tunnel = '',
@@ -318,6 +518,8 @@ export function renderDoctorReport({
318
518
  lines.push(` tunnel : ${tunnel || configured}`);
319
519
  }
320
520
  lines.push(` database : ${dbSizeBytes !== null ? `${dbPath} (${(dbSizeBytes / 1048576).toFixed(1)} MB)` : `${dbPath} (not created yet)`}`);
521
+ for (const note of databaseNotes) lines.push(` ${note}`);
522
+ if (backups) lines.push(...renderBackupLines(backups, now));
321
523
  lines.push(' provider CLIs :');
322
524
  for (const probe of probes) {
323
525
  lines.push(` ${probe.id.padEnd(14)}: ${probe.ok ? probe.version || 'installed' : 'not found'}`);
@@ -76,6 +76,7 @@ const SERVICE_TEXT = {
76
76
  active: 'installed, running',
77
77
  installed: 'installed',
78
78
  none: 'not installed',
79
+ other: 'another OAR install\'s',
79
80
  unavailable: 'not available here',
80
81
  };
81
82
 
@@ -23,10 +23,10 @@ import fs from 'node:fs';
23
23
  import path from 'node:path';
24
24
  import os from 'node:os';
25
25
 
26
+ import { DB_FILE_NAMES, LEGACY_DB_FILE_NAME, resolveDatabaseFile, sideFiles } from './database-file.mjs';
26
27
  import { createRelaySingletonGuard } from './relay-singleton-guard.mjs';
27
28
 
28
29
  export const MIGRATION_MARKER = '.migrated';
29
- const DB_FILE = 'copilot.db';
30
30
  const MAX_LOGS_BYTES = 50 * 1024 * 1024;
31
31
 
32
32
  export function resolveOarRoot(env = process.env, platform = process.platform) {
@@ -257,16 +257,23 @@ export async function migrateStateToOarRoot({
257
257
  const markerPath = path.join(root, MIGRATION_MARKER);
258
258
  if (!repoServerDir && fs.existsSync(markerPath)) return { status: 'already-migrated', root };
259
259
 
260
- const targetDbFiles = [DB_FILE, `${DB_FILE}-wal`, `${DB_FILE}-shm`].map((name) => path.join(dataDir, name));
260
+ // A database there under either name, and the -wal/-shm of each.
261
+ const targetDbFiles = DB_FILE_NAMES.flatMap(sideFiles).map((name) => path.join(dataDir, name));
262
+ const takenTargetDbs = () => DB_FILE_NAMES.map((name) => path.join(dataDir, name)).filter((file) => pathTaken(file));
261
263
  const targetLock = path.join(dataDir, 'relay-server.lock');
262
264
  // The same checkout once more (a repeated command, an alias): nothing to
263
265
  // do while the database it brought is still there.
264
- if (repoServerDir && pathTaken(targetDbFiles[0]) && wasMigratedFrom(root, repoServerDir)) {
266
+ if (repoServerDir && takenTargetDbs().length && wasMigratedFrom(root, repoServerDir)) {
265
267
  return { status: 'already-migrated', root, source: repoServerDir };
266
268
  }
267
269
 
268
270
  const sourceData = repoServerDir ? path.join(repoServerDir, 'data') : null;
269
- const sourceDb = sourceData ? path.join(sourceData, DB_FILE) : null;
271
+ // The database keeps its name; with both names there the other comes
272
+ // along as it is, and the relay's next start compares the two.
273
+ const sourceFile = sourceData ? resolveDatabaseFile(sourceData) : null;
274
+ const sourceDb = sourceFile && !sourceFile.created ? sourceFile.path : null;
275
+ const sourceOther = sourceFile?.both ? path.join(sourceData, LEGACY_DB_FILE_NAME) : null;
276
+ const targetDb = sourceDb ? path.join(dataDir, path.basename(sourceDb)) : null;
270
277
  const sourceRepoConfig = repoServerDir ? path.join(repoServerDir, 'config.json') : null;
271
278
  const sourceManagedConfig = managedConfigDir ? path.join(managedConfigDir, 'config.json') : null;
272
279
 
@@ -305,20 +312,28 @@ export async function migrateStateToOarRoot({
305
312
  const refusedWithConversations = () => ({
306
313
  status: 'refused-existing-database',
307
314
  root,
308
- error: `The state root already has a database with conversations (${targetDbFiles[0]}); `
315
+ error: `The state root already has a database with conversations (${takenTargetDbs().join(', ')}); `
309
316
  + `a migration never overwrites one. To bring ${repoServerDir} over instead: `
310
317
  + `stop the relay (oar stop), move ${dataDir} aside, then run the migration again.`,
311
318
  });
319
+ const anyTargetNotFresh = async () => {
320
+ const Database = await loadDatabase(DatabaseImpl);
321
+ return takenTargetDbs().some((file) => !isFreshDatabase(Database, file));
322
+ };
312
323
  if (hasDb && existingDbFiles().length) {
313
- if (!pathTaken(targetDbFiles[0])) {
324
+ // A -wal or -shm whose database is not there.
325
+ const orphans = DB_FILE_NAMES.filter((name) => !pathTaken(path.join(dataDir, name)))
326
+ .flatMap((name) => sideFiles(name).slice(1).map((side) => path.join(dataDir, side)))
327
+ .filter((file) => pathTaken(file));
328
+ if (orphans.length) {
314
329
  return {
315
330
  status: 'refused-existing-database',
316
331
  root,
317
- error: `The state root has database files without copilot.db (${existingDbFiles().join(', ')}); `
332
+ error: `The state root has database files without their database (${orphans.join(', ')}); `
318
333
  + 'a migration never mixes a database with them. Move them aside, then run the migration again.',
319
334
  };
320
335
  }
321
- if (!isFreshDatabase(await loadDatabase(DatabaseImpl), targetDbFiles[0])) return refusedWithConversations();
336
+ if (await anyTargetNotFresh()) return refusedWithConversations();
322
337
  }
323
338
 
324
339
  const copied = [];
@@ -355,7 +370,7 @@ export async function migrateStateToOarRoot({
355
370
  }
356
371
  // Asked again under the lock: a relay that ran between the first look and
357
372
  // the lock may have written conversations into the database meanwhile.
358
- if (pathTaken(targetDbFiles[0]) && !isFreshDatabase(await loadDatabase(DatabaseImpl), targetDbFiles[0])) {
373
+ if (await anyTargetNotFresh()) {
359
374
  try { guard.release(); } catch {}
360
375
  removeCreated(created);
361
376
  return refusedWithConversations();
@@ -384,9 +399,14 @@ export async function migrateStateToOarRoot({
384
399
  // Checkpoint first so recent writes leave the WAL, then copy all three
385
400
  // files anyway — a hot -wal must never be dropped on the floor.
386
401
  sourceCounts = readDbCounts(Database, sourceDb, { checkpoint: true });
387
- copyIfExists(sourceDb, targetDbFiles[0], copied, created, skipped);
388
- copyIfExists(`${sourceDb}-wal`, targetDbFiles[1], copied, created, skipped);
389
- copyIfExists(`${sourceDb}-shm`, targetDbFiles[2], copied, created, skipped);
402
+ copyIfExists(sourceDb, targetDb, copied, created, skipped);
403
+ copyIfExists(`${sourceDb}-wal`, `${targetDb}-wal`, copied, created, skipped);
404
+ copyIfExists(`${sourceDb}-shm`, `${targetDb}-shm`, copied, created, skipped);
405
+ if (sourceOther) {
406
+ for (const name of sideFiles(path.basename(sourceOther))) {
407
+ copyIfExists(path.join(sourceData, name), path.join(dataDir, name), copied, created, skipped);
408
+ }
409
+ }
390
410
  copyIfExists(path.join(sourceData, 'cursor-agents'), path.join(dataDir, 'cursor-agents'), copied, created, skipped);
391
411
  }
392
412
 
@@ -410,7 +430,7 @@ export async function migrateStateToOarRoot({
410
430
 
411
431
  if (hasDb) {
412
432
  const Database = await loadDatabase(DatabaseImpl);
413
- const targetCounts = readDbCounts(Database, targetDbFiles[0]);
433
+ const targetCounts = readDbCounts(Database, targetDb);
414
434
  const mismatch = targetCounts.integrity !== 'ok'
415
435
  || targetCounts.conversations !== sourceCounts.conversations
416
436
  || targetCounts.messages !== sourceCounts.messages;
@@ -17,17 +17,19 @@ import crypto from 'crypto';
17
17
  import fs from 'fs';
18
18
  import path from 'path';
19
19
 
20
+ import { DB_FILE_NAMES, sideFiles } from './database-file.mjs';
20
21
  import { RESCUE_DIR_NAME, copyMissing, isRetiredName } from './earlier-database-service.mjs';
21
22
  import { resolveRelayStateDirs, resolveStateLayout } from './oar-state-layout.mjs';
22
23
 
23
- const DB_FILES = ['copilot.db', 'copilot.db-wal', 'copilot.db-shm'];
24
+ // Old packages only ever held the name before 0.9.20; a later one may hold oar.db.
25
+ const DB_FILES = DB_FILE_NAMES.flatMap(sideFiles);
24
26
 
25
27
  // What a relay left in a package's server folder; a folder that cannot be
26
28
  // read throws (no copy failure: nothing was detected yet).
27
29
  function hasRelayData(fsImpl, serverDir) {
28
30
  const has = (...parts) => fsImpl.existsSync(path.join(serverDir, ...parts));
29
31
  const anyUpload = (...parts) => has(...parts) && fsImpl.readdirSync(path.join(serverDir, ...parts)).some((name) => /^[a-f0-9]{64}$/.test(name));
30
- return has('data', 'copilot.db') || has('data', 'cursor-agents') || has('config.json') || anyUpload('uploads') || anyUpload('data', 'uploads');
32
+ return DB_FILE_NAMES.some((name) => has('data', name)) || has('data', 'cursor-agents') || has('config.json') || anyUpload('uploads') || anyUpload('data', 'uploads');
31
33
  }
32
34
 
33
35
  /** Where npm's reify moves a package it replaces (@npmcli/arborist retire-path.js). */
@@ -326,20 +326,7 @@ export function buildClaudeApiPlanCard({
326
326
  note: 'Claude API turns on this relay only; the Console has every request on the key.',
327
327
  open: true,
328
328
  }));
329
- details.push(buildDetailSection({
330
- id: 'claude-api-tokens-by-model',
331
- label: 'Tokens by model, this month',
332
- rows: month.models.map((entry) => {
333
- const input = entry.hitTokens + entry.missTokens;
334
- return {
335
- label: modelLabel(entry.model, settings),
336
- value: `cache read ${formatTokens(entry.hitTokens)} · other input ${formatTokens(entry.missTokens)} · output ${formatTokens(entry.outputTokens)}`,
337
- hint: input > 0 ? `${Math.round((entry.hitTokens / input) * 100)}% of input from the cache` : null,
338
- };
339
- }),
340
- note: 'Other input includes the writes to the prompt cache.',
341
- open: true,
342
- }));
329
+ // Tokens by model: the Tokens sections every tab gets (plan-usage-tokens.mjs).
343
330
  }
344
331
 
345
332
  return buildProviderCard({
@@ -395,7 +382,19 @@ export function createClaudeApiPlanUsage({
395
382
  return buildClaudeApiPlanCard({ settings, spend, resetAt, timeZone: zoneName, error });
396
383
  }
397
384
 
398
- return { readSpend, getCapState, buildCard };
385
+ /**
386
+ * A model's name as the card shows it: the Console's display name, else
387
+ * the Claude label for the id (the Tokens sections use it too).
388
+ */
389
+ function labelModel(model) {
390
+ try {
391
+ return modelLabel(model, getSettings());
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+
397
+ return { readSpend, getCapState, buildCard, modelLabel: labelModel };
399
398
  }
400
399
 
401
400
  /**
@@ -260,7 +260,10 @@ export function buildCardNote({ id, text, link = null, hours = null, label = nul
260
260
  };
261
261
  }
262
262
 
263
- /** A collapsible block of label/value rows shown under the meters. */
263
+ /**
264
+ * A collapsible block of label/value rows shown under the meters. A row may
265
+ * carry a `title`, shown when pointing at it (the model ids behind one name).
266
+ */
264
267
  export function buildDetailSection({ id, label, rows = [], note = null, open = false } = {}) {
265
268
  const sectionId = toTrimmedString(id);
266
269
  const sectionLabel = toTrimmedString(label);
@@ -270,7 +273,8 @@ export function buildDetailSection({ id, label, rows = [], note = null, open = f
270
273
  const rowLabel = toTrimmedString(row?.label);
271
274
  const rowValue = row?.value === 0 ? '0' : toTrimmedString(row?.value);
272
275
  if (!rowLabel || rowValue === null) return null;
273
- return { label: rowLabel, value: rowValue, hint: toTrimmedString(row?.hint) };
276
+ const rowTitle = toTrimmedString(row?.title);
277
+ return { label: rowLabel, value: rowValue, hint: toTrimmedString(row?.hint), ...(rowTitle ? { title: rowTitle } : {}) };
274
278
  })
275
279
  .filter(Boolean);
276
280
  if (!normalizedRows.length) return null;
@@ -877,7 +877,7 @@ export function buildDeepSeekPlanCard({
877
877
  }));
878
878
  details.push(buildDetailSection({
879
879
  id: 'deepseek-tokens-by-model',
880
- label: 'Tokens by model',
880
+ label: 'Priced tokens by model',
881
881
  rows: spend.models.map((entry) => {
882
882
  const input = entry.hitTokens + entry.missTokens;
883
883
  return {