@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.
Files changed (146) hide show
  1. package/.github/extensions/web-relay/extension.mjs +0 -3
  2. package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
  3. package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
  4. package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
  5. package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
  6. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
  7. package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
  8. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
  9. package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
  10. package/CHANGELOG.md +269 -0
  11. package/README.md +407 -424
  12. package/bin/oar.js +142 -11
  13. package/package.json +8 -4
  14. package/server/README.md +59 -22
  15. package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
  16. package/server/claude-worker/claude-session-process.mjs +1013 -45
  17. package/server/claude-worker/claude-session-worker.mjs +54 -9
  18. package/server/claude-worker/claude-turn-publisher.mjs +110 -19
  19. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  20. package/server/copilot-sdk-runtime.mjs +12 -3
  21. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  22. package/server/copilot-worker/copilot-attachments.mjs +35 -2
  23. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  24. package/server/copilot-worker/copilot-model-switch.mjs +425 -0
  25. package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
  26. package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
  27. package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
  28. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
  29. package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
  30. package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
  31. package/server/cursor-worker/cursor-session-worker.mjs +5 -5
  32. package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
  33. package/server/db-schema.mjs +61 -1
  34. package/server/features.mjs +155 -41
  35. package/server/grok-worker/grok-session-worker.mjs +5 -5
  36. package/server/grok-worker/grok-turn-runner.mjs +45 -4
  37. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  38. package/server/public/app/annotate-core.mjs +181 -0
  39. package/server/public/app/annotate-editor.js +617 -0
  40. package/server/public/app/api-client.js +86 -5
  41. package/server/public/app/ask-user-view.js +102 -13
  42. package/server/public/app/attachments-view.js +172 -3
  43. package/server/public/app/background-task-timeout-options.mjs +9 -0
  44. package/server/public/app/background-tasks-view.mjs +34 -19
  45. package/server/public/app/bootstrap.js +153 -69
  46. package/server/public/app/composer-control-state.mjs +52 -34
  47. package/server/public/app/context-tier-options.mjs +24 -4
  48. package/server/public/app/conversation-draft-sync.mjs +187 -0
  49. package/server/public/app/conversation-list-filter.mjs +86 -0
  50. package/server/public/app/conversation-preferences.mjs +47 -10
  51. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  52. package/server/public/app/conversation-view.js +1032 -218
  53. package/server/public/app/emoji-view.js +2 -0
  54. package/server/public/app/font-scaling.js +4 -37
  55. package/server/public/app/journal-view.js +190 -24
  56. package/server/public/app/model-selector-options.mjs +185 -1
  57. package/server/public/app/new-conversation-model-choice.mjs +29 -9
  58. package/server/public/app/pwa-install.js +64 -136
  59. package/server/public/app/question-multi-select.mjs +52 -0
  60. package/server/public/app/reasoning-effort-labels.mjs +13 -3
  61. package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
  62. package/server/public/app/router.js +120 -11
  63. package/server/public/app/settings-modal.js +225 -20
  64. package/server/public/app/settings-tabs.js +1 -1
  65. package/server/public/app/socket-handlers.js +47 -4
  66. package/server/public/app/steered-turn-merge.mjs +100 -0
  67. package/server/public/app/store.js +159 -16
  68. package/server/public/app/sync-outbox.mjs +16 -8
  69. package/server/public/app/tool-activity-emoji.mjs +36 -0
  70. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  71. package/server/public/app/update-ui.js +194 -0
  72. package/server/public/index.html +483 -24
  73. package/server/public/sw.js +7 -0
  74. package/server/relay-tools.md +2 -0
  75. package/server/relay.mjs +23 -21
  76. package/server/repositories/message-repository.mjs +78 -21
  77. package/server/repositories/question-repository.mjs +19 -1
  78. package/server/repositories/session-repository.mjs +7 -31
  79. package/server/routes/ask-user-routes.mjs +10 -0
  80. package/server/routes/messages-routes.mjs +689 -159
  81. package/server/routes/sessions-routes.mjs +574 -80
  82. package/server/server-runtime.mjs +393 -925
  83. package/server/services/claude-auth-service.mjs +7 -1
  84. package/server/services/cli-install-service.mjs +8 -27
  85. package/server/services/cli-process-runner.mjs +30 -0
  86. package/server/services/copilot-model-discovery-service.mjs +202 -0
  87. package/server/services/data-dir-resolution.mjs +47 -0
  88. package/server/services/grok-auth-service.mjs +9 -1
  89. package/server/services/live-turn-picker.mjs +47 -0
  90. package/server/services/model-variant-catalog-service.mjs +1183 -0
  91. package/server/services/oar-cli-helpers.mjs +6 -2
  92. package/server/services/relay-prompt-sanitizer.mjs +30 -3
  93. package/server/services/relay-question-context.mjs +46 -0
  94. package/server/services/sdk-session-import-service.mjs +144 -16
  95. package/server/services/sdk-session-sync-service.mjs +84 -27
  96. package/server/services/session-worker-registry-service.mjs +116 -3
  97. package/server/services/session-worker-supervisor-service.mjs +7 -1
  98. package/server/services/session-worker-websocket-service.mjs +119 -24
  99. package/server/services/status-event-service.mjs +8 -2
  100. package/server/services/steer-resend.mjs +45 -0
  101. package/server/services/update-check-service.mjs +220 -0
  102. package/server/services/update-install-service.mjs +279 -0
  103. package/server/services/windows-autostart-service.mjs +42 -6
  104. package/server/services/windows-boot-autostart-service.mjs +487 -0
  105. package/shared/ask-user-bridge.mjs +37 -5
  106. package/shared/background-task-timeout.mjs +12 -4
  107. package/shared/claude-model-catalog.mjs +49 -0
  108. package/shared/context-window-fallbacks.mjs +1 -0
  109. package/shared/control-poller.mjs +4 -1
  110. package/shared/copilot-model-order.mjs +182 -0
  111. package/shared/media-embed-instructions.mjs +35 -0
  112. package/shared/model-descriptors.mjs +181 -30
  113. package/shared/model-id.mjs +6 -0
  114. package/shared/pwa-app-name.mjs +44 -0
  115. package/shared/question-multi-select.mjs +16 -0
  116. package/shared/relay-stop-failure.mjs +15 -0
  117. package/shared/slash-command-guard.mjs +76 -0
  118. package/shared/steer-note.mjs +66 -0
  119. package/shared/steer-settle-failure.mjs +89 -0
  120. package/shared/steer-settle-markers.mjs +18 -0
  121. package/shared/update-semver.mjs +50 -0
  122. package/shared/worker-crash-guard.mjs +40 -14
  123. package/shared/worker-runtime/api-client.mjs +34 -0
  124. package/shared/worker-runtime/config-loader.mjs +104 -0
  125. package/shared/worker-runtime/heartbeat.mjs +109 -0
  126. package/shared/worker-runtime/prompt-context.mjs +160 -0
  127. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  128. package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
  129. package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
  130. package/server/claude-worker/claude-session-test-harness.mjs +0 -236
  131. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  132. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
  133. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  134. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  135. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  136. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  137. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  138. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  139. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  140. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  141. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  142. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  143. package/server/routes/messages-routes-test-harness.mjs +0 -111
  144. package/server/services/fixtures/claude-auth-stub.sh +0 -62
  145. package/server/services/fixtures/cli-install-stub.sh +0 -75
  146. 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
- * Windows cannot `CreateProcess` a `.cmd`/`.bat` at all, and Node has refused
503
- * to pretend otherwise since the CVE-2024-27980 fix — it fails the spawn with
504
- * EINVAL unless a shell is asked for explicitly. `candidateNames()` resolves
505
- * exactly those extensions through PATHEXT, and npm's shim for `copilot` on a
506
- * Windows host *is* `copilot.cmd` — so without this, that row would report
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 platform === 'win32' && /\.(cmd|bat)$/i.test(String(command || ''));
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
- return spawnImpl(bin, args, {
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
+ }