@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.
- package/.github/extensions/web-relay/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +508 -0
- package/README.md +12 -12
- package/bin/oar.js +155 -94
- package/package.json +5 -5
- package/server/README.md +479 -157
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +92 -15
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +60 -4
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +76 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +17 -9
- package/server/repositories/message-repository.mjs +32 -28
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +220 -37
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +79 -5
- package/server/server-runtime.mjs +335 -218
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +240 -13
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/oar-cli-helpers.mjs +6 -1
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +42 -8
- package/server/services/relay-event-service.mjs +210 -14
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +387 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +217 -28
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/task-output-service.mjs +217 -18
- package/server/services/windows-autostart-service.mjs +329 -31
- package/server/services/windows-boot-autostart-service.mjs +188 -60
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +106 -3
- package/shared/remote-relay-contract.mjs +32 -15
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/task-output-text.mjs +196 -83
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
- package/shared/worker-runtime/working-directory.mjs +65 -0
- 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
|
|
691
|
-
+ `close action (list shows
|
|
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
|
-
|
|
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
|
-
|
|
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 = `${
|
|
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: `
|
|
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,
|