@oar-sh/oar 0.9.1 → 0.9.3
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 +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
|
@@ -9,8 +9,10 @@ import { spawn } from 'child_process';
|
|
|
9
9
|
import {
|
|
10
10
|
CLI_SPAWN_DISABLED_ERROR,
|
|
11
11
|
MAX_CAPTURED_OUTPUT_CHARS,
|
|
12
|
+
isBatchLauncher,
|
|
12
13
|
joinWrappedLines,
|
|
13
14
|
killTree as killProcessTree,
|
|
15
|
+
quoteForCmd,
|
|
14
16
|
runToCompletion as runProcessToCompletion,
|
|
15
17
|
scrubSecrets,
|
|
16
18
|
stripTerminalEscapes,
|
|
@@ -235,11 +237,15 @@ export function createClaudeAuthService({
|
|
|
235
237
|
function spawnAuthProcess(args, { pty = true, stdin = 'pipe' } = {}) {
|
|
236
238
|
if (cliSpawnsDisabled()) throw new Error(CLI_SPAWN_DISABLED_ERROR);
|
|
237
239
|
const bin = resolveClaudeAuthBinary(env);
|
|
240
|
+
// On Windows the npm-installed `claude` is `claude.cmd`, which only spawns
|
|
241
|
+
// through a shell — shared batch-launcher handling in cli-process-runner.mjs.
|
|
242
|
+
const batch = isBatchLauncher(bin, platform);
|
|
238
243
|
const child = (!pty || platform === 'win32')
|
|
239
|
-
? spawnImpl(bin, args, {
|
|
244
|
+
? spawnImpl(batch ? quoteForCmd(bin) : bin, batch ? args.map(quoteForCmd) : args, {
|
|
240
245
|
stdio: [stdin, 'pipe', 'pipe'],
|
|
241
246
|
env: buildSpawnEnv(),
|
|
242
247
|
windowsHide: true,
|
|
248
|
+
...(batch ? { shell: true } : {}),
|
|
243
249
|
})
|
|
244
250
|
: spawnImpl('script', ['-qec', [bin, ...args].map(shellQuote).join(' '), '/dev/null'], {
|
|
245
251
|
stdio: [stdin, 'pipe', 'pipe'],
|
|
@@ -7,7 +7,9 @@ import path from 'path';
|
|
|
7
7
|
import {
|
|
8
8
|
CLI_SPAWN_DISABLED_ERROR,
|
|
9
9
|
MAX_CAPTURED_OUTPUT_CHARS,
|
|
10
|
+
isBatchLauncher as isBatchLauncherImpl,
|
|
10
11
|
killTree as killProcessTree,
|
|
12
|
+
quoteForCmd,
|
|
11
13
|
runToCompletion as runProcessToCompletion,
|
|
12
14
|
stripTerminalEscapes,
|
|
13
15
|
tailOf,
|
|
@@ -499,35 +501,14 @@ export function createCliInstallService({
|
|
|
499
501
|
}
|
|
500
502
|
|
|
501
503
|
/**
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
* "installed, version unknown" on every probe and any `.cmd`-shimmed
|
|
508
|
-
* update/migrate would die instantly.
|
|
504
|
+
* `candidateNames()` resolves `.cmd`/`.bat` through PATHEXT, and npm's shim
|
|
505
|
+
* for `copilot` on a Windows host *is* `copilot.cmd` — so without the shared
|
|
506
|
+
* batch-launcher handling (cli-process-runner.mjs, where the EINVAL story is
|
|
507
|
+
* documented), that row would report "installed, version unknown" on every
|
|
508
|
+
* probe and any `.cmd`-shimmed update/migrate would die instantly.
|
|
509
509
|
*/
|
|
510
510
|
function isBatchLauncher(command) {
|
|
511
|
-
return
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
/**
|
|
515
|
-
* `shell: true` makes Node join `[command, ...args]` with spaces into one
|
|
516
|
-
* string and hand it to `cmd.exe /d /s /c` *without quoting anything itself*,
|
|
517
|
-
* so every token has to be quoted here — `C:\Program Files\…\copilot.cmd`
|
|
518
|
-
* would otherwise split on its own space.
|
|
519
|
-
*
|
|
520
|
-
* This is belt-and-braces, not the security boundary: the argv is frozen
|
|
521
|
-
* literals from the descriptor table plus a path this module resolved off the
|
|
522
|
-
* filesystem, never anything a caller sent (§6). cmd.exe has no escape for a
|
|
523
|
-
* quote inside a quoted token — doubling it is the closest thing, and a
|
|
524
|
-
* Windows path cannot contain one anyway. `%VAR%` still expands inside double
|
|
525
|
-
* quotes; a bin directory named after a *set* environment variable would
|
|
526
|
-
* mis-resolve, which is the one residual gap and is still better than the
|
|
527
|
-
* alternative of not running batch launchers at all.
|
|
528
|
-
*/
|
|
529
|
-
function quoteForCmd(value) {
|
|
530
|
-
return `"${String(value).replace(/"/g, '""')}"`;
|
|
511
|
+
return isBatchLauncherImpl(command, platform);
|
|
531
512
|
}
|
|
532
513
|
|
|
533
514
|
function spawnCliProcess(argv) {
|
|
@@ -102,6 +102,36 @@ export function tailOf(text, limit = MAX_ERROR_TAIL_CHARS) {
|
|
|
102
102
|
return joined.length > limit ? `…${joined.slice(-limit)}` : joined;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Windows cannot `CreateProcess` a `.cmd`/`.bat` at all, and Node has refused
|
|
107
|
+
* to pretend otherwise since the CVE-2024-27980 fix — it fails the spawn with
|
|
108
|
+
* EINVAL unless a shell is asked for explicitly. npm ships every global CLI on
|
|
109
|
+
* Windows as exactly such a shim (`claude.cmd`, `copilot.cmd`), so every
|
|
110
|
+
* service that spawns a provider CLI needs the same detection; this is the one
|
|
111
|
+
* copy (lifted out of cli-install-service.mjs, which held it first).
|
|
112
|
+
*/
|
|
113
|
+
export function isBatchLauncher(command, platform = process.platform) {
|
|
114
|
+
return platform === 'win32' && /\.(cmd|bat)$/i.test(String(command || ''));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `shell: true` makes Node join `[command, ...args]` with spaces into one
|
|
119
|
+
* string and hand it to `cmd.exe /d /s /c` *without quoting anything itself*,
|
|
120
|
+
* so every token has to be quoted here — `C:\Program Files\…\claude.cmd`
|
|
121
|
+
* would otherwise split on its own space.
|
|
122
|
+
*
|
|
123
|
+
* This is belt-and-braces, not a security boundary: every caller passes argv
|
|
124
|
+
* built from frozen literals or a path it resolved off the filesystem, never
|
|
125
|
+
* anything a request sent. cmd.exe has no escape for a quote inside a quoted
|
|
126
|
+
* token — doubling it is the closest thing, and a Windows path cannot contain
|
|
127
|
+
* one anyway. `%VAR%` still expands inside double quotes; a directory named
|
|
128
|
+
* after a *set* environment variable would mis-resolve, which is the one
|
|
129
|
+
* residual gap and still better than refusing batch launchers outright.
|
|
130
|
+
*/
|
|
131
|
+
export function quoteForCmd(value) {
|
|
132
|
+
return `"${String(value).replace(/"/g, '""')}"`;
|
|
133
|
+
}
|
|
134
|
+
|
|
105
135
|
/**
|
|
106
136
|
* Signals the child's whole process group where the platform has one, so a
|
|
107
137
|
* `script`/`bash -lc` wrapper cannot leave the real CLI running behind it.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Server-side Copilot model discovery (Phase 5A of the extension-retirement
|
|
2
|
+
// track).
|
|
3
|
+
//
|
|
4
|
+
// Today the model catalog is only ever POPULATED by a running engine — the
|
|
5
|
+
// extension and the standalone relay POST `/api/models/snapshot` when their
|
|
6
|
+
// session comes up. Once the extension retires, a relay restart leaves the
|
|
7
|
+
// catalog empty until the first SDK worker happens to spawn, and the composer's
|
|
8
|
+
// model picker with it. This service closes that gap from the server side: a
|
|
9
|
+
// short-lived `CopilotClient` (the same installed-CLI runtime the session
|
|
10
|
+
// import service boots) answers `listModels()`, and the result feeds the SAME
|
|
11
|
+
// in-process catalog the snapshot route feeds — no HTTP self-call, and the
|
|
12
|
+
// entries are tagged `server-discovery:<reason>` alongside the existing
|
|
13
|
+
// `web-relay-extension:<reason>` / `standalone-relay:<reason>` sources.
|
|
14
|
+
//
|
|
15
|
+
// Failure policy is log-and-skip throughout: the catalog keeps its previous
|
|
16
|
+
// contents (persisted model-variant rows survive restarts), so a missing CLI,
|
|
17
|
+
// a hung runtime or a refused list costs nothing but this refresh. The client
|
|
18
|
+
// is disposed in `finally` — a discovery must never leave a runtime process
|
|
19
|
+
// behind — and `dispose()` follows the SDK-session-import pattern so a refresh
|
|
20
|
+
// in flight at shutdown cannot hang the exit.
|
|
21
|
+
import { buildModelSnapshotFields, extractModelDescriptors } from '../../shared/model-descriptors.mjs';
|
|
22
|
+
|
|
23
|
+
/** Same budget the Claude model discovery races `supportedModels()` against. */
|
|
24
|
+
export const DEFAULT_COPILOT_MODEL_DISCOVERY_TIMEOUT_MS = 20_000;
|
|
25
|
+
/**
|
|
26
|
+
* How long after boot the startup refresh fires. Staggered like the SDK
|
|
27
|
+
* session importer's startup sweep (which also boots an installed-CLI runtime
|
|
28
|
+
* from the listen callback) so the two spawns don't compete with serving the
|
|
29
|
+
* first page loads; nothing awaits it, so it never blocks startup.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_COPILOT_MODEL_DISCOVERY_BOOT_DELAY_MS = 5_000;
|
|
32
|
+
|
|
33
|
+
export function createCopilotModelDiscoveryService({
|
|
34
|
+
// () => Promise<{ client, dispose }> — createInstalledCopilotClient bound to
|
|
35
|
+
// the relay's config/workspace by the caller.
|
|
36
|
+
createClient,
|
|
37
|
+
// Feature detection: throws when no Copilot CLI runtime is resolvable
|
|
38
|
+
// (resolveInstalledCopilotPaths' contract). A relay without the CLI must not
|
|
39
|
+
// spawn anything or log an error per refresh — Copilot may simply not be one
|
|
40
|
+
// of its providers.
|
|
41
|
+
resolveInstalledPaths,
|
|
42
|
+
// The in-process catalog feed the snapshot route uses (server-runtime's
|
|
43
|
+
// updateModelCatalog) — fed directly, no HTTP self-call.
|
|
44
|
+
updateModelCatalog,
|
|
45
|
+
timeoutMs = DEFAULT_COPILOT_MODEL_DISCOVERY_TIMEOUT_MS,
|
|
46
|
+
bootDelayMs = DEFAULT_COPILOT_MODEL_DISCOVERY_BOOT_DELAY_MS,
|
|
47
|
+
logger = console,
|
|
48
|
+
} = {}) {
|
|
49
|
+
if (typeof createClient !== 'function' || typeof updateModelCatalog !== 'function') {
|
|
50
|
+
throw new Error('Copilot model discovery requires a client factory and a catalog feed');
|
|
51
|
+
}
|
|
52
|
+
let closing = false;
|
|
53
|
+
let activeRefresh = null;
|
|
54
|
+
// The live client handle mid-refresh, so dispose() can tear it down FIRST —
|
|
55
|
+
// that is what makes a hung listModels() settle promptly at shutdown instead
|
|
56
|
+
// of holding the refresh (and the exit behind it) for the full timeout.
|
|
57
|
+
let activeRuntime = null;
|
|
58
|
+
let startupTimer = null;
|
|
59
|
+
// Set by the routes layer (which owns the provider-enriched catalog payload
|
|
60
|
+
// and the socket) so a successful discovery reaches connected clients the
|
|
61
|
+
// same way a snapshot POST does.
|
|
62
|
+
let onCatalogUpdated = () => {};
|
|
63
|
+
|
|
64
|
+
function raceTimeout(promise, label) {
|
|
65
|
+
return Promise.race([
|
|
66
|
+
promise,
|
|
67
|
+
new Promise((_resolve, reject) => {
|
|
68
|
+
const timer = setTimeout(
|
|
69
|
+
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
|
|
70
|
+
timeoutMs,
|
|
71
|
+
);
|
|
72
|
+
timer.unref?.();
|
|
73
|
+
}),
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isCliResolvable() {
|
|
78
|
+
if (typeof resolveInstalledPaths !== 'function') return false;
|
|
79
|
+
try {
|
|
80
|
+
resolveInstalledPaths();
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function refresh(reason = 'unspecified') {
|
|
88
|
+
if (closing) return { ok: false, skipped: true, models: [], error: 'Copilot model discovery is shutting down' };
|
|
89
|
+
// Single-flight: a manual refresh racing the boot refresh shares its run.
|
|
90
|
+
if (activeRefresh) return activeRefresh;
|
|
91
|
+
if (!isCliResolvable()) {
|
|
92
|
+
return { ok: false, skipped: true, models: [], error: 'Copilot CLI runtime is not installed' };
|
|
93
|
+
}
|
|
94
|
+
activeRefresh = (async () => {
|
|
95
|
+
const clientPromise = createClient();
|
|
96
|
+
let runtime = null;
|
|
97
|
+
try {
|
|
98
|
+
runtime = await raceTimeout(clientPromise, 'Copilot client start');
|
|
99
|
+
activeRuntime = runtime;
|
|
100
|
+
if (closing) return { ok: false, skipped: true, models: [], error: 'Copilot model discovery is shutting down' };
|
|
101
|
+
const modelInfos = await raceTimeout(runtime.client.listModels(), 'Copilot model discovery');
|
|
102
|
+
const descriptors = extractModelDescriptors(modelInfos);
|
|
103
|
+
if (!descriptors.length) {
|
|
104
|
+
// Log-and-skip, and deliberately no error write into the catalog:
|
|
105
|
+
// an empty answer must not degrade whatever a worker snapshot or a
|
|
106
|
+
// previous run already published.
|
|
107
|
+
logger.warn?.('[copilot-model-discovery] listModels returned no usable models');
|
|
108
|
+
return { ok: false, models: [], error: 'Copilot model discovery returned no models' };
|
|
109
|
+
}
|
|
110
|
+
// The typed client-level entries and the worker's raw session-level
|
|
111
|
+
// entries go through the same shared builder, so both publishers
|
|
112
|
+
// agree on every per-model metadata field.
|
|
113
|
+
const { models, contextLimitsByModel, modelMetadataByModel } = buildModelSnapshotFields(descriptors);
|
|
114
|
+
// No currentModel/defaultModel: a session-less listModels cannot know
|
|
115
|
+
// the active model, and updateModelCatalog keeps its existing pair
|
|
116
|
+
// when the snapshot omits them.
|
|
117
|
+
updateModelCatalog({
|
|
118
|
+
models,
|
|
119
|
+
contextLimitsByModel,
|
|
120
|
+
modelMetadataByModel,
|
|
121
|
+
source: `server-discovery:${reason}`,
|
|
122
|
+
error: null,
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
onCatalogUpdated();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
logger.warn?.(`[copilot-model-discovery] catalog-updated listener failed: ${error?.message || error}`);
|
|
128
|
+
}
|
|
129
|
+
return { ok: true, models, error: null };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const detail = String(error?.message || error || 'unknown error');
|
|
132
|
+
logger.warn?.(`[copilot-model-discovery] refresh (${reason}) failed: ${detail}`);
|
|
133
|
+
return { ok: false, models: [], error: `Copilot model discovery failed: ${detail}` };
|
|
134
|
+
} finally {
|
|
135
|
+
// dispose() may already have taken the handle mid-refresh; only tear
|
|
136
|
+
// down what this refresh still owns, so the runtime is disposed exactly
|
|
137
|
+
// once whichever side gets there first.
|
|
138
|
+
const owned = activeRuntime === runtime ? runtime : null;
|
|
139
|
+
activeRuntime = null;
|
|
140
|
+
if (owned) {
|
|
141
|
+
try {
|
|
142
|
+
await owned.dispose?.();
|
|
143
|
+
} catch (error) {
|
|
144
|
+
logger.warn?.(`[copilot-model-discovery] client dispose failed: ${error?.message || error}`);
|
|
145
|
+
}
|
|
146
|
+
} else if (!runtime) {
|
|
147
|
+
// The timeout won the start race: the spawn may still resolve later
|
|
148
|
+
// with a live runtime nobody owns — dispose it then (the same trade
|
|
149
|
+
// refreshGrokProviderModels makes with its late agent handle).
|
|
150
|
+
clientPromise.then((late) => late?.dispose?.()).catch(() => {});
|
|
151
|
+
}
|
|
152
|
+
activeRefresh = null;
|
|
153
|
+
}
|
|
154
|
+
})();
|
|
155
|
+
return activeRefresh;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The deferred boot trigger. Unref'd so a relay that exits immediately (a
|
|
160
|
+
* failed listen, a test) is not held open by a discovery it never wanted.
|
|
161
|
+
*/
|
|
162
|
+
function scheduleStartupRefresh({ delayMs = bootDelayMs, reason = 'boot' } = {}) {
|
|
163
|
+
if (closing || startupTimer) return;
|
|
164
|
+
startupTimer = setTimeout(() => {
|
|
165
|
+
startupTimer = null;
|
|
166
|
+
void refresh(reason);
|
|
167
|
+
}, Math.max(0, Number(delayMs) || 0));
|
|
168
|
+
startupTimer.unref?.();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function setOnCatalogUpdated(listener) {
|
|
172
|
+
onCatalogUpdated = typeof listener === 'function' ? listener : () => {};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function dispose() {
|
|
176
|
+
// Flag first, exactly like the session importer: refresh() must refuse new
|
|
177
|
+
// work before anything is torn down, or a concurrent caller respawns a
|
|
178
|
+
// runtime while the server is exiting.
|
|
179
|
+
closing = true;
|
|
180
|
+
if (startupTimer) {
|
|
181
|
+
clearTimeout(startupTimer);
|
|
182
|
+
startupTimer = null;
|
|
183
|
+
}
|
|
184
|
+
// Tear the live client down before awaiting the refresh: a hung
|
|
185
|
+
// listModels() only settles when its transport dies, and waiting the full
|
|
186
|
+
// discovery timeout here is exactly the shutdown hang this must avoid.
|
|
187
|
+
const runtime = activeRuntime;
|
|
188
|
+
activeRuntime = null;
|
|
189
|
+
if (runtime) {
|
|
190
|
+
try { await runtime.dispose?.(); } catch { /* best-effort at shutdown */ }
|
|
191
|
+
}
|
|
192
|
+
try { await activeRefresh; } catch { /* refresh() never rejects; belt only */ }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
refresh,
|
|
197
|
+
scheduleStartupRefresh,
|
|
198
|
+
setOnCatalogUpdated,
|
|
199
|
+
isCliResolvable,
|
|
200
|
+
dispose,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where the relay's database (and lock) live, decided before anything else
|
|
6
|
+
* touches disk.
|
|
7
|
+
*
|
|
8
|
+
* Precedence: the COPILOT_WEB_RELAY_DATA_DIR env var (launchers and the e2e
|
|
9
|
+
* harness pin it), then a `dataDir` key in config.json, then the server
|
|
10
|
+
* directory's own `data/`. The config key exists so an instance's identity —
|
|
11
|
+
* including where its data lives — travels entirely inside config.json: a
|
|
12
|
+
* dev checkout can point at a release install's data dir (the singleton
|
|
13
|
+
* guard's lock lives inside the data dir, so two relays sharing one refuse
|
|
14
|
+
* to run concurrently), and a regenerated autostart launcher only has to pin
|
|
15
|
+
* the config path to reproduce the whole instance.
|
|
16
|
+
*
|
|
17
|
+
* A relative `dataDir` resolves against the config file's directory, not the
|
|
18
|
+
* process cwd — the same config must mean the same data dir no matter where
|
|
19
|
+
* the server was launched from.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveDataDir({
|
|
22
|
+
env = process.env,
|
|
23
|
+
configPath,
|
|
24
|
+
serverDir,
|
|
25
|
+
fsImpl = fs,
|
|
26
|
+
pathImpl = path,
|
|
27
|
+
} = {}) {
|
|
28
|
+
const fromEnv = String(env?.COPILOT_WEB_RELAY_DATA_DIR || '').trim();
|
|
29
|
+
if (fromEnv) return pathImpl.resolve(fromEnv);
|
|
30
|
+
|
|
31
|
+
const resolvedConfigPath = String(configPath || '').trim();
|
|
32
|
+
if (resolvedConfigPath) {
|
|
33
|
+
let parsed = null;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(fsImpl.readFileSync(resolvedConfigPath, 'utf8'));
|
|
36
|
+
} catch {
|
|
37
|
+
// Unreadable/absent/invalid config falls through to the default; the
|
|
38
|
+
// runtime reports config problems itself when it loads the file.
|
|
39
|
+
}
|
|
40
|
+
const fromConfig = String(parsed?.dataDir || '').trim();
|
|
41
|
+
if (fromConfig) {
|
|
42
|
+
return pathImpl.resolve(pathImpl.dirname(resolvedConfigPath), fromConfig);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return pathImpl.join(String(serverDir || ''), 'data');
|
|
47
|
+
}
|
|
@@ -8,7 +8,9 @@ import { spawn } from 'child_process';
|
|
|
8
8
|
import {
|
|
9
9
|
CLI_SPAWN_DISABLED_ERROR,
|
|
10
10
|
MAX_CAPTURED_OUTPUT_CHARS,
|
|
11
|
+
isBatchLauncher,
|
|
11
12
|
killTree as killProcessTree,
|
|
13
|
+
quoteForCmd,
|
|
12
14
|
runToCompletion as runProcessToCompletion,
|
|
13
15
|
scrubSecrets,
|
|
14
16
|
stripTerminalEscapes,
|
|
@@ -243,7 +245,13 @@ export function createGrokAuthService({
|
|
|
243
245
|
function spawnGrokProcess(args) {
|
|
244
246
|
if (cliSpawnsDisabled()) throw new Error(CLI_SPAWN_DISABLED_ERROR);
|
|
245
247
|
const bin = resolveGrokAuthBinary(env);
|
|
246
|
-
|
|
248
|
+
// A `grok` installed through npm (or any wrapper shipped as a shim) is
|
|
249
|
+
// `grok.cmd` on Windows, which only spawns through a shell — the shared
|
|
250
|
+
// batch-launcher handling (cli-process-runner.mjs) covers the EINVAL story
|
|
251
|
+
// and the quoting rules.
|
|
252
|
+
const batch = isBatchLauncher(bin, platform);
|
|
253
|
+
return spawnImpl(batch ? quoteForCmd(bin) : bin, batch ? args.map(quoteForCmd) : args, {
|
|
254
|
+
...(batch ? { shell: true } : {}),
|
|
247
255
|
// stdin is never written to, so it is closed outright rather than left as
|
|
248
256
|
// a pipe nothing ever drains.
|
|
249
257
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Which of a conversation's processing queue rows is the LIVE turn.
|
|
2
|
+
//
|
|
3
|
+
// Mid-turn steering means a conversation can have more than one row in
|
|
4
|
+
// `processing` at once: the running turn plus a message the user steered in,
|
|
5
|
+
// which the CLI folds into that turn and which produces no stream or activity
|
|
6
|
+
// of its own — or, after a replay handoff, the row that just took the turn
|
|
7
|
+
// over while the handed-off row is still settling. The live turn is the row
|
|
8
|
+
// that produced output MOST RECENTLY; with no output to separate them, the
|
|
9
|
+
// oldest processing row (the turn a steered message joins). Kept pure so the
|
|
10
|
+
// tie-break rules are testable without a DB.
|
|
11
|
+
//
|
|
12
|
+
// Recency, not volume: relay_stream_events.seq is only unique within one
|
|
13
|
+
// queue_message_id, so comparing it across rows picked "the row with the most
|
|
14
|
+
// events" — a long handed-off turn kept the live bubble (and Stop) from the
|
|
15
|
+
// row that had actually taken over.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {Array<{id:string, lastOutputAtMs?:number, streamDone?:boolean, processingAtMs?:number}>} rows
|
|
19
|
+
* Processing rows for one conversation. `lastOutputAtMs` is when the row
|
|
20
|
+
* last wrote a stream snapshot or activity line (0 = never); `streamDone`
|
|
21
|
+
* marks a row whose main stream snapshot is final — a handed-off row
|
|
22
|
+
* settling, whose closing snapshot re-stamps it as the newest write — and
|
|
23
|
+
* ranks it below every row still open; `processingAtMs` is when it entered
|
|
24
|
+
* processing (final tie-break; smaller = older = wins).
|
|
25
|
+
* @returns {string} the live turn's row id, or '' when there are no rows.
|
|
26
|
+
*/
|
|
27
|
+
export function pickLiveTurnRowId(rows = []) {
|
|
28
|
+
const list = Array.isArray(rows) ? rows.filter((row) => row && row.id) : [];
|
|
29
|
+
if (!list.length) return '';
|
|
30
|
+
if (list.length === 1) return String(list[0].id);
|
|
31
|
+
const finite = (value) => (Number.isFinite(Number(value)) ? Number(value) : 0);
|
|
32
|
+
let best = null;
|
|
33
|
+
for (const row of list) {
|
|
34
|
+
if (!best) { best = row; continue; }
|
|
35
|
+
if (Boolean(row.streamDone) !== Boolean(best.streamDone)) {
|
|
36
|
+
if (!row.streamDone) best = row;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const output = finite(row.lastOutputAtMs);
|
|
40
|
+
const bestOutput = finite(best.lastOutputAtMs);
|
|
41
|
+
if (output > bestOutput
|
|
42
|
+
|| (output === bestOutput && finite(row.processingAtMs) < finite(best.processingAtMs))) {
|
|
43
|
+
best = row;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return String(best.id);
|
|
47
|
+
}
|