@oar-sh/oar 0.9.12 → 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 (115) 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 +508 -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 +479 -157
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +92 -15
  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/claude-worker/task-output-discovery.mjs +28 -2
  14. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  15. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  16. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  17. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  18. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
  20. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  21. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  22. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  23. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  24. package/server/db-schema.mjs +10 -0
  25. package/server/grok-worker/acp-host-services.mjs +6 -3
  26. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  27. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  28. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  29. package/server/migrations/0018-background-task-rows.mjs +101 -0
  30. package/server/public/app/action-confirmations.js +12 -15
  31. package/server/public/app/api-client.js +60 -4
  32. package/server/public/app/ask-user-view.js +29 -4
  33. package/server/public/app/background-tasks-view.mjs +56 -7
  34. package/server/public/app/bootstrap.js +7 -0
  35. package/server/public/app/conversation-preferences.mjs +10 -4
  36. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  37. package/server/public/app/conversation-view.js +33 -9
  38. package/server/public/app/host-suspend-ui.js +45 -44
  39. package/server/public/app/journal-view.js +9 -3
  40. package/server/public/app/push-settings.js +1 -0
  41. package/server/public/app/question-answer-rule.mjs +15 -0
  42. package/server/public/app/question-multi-select.mjs +9 -2
  43. package/server/public/app/relay-event-view.mjs +5 -1
  44. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  45. package/server/public/app/relay-restart-note-view.mjs +36 -0
  46. package/server/public/app/relay-restart-ui.js +568 -0
  47. package/server/public/app/settings-modal.js +52 -0
  48. package/server/public/app/socket-handlers.js +1 -1
  49. package/server/public/app/task-console-render.mjs +171 -7
  50. package/server/public/app/task-console.mjs +111 -11
  51. package/server/public/app/task-output-text.mjs +15 -6
  52. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  53. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  54. package/server/public/index.html +76 -5
  55. package/server/relay-exit-codes.mjs +6 -0
  56. package/server/relay-self-restart.mjs +6 -1
  57. package/server/relay-tools.md +17 -9
  58. package/server/repositories/message-repository.mjs +32 -28
  59. package/server/repositories/remote-relay-repository.mjs +4 -2
  60. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  61. package/server/routes/ask-user-routes.mjs +41 -7
  62. package/server/routes/messages-routes.mjs +220 -37
  63. package/server/routes/preview-routes.mjs +73 -7
  64. package/server/routes/relay-board-routes.mjs +19 -2
  65. package/server/routes/relay-restart-routes.mjs +73 -0
  66. package/server/routes/sessions-routes.mjs +79 -5
  67. package/server/server-runtime.mjs +335 -218
  68. package/server/services/about-service.mjs +13 -12
  69. package/server/services/background-task-persistence.mjs +133 -0
  70. package/server/services/background-task-store.mjs +240 -13
  71. package/server/services/conversation-model-fallback.mjs +57 -0
  72. package/server/services/cursor-session-root-service.mjs +70 -6
  73. package/server/services/data-dir-resolution.mjs +208 -19
  74. package/server/services/earlier-database-service.mjs +631 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/oar-cli-helpers.mjs +6 -1
  77. package/server/services/oar-state-layout.mjs +107 -0
  78. package/server/services/oar-state-migration-service.mjs +387 -96
  79. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  80. package/server/services/package-data-rescue.mjs +137 -0
  81. package/server/services/preview-proxy-server.mjs +14 -1
  82. package/server/services/preview-registry-service.mjs +3 -2
  83. package/server/services/process-list-service.mjs +2 -1
  84. package/server/services/provider-model-selection.mjs +195 -0
  85. package/server/services/push-dispatch-service.mjs +42 -8
  86. package/server/services/relay-event-service.mjs +210 -14
  87. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  88. package/server/services/relay-restart-picture.mjs +209 -0
  89. package/server/services/relay-restart-queue.mjs +387 -0
  90. package/server/services/relay-shutdown-gate.mjs +4 -1
  91. package/server/services/relay-singleton-guard.mjs +300 -36
  92. package/server/services/remote-relay-caller-context.mjs +7 -2
  93. package/server/services/remote-relay-dispatcher.mjs +217 -28
  94. package/server/services/restart-resume-service.mjs +303 -0
  95. package/server/services/session-activity-tracker.mjs +52 -15
  96. package/server/services/session-worker-launch-service.mjs +31 -1
  97. package/server/services/session-worker-process-service.mjs +6 -4
  98. package/server/services/stale-turn-recovery.mjs +99 -0
  99. package/server/services/subagent-timeline-service.mjs +4 -1
  100. package/server/services/task-output-service.mjs +217 -18
  101. package/server/services/windows-autostart-service.mjs +329 -31
  102. package/server/services/windows-boot-autostart-service.mjs +188 -60
  103. package/shared/preview-instructions.mjs +17 -12
  104. package/shared/preview-tool-core.mjs +32 -14
  105. package/shared/question-answer-rule.mjs +63 -0
  106. package/shared/relay-events.mjs +106 -3
  107. package/shared/remote-relay-contract.mjs +32 -15
  108. package/shared/subagent-timeline.mjs +4 -0
  109. package/shared/task-output-text.mjs +196 -83
  110. package/shared/windows-system-tools.mjs +25 -0
  111. package/shared/worker-runtime/config-loader.mjs +54 -8
  112. package/shared/worker-runtime/prompt-context.mjs +3 -1
  113. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
  114. package/shared/worker-runtime/working-directory.mjs +65 -0
  115. package/server/uploads/image_input.md +0 -540
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The `preinstall` of @oar-sh/oar. Nothing of OAR loads before the `try`:
3
+ * a module of this package that cannot load (a broken file, a missing one)
4
+ * must not fail the install on any platform. Only a copy of an old
5
+ * database that failed (RescueCopyError) exits 1, so npm stops the update
6
+ * and puts the old package, data and all, back in place; anything else is
7
+ * one line and exit 0, as this script only ever copies. See
8
+ * package-data-rescue.mjs.
9
+ */
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ try {
13
+ const { rescuePackageData } = await import('./package-data-rescue.mjs');
14
+ rescuePackageData({ packageRoot: fileURLToPath(new URL('../..', import.meta.url)) });
15
+ } catch (error) {
16
+ if (error?.rescueCopyFailed === true) {
17
+ console.error(`[oar] ${error.message}`);
18
+ process.exitCode = 1;
19
+ } else {
20
+ console.error(`[oar] Did not copy the data of the version being replaced: ${error?.message || error}`);
21
+ }
22
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The preinstall's work (package-data-rescue-preinstall.mjs), on every
3
+ * platform. Relays before 0.9.14 could run on the database and uploads
4
+ * inside the package (Windows autostart, a plain `gh copilot`), and
5
+ * `npm i -g` deletes the old package. While install scripts run, npm keeps
6
+ * it as its retired folder (npmRetiredPath; a scan for npm-shaped names only
7
+ * when that is missing): its database (-wal, -shm), Cursor stores, uploads
8
+ * and config.json are copied, copy only, to
9
+ * `<data folder>/rescued-package-data/<time>/` (written as `.partial`, then
10
+ * renamed), and the relay takes them over at its next start. Any of them
11
+ * makes the old package a source (its uploads beyond the README.md and
12
+ * image_input.md it ships). Nothing to copy, or not an installed package:
13
+ * nothing happens, nothing is printed. A failed copy throws
14
+ * RescueCopyError: exit 1, npm puts the old package back. npm hides a successful script's output, hence the README.txt.
15
+ */
16
+ import crypto from 'crypto';
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+
20
+ import { RESCUE_DIR_NAME, copyMissing, isRetiredName } from './earlier-database-service.mjs';
21
+ import { resolveRelayStateDirs, resolveStateLayout } from './oar-state-layout.mjs';
22
+
23
+ const DB_FILES = ['copilot.db', 'copilot.db-wal', 'copilot.db-shm'];
24
+
25
+ // What a relay left in a package's server folder; a folder that cannot be
26
+ // read throws (no copy failure: nothing was detected yet).
27
+ function hasRelayData(fsImpl, serverDir) {
28
+ const has = (...parts) => fsImpl.existsSync(path.join(serverDir, ...parts));
29
+ 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');
31
+ }
32
+
33
+ /** Where npm's reify moves a package it replaces (@npmcli/arborist retire-path.js). */
34
+ export function npmRetiredPath(packageDir) {
35
+ const hash = crypto.createHash('sha1').update(packageDir).digest('base64').replace(/[^a-zA-Z0-9]+/g, '').slice(0, 8);
36
+ return path.resolve(path.dirname(packageDir), `.${path.basename(packageDir)}-${hash}`);
37
+ }
38
+
39
+ /** A copy of an old database failed: the one error that stops the install. */
40
+ export class RescueCopyError extends Error {
41
+ constructor(message, options) {
42
+ super(message, options);
43
+ this.name = 'RescueCopyError';
44
+ this.rescueCopyFailed = true;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Returns { status: 'not-installed' | 'nothing' | 'rescued', rescued: [{ from,
50
+ * to }] }; throws a RescueCopyError, naming what to save, when a copy fails.
51
+ */
52
+ export function rescuePackageData({
53
+ packageRoot,
54
+ env = process.env,
55
+ platform = process.platform,
56
+ now = () => new Date(),
57
+ fsImpl = fs,
58
+ logger = console,
59
+ } = {}) {
60
+ const resolvedRoot = path.resolve(String(packageRoot || ''));
61
+ const scope = path.dirname(resolvedRoot);
62
+ if (path.basename(scope) !== '@oar-sh' || path.basename(path.dirname(scope)) !== 'node_modules') {
63
+ return { status: 'not-installed', rescued: [] };
64
+ }
65
+ // Detection: npm's retired name, else a scan for npm-shaped names (a path
66
+ // in another case hashes differently). Failing here is no copy failure.
67
+ let sources;
68
+ try {
69
+ const exact = npmRetiredPath(resolvedRoot);
70
+ const retired = fsImpl.existsSync(exact)
71
+ ? [exact]
72
+ : fsImpl.readdirSync(scope).filter((name) => isRetiredName(name, path.basename(resolvedRoot))).map((name) => path.join(scope, name));
73
+ sources = retired.filter((dir) => hasRelayData(fsImpl, path.join(dir, 'server')));
74
+ } catch (error) {
75
+ throw new Error(`Could not look for the data of the version being replaced: ${error?.message || error}`, { cause: error });
76
+ }
77
+ if (!sources.length) return { status: 'nothing', rescued: [] };
78
+
79
+ // Old data is there: any failure from here stops the install.
80
+ const exists = (file) => fsImpl.existsSync(file);
81
+ const kept = path.join(resolvedRoot, 'server');
82
+ const copyFailed = (from, to, error) => new RescueCopyError(
83
+ `Could not copy the data of the version being replaced (${from}) to ${to}: ${error?.message || error}. `
84
+ + `The update stops and the version installed stays as it is; copy ${path.join(kept, 'data')} and ${path.join(kept, 'uploads')} somewhere safe by hand, then update again.`,
85
+ { cause: error },
86
+ );
87
+ let rescueRoot;
88
+ try {
89
+ const layout = resolveStateLayout({ packageRoot: resolvedRoot, env, platform });
90
+ rescueRoot = path.join(resolveRelayStateDirs({ packageRoot: resolvedRoot, env, platform, layout }).dataDir, RESCUE_DIR_NAME);
91
+ } catch (error) {
92
+ throw copyFailed(path.join(sources[0], 'server'), 'the OAR data folder', error);
93
+ }
94
+ const stamp = now().toISOString().replace(/[:.]/g, '-');
95
+ const rescued = [];
96
+ for (const [index, source] of sources.entries()) {
97
+ const serverDir = path.join(source, 'server');
98
+ let target = path.join(rescueRoot, sources.length > 1 ? `${stamp}-${index}` : stamp);
99
+ // Written under a .partial name and renamed when complete: a half copy
100
+ // is never taken for a rescue.
101
+ try {
102
+ for (let n = 1; exists(target) || exists(`${target}.partial`); n += 1) target = `${path.join(rescueRoot, stamp)}-${n}`;
103
+ const partial = `${target}.partial`;
104
+ fsImpl.mkdirSync(rescueRoot, { recursive: true });
105
+ fsImpl.mkdirSync(partial);
106
+ fsImpl.mkdirSync(path.join(partial, 'data'));
107
+ for (const name of DB_FILES) {
108
+ const from = path.join(serverDir, 'data', name);
109
+ if (exists(from)) fsImpl.copyFileSync(from, path.join(partial, 'data', name), fs.constants.COPYFILE_EXCL);
110
+ }
111
+ if (exists(path.join(serverDir, 'data', 'cursor-agents'))) {
112
+ copyMissing(fsImpl, path.join(serverDir, 'data', 'cursor-agents'), path.join(partial, 'data', 'cursor-agents'));
113
+ }
114
+ // Uploads of server/uploads and server/data/uploads, content-addressed.
115
+ for (const uploads of [path.join(serverDir, 'uploads'), path.join(serverDir, 'data', 'uploads')]) {
116
+ if (exists(uploads)) copyMissing(fsImpl, uploads, path.join(partial, 'uploads'));
117
+ }
118
+ if (exists(path.join(serverDir, 'config.json'))) {
119
+ fsImpl.copyFileSync(path.join(serverDir, 'config.json'), path.join(partial, 'config.json'), fs.constants.COPYFILE_EXCL);
120
+ }
121
+ fsImpl.writeFileSync(path.join(partial, 'README.txt'), [
122
+ `Copied by the @oar-sh/oar update on ${now().toISOString()} from`,
123
+ `${serverDir}`,
124
+ 'before npm deleted the version it replaced. The relay takes the',
125
+ 'database in data\\ over at its next start if it is newer than the',
126
+ 'one it has. Nothing here is deleted by OAR.',
127
+ '',
128
+ ].join('\r\n'));
129
+ fsImpl.renameSync(partial, target);
130
+ } catch (error) {
131
+ throw copyFailed(serverDir, target, error);
132
+ }
133
+ rescued.push({ from: serverDir, to: target });
134
+ logger.log?.(`[oar] Copied the data of the version being replaced from ${serverDir} to ${target}; the relay takes it over at its next start.`);
135
+ }
136
+ return { status: 'rescued', rescued };
137
+ }
@@ -261,6 +261,10 @@ export function createPreviewProxyServer({
261
261
  // open HMR connection would therefore hang shutdown forever unless the lane
262
262
  // tracks the pairs itself.
263
263
  const upgradedSockets = new Set();
264
+ // Upgrades still waiting on the dev server's answer, by their teardown: the
265
+ // client socket is detached from the http.Server already, so it would hold
266
+ // close() open just the same.
267
+ const pendingUpgrades = new Set();
264
268
 
265
269
  function trackUpgradedSocket(socket) {
266
270
  if (!socket) return;
@@ -411,14 +415,20 @@ export function createPreviewProxyServer({
411
415
  let upstreamReq = null;
412
416
  let upstreamSocket = null;
413
417
  const teardown = () => {
418
+ pendingUpgrades.delete(teardown);
414
419
  try { socket.destroy(); } catch {}
415
420
  try { upstreamSocket?.destroy(); } catch {}
416
421
  try { upstreamReq?.destroy(); } catch {}
417
422
  };
423
+ pendingUpgrades.add(teardown);
418
424
  socket.on('error', teardown);
419
425
  // A client that leaves before the dev server answers abandons the
420
426
  // handshake. Once both sides are piped, an orderly close travels through
421
- // the pipes, so the pending writes still reach the other side.
427
+ // the pipes, so the pending writes still reach the other side. Leaving
428
+ // can arrive as a bare FIN: the server's sockets allow half-open
429
+ // connections, so 'end' alone never closes this one, and nothing reads
430
+ // it again to notice a reset that follows the FIN.
431
+ socket.once('end', teardown);
422
432
  socket.once('close', teardown);
423
433
  const reply = (text) => {
424
434
  if (socket.destroyed) return;
@@ -477,7 +487,9 @@ export function createPreviewProxyServer({
477
487
  reply(`${lines.join('\r\n')}\r\n\r\n`);
478
488
  if (upstreamHead?.length) reply(upstreamHead);
479
489
  if (head?.length) upstreamSocket.write(head);
490
+ socket.removeListener('end', teardown);
480
491
  socket.removeListener('close', teardown);
492
+ pendingUpgrades.delete(teardown);
481
493
  trackUpgradedSocket(socket);
482
494
  trackUpgradedSocket(upstreamSocket);
483
495
  upstreamSocket.pipe(socket);
@@ -527,6 +539,7 @@ export function createPreviewProxyServer({
527
539
  try { socket.destroy(); } catch {}
528
540
  }
529
541
  upgradedSockets.clear();
542
+ for (const teardown of [...pendingUpgrades]) teardown();
530
543
  return new Promise((resolve) => {
531
544
  if (!server) return resolve();
532
545
  const current = server;
@@ -687,8 +687,9 @@ export function createPreviewRegistry({
687
687
  ok: false,
688
688
  status: 429,
689
689
  error: `Too many live previews (max ${settings.maxLive}); previews stay until closed. `
690
- + `Oldest: ${oldest.join('; ')}. Close ${toClose === 1 ? 'one' : toClose} in Settings → Previews, with the preview tool's `
691
- + `close action (list shows the tokens), or with DELETE /api/previews/:token.`,
690
+ + `Oldest: ${oldest.join('; ')}. Close ${toClose === 1 ? 'one' : toClose} in Settings → Previews; an agent can close `
691
+ + `its own conversation's with the preview tool's close action (list shows their tokens) or with `
692
+ + `DELETE /api/previews/:token?conversationId=<conv>.`,
692
693
  };
693
694
  }
694
695
 
@@ -67,10 +67,11 @@ export function createProcessLister({
67
67
  readdirSyncImpl = fs.readdirSync,
68
68
  readFileSyncImpl = fs.readFileSync,
69
69
  execFileImpl = execFile,
70
+ systemToolPath,
70
71
  inspector = null,
71
72
  } = {}) {
72
73
  if (platform === 'win32') {
73
- const windows = inspector || createSessionWorkerProcessInspector({ platform, execFileImpl });
74
+ const windows = inspector || createSessionWorkerProcessInspector({ platform, execFileImpl, systemToolPath });
74
75
  return {
75
76
  // A child keeps the pid of its parent after the parent has ended.
76
77
  orphansKeepParent: true,
@@ -89,3 +89,198 @@ export function resolveProviderModelSelection({
89
89
  availableModels: Array.from(index.values()),
90
90
  };
91
91
  }
92
+
93
+ // ─── Checking a model an agent names (remote_relay create_session / send) ───
94
+
95
+ const PROVIDER_LABELS = Object.freeze({
96
+ github: 'Copilot',
97
+ openai: 'OpenAI',
98
+ claude: 'Claude',
99
+ cursor: 'Cursor',
100
+ grok: 'Grok',
101
+ 'claude-cloud': 'Claude Cloud',
102
+ });
103
+ const CLAUDE_PROVIDERS = new Set(['claude', 'claude-cloud']);
104
+ const LONG_CONTEXT_SUFFIX = /\[1m\]$/i;
105
+ // A Copilot model with its effort, as the composer sends it ("gpt-5.4-high").
106
+ const EFFORT_VARIANT_SUFFIX = /-(none|minimal|low|medium|high|xhigh|max)$/i;
107
+ // A Claude alias ("opus", "sonnet[1m]"): one word, no version.
108
+ const CLAUDE_ALIAS = /^([a-z]+)(\[1m\])?$/i;
109
+ const SUGGESTIONS_MAX = 3;
110
+
111
+ /** How a relay calls a provider in its messages. */
112
+ export function providerModelLabel(provider) {
113
+ return PROVIDER_LABELS[String(provider || '').trim().toLowerCase()] || String(provider || '').trim() || 'this provider';
114
+ }
115
+
116
+ /** Lower case, no tier suffix, letters and digits only: "GPT-5.4" and "gpt-5-4" meet as "gpt54". */
117
+ function looseModelKey(value) {
118
+ return normalizeProviderModelId(value).toLowerCase().replace(LONG_CONTEXT_SUFFIX, '').replace(/[^a-z0-9]+/g, '');
119
+ }
120
+
121
+ function editDistance(a, b) {
122
+ if (a === b) return 0;
123
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
124
+ for (let i = 1; i <= a.length; i += 1) {
125
+ const current = [i];
126
+ for (let j = 1; j <= b.length; j += 1) {
127
+ current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
128
+ }
129
+ previous = current;
130
+ }
131
+ return previous[b.length];
132
+ }
133
+
134
+ /**
135
+ * The ids of `availableModels` closest to `requestedModel`, best first, at
136
+ * most `max`: the same id written with other separators or casing, then ids
137
+ * a few edits away, then ids that contain what was asked for.
138
+ */
139
+ export function suggestProviderModelIds(requestedModel, availableModels = [], { max = SUGGESTIONS_MAX } = {}) {
140
+ const requested = normalizeProviderModelId(requestedModel).toLowerCase().replace(LONG_CONTEXT_SUFFIX, '');
141
+ const loose = looseModelKey(requestedModel);
142
+ if (!requested || !loose) return [];
143
+ const scored = [];
144
+ for (const candidate of buildCanonicalModelIndex(availableModels).values()) {
145
+ const plain = candidate.toLowerCase().replace(LONG_CONTEXT_SUFFIX, '');
146
+ const candidateLoose = looseModelKey(candidate);
147
+ let score = null;
148
+ if (candidateLoose === loose) score = 0;
149
+ else {
150
+ // A few edits for a real id, none for a stub ("x" is no typo of "o3").
151
+ const allowed = requested.length >= 4 ? Math.max(1, Math.floor(requested.length / 4)) : 0;
152
+ const distance = allowed ? editDistance(requested, plain) : Infinity;
153
+ if (distance <= allowed) score = 1 + distance;
154
+ else if (loose.length >= 3 && candidateLoose.includes(loose)) score = 100 + candidateLoose.length - loose.length;
155
+ }
156
+ if (score !== null) scored.push({ candidate, score });
157
+ }
158
+ return scored
159
+ .sort((a, b) => a.score - b.score || a.candidate.localeCompare(b.candidate))
160
+ .slice(0, Math.max(0, max))
161
+ .map((entry) => entry.candidate);
162
+ }
163
+
164
+ const DATE_SUFFIX = /-\d{8}$/;
165
+
166
+ /** "claude-opus-4-1-20250805" → { version: [4, 1], dated: true }. */
167
+ function claudeVersionOf(model, prefix) {
168
+ const rest = model.toLowerCase().slice(prefix.length);
169
+ const dated = DATE_SUFFIX.test(rest);
170
+ const version = rest.replace(DATE_SUFFIX, '').split(/[^0-9]+/).filter(Boolean).map(Number);
171
+ return { version, dated };
172
+ }
173
+
174
+ function compareVersionsDescending(a, b) {
175
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
176
+ const difference = (b[index] ?? -1) - (a[index] ?? -1);
177
+ if (difference) return difference;
178
+ }
179
+ return 0;
180
+ }
181
+
182
+ /**
183
+ * The newest listed Claude model of an alias's family ("opus" →
184
+ * "claude-opus-5-5" over "claude-opus-5" and "claude-opus-4-1-20250805"):
185
+ * by version number, a date suffix ignored, the undated id before a dated
186
+ * one of the same version. "[1m]" ids are not the family's; with `longContext`
187
+ * the chosen model's "[1m]" id is taken when it is listed. Returns
188
+ * `{ model, tierDropped }`, or null for a family with no listed model.
189
+ */
190
+ function resolveClaudeAlias(word, index, longContext) {
191
+ const prefix = `claude-${word.toLowerCase()}-`;
192
+ const members = (tiered) => [...index.values()]
193
+ .filter((model) => LONG_CONTEXT_SUFFIX.test(model) === tiered && model.toLowerCase().startsWith(prefix))
194
+ .map((model) => ({ model, ...claudeVersionOf(model.replace(LONG_CONTEXT_SUFFIX, ''), prefix) }))
195
+ .filter((entry) => entry.version.length);
196
+ let family = members(false);
197
+ // A list that holds the family only as "[1m]" ids: those are its models.
198
+ if (!family.length) {
199
+ family = members(true);
200
+ if (!family.length) return null;
201
+ family.sort((a, b) => compareVersionsDescending(a.version, b.version) || Number(a.dated) - Number(b.dated) || a.model.localeCompare(b.model));
202
+ return { model: family[0].model, tierDropped: false };
203
+ }
204
+ family.sort((a, b) => compareVersionsDescending(a.version, b.version) || Number(a.dated) - Number(b.dated) || a.model.localeCompare(b.model));
205
+ const chosen = family[0].model;
206
+ if (!longContext) return { model: chosen, tierDropped: false };
207
+ const tiered = index.get(`${chosen}[1m]`.toLowerCase());
208
+ return tiered ? { model: tiered, tierDropped: false } : { model: chosen, tierDropped: true };
209
+ }
210
+
211
+ /**
212
+ * Whether a provider offers the model an agent asked for, by the list New
213
+ * Chat offers for it. `models` is that list, or null when it is not known
214
+ * (discovery has not run, the relay did not answer). Returns one of:
215
+ *
216
+ * - `{ status: 'known', model }`: offered; `model` is the provider's own
217
+ * casing, the same Claude model without its "[1m]" tier when only the
218
+ * base is listed (`tierDropped`), or "auto". A Copilot model with its
219
+ * effort ("gpt-5.4-high") also has `base` (the listed id) and `effort`:
220
+ * the bootstrap knows base ids only, so callers pass them apart. Taken so
221
+ * only when `effortsFor(base)` (the efforts the catalogue lists for it)
222
+ * holds that effort; else the id is unknown, with the base suggested.
223
+ * - `{ status: 'alias', model, tierDropped }`: a Claude alias ("opus",
224
+ * "sonnet[1m]"), taken as the newest listed model of its family.
225
+ * - `{ status: 'unchecked', model }`: no list to check against; the request
226
+ * goes on as asked.
227
+ * - `{ status: 'unknown', model, suggestions, models }`: not offered.
228
+ */
229
+ export function checkRequestedProviderModel({ requestedModel = '', provider = '', models = null, defaultModel = '', effortsFor = null } = {}) {
230
+ const requested = normalizeProviderModelId(requestedModel);
231
+ if (!requested || isAutoModelId(requested)) return { status: 'known', model: requested };
232
+ if (!Array.isArray(models)) return { status: 'unchecked', model: requested };
233
+ // "auto" is listed before discovery has run too: it makes no list.
234
+ const list = [...buildCanonicalModelIndex([defaultModel, ...models]).values()].filter((model) => !isAutoModelId(model));
235
+ if (!list.length) return { status: 'unchecked', model: requested };
236
+ const index = buildCanonicalModelIndex(list);
237
+ const exact = index.get(requested.toLowerCase());
238
+ if (exact) return { status: 'known', model: exact };
239
+ const claude = CLAUDE_PROVIDERS.has(String(provider || '').trim().toLowerCase());
240
+ if (claude && LONG_CONTEXT_SUFFIX.test(requested)) {
241
+ const base = index.get(requested.replace(LONG_CONTEXT_SUFFIX, '').toLowerCase());
242
+ if (base) return { status: 'known', model: base, tierDropped: true };
243
+ }
244
+ if (claude) {
245
+ const alias = CLAUDE_ALIAS.exec(requested);
246
+ const resolved = alias ? resolveClaudeAlias(alias[1], index, !!alias[2]) : null;
247
+ if (resolved) return { status: 'alias', ...resolved };
248
+ }
249
+ const variant = EFFORT_VARIANT_SUFFIX.exec(requested);
250
+ const base = variant ? requested.slice(0, variant.index) : '';
251
+ // Only Copilot takes a model with its effort in the id, and only an effort
252
+ // the listed model is known to take: "gpt-5.1-codex-max" is a model of its
253
+ // own, not gpt-5.1-codex at effort max.
254
+ const listedBase = base && String(provider || '').trim().toLowerCase() === 'github' ? index.get(base.toLowerCase()) : '';
255
+ const effort = variant ? variant[1].toLowerCase() : '';
256
+ if (listedBase) {
257
+ const efforts = typeof effortsFor === 'function' ? effortsFor(listedBase) : null;
258
+ if (Array.isArray(efforts) && efforts.map((value) => String(value || '').trim().toLowerCase()).includes(effort)) {
259
+ return { status: 'known', model: `${listedBase}-${effort}`, base: listedBase, effort };
260
+ }
261
+ }
262
+ const suggestions = suggestProviderModelIds(requested, list);
263
+ const baseFirst = listedBase ? [listedBase, ...suggestions.filter((model) => model !== listedBase)] : suggestions;
264
+ return {
265
+ status: 'unknown',
266
+ model: requested,
267
+ suggestions: (baseFirst.length || !base ? baseFirst : suggestProviderModelIds(base, list)).slice(0, SUGGESTIONS_MAX),
268
+ models: list,
269
+ };
270
+ }
271
+
272
+ const MODELS_LISTED_MAX = 20;
273
+
274
+ /** The refusal of an unknown model: "Unknown model 'gpt-5-4' for Copilot; did you mean 'gpt-5.4'? …". */
275
+ export function unknownProviderModelText({ model, provider, suggestions = [], models = [], where = '' } = {}) {
276
+ const label = providerModelLabel(provider);
277
+ const quoted = (value) => `'${value}'`;
278
+ const parts = [`Unknown model ${quoted(model)} for ${label}${where ? ` ${where}` : ''}`];
279
+ if (suggestions.length) parts.push(`; did you mean ${suggestions.map(quoted).join(' or ')}?`);
280
+ else parts.push('.');
281
+ const shown = models.slice(0, MODELS_LISTED_MAX);
282
+ if (shown.length) {
283
+ parts.push(` Valid ids: ${shown.join(', ')}${models.length > shown.length ? ` (and ${models.length - shown.length} more)` : ''}.`);
284
+ }
285
+ return parts.join('');
286
+ }
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ import { providerDisplayName } from '../public/app/conversation-provider-indicator.mjs';
4
+
3
5
  /**
4
6
  * Web Push dispatch: decides whether a push should go out at all (suppressed
5
7
  * while any device is actively foregrounded), renders the notification per
@@ -10,7 +12,7 @@
10
12
  * reports outcomes through the status-events store instead.
11
13
  */
12
14
 
13
- export const PUSH_EVENT_TYPES = Object.freeze(['question', 'turnComplete', 'turnFailed', 'board', 'cliOffline', 'hostSuspend']);
15
+ export const PUSH_EVENT_TYPES = Object.freeze(['question', 'turnComplete', 'turnFailed', 'board', 'cliOffline', 'hostSuspend', 'relayRestart']);
14
16
 
15
17
  export const VAPID_PUBLIC_KEY_SETTING = 'push_vapid_public_key';
16
18
  export const VAPID_PRIVATE_KEY_SETTING = 'push_vapid_private_key';
@@ -29,17 +31,25 @@ const FAILURE_PRUNE_THRESHOLD = 8;
29
31
  const PUSH_TTL_SECONDS = 12 * 60 * 60;
30
32
 
31
33
  const EVENT_LABELS = Object.freeze({
32
- question: { title: 'Copilot needs your input', generic: 'The agent asked a question.' },
34
+ // A question's title names the provider that asks (questionTitle).
35
+ question: { title: 'An agent needs your input', generic: 'The agent asked a question.' },
33
36
  turnComplete: { title: 'Turn completed', generic: 'The agent finished its turn.' },
34
37
  turnFailed: { title: 'Turn failed', generic: 'The agent turn failed.' },
35
38
  board: { title: 'Plan ready for review', generic: 'The agent posted a board for review.' },
36
39
  cliOffline: { title: 'CLI offline', generic: 'The relay lost its connection to the CLI host.' },
37
40
  hostSuspend: { title: 'Host suspend', generic: 'The queued host suspend changed state.' },
41
+ relayRestart: { title: 'Relay restart waiting', generic: 'The queued relay restart has waited its maximum time.' },
38
42
  });
39
43
 
44
+ function eventTitle(type, providerName) {
45
+ const name = String(providerName || '').trim();
46
+ if (type === 'question' && name) return `${name} needs your input`;
47
+ return EVENT_LABELS[type].title;
48
+ }
49
+
40
50
  // System events carry their own body: no conversation text to preview, and
41
51
  // the body is the message itself regardless of the preview preference.
42
- const SYSTEM_BODY_TYPES = new Set(['hostSuspend']);
52
+ const SYSTEM_BODY_TYPES = new Set(['hostSuspend', 'relayRestart']);
43
53
 
44
54
  function clampInt(value, min, max, fallback) {
45
55
  const numeric = Number(value);
@@ -78,7 +88,7 @@ export function normalizePushPreferences(raw) {
78
88
  * Render the notification payload for one subscription. Pure so every content
79
89
  * preference combination is unit-testable.
80
90
  *
81
- * @param {{ type: string, conversationTitle?: string|null, body?: string|null, data?: object }} event
91
+ * @param {{ type: string, conversationTitle?: string|null, providerName?: string|null, body?: string|null, data?: object }} event
82
92
  * @param {object} preferences canonical shape from normalizePushPreferences
83
93
  */
84
94
  export function renderPushNotification(event, preferences) {
@@ -86,10 +96,11 @@ export function renderPushNotification(event, preferences) {
86
96
  const prefs = normalizePushPreferences(preferences);
87
97
  const labels = EVENT_LABELS[type];
88
98
 
89
- let title = labels.title;
99
+ const baseTitle = eventTitle(type, event?.providerName);
100
+ let title = baseTitle;
90
101
  const conversationTitle = String(event?.conversationTitle || '').trim();
91
102
  if (prefs.content.includeTitle && conversationTitle) {
92
- title = `${labels.title} — ${conversationTitle}`.slice(0, MAX_TITLE_CHARS);
103
+ title = `${baseTitle} — ${conversationTitle}`.slice(0, MAX_TITLE_CHARS);
93
104
  }
94
105
 
95
106
  let body = labels.generic;
@@ -115,7 +126,7 @@ export function renderPushNotification(event, preferences) {
115
126
  return {
116
127
  title,
117
128
  body,
118
- tag: `copilot-${type}-${tagSeed}`,
129
+ tag: `oar-${type}-${tagSeed}`,
119
130
  timestamp: Date.now(),
120
131
  data,
121
132
  };
@@ -340,6 +351,21 @@ export function createPushDispatchService({
340
351
  }
341
352
  }
342
353
 
354
+ // The provider of a question's conversation, for the push title. Prepared
355
+ // lazily: a schema without runtime sessions just means "not known".
356
+ let providerTypeStatement = null;
357
+ function conversationProviderName(conversationId) {
358
+ const id = String(conversationId || '').trim();
359
+ if (!id) return null;
360
+ try {
361
+ providerTypeStatement ??= db.prepare(`SELECT provider_type FROM runtime_sessions WHERE conversation_id = ?`);
362
+ // Named as the question card's head names it; unknown stays neutral.
363
+ return providerDisplayName(providerTypeStatement.get(id)?.provider_type) || null;
364
+ } catch {
365
+ return null;
366
+ }
367
+ }
368
+
343
369
  async function sendToSubscription(row, event) {
344
370
  const preferences = normalizePushPreferences(parseJsonObject(row.preferences_json));
345
371
  const payload = renderPushNotification(event, preferences);
@@ -383,7 +409,7 @@ export function createPushDispatchService({
383
409
  * Fire-and-forget push for one event. Never throws; suppression failures
384
410
  * fail closed (no push when the active-device state is unknown).
385
411
  */
386
- async function dispatch({ type, conversationId = null, body = null, data = {} }) {
412
+ async function dispatch({ type, conversationId = null, body = null, data = {}, providerName = null }) {
387
413
  try {
388
414
  if (!PUSH_EVENT_TYPES.includes(type)) return { outcome: 'invalid-type' };
389
415
 
@@ -409,6 +435,7 @@ export function createPushDispatchService({
409
435
  const event = {
410
436
  type,
411
437
  conversationTitle: conversationTitle(conversationId),
438
+ providerName,
412
439
  body,
413
440
  data: { ...data, conversationId: conversationId || undefined },
414
441
  };
@@ -444,6 +471,7 @@ export function createPushDispatchService({
444
471
  return dispatch({
445
472
  type: 'question',
446
473
  conversationId: question?.conversationId || null,
474
+ providerName: conversationProviderName(question?.conversationId),
447
475
  body: question?.prompt || null,
448
476
  data: {
449
477
  questionId: question?.id || undefined,
@@ -493,8 +521,14 @@ export function createPushDispatchService({
493
521
  return dispatch({ type: 'hostSuspend', body: body || null, data: { hostSuspendAt: Date.now() } });
494
522
  }
495
523
 
524
+ // The queued restart's maximum wait ran out: it asks, it does not restart.
525
+ function notifyRelayRestart({ body = null } = {}) {
526
+ return dispatch({ type: 'relayRestart', body: body || null, data: { relayRestartAt: Date.now() } });
527
+ }
528
+
496
529
  return {
497
530
  notifyHostSuspend,
531
+ notifyRelayRestart,
498
532
  listDevices,
499
533
  getDevice,
500
534
  upsertSubscription,