@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
@@ -24,6 +24,9 @@ import {
24
24
  normalizeWorkspaceRootKey,
25
25
  } from './services/workspace-root-path-policy.mjs';
26
26
  import { stopSessionWorkerProcesses } from './services/session-worker-stop-service.mjs';
27
+ import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
28
+ import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
29
+ import { sanitizeRelayQuestionContext as sanitizeRelayQuestionContextWith } from './services/relay-question-context.mjs';
27
30
  import { isRealPathWithinRoot } from './services/workspace-symlink-guard.mjs';
28
31
  import { applySchema } from './db-schema.mjs';
29
32
  import { createSessionRepository } from './repositories/session-repository.mjs';
@@ -64,20 +67,21 @@ import { createCursorDashboardUsageFetcher, readCursorIdeSessionToken } from './
64
67
  import { fetchPersonalBillingUsage } from './services/github-billing-usage.mjs';
65
68
  import { createSessionTranscriptService } from './services/session-transcript-service.mjs';
66
69
  import { createSdkSessionImportService } from './services/sdk-session-import-service.mjs';
67
- import { createInstalledCopilotClient } from './copilot-sdk-runtime.mjs';
70
+ import { createCopilotModelDiscoveryService } from './services/copilot-model-discovery-service.mjs';
71
+ import { createInstalledCopilotClient, getCopilotBaseDirs, resolveInstalledCopilotPaths } from './copilot-sdk-runtime.mjs';
68
72
  import { createSessionHistoryRefreshService } from './services/session-history-refresh-service.mjs';
69
73
  import { createContextSnapshotService } from './services/context-snapshot-service.mjs';
70
74
  import { createClaudeSessionRootResolver } from './services/claude-session-root-service.mjs';
71
75
  import { createCursorSessionRootResolver } from './services/cursor-session-root-service.mjs';
72
76
  import { createGitChangesService } from './services/git-changes-service.mjs';
73
77
  import { createRelaySingletonGuard } from './services/relay-singleton-guard.mjs';
78
+ import { resolveDataDir } from './services/data-dir-resolution.mjs';
74
79
  import { createRelayRestartOrchestrator } from './services/relay-restart-orchestrator-service.mjs';
75
80
  import { createRelayBridgeOwnerService } from './services/relay-bridge-owner-service.mjs';
76
81
  import { createRelayCliLauncherService } from './services/relay-cli-launcher-service.mjs';
77
82
  import { createSessionWorkerRegistry } from './services/session-worker-registry-service.mjs';
78
83
  import { createSessionWorkerSupervisor } from './services/session-worker-supervisor-service.mjs';
79
84
  import { createSessionWorkerProcessInspector } from './services/session-worker-process-service.mjs';
80
- import { latestModelCatalogRefresh } from '../shared/model-catalog-freshness.mjs';
81
85
  import { applyClaudeProviderEnvironment, applyCopilotSdkProviderEnvironment, applyCursorProviderEnvironment, applyGrokProviderEnvironment, applyOpenAIProviderEnvironment, copilotSdkEngineUnavailableReason, killTmuxSession, launchSessionCli } from './services/session-worker-launch-service.mjs';
82
86
  import { createSshTunnelManager } from './services/ssh-tunnel-manager-service.mjs';
83
87
  import { createCloudflaredTunnelManager } from './services/cloudflared-tunnel-service.mjs';
@@ -89,6 +93,7 @@ import { createPreviewProxyServer } from './services/preview-proxy-server.mjs';
89
93
  import { createPreviewHealthProbe } from './services/preview-health-probe.mjs';
90
94
  import { createTunnelWorkerPathGuard } from './services/tunnel-worker-path-guard.mjs';
91
95
  import { createWindowsAutostartService } from './services/windows-autostart-service.mjs';
96
+ import { createWindowsBootAutostartService } from './services/windows-boot-autostart-service.mjs';
92
97
  import { createSessionWorkerWebSocketService } from './services/session-worker-websocket-service.mjs';
93
98
  import { createTmuxInspectorAccessPolicy } from './services/tmux-inspector-access-policy.mjs';
94
99
  import { createTmuxInspectorStreamService } from './services/tmux-inspector-stream-service.mjs';
@@ -98,7 +103,13 @@ import {
98
103
  resolveLaunchWorkspaceRootPath,
99
104
  } from './services/workspace-root-defaults-service.mjs';
100
105
  import { maybeStartTtyConsole } from './tty-console-bootstrap.mjs';
101
- import { FEATURES, normalizeFeatureFlags } from './features.mjs';
106
+ import {
107
+ computeFeatureFlagState,
108
+ FEATURE_REGISTRY,
109
+ featureSettingKey,
110
+ migrateLegacyConfigFeatures,
111
+ resolveBootFeatureFlags,
112
+ } from './features.mjs';
102
113
  import { RELAY_RESTART_EXIT_CODE } from './relay-exit-codes.mjs';
103
114
  import { DEFAULT_QUESTION_TIMEOUT_MS, questionExpiresAt } from '../shared/question-timeout.mjs';
104
115
  import {
@@ -106,6 +117,24 @@ import {
106
117
  readTurnCeilingSetting,
107
118
  turnCeilingMinutesToMs,
108
119
  } from '../shared/turn-ceiling.mjs';
120
+ import {
121
+ normalizePwaAppName,
122
+ readPwaAppNameSetting,
123
+ resolvePwaManifestNames,
124
+ } from '../shared/pwa-app-name.mjs';
125
+ import {
126
+ createUpdateCheckService,
127
+ isUpdateCheckKilled,
128
+ resolveUpdateManifestUrl,
129
+ } from './services/update-check-service.mjs';
130
+ import {
131
+ createUpdateInstallService,
132
+ reconcileUpdateAttempt,
133
+ } from './services/update-install-service.mjs';
134
+ import {
135
+ mergeClaudeModelEfforts,
136
+ mergeDiscoveredClaudeModels,
137
+ } from '../shared/claude-model-catalog.mjs';
109
138
  import { parseAutoCompactWindow } from '../shared/auto-compact-window.mjs';
110
139
  import { parseThinkingDisplay, parseThinkingEnabled } from '../shared/claude-thinking.mjs';
111
140
  import {
@@ -116,19 +145,30 @@ import {
116
145
  import { normalizeRelayThoughtList } from './public/app/relay-thoughts.mjs';
117
146
  import { capRelayActivityEntries } from './public/app/activity-replay-state.mjs';
118
147
  import {
119
- canonicalizeModelId,
120
- filterValidModelIds,
121
148
  isOpenAIModelId,
122
149
  isSafeClaudeModelId,
123
150
  isSafeCursorModelId,
124
151
  isSafeGrokModelId,
125
152
  isSafeProviderModelId,
126
- isValidModelId,
127
153
  } from '../shared/model-id.mjs';
128
- import { selectModelIdsForVariantRefresh } from '../shared/model-refresh.mjs';
154
+ import { createModelVariantCatalogService } from './services/model-variant-catalog-service.mjs';
129
155
 
130
156
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
131
157
  const REPO_ROOT = path.resolve(__dirname, '..');
158
+ // The installed package root: set by bin/oar.js for launched relays, falling
159
+ // back to the checkout for direct `node server/server.js` runs. Version and
160
+ // install method feed the update mechanism and /api/status.
161
+ const PACKAGE_ROOT = String(process.env.COPILOT_WEB_RELAY_ROOT || '').trim()
162
+ ? path.resolve(String(process.env.COPILOT_WEB_RELAY_ROOT).trim())
163
+ : REPO_ROOT;
164
+ const RUNNING_VERSION = (() => {
165
+ try {
166
+ return String(JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).version || '0.0.0');
167
+ } catch {
168
+ return '0.0.0';
169
+ }
170
+ })();
171
+ const INSTALL_METHOD = fs.existsSync(path.join(PACKAGE_ROOT, '.git')) ? 'git-checkout' : 'npm-global';
132
172
  const PUBLIC_DIR = path.join(__dirname, 'public');
133
173
  const INDEX_HTML_PATH = path.join(PUBLIC_DIR, 'index.html');
134
174
  const PWA_MANIFEST_PATH = path.join(PUBLIC_DIR, 'manifest.webmanifest');
@@ -146,9 +186,10 @@ const ttyConsoleRuntime = await maybeStartTtyConsole({
146
186
  const CONFIG_PATH = process.env.COPILOT_WEB_RELAY_CONFIG
147
187
  ? path.resolve(String(process.env.COPILOT_WEB_RELAY_CONFIG))
148
188
  : path.join(__dirname, 'config.json');
149
- const DATA_DIR = process.env.COPILOT_WEB_RELAY_DATA_DIR
150
- ? path.resolve(String(process.env.COPILOT_WEB_RELAY_DATA_DIR))
151
- : path.join(__dirname, 'data');
189
+ // env var > config.json `dataDir` > <server>/data — see data-dir-resolution.mjs
190
+ // for why the config can carry it (a dev checkout pointing at a release
191
+ // install's data, and autostart launchers that only pin the config path).
192
+ const DATA_DIR = resolveDataDir({ env: process.env, configPath: CONFIG_PATH, serverDir: __dirname });
152
193
  const DB_PATH = path.join(DATA_DIR, 'copilot.db');
153
194
  const RELAY_LOCK_PATH = path.join(DATA_DIR, 'relay-server.lock');
154
195
  const UPLOAD_DIR = process.env.COPILOT_WEB_RELAY_DATA_DIR
@@ -492,9 +533,17 @@ async function refreshClaudeProviderModels() {
492
533
  return { ok: false, models: settings.models, error: 'Claude model discovery returned no models' };
493
534
  }
494
535
  const nowIso = new Date().toISOString();
495
- const models = Array.from(new Set([settings.model, ...discovered])).filter(Boolean);
536
+ // Merge rather than replace: supportedModels() reports only the current
537
+ // lineup, so replacing would drop a model mid-use the moment a successor
538
+ // ships (Fable 5 -> Fable 5.1). Retained ids still run via `--model`.
539
+ const models = mergeDiscoveredClaudeModels({
540
+ defaultModel: settings.model,
541
+ discovered,
542
+ previouslyKnown: readClaudeModelListSetting(CLAUDE_MODELS_SETTING_KEY),
543
+ });
544
+ const mergedEfforts = mergeClaudeModelEfforts(readClaudeModelEffortsSetting(), effortsByModel);
496
545
  stmts.upsertAppSetting.run(CLAUDE_MODELS_SETTING_KEY, JSON.stringify(models), nowIso);
497
- stmts.upsertAppSetting.run(CLAUDE_MODEL_EFFORTS_SETTING_KEY, JSON.stringify(effortsByModel), nowIso);
546
+ stmts.upsertAppSetting.run(CLAUDE_MODEL_EFFORTS_SETTING_KEY, JSON.stringify(mergedEfforts), nowIso);
498
547
  return { ok: true, models, error: null };
499
548
  } catch (error) {
500
549
  return {
@@ -1222,7 +1271,12 @@ const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
1222
1271
  // spawn, so model catalogs, usage cards and routing all stay put.
1223
1272
  const COPILOT_ENGINE_SETTING_KEY = 'copilot_engine';
1224
1273
  const COPILOT_ENGINES = ['extension', 'sdk'];
1225
- const DEFAULT_COPILOT_ENGINE = 'extension';
1274
+ // Phase 6 of the extension retirement (2026-09-07): the headless SDK worker is
1275
+ // the default engine. The extension remains reachable two ways until deletion:
1276
+ // an explicit stored setting, or automatically when this relay cannot run the
1277
+ // SDK engine at all (no resolvable Copilot CLI, or session-worker routing off).
1278
+ const DEFAULT_COPILOT_ENGINE = 'sdk';
1279
+ const FALLBACK_COPILOT_ENGINE = 'extension';
1226
1280
  const CLAUDE_ENABLED_SETTING_KEY = 'claude_enabled';
1227
1281
  const CLAUDE_MODEL_SETTING_KEY = 'claude_model';
1228
1282
  const CLAUDE_MODELS_SETTING_KEY = 'claude_models';
@@ -1533,8 +1587,6 @@ const CURATED_MODEL_IDS = [
1533
1587
  'claude-sonnet-4.6',
1534
1588
  'claude-haiku-4.5',
1535
1589
  ];
1536
- const SUPPORTED_REASONING_EFFORTS = ['none', 'low', 'medium', 'high', 'xhigh', 'max'];
1537
- const REASONING_VARIANT_SUPPORTED_PREFIXES = ['gpt-', 'claude-'];
1538
1590
 
1539
1591
  let config = { ...DEFAULT_CONFIG };
1540
1592
  if (fs.existsSync(CONFIG_PATH)) {
@@ -1704,15 +1756,6 @@ function socketIoPath() {
1704
1756
  const remotePath = normalizeRemotePath(config.remotePath);
1705
1757
  const COOKIE_PATH = remotePath || '/';
1706
1758
 
1707
- let modelCatalog = {
1708
- models: [DEFAULT_MODEL],
1709
- currentModel: DEFAULT_MODEL,
1710
- defaultModel: DEFAULT_MODEL,
1711
- source: 'bootstrap',
1712
- refreshedAt: null,
1713
- error: null,
1714
- };
1715
- let modelSelectorSql = null;
1716
1759
 
1717
1760
  const workspaceFileMetaCache = new Map();
1718
1761
  let workspaceFileWatcher = null;
@@ -1810,14 +1853,22 @@ function readAppSettingValue(key) {
1810
1853
  }
1811
1854
 
1812
1855
  /**
1813
- * The Copilot engine setting, normalised. An unset or unrecognised stored value
1814
- * reads as the default (`extension`) rather than throwing: this value picks a
1815
- * worker kind at spawn time, and a relay that cannot decide must still be able
1816
- * to start a conversation on the engine that has been shipping.
1856
+ * The Copilot engine setting, normalised. An explicit stored value always
1857
+ * wins. Unset (or unrecognised) reads as the SDK default — but only when this
1858
+ * relay can actually run the SDK engine: the save path refuses to persist
1859
+ * 'sdk' on a relay that cannot honour it, and the default must not sneak past
1860
+ * that same guard. A relay with no resolvable Copilot CLI or with
1861
+ * session-worker routing disabled falls back to the extension engine so a
1862
+ * Copilot conversation can still start at all.
1817
1863
  */
1818
1864
  function getCopilotEngine() {
1819
1865
  const stored = readAppSettingValue(COPILOT_ENGINE_SETTING_KEY).toLowerCase();
1820
- return COPILOT_ENGINES.includes(stored) ? stored : DEFAULT_COPILOT_ENGINE;
1866
+ if (COPILOT_ENGINES.includes(stored)) return stored;
1867
+ const unavailable = copilotSdkEngineUnavailableReason({
1868
+ env: sessionWorkerLaunchEnv,
1869
+ routingEnabled: featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true,
1870
+ });
1871
+ return unavailable ? FALLBACK_COPILOT_ENGINE : DEFAULT_COPILOT_ENGINE;
1821
1872
  }
1822
1873
 
1823
1874
  function getCopilotProviderSettings() {
@@ -1861,6 +1912,54 @@ function setTurnCeilingMinutes(value) {
1861
1912
  return { ok: true, ceilingMinutes: parsed.minutes };
1862
1913
  }
1863
1914
 
1915
+ const PWA_APP_NAME_SETTING_KEY = 'pwa_app_name';
1916
+
1917
+ /** '' means "no custom name": the manifest falls back to the template default. */
1918
+ function getPwaAppName() {
1919
+ return readPwaAppNameSetting(readAppSettingValue(PWA_APP_NAME_SETTING_KEY));
1920
+ }
1921
+
1922
+ function setPwaAppName(rawValue) {
1923
+ if (typeof stmts?.upsertAppSetting?.run !== 'function' || typeof stmts?.deleteAppSetting?.run !== 'function') {
1924
+ // status carries the intended HTTP mapping so the route never has to key
1925
+ // off the error text (a reworded message must not turn 500s into 400s).
1926
+ return { ok: false, status: 500, error: 'App name settings are unavailable' };
1927
+ }
1928
+ const normalized = normalizePwaAppName(rawValue);
1929
+ if (!normalized.ok) return normalized;
1930
+ if (normalized.value) {
1931
+ stmts.upsertAppSetting.run(PWA_APP_NAME_SETTING_KEY, normalized.value, new Date().toISOString());
1932
+ } else {
1933
+ stmts.deleteAppSetting.run(PWA_APP_NAME_SETTING_KEY);
1934
+ }
1935
+ return { ok: true, appName: normalized.value };
1936
+ }
1937
+
1938
+ function getFeatureFlagsSettingsState() {
1939
+ // `featureFlags` is const-declared later in boot; this only runs from request
1940
+ // handlers, well after the snapshot exists (same shape as setCopilotProviderSettings).
1941
+ return computeFeatureFlagState({
1942
+ readSetting: readAppSettingValue,
1943
+ env: process.env,
1944
+ activeFlags: featureFlags,
1945
+ });
1946
+ }
1947
+
1948
+ function setFeatureFlagSetting(name, enabled) {
1949
+ if (typeof stmts?.upsertAppSetting?.run !== 'function') {
1950
+ return { ok: false, error: 'Feature settings are unavailable' };
1951
+ }
1952
+ const normalized = String(name || '').trim();
1953
+ if (!FEATURE_REGISTRY.some((entry) => entry.name === normalized)) {
1954
+ return { ok: false, error: 'Unknown feature flag' };
1955
+ }
1956
+ if (typeof enabled !== 'boolean') {
1957
+ return { ok: false, error: 'enabled must be a boolean' };
1958
+ }
1959
+ stmts.upsertAppSetting.run(featureSettingKey(normalized), enabled ? '1' : '0', new Date().toISOString());
1960
+ return { ok: true };
1961
+ }
1962
+
1864
1963
  const BACKGROUND_TASK_TIMEOUT_SETTING_KEY = 'background_task_timeout_minutes';
1865
1964
 
1866
1965
  function getBackgroundTaskTimeoutMinutes() {
@@ -2164,447 +2263,30 @@ function uniqueStringList(values) {
2164
2263
  return out;
2165
2264
  }
2166
2265
 
2167
- function validatedModelIdList(values) {
2168
- return filterValidModelIds(uniqueStringList(values));
2169
- }
2170
-
2171
- function curatedModelList() {
2172
- return uniqueStringList(CURATED_MODEL_IDS);
2173
- }
2174
-
2175
- function normalizeReasoningEffort(value) {
2176
- const text = String(value || '').trim().toLowerCase();
2177
- if (!text) return null;
2178
- return SUPPORTED_REASONING_EFFORTS.includes(text) ? text : null;
2179
- }
2180
-
2181
- function isReasoningVariantEligibleModel(modelId) {
2182
- const normalized = String(modelId || '').trim().toLowerCase();
2183
- if (!normalized) return false;
2184
- return REASONING_VARIANT_SUPPORTED_PREFIXES.some((prefix) => normalized.startsWith(prefix));
2185
- }
2186
-
2187
- function modelProviderForId(modelId) {
2188
- const text = String(modelId || '').trim().toLowerCase();
2189
- if (!text) return 'other';
2190
- if (
2191
- text.startsWith('gpt-')
2192
- || text.startsWith('o1-')
2193
- || text.startsWith('o3-')
2194
- || text.startsWith('codex-')
2195
- || text.startsWith('openai/')
2196
- ) return 'openai';
2197
- if (text.startsWith('claude-')) return 'anthropic';
2198
- if (text.startsWith('gemini-')) return 'google';
2199
- if (text.startsWith('mai-')) return 'microsoft';
2200
- return 'other';
2201
- }
2202
-
2203
- function titleCaseWord(word) {
2204
- const text = String(word || '').trim();
2205
- if (!text) return '';
2206
- return text.charAt(0).toUpperCase() + text.slice(1);
2207
- }
2208
-
2209
- function modelDisplayLabel(modelId) {
2210
- const text = String(modelId || '').trim();
2211
- if (!text) return '';
2212
- if (/^gpt-/i.test(text)) {
2213
- return text
2214
- .replace(/^gpt-/i, 'GPT-')
2215
- .replace(/-codex$/i, ' Codex')
2216
- .replace(/-mini$/i, ' Mini')
2217
- .replace(/\bmini\b/gi, 'Mini')
2218
- .replace(/\s+/g, ' ')
2219
- .trim();
2220
- }
2221
- if (/^claude-/i.test(text)) {
2222
- return text
2223
- .replace(/^claude-/i, 'Claude ')
2224
- .split('-')
2225
- .map((part) => (/^\d+(\.\d+)?$/.test(part) ? part : titleCaseWord(part)))
2226
- .join(' ')
2227
- .replace(/\s+/g, ' ')
2228
- .trim();
2229
- }
2230
- if (/^gemini-/i.test(text)) {
2231
- return text
2232
- .replace(/^gemini-/i, 'Gemini ')
2233
- .split('-')
2234
- .map((part) => (/^\d+(\.\d+)?$/.test(part) ? part : titleCaseWord(part)))
2235
- .join(' ')
2236
- .replace(/\s+/g, ' ')
2237
- .trim();
2238
- }
2239
- return text;
2240
- }
2241
-
2242
- function buildModelVariantId(baseModelId, reasoningEffort = null) {
2243
- const base = String(baseModelId || '').trim();
2244
- if (!base) return '';
2245
- const effort = normalizeReasoningEffort(reasoningEffort);
2246
- if (!effort) return base;
2247
- return `${base}-${effort}`;
2248
- }
2249
-
2250
- function parseModelVariantId(variantId = '', {
2251
- knownBaseModels = [],
2252
- } = {}) {
2253
- const value = String(variantId || '').trim();
2254
- if (!value) return null;
2255
- const known = Array.isArray(knownBaseModels)
2256
- ? knownBaseModels.map((entry) => String(entry || '').trim()).filter(Boolean)
2257
- : [];
2258
- const orderedKnown = known.sort((a, b) => b.length - a.length);
2259
- for (const candidate of orderedKnown) {
2260
- if (!value.toLowerCase().startsWith(`${candidate.toLowerCase()}-`)) continue;
2261
- const suffix = value.slice(candidate.length + 1);
2262
- const effort = normalizeReasoningEffort(suffix);
2263
- if (effort) {
2264
- return {
2265
- variantId: buildModelVariantId(candidate, effort),
2266
- baseModelId: candidate,
2267
- reasoningEffort: effort,
2268
- };
2269
- }
2270
- }
2271
- const trailingEffortMatch = value.match(/^(.*?)-([a-z]+)$/i);
2272
- if (trailingEffortMatch) {
2273
- const effort = normalizeReasoningEffort(trailingEffortMatch[2]);
2274
- const maybeBase = String(trailingEffortMatch[1] || '').trim();
2275
- if (effort && maybeBase) {
2276
- return {
2277
- variantId: buildModelVariantId(maybeBase, effort),
2278
- baseModelId: maybeBase,
2279
- reasoningEffort: effort,
2280
- };
2281
- }
2282
- }
2283
- return {
2284
- variantId: value,
2285
- baseModelId: value,
2286
- reasoningEffort: null,
2287
- };
2288
- }
2289
-
2290
- function normalizeModelVariantRow(row = {}) {
2291
- const rawBaseModelId = String(row?.base_model_id || row?.baseModelId || '').trim();
2292
- const baseModelId = canonicalizeModelId(rawBaseModelId) || rawBaseModelId;
2293
- const reasoningEffort = normalizeReasoningEffort(row?.reasoning_effort ?? row?.reasoningEffort);
2294
- const rawVariantId = String(row?.variant_id || row?.variantId || buildModelVariantId(baseModelId, reasoningEffort)).trim();
2295
- const parsed = parseModelVariantId(rawVariantId, { knownBaseModels: baseModelId ? [baseModelId] : [] });
2296
- const normalizedBaseModelId = canonicalizeModelId(parsed?.baseModelId || baseModelId) || baseModelId;
2297
- const normalizedReasoningEffort = normalizeReasoningEffort(parsed?.reasoningEffort || reasoningEffort);
2298
- const variantId = buildModelVariantId(normalizedBaseModelId, normalizedReasoningEffort);
2299
- const provider = String(row?.provider || row?.providerId || modelProviderForId(baseModelId)).trim() || 'other';
2300
- const label = String(row?.label || row?.displayName || modelDisplayLabel(normalizedBaseModelId)).trim() || normalizedBaseModelId;
2301
- const enabledValue = Number(row?.enabled);
2302
- const enabled = Number.isFinite(enabledValue) ? enabledValue === 1 : !!row?.enabled;
2303
- return {
2304
- variantId,
2305
- baseModelId: normalizedBaseModelId,
2306
- provider,
2307
- reasoningEffort: normalizedReasoningEffort,
2308
- label,
2309
- releaseStatus: String(row?.release_status || row?.releaseStatus || '').trim() || null,
2310
- contextLimitTokens: Number.isFinite(Number(row?.context_limit_tokens ?? row?.contextLimitTokens))
2311
- && Number(row?.context_limit_tokens ?? row?.contextLimitTokens) > 0
2312
- ? Math.round(Number(row?.context_limit_tokens ?? row?.contextLimitTokens))
2313
- : null,
2314
- longContextLimitTokens: Number.isFinite(Number(row?.long_context_limit_tokens ?? row?.longContextLimitTokens))
2315
- && Number(row?.long_context_limit_tokens ?? row?.longContextLimitTokens) > 0
2316
- ? Math.round(Number(row?.long_context_limit_tokens ?? row?.longContextLimitTokens))
2317
- : null,
2318
- pricing: (() => {
2319
- const value = row?.pricing_json ?? row?.pricing;
2320
- if (!value || typeof value === 'object') return value || null;
2321
- try { return JSON.parse(value); } catch { return null; }
2322
- })(),
2323
- enabled,
2324
- sortOrder: Number.isFinite(Number(row?.sort_order ?? row?.sortOrder))
2325
- ? Math.max(0, Math.trunc(Number(row?.sort_order ?? row?.sortOrder)))
2326
- : 0,
2327
- updatedAt: row?.updated_at || row?.updatedAt || null,
2328
- };
2329
- }
2330
-
2331
- function buildModelVariantEntries(baseModels = [], {
2332
- defaultEnabled = true,
2333
- contextLimitsByModel = {},
2334
- modelMetadataByModel = {},
2335
- } = {}) {
2336
- const models = uniqueStringList(baseModels);
2337
- const entries = [];
2338
- let sortOrder = 0;
2339
- for (const baseModelId of models) {
2340
- const provider = modelProviderForId(baseModelId);
2341
- const label = modelDisplayLabel(baseModelId);
2342
- const contextLimitTokens = Number(contextLimitsByModel?.[baseModelId]);
2343
- const normalizedContextLimitTokens = Number.isFinite(contextLimitTokens) && contextLimitTokens > 0
2344
- ? Math.round(contextLimitTokens)
2345
- : null;
2346
- const metadata = modelMetadataByModel?.[baseModelId] || {};
2347
- if (isReasoningVariantEligibleModel(baseModelId)) {
2348
- for (const effort of SUPPORTED_REASONING_EFFORTS) {
2349
- entries.push({
2350
- variantId: buildModelVariantId(baseModelId, effort),
2351
- baseModelId,
2352
- provider,
2353
- label,
2354
- reasoningEffort: effort,
2355
- releaseStatus: null,
2356
- contextLimitTokens: normalizedContextLimitTokens,
2357
- longContextLimitTokens: toNullableInt(metadata.longContextLimitTokens),
2358
- pricing: metadata.pricing || null,
2359
- enabled: defaultEnabled ? 1 : 0,
2360
- sortOrder: sortOrder++,
2361
- });
2362
- }
2363
- continue;
2364
- }
2365
- entries.push({
2366
- variantId: buildModelVariantId(baseModelId),
2367
- baseModelId,
2368
- provider,
2369
- label,
2370
- reasoningEffort: null,
2371
- releaseStatus: null,
2372
- contextLimitTokens: normalizedContextLimitTokens,
2373
- longContextLimitTokens: toNullableInt(metadata.longContextLimitTokens),
2374
- pricing: metadata.pricing || null,
2375
- enabled: defaultEnabled ? 1 : 0,
2376
- sortOrder: sortOrder++,
2377
- });
2378
- }
2379
-
2380
- return entries;
2381
- }
2382
-
2383
- function normalizeModelMetadataByModel(value = {}) {
2384
- if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
2385
- const normalized = {};
2386
- for (const [rawModelId, rawMetadata] of Object.entries(value)) {
2387
- const modelId = canonicalizeModelId(rawModelId);
2388
- if (!isValidModelId(modelId) || !rawMetadata || typeof rawMetadata !== 'object') continue;
2389
- const defaultContextLimitTokens = toNullableInt(rawMetadata.defaultContextLimitTokens);
2390
- const longContextLimitTokens = toNullableInt(rawMetadata.longContextLimitTokens);
2391
- const pricing = rawMetadata.pricing && typeof rawMetadata.pricing === 'object' ? rawMetadata.pricing : null;
2392
- if (defaultContextLimitTokens === null && longContextLimitTokens === null && pricing === null) continue;
2393
- normalized[modelId] = { defaultContextLimitTokens, longContextLimitTokens, pricing };
2394
- }
2395
- return normalized;
2396
- }
2397
-
2398
- function normalizeContextLimitsByModel(value = {}) {
2399
- if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
2400
- const normalized = {};
2401
- for (const [rawModelId, rawLimit] of Object.entries(value)) {
2402
- const modelId = canonicalizeModelId(rawModelId);
2403
- const limit = Number(rawLimit);
2404
- if (!isValidModelId(modelId) || !Number.isFinite(limit) || limit <= 0) continue;
2405
- normalized[modelId] = Math.round(limit);
2406
- }
2407
- return normalized;
2408
- }
2409
-
2410
- function hasValidReasoningByModel(reasoningByModel = {}) {
2411
- if (!reasoningByModel || typeof reasoningByModel !== 'object') return false;
2412
- const modelIds = Object.keys(reasoningByModel).filter((modelId) => modelId !== AUTO_MODEL_SENTINEL);
2413
- if (!modelIds.length) return false;
2414
- return modelIds.every((modelId) => {
2415
- const efforts = reasoningByModel[modelId];
2416
- return Array.isArray(efforts) && efforts.length > 0;
2417
- });
2418
- }
2419
-
2420
- function getModelCatalogState() {
2421
- const selectorState = getModelVariantSelectorState();
2422
- const enabledRows = listEnabledModelVariantRows();
2423
- const modelRows = enabledRows.length ? enabledRows : listModelVariantRows().filter((row) => row.enabled);
2424
- const allRows = listModelVariantRows();
2425
- const reasoningByModel = {};
2426
- const contextLimitsByModel = {};
2427
- const modelMetadataByModel = {};
2428
- const models = [];
2429
- const seenModels = new Set();
2430
- for (const row of modelRows) {
2431
- const baseModelId = String(row?.baseModelId || '').trim();
2432
- if (!baseModelId) continue;
2433
- if (!seenModels.has(baseModelId)) {
2434
- seenModels.add(baseModelId);
2435
- models.push(baseModelId);
2436
- }
2437
- const effort = normalizeReasoningEffort(row?.reasoningEffort || 'none') || 'none';
2438
- const current = reasoningByModel[baseModelId] || [];
2439
- if (!current.includes(effort)) current.push(effort);
2440
- reasoningByModel[baseModelId] = current;
2441
- if (row.contextLimitTokens !== null && row.contextLimitTokens > 0) {
2442
- contextLimitsByModel[baseModelId] = row.contextLimitTokens;
2443
- }
2444
- if (!modelMetadataByModel[baseModelId]) {
2445
- modelMetadataByModel[baseModelId] = {
2446
- defaultContextLimitTokens: row.contextLimitTokens,
2447
- longContextLimitTokens: row.longContextLimitTokens,
2448
- pricing: row.pricing,
2449
- };
2450
- }
2451
- }
2452
- const knownModelIds = new Set(Object.keys(reasoningByModel));
2453
- for (const row of allRows) {
2454
- const baseModelId = String(row?.baseModelId || '').trim();
2455
- if (!baseModelId) continue;
2456
- knownModelIds.add(baseModelId);
2457
- const effort = normalizeReasoningEffort(row?.reasoningEffort || 'none') || 'none';
2458
- const current = reasoningByModel[baseModelId] || [];
2459
- if (!current.includes(effort)) current.push(effort);
2460
- reasoningByModel[baseModelId] = current;
2461
- if (row.contextLimitTokens !== null && row.contextLimitTokens > 0) {
2462
- contextLimitsByModel[baseModelId] = row.contextLimitTokens;
2463
- }
2464
- if (!modelMetadataByModel[baseModelId]) {
2465
- modelMetadataByModel[baseModelId] = {
2466
- defaultContextLimitTokens: row.contextLimitTokens,
2467
- longContextLimitTokens: row.longContextLimitTokens,
2468
- pricing: row.pricing,
2469
- };
2470
- }
2471
- }
2472
- for (const modelId of knownModelIds) {
2473
- const efforts = uniqueStringList(
2474
- (reasoningByModel[modelId] || [])
2475
- .map((value) => normalizeReasoningEffort(value))
2476
- .filter(Boolean),
2477
- );
2478
- reasoningByModel[modelId] = efforts;
2479
- }
2480
- const autoEfforts = uniqueStringList(
2481
- Object.entries(reasoningByModel)
2482
- .filter(([modelId]) => modelId !== AUTO_MODEL_SENTINEL)
2483
- .flatMap(([, list]) => Array.isArray(list) ? list : [])
2484
- .map((value) => normalizeReasoningEffort(value))
2485
- .filter(Boolean),
2486
- );
2487
- if (autoEfforts.length) {
2488
- reasoningByModel[AUTO_MODEL_SENTINEL] = autoEfforts;
2489
- } else {
2490
- delete reasoningByModel[AUTO_MODEL_SENTINEL];
2491
- }
2492
- const catalogModels = [AUTO_MODEL_SENTINEL, ...models.filter((value) => value.toLowerCase() !== AUTO_MODEL_SENTINEL)];
2493
- const currentResolved = parseModelVariantSelection(selectorState.currentModel);
2494
- const defaultResolved = parseModelVariantSelection(selectorState.defaultModel);
2495
- const currentModel = String(currentResolved?.baseModelId || selectorState.currentModel || '').trim() || catalogModels[0] || DEFAULT_MODEL;
2496
- const defaultModel = String(defaultResolved?.baseModelId || selectorState.defaultModel || '').trim() || currentModel || catalogModels[0] || DEFAULT_MODEL;
2497
- const reasoningMetadataValid = hasValidReasoningByModel(reasoningByModel);
2498
- const inMemoryRefresh = modelCatalog.refreshedAt || null;
2499
- const refreshedAt = latestModelCatalogRefresh(selectorState.refreshedAt, inMemoryRefresh);
2500
- const metadataError = !reasoningMetadataValid || !!selectorState.error || modelRows.length === 0;
2501
- const stale = metadataError;
2502
- const metadataValid = !metadataError;
2503
- const warning = selectorState.warning || null;
2504
- const reasoningEfforts = uniqueStringList(
2505
- Object.values(reasoningByModel)
2506
- .flatMap((list) => Array.isArray(list) ? list : [])
2507
- .map((value) => normalizeReasoningEffort(value))
2508
- .filter(Boolean),
2509
- );
2510
- return {
2511
- models: catalogModels,
2512
- currentModel,
2513
- defaultModel,
2514
- source: selectorState.source,
2515
- refreshedAt,
2516
- stale,
2517
- metadataValid,
2518
- reasoningMetadataValid,
2519
- warning,
2520
- error: selectorState.error,
2521
- reasoningByModel,
2522
- reasoningEfforts,
2523
- contextLimitsByModel,
2524
- modelMetadataByModel,
2525
- };
2526
- }
2527
-
2528
- function touchModelSelectorState({
2529
- source = 'snapshot',
2530
- error = null,
2531
- refreshedAt = new Date().toISOString(),
2532
- } = {}) {
2533
- if (!modelSelectorSql?.upsertSelectorState?.run) return;
2534
- const timestamp = latestModelCatalogRefresh(refreshedAt) || new Date().toISOString();
2535
- modelSelectorSql.upsertSelectorState.run(
2536
- String(source || 'snapshot').trim() || 'snapshot',
2537
- timestamp,
2538
- error ? String(error).trim().slice(0, 300) : null,
2539
- timestamp,
2540
- );
2541
- }
2266
+ // The Copilot model catalog (snapshot state + model_variants rows) lives in
2267
+ // its own service so it can be tested against an in-memory database; the
2268
+ // runtime only wires it to its constants and the copilot CLI runner. It is
2269
+ // created BEFORE the database so the bootstrap catalog publish below keeps
2270
+ // its historical ordering; bindDatabase() attaches the statements later.
2271
+ const modelVariantCatalog = createModelVariantCatalogService({
2272
+ defaultModel: DEFAULT_MODEL,
2273
+ curatedModelIds: CURATED_MODEL_IDS,
2274
+ autoModelSentinel: AUTO_MODEL_SENTINEL,
2275
+ runCopilotCliCommand,
2276
+ });
2277
+ const {
2278
+ SUPPORTED_REASONING_EFFORTS,
2279
+ normalizeReasoningEffort,
2280
+ buildModelVariantId,
2281
+ getModelCatalogState,
2282
+ updateModelCatalog,
2283
+ listModelVariantRows,
2284
+ getModelContextLimitTokens,
2285
+ parseModelVariantSelection,
2286
+ setEnabledModelVariants,
2287
+ refreshModelVariantCatalogFromCli,
2288
+ } = modelVariantCatalog;
2542
2289
 
2543
- function updateModelCatalog(snapshot = {}) {
2544
- const incomingModels = validatedModelIdList(Array.isArray(snapshot.models) ? snapshot.models : []);
2545
- const incomingCurrentRaw = String(snapshot.currentModel || '').trim();
2546
- const incomingDefaultRaw = String(snapshot.defaultModel || '').trim();
2547
- const incomingCurrent = isValidModelId(incomingCurrentRaw) ? incomingCurrentRaw : '';
2548
- const incomingDefault = isValidModelId(incomingDefaultRaw) ? incomingDefaultRaw : '';
2549
- const contextLimitsByModel = normalizeContextLimitsByModel(snapshot.contextLimitsByModel);
2550
- const modelMetadataByModel = normalizeModelMetadataByModel(snapshot.modelMetadataByModel);
2551
- const receivedMetadata = incomingModels.length > 0 || Object.keys(contextLimitsByModel).length > 0 || Object.keys(modelMetadataByModel).length > 0;
2552
- const merged = validatedModelIdList([
2553
- ...curatedModelList(),
2554
- ...incomingModels,
2555
- incomingCurrent,
2556
- incomingDefault,
2557
- ...(Array.isArray(modelCatalog.models) ? modelCatalog.models : []),
2558
- modelCatalog.currentModel,
2559
- modelCatalog.defaultModel,
2560
- ]);
2561
- if (!merged.length) merged.push(DEFAULT_MODEL);
2562
- const currentModel = incomingCurrent || modelCatalog.currentModel || incomingDefault || merged[0] || DEFAULT_MODEL;
2563
- const defaultModel = incomingDefault || modelCatalog.defaultModel || currentModel || DEFAULT_MODEL;
2564
- const models = uniqueStringList([currentModel, defaultModel, ...merged]);
2565
-
2566
- modelCatalog = {
2567
- models,
2568
- currentModel,
2569
- defaultModel,
2570
- source: String(snapshot.source || modelCatalog.source || 'unknown').trim() || 'unknown',
2571
- refreshedAt: receivedMetadata ? new Date().toISOString() : modelCatalog.refreshedAt,
2572
- error: snapshot.error ? String(snapshot.error).trim().slice(0, 300) : null,
2573
- };
2574
- if (modelSelectorSql?.upsertVariant?.run) {
2575
- const existingBaseIds = new Set(listModelVariantRows().map((r) => r.baseModelId));
2576
- const newBaseIds = models.filter((id) => !existingBaseIds.has(id));
2577
- if (newBaseIds.length) {
2578
- const newEntries = buildModelVariantEntries(newBaseIds, { defaultEnabled: false, contextLimitsByModel, modelMetadataByModel });
2579
- upsertModelVariantCatalogEntries(newEntries, {
2580
- source: String(modelCatalog.source || 'snapshot').trim() || 'snapshot',
2581
- error: modelCatalog.error || null,
2582
- preserveEnabled: true,
2583
- });
2584
- }
2585
- const nowIso = new Date().toISOString();
2586
- for (const [modelId, contextLimitTokens] of Object.entries(contextLimitsByModel)) {
2587
- modelSelectorSql.updateContextLimitForBase.run(contextLimitTokens, nowIso, modelId);
2588
- }
2589
- for (const [modelId, metadata] of Object.entries(modelMetadataByModel)) {
2590
- modelSelectorSql.updateModelMetadataForBase.run(
2591
- metadata.defaultContextLimitTokens,
2592
- metadata.longContextLimitTokens,
2593
- metadata.pricing ? JSON.stringify(metadata.pricing) : null,
2594
- nowIso,
2595
- modelId,
2596
- );
2597
- }
2598
- }
2599
- if (receivedMetadata && modelSelectorSql?.upsertSelectorState?.run) {
2600
- touchModelSelectorState({
2601
- source: modelCatalog.source,
2602
- error: modelCatalog.error,
2603
- refreshedAt: modelCatalog.refreshedAt,
2604
- });
2605
- }
2606
- return getModelCatalogState();
2607
- }
2608
2290
 
2609
2291
  function getSupportedReasoningEffortsForModel(model = '') {
2610
2292
  const modelId = String(model || '').trim().toLowerCase();
@@ -2693,200 +2375,6 @@ function resolveRequestedModel(model) {
2693
2375
  };
2694
2376
  }
2695
2377
 
2696
- function listModelVariantRows() {
2697
- if (!modelSelectorSql?.listVariants?.all) return [];
2698
- return modelSelectorSql.listVariants.all().map((row) => normalizeModelVariantRow(row));
2699
- }
2700
-
2701
- function listEnabledModelVariantRows() {
2702
- if (!modelSelectorSql?.listEnabledVariants?.all) return [];
2703
- return modelSelectorSql.listEnabledVariants.all().map((row) => normalizeModelVariantRow(row));
2704
- }
2705
-
2706
- function getModelContextLimitTokens(modelId = '') {
2707
- const normalizedModelId = canonicalizeModelId(modelId);
2708
- if (!normalizedModelId) return null;
2709
- const row = listModelVariantRows().find((entry) => entry.baseModelId === normalizedModelId
2710
- && entry.contextLimitTokens !== null
2711
- && entry.contextLimitTokens > 0);
2712
- return row?.contextLimitTokens || null;
2713
- }
2714
-
2715
- function parseModelVariantSelection(value) {
2716
- const variantId = String(value || '').trim();
2717
- if (!variantId) return null;
2718
- const knownBaseModels = listModelVariantRows().map((row) => row.baseModelId);
2719
- const parsed = parseModelVariantId(variantId, { knownBaseModels });
2720
- if (!parsed) return null;
2721
- const match = listModelVariantRows().find((row) => row.variantId === parsed.variantId);
2722
- if (match) {
2723
- return {
2724
- variantId: match.variantId,
2725
- baseModelId: match.baseModelId,
2726
- reasoningEffort: match.reasoningEffort,
2727
- provider: match.provider,
2728
- label: match.label,
2729
- };
2730
- }
2731
- return parsed;
2732
- }
2733
-
2734
- function getModelVariantSelectorState() {
2735
- const fallbackVariant = buildModelVariantId(DEFAULT_MODEL, isReasoningVariantEligibleModel(DEFAULT_MODEL) ? 'none' : null);
2736
- if (!modelSelectorSql?.listEnabledVariants?.all) {
2737
- const fallbackModels = buildModelVariantEntries(curatedModelList(), { defaultEnabled: true }).map((entry) => entry.variantId);
2738
- const models = fallbackModels.length ? fallbackModels : [fallbackVariant];
2739
- return {
2740
- models,
2741
- currentModel: models[0] || fallbackVariant,
2742
- defaultModel: models[0] || fallbackVariant,
2743
- source: 'bootstrap',
2744
- refreshedAt: null,
2745
- warning: null,
2746
- error: null,
2747
- };
2748
- }
2749
- const enabledRows = listEnabledModelVariantRows();
2750
- const selectorState = modelSelectorSql.getSelectorState.get() || null;
2751
- const enabledVariants = enabledRows.map((row) => row.variantId);
2752
- const models = enabledVariants.length ? enabledVariants : [fallbackVariant];
2753
- const warning = enabledVariants.length
2754
- ? null
2755
- : 'No model variants are enabled. Using fallback.';
2756
- return {
2757
- models,
2758
- currentModel: models[0] || fallbackVariant,
2759
- defaultModel: models[0] || fallbackVariant,
2760
- source: String(selectorState?.source || 'db').trim() || 'db',
2761
- refreshedAt: selectorState?.refreshed_at || null,
2762
- warning,
2763
- error: selectorState?.error ? String(selectorState.error) : null,
2764
- };
2765
- }
2766
-
2767
- function upsertModelVariantCatalogEntries(entries = [], {
2768
- source = 'manual-refresh',
2769
- error = null,
2770
- preserveEnabled = true,
2771
- } = {}) {
2772
- if (!modelSelectorSql?.upsertVariant?.run || !modelSelectorSql?.upsertSelectorState?.run) {
2773
- return getModelVariantSelectorState();
2774
- }
2775
- const normalizedEntries = Array.isArray(entries)
2776
- ? entries.map((entry) => normalizeModelVariantRow(entry)).filter((entry) => entry.variantId && entry.baseModelId)
2777
- : [];
2778
- const existingEnabled = new Map(
2779
- listModelVariantRows().map((row) => [row.variantId, row.enabled ? 1 : 0]),
2780
- );
2781
- const incomingIds = new Set(normalizedEntries.map((entry) => entry.variantId));
2782
- const incomingBaseIds = new Set(normalizedEntries.map((entry) => entry.baseModelId));
2783
- const nowIso = new Date().toISOString();
2784
- const tx = db.transaction(() => {
2785
- for (const entry of normalizedEntries) {
2786
- const enabled = preserveEnabled && existingEnabled.has(entry.variantId)
2787
- ? existingEnabled.get(entry.variantId)
2788
- : (entry.enabled ? 1 : 0);
2789
- modelSelectorSql.upsertVariant.run(
2790
- entry.variantId,
2791
- entry.baseModelId,
2792
- entry.provider || modelProviderForId(entry.baseModelId),
2793
- entry.label || modelDisplayLabel(entry.baseModelId),
2794
- entry.releaseStatus || null,
2795
- entry.reasoningEffort || null,
2796
- entry.contextLimitTokens || null,
2797
- entry.longContextLimitTokens || null,
2798
- entry.pricing ? JSON.stringify(entry.pricing) : null,
2799
- enabled,
2800
- entry.sortOrder,
2801
- nowIso,
2802
- );
2803
- }
2804
- for (const row of listModelVariantRows()) {
2805
- if (incomingIds.has(row.variantId)) continue;
2806
- const shouldMarkUnavailable = row.enabled || incomingBaseIds.has(row.baseModelId);
2807
- if (shouldMarkUnavailable) {
2808
- modelSelectorSql.upsertVariant.run(
2809
- row.variantId,
2810
- row.baseModelId,
2811
- row.provider || modelProviderForId(row.baseModelId),
2812
- row.label || modelDisplayLabel(row.baseModelId),
2813
- 'unavailable',
2814
- row.reasoningEffort || null,
2815
- row.contextLimitTokens || null,
2816
- row.longContextLimitTokens || null,
2817
- row.pricing ? JSON.stringify(row.pricing) : null,
2818
- row.enabled ? 1 : 0,
2819
- row.sortOrder,
2820
- nowIso,
2821
- );
2822
- continue;
2823
- }
2824
- // Variants that are both disabled and fully absent from the incoming base set
2825
- // are no longer relevant; prune them so they do not linger as selectable.
2826
- modelSelectorSql.deleteVariant.run(row.variantId);
2827
- }
2828
- modelSelectorSql.upsertSelectorState.run(
2829
- String(source || 'manual-refresh').trim() || 'manual-refresh',
2830
- nowIso,
2831
- error ? String(error).trim().slice(0, 300) : null,
2832
- nowIso,
2833
- );
2834
- });
2835
- tx();
2836
- return getModelVariantSelectorState();
2837
- }
2838
-
2839
- function setEnabledModelVariants(variantIds = []) {
2840
- if (!modelSelectorSql?.disableAllVariants?.run || !modelSelectorSql?.enableVariant?.run) {
2841
- return getModelVariantSelectorState();
2842
- }
2843
- const nextEnabled = new Set(
2844
- Array.isArray(variantIds) ? variantIds.map((value) => String(value || '').trim()).filter(Boolean) : [],
2845
- );
2846
- const existingRows = listModelVariantRows();
2847
- const nowIso = new Date().toISOString();
2848
- const tx = db.transaction(() => {
2849
- modelSelectorSql.disableAllVariants.run(nowIso);
2850
- for (const row of existingRows) {
2851
- if (!nextEnabled.has(row.variantId)) continue;
2852
- modelSelectorSql.enableVariant.run(nowIso, row.variantId);
2853
- }
2854
- });
2855
- tx();
2856
- return getModelVariantSelectorState();
2857
- }
2858
-
2859
- function parseModelsFromHelpConfigOutput(text) {
2860
- const content = String(text || '');
2861
- if (!content) return [];
2862
- const sectionMatch = content.match(/`model`:[\s\S]*?(?=\n\s*`[a-zA-Z][^`]*`:\s|$)/);
2863
- const section = sectionMatch ? sectionMatch[0] : content;
2864
- const models = [];
2865
- const regex = /"([^"]+)"/g;
2866
- let match;
2867
- while ((match = regex.exec(section))) {
2868
- const candidate = String(match[1] || '').trim();
2869
- if (!candidate || candidate === 'auto') continue;
2870
- if (!isValidModelId(candidate)) continue;
2871
- models.push(candidate);
2872
- }
2873
- return validatedModelIdList(models);
2874
- }
2875
-
2876
- function parseReasoningEffortsFromHelpOutput(text) {
2877
- const content = String(text || '');
2878
- if (!content) return SUPPORTED_REASONING_EFFORTS.slice();
2879
- const values = [];
2880
- const regex = /"([a-z]+)"/g;
2881
- let match;
2882
- while ((match = regex.exec(content))) {
2883
- const effort = normalizeReasoningEffort(match[1]);
2884
- if (effort) values.push(effort);
2885
- }
2886
- const unique = uniqueStringList(values);
2887
- return unique.length ? unique : SUPPORTED_REASONING_EFFORTS.slice();
2888
- }
2889
-
2890
2378
  function runCopilotCliCommand(args = [], timeoutMs = 30_000) {
2891
2379
  const commandArgs = Array.isArray(args) ? args.map((item) => String(item || '').trim()).filter(Boolean) : [];
2892
2380
  return new Promise((resolve, reject) => {
@@ -2915,71 +2403,6 @@ function runCopilotCliCommand(args = [], timeoutMs = 30_000) {
2915
2403
  });
2916
2404
  }
2917
2405
 
2918
- async function refreshModelVariantCatalogFromCli() {
2919
- let source = 'rpc-snapshot';
2920
- let modelIds = [];
2921
- let reasoningEfforts = SUPPORTED_REASONING_EFFORTS.slice();
2922
- const hasAuthoritativeSnapshot = /^(web-relay-extension|standalone-relay):/.test(String(modelCatalog.source || ''));
2923
- const refreshSelectionFromSnapshot = selectModelIdsForVariantRefresh({
2924
- snapshotModels: hasAuthoritativeSnapshot && Array.isArray(modelCatalog.models) ? modelCatalog.models : [],
2925
- currentModel: modelCatalog.currentModel,
2926
- defaultModel: modelCatalog.defaultModel,
2927
- helpModelIds: [],
2928
- });
2929
- source = refreshSelectionFromSnapshot.source;
2930
- modelIds = refreshSelectionFromSnapshot.modelIds;
2931
- const genericHelpText = await runCopilotCliCommand(['help']).catch(() => '');
2932
- reasoningEfforts = parseReasoningEffortsFromHelpOutput(genericHelpText);
2933
- if (!modelIds.length) {
2934
- const configHelpText = await runCopilotCliCommand(['help', 'config']);
2935
- const helpModelIds = parseModelsFromHelpConfigOutput(configHelpText);
2936
- const refreshSelectionFromHelp = selectModelIdsForVariantRefresh({
2937
- snapshotModels: [],
2938
- currentModel: '',
2939
- defaultModel: '',
2940
- helpModelIds,
2941
- });
2942
- source = refreshSelectionFromHelp.source;
2943
- modelIds = refreshSelectionFromHelp.modelIds;
2944
- }
2945
- const entries = [];
2946
- let sortOrder = 0;
2947
- for (const modelId of modelIds) {
2948
- const provider = modelProviderForId(modelId);
2949
- const label = modelDisplayLabel(modelId);
2950
- if (isReasoningVariantEligibleModel(modelId)) {
2951
- for (const effort of reasoningEfforts) {
2952
- entries.push({
2953
- variantId: buildModelVariantId(modelId, effort),
2954
- baseModelId: modelId,
2955
- provider,
2956
- label,
2957
- reasoningEffort: effort,
2958
- enabled: 0,
2959
- sortOrder: sortOrder++,
2960
- });
2961
- }
2962
- continue;
2963
- }
2964
- entries.push({
2965
- variantId: buildModelVariantId(modelId),
2966
- baseModelId: modelId,
2967
- provider,
2968
- label,
2969
- reasoningEffort: null,
2970
- enabled: 0,
2971
- sortOrder: sortOrder++,
2972
- });
2973
- }
2974
- if (!entries.length) {
2975
- throw new Error('No models found in snapshot/help model output');
2976
- }
2977
- return upsertModelVariantCatalogEntries(entries, {
2978
- source,
2979
- error: null,
2980
- preserveEnabled: true,
2981
- });
2982
- }
2983
2406
 
2984
2407
  function normalizeRelayMode(mode) {
2985
2408
  const value = String(mode || '').trim().toLowerCase();
@@ -3115,154 +2538,7 @@ const imageOperationService = createImageOperationService({
3115
2538
  uuidv4,
3116
2539
  });
3117
2540
 
3118
- modelSelectorSql = {
3119
- listVariants: db.prepare(`
3120
- SELECT variant_id, base_model_id, provider, label, release_status, reasoning_effort, context_limit_tokens, long_context_limit_tokens, pricing_json, enabled, sort_order, updated_at
3121
- FROM model_variants
3122
- ORDER BY provider ASC, sort_order ASC, variant_id ASC
3123
- `),
3124
- listEnabledVariants: db.prepare(`
3125
- SELECT variant_id, base_model_id, provider, label, release_status, reasoning_effort, context_limit_tokens, long_context_limit_tokens, pricing_json, enabled, sort_order, updated_at
3126
- FROM model_variants
3127
- WHERE enabled = 1
3128
- ORDER BY provider ASC, sort_order ASC, variant_id ASC
3129
- `),
3130
- upsertVariant: db.prepare(`
3131
- INSERT INTO model_variants (
3132
- variant_id, base_model_id, provider, label, release_status, reasoning_effort, context_limit_tokens, long_context_limit_tokens, pricing_json, enabled, sort_order, updated_at
3133
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3134
- ON CONFLICT(variant_id) DO UPDATE SET
3135
- base_model_id = excluded.base_model_id,
3136
- provider = excluded.provider,
3137
- label = excluded.label,
3138
- release_status = excluded.release_status,
3139
- reasoning_effort = excluded.reasoning_effort,
3140
- context_limit_tokens = COALESCE(excluded.context_limit_tokens, model_variants.context_limit_tokens),
3141
- long_context_limit_tokens = COALESCE(excluded.long_context_limit_tokens, model_variants.long_context_limit_tokens),
3142
- pricing_json = COALESCE(excluded.pricing_json, model_variants.pricing_json),
3143
- enabled = excluded.enabled,
3144
- sort_order = excluded.sort_order,
3145
- updated_at = excluded.updated_at
3146
- `),
3147
- updateContextLimitForBase: db.prepare(`
3148
- UPDATE model_variants
3149
- SET context_limit_tokens = ?, updated_at = ?
3150
- WHERE base_model_id = ?
3151
- `),
3152
- updateModelMetadataForBase: db.prepare(`
3153
- UPDATE model_variants
3154
- SET context_limit_tokens = COALESCE(?, context_limit_tokens),
3155
- long_context_limit_tokens = COALESCE(?, long_context_limit_tokens),
3156
- pricing_json = COALESCE(?, pricing_json),
3157
- updated_at = ?
3158
- WHERE base_model_id = ?
3159
- `),
3160
- disableAllVariants: db.prepare(`UPDATE model_variants SET enabled = 0, updated_at = ?`),
3161
- enableVariant: db.prepare(`UPDATE model_variants SET enabled = 1, updated_at = ? WHERE variant_id = ?`),
3162
- deleteVariant: db.prepare(`DELETE FROM model_variants WHERE variant_id = ?`),
3163
- getSelectorState: db.prepare(`SELECT source, refreshed_at, error, updated_at FROM model_selector_state WHERE id = 1 LIMIT 1`),
3164
- upsertSelectorState: db.prepare(`
3165
- INSERT INTO model_selector_state (id, source, refreshed_at, error, updated_at)
3166
- VALUES (1, ?, ?, ?, ?)
3167
- ON CONFLICT(id) DO UPDATE SET
3168
- source = excluded.source,
3169
- refreshed_at = excluded.refreshed_at,
3170
- error = excluded.error,
3171
- updated_at = excluded.updated_at
3172
- `),
3173
- };
3174
-
3175
- {
3176
- const existingCount = Number(db.prepare(`SELECT COUNT(*) AS cnt FROM model_variants`).get()?.cnt || 0);
3177
- const normalizeLegacyVariantIdsTx = db.transaction(() => {
3178
- const rows = modelSelectorSql.listVariants.all();
3179
- const canonicalRows = new Map();
3180
- for (const rawRow of rows) {
3181
- const row = normalizeModelVariantRow(rawRow);
3182
- const canonicalBaseModelId = canonicalizeModelId(row.baseModelId);
3183
- if (!canonicalBaseModelId) continue;
3184
- const canonicalVariantId = buildModelVariantId(canonicalBaseModelId, row.reasoningEffort);
3185
- const existing = canonicalRows.get(canonicalVariantId);
3186
- if (!existing) {
3187
- canonicalRows.set(canonicalVariantId, {
3188
- variantId: canonicalVariantId,
3189
- baseModelId: canonicalBaseModelId,
3190
- provider: row.provider || modelProviderForId(canonicalBaseModelId),
3191
- label: row.label || modelDisplayLabel(canonicalBaseModelId),
3192
- releaseStatus: row.releaseStatus || null,
3193
- reasoningEffort: row.reasoningEffort || null,
3194
- enabled: row.enabled ? 1 : 0,
3195
- sortOrder: row.sortOrder,
3196
- updatedAt: row.updatedAt,
3197
- });
3198
- continue;
3199
- }
3200
- existing.enabled = existing.enabled || (row.enabled ? 1 : 0) ? 1 : 0;
3201
- if (existing.releaseStatus !== null && row.releaseStatus === null) {
3202
- existing.releaseStatus = null;
3203
- }
3204
- existing.sortOrder = Math.min(existing.sortOrder, row.sortOrder);
3205
- if (!existing.label && row.label) existing.label = row.label;
3206
- }
3207
-
3208
- const nowIso = new Date().toISOString();
3209
- for (const entry of canonicalRows.values()) {
3210
- modelSelectorSql.upsertVariant.run(
3211
- entry.variantId,
3212
- entry.baseModelId,
3213
- entry.provider || modelProviderForId(entry.baseModelId),
3214
- entry.label || modelDisplayLabel(entry.baseModelId),
3215
- entry.releaseStatus || null,
3216
- entry.reasoningEffort || null,
3217
- entry.contextLimitTokens || null,
3218
- entry.longContextLimitTokens || null,
3219
- entry.pricing ? JSON.stringify(entry.pricing) : null,
3220
- entry.enabled ? 1 : 0,
3221
- entry.sortOrder,
3222
- nowIso,
3223
- );
3224
- }
3225
- for (const rawRow of rows) {
3226
- const rawVariantId = String(rawRow?.variant_id || '').trim();
3227
- const rawBaseModelId = String(rawRow?.base_model_id || '').trim();
3228
- const canonicalBaseModelId = canonicalizeModelId(rawBaseModelId);
3229
- if (!canonicalBaseModelId) continue;
3230
- const canonicalReasoningEffort = normalizeReasoningEffort(rawRow?.reasoning_effort);
3231
- const canonicalVariantId = buildModelVariantId(canonicalBaseModelId, canonicalReasoningEffort);
3232
- if (rawVariantId !== canonicalVariantId) {
3233
- modelSelectorSql.deleteVariant.run(rawVariantId);
3234
- }
3235
- }
3236
- });
3237
- if (existingCount === 0) {
3238
- const nowIso = new Date().toISOString();
3239
- const seedEntries = buildModelVariantEntries(curatedModelList(), { defaultEnabled: true });
3240
- const tx = db.transaction(() => {
3241
- for (const entry of seedEntries) {
3242
- modelSelectorSql.upsertVariant.run(
3243
- entry.variantId,
3244
- entry.baseModelId,
3245
- entry.provider,
3246
- entry.label,
3247
- entry.releaseStatus || null,
3248
- entry.reasoningEffort || null,
3249
- entry.contextLimitTokens || null,
3250
- entry.longContextLimitTokens || null,
3251
- entry.pricing ? JSON.stringify(entry.pricing) : null,
3252
- entry.enabled ? 1 : 0,
3253
- entry.sortOrder,
3254
- nowIso,
3255
- );
3256
- }
3257
- modelSelectorSql.upsertSelectorState.run('bootstrap-seed', nowIso, null, nowIso);
3258
- });
3259
- tx();
3260
- } else if (!modelSelectorSql.getSelectorState.get()) {
3261
- const nowIso = new Date().toISOString();
3262
- modelSelectorSql.upsertSelectorState.run('legacy', nowIso, null, nowIso);
3263
- }
3264
- normalizeLegacyVariantIdsTx();
3265
- }
2541
+ modelVariantCatalog.bindDatabase(db);
3266
2542
 
3267
2543
  const deleteArchiveService = createDeleteArchiveService(db, null, { resolveSessionStateRoot });
3268
2544
  void deleteArchiveService.retryPendingDeletesOnStartup()
@@ -3311,21 +2587,32 @@ function buildSessionWorkerLaunchEnv() {
3311
2587
  const candidate = path.join(versionDir, 'preloads', 'extension_bootstrap.mjs');
3312
2588
  if (fs.existsSync(candidate)) return candidate;
3313
2589
  }
3314
- const platformName = process.platform === 'linux'
3315
- ? (process.arch === 'x64' ? 'linux-x64' : `linux-${process.arch}`)
3316
- : `${process.platform}-${process.arch}`;
3317
- const cacheRoot = path.join(os.homedir(), '.cache', 'copilot', 'pkg', platformName);
3318
- try {
3319
- const versions = fs.readdirSync(cacheRoot)
3320
- .filter((entry) => fs.existsSync(path.join(cacheRoot, entry, 'preloads', 'extension_bootstrap.mjs')))
3321
- .sort()
3322
- .reverse();
3323
- if (versions.length) {
3324
- return path.join(cacheRoot, versions[0], 'preloads', 'extension_bootstrap.mjs');
2590
+ // The CLI cache lives in a per-platform location (LOCALAPPDATA on Windows,
2591
+ // Application Support on macOS, XDG dirs on Linux) — scan the same base
2592
+ // dirs as the SDK client instead of assuming the Linux layout. Selection
2593
+ // deliberately keys on the bootstrap file itself rather than the bundled
2594
+ // SDK: CLI versions without a copilot-sdk directory must still resolve
2595
+ // here for the extension engine.
2596
+ const subdirs = [`${process.platform}-${process.arch}`, 'universal'];
2597
+ const candidates = [];
2598
+ for (const [baseDirIdx, baseDir] of getCopilotBaseDirs().entries()) {
2599
+ for (const [subdirIdx, subdir] of subdirs.entries()) {
2600
+ let entries = [];
2601
+ try { entries = fs.readdirSync(path.join(baseDir, subdir)); } catch { continue; }
2602
+ for (const version of entries) {
2603
+ if (!/^\d+\.\d+\.\d+$/.test(version)) continue;
2604
+ const candidate = path.join(baseDir, subdir, version, 'preloads', 'extension_bootstrap.mjs');
2605
+ if (fs.existsSync(candidate)) candidates.push({ version, baseDirIdx, subdirIdx, candidate });
2606
+ }
3325
2607
  }
3326
- } catch {
3327
- // The CLI cache is optional; retain the normal fallback when unavailable.
3328
2608
  }
2609
+ candidates.sort((a, b) => {
2610
+ const aVersion = a.version.split('.').map(Number);
2611
+ const bVersion = b.version.split('.').map(Number);
2612
+ return bVersion[0] - aVersion[0] || bVersion[1] - aVersion[1] || bVersion[2] - aVersion[2]
2613
+ || a.baseDirIdx - b.baseDirIdx || a.subdirIdx - b.subdirIdx;
2614
+ });
2615
+ if (candidates.length) return candidates[0].candidate;
3329
2616
  return null;
3330
2617
  };
3331
2618
 
@@ -3503,7 +2790,65 @@ const sessionWorkerSupervisor = createSessionWorkerSupervisor({
3503
2790
  diagnosticPlanReference: () => path.join(currentWorkspaceRootPath(), '.cursor', 'plans', 'worker-startup-monitoring-plan.md'),
3504
2791
  log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
3505
2792
  });
3506
- const featureFlags = normalizeFeatureFlags(FEATURES);
2793
+ // Feature flags moved from config.json into app_settings; adopt any legacy
2794
+ // `features` key once, then resolve the boot snapshot (defaults < stored rows
2795
+ // < COPILOT_REMOTE_* env). The snapshot is frozen for the process lifetime —
2796
+ // the Features settings tab persists changes and asks for a relay restart.
2797
+ migrateLegacyConfigFeatures({
2798
+ configPath: CONFIG_PATH,
2799
+ readSetting: readAppSettingValue,
2800
+ writeSetting: (key, value) => stmts.upsertAppSetting.run(key, value, new Date().toISOString()),
2801
+ });
2802
+ const featureFlags = resolveBootFeatureFlags({ readSetting: readAppSettingValue });
2803
+
2804
+ // Settle any persisted self-update attempt from the previous run: an unchanged
2805
+ // version after the restart becomes a visible failure carrying the npm log.
2806
+ reconcileUpdateAttempt({
2807
+ readSetting: readAppSettingValue,
2808
+ writeSetting: (key, value) => stmts.upsertAppSetting.run(key, value, new Date().toISOString()),
2809
+ deleteSetting: (key) => stmts.deleteAppSetting.run(key),
2810
+ runningVersion: RUNNING_VERSION,
2811
+ });
2812
+
2813
+ // Update checking is opt-in (zero telemetry by default): the service exists so
2814
+ // manual "check now" and the settings toggle work, but never fetches unless
2815
+ // the stored setting says so. OAR_NO_UPDATE_CHECK=1 kills even that.
2816
+ const updateCheckKilled = isUpdateCheckKilled(process.env);
2817
+ const updateCheckService = updateCheckKilled ? null : createUpdateCheckService({
2818
+ runningVersion: RUNNING_VERSION,
2819
+ installMethod: INSTALL_METHOD,
2820
+ manifestUrl: resolveUpdateManifestUrl(process.env),
2821
+ readSetting: readAppSettingValue,
2822
+ writeSetting: (key, value) => stmts.upsertAppSetting.run(key, value, new Date().toISOString()),
2823
+ logger: console,
2824
+ onStateChange: () => emitUpdateState(),
2825
+ });
2826
+ const updateInstallService = createUpdateInstallService({
2827
+ runningVersion: RUNNING_VERSION,
2828
+ installMethod: INSTALL_METHOD,
2829
+ readSetting: readAppSettingValue,
2830
+ writeSetting: (key, value) => stmts.upsertAppSetting.run(key, value, new Date().toISOString()),
2831
+ deleteSetting: (key) => stmts.deleteAppSetting.run(key),
2832
+ requestRelayShutdown: (options) => requestRelayShutdown(options),
2833
+ logger: console,
2834
+ });
2835
+ updateInstallService.subscribe(() => emitUpdateState());
2836
+
2837
+ function buildUpdateStatePayload() {
2838
+ return {
2839
+ runningVersion: RUNNING_VERSION,
2840
+ installMethod: INSTALL_METHOD,
2841
+ checkKilled: updateCheckKilled,
2842
+ check: updateCheckService?.getSnapshot() || null,
2843
+ install: updateInstallService.getSnapshot(),
2844
+ };
2845
+ }
2846
+
2847
+ function emitUpdateState() {
2848
+ // `io` is created later in boot; emits only fire from user actions and
2849
+ // timers, both post-boot, so the reference is safe by then.
2850
+ try { io.emit('update_state', buildUpdateStatePayload()); } catch {}
2851
+ }
3507
2852
 
3508
2853
  async function stopSessionWorkerForProviderRebind(sdkSessionId) {
3509
2854
  const sessionId = String(sdkSessionId || '').trim();
@@ -5538,8 +4883,35 @@ const backgroundTaskStore = {
5538
4883
  },
5539
4884
  };
5540
4885
 
4886
+ // The live turn among a conversation's processing rows. Mid-turn steering can
4887
+ // leave several processing at once — the running turn, steered messages the
4888
+ // CLI folded into it (no output of their own), a handed-off row still settling
4889
+ // — so the live turn is the row that produced output most recently; with
4890
+ // nothing to separate them yet, the oldest processing row (the turn a steered
4891
+ // message joins) wins. See live-turn-picker.mjs for why recency, not per-row
4892
+ // stream seq. Returns the whole row so callers get relay_mode/timestamps too.
4893
+ function resolveLiveTurnQueueRow(conversationId) {
4894
+ const rows = stmts.listProcessingQueueByConversation?.all(conversationId)
4895
+ || (stmts.getLatestProcessingQueueByConversation.get(conversationId)
4896
+ ? [stmts.getLatestProcessingQueueByConversation.get(conversationId)]
4897
+ : []);
4898
+ if (!rows.length) return null;
4899
+ if (rows.length === 1) return rows[0];
4900
+ const byId = new Map(rows.map((row) => [String(row.id), row]));
4901
+ const liveId = pickLiveTurnRowId(rows.map((row) => {
4902
+ const output = stmts.getLastOutputAtByQueueMessage?.get({ id: row.id }) || null;
4903
+ return {
4904
+ id: String(row.id),
4905
+ lastOutputAtMs: Date.parse(String(output?.last_output_at || '')) || 0,
4906
+ streamDone: Number(output?.stream_done || 0) === 1,
4907
+ processingAtMs: Date.parse(String(row.processing_at || row.timestamp || '')) || 0,
4908
+ };
4909
+ }));
4910
+ return byId.get(liveId) || rows[0];
4911
+ }
4912
+
5541
4913
  function inFlightStateForConversation(conversationId) {
5542
- const row = stmts.getLatestProcessingQueueByConversation.get(conversationId);
4914
+ const row = resolveLiveTurnQueueRow(conversationId);
5543
4915
  if (!row) return null;
5544
4916
  const streamEvents = relayStreamEventsForQueueMessage(row.id);
5545
4917
  const lastStreamEvent = streamEvents.length ? streamEvents[streamEvents.length - 1] : null;
@@ -5564,14 +4936,17 @@ function inFlightStateForConversation(conversationId) {
5564
4936
  // retry_count like /api/requeue does, but bypasses that route's budget check,
5565
4937
  // so the sweep enforces it here). Minimal mirror of the route-side
5566
4938
  // failQueueMessage: mark failed, insert the assistant failure message, emit.
5567
- function failRecoveredRowTerminally(row, reason) {
4939
+ function failRecoveredRowTerminally(row, reason, { failureRecord = null, failureText: presetText = null } = {}) {
5568
4940
  const now = new Date().toISOString();
5569
4941
  const responseId = uuidv4();
5570
4942
  const retryCount = Number(row?.retry_count || 0) + 1;
5571
- const failureText = `Relay recovery limit reached after ${retryCount} attempts (${reason}). The worker kept dying before completing this turn, so the message was failed to keep the queue moving. Send it again to retry.`;
4943
+ const failureText = presetText || `Relay recovery limit reached after ${retryCount} attempts (${reason}). The worker kept dying before completing this turn, so the message was failed to keep the queue moving. Send it again to retry.`;
5572
4944
  const requestedModel = String(row?.model || '').trim() || null;
5573
4945
  const tx = db.transaction(() => {
5574
- stmts.setFailed.run(JSON.stringify({
4946
+ // setFailed is compare-and-set on status (processing/pending/parked): a
4947
+ // row that settled between the sweep's listing and this write must not
4948
+ // grow an orphan failure bubble on top of its real outcome.
4949
+ const result = stmts.setFailed.run(JSON.stringify(failureRecord || {
5575
4950
  kind: 'recovery-limit',
5576
4951
  error: 'recovery-limit',
5577
4952
  code: 'recovery-limit',
@@ -5580,6 +4955,7 @@ function failRecoveredRowTerminally(row, reason) {
5580
4955
  retryCount,
5581
4956
  failedAt: now,
5582
4957
  }), row.id);
4958
+ if (result.changes === 0) return false;
5583
4959
  stmts.setQueueResponseMessageId?.run(responseId, row.id);
5584
4960
  stmts.insertMsg.run(
5585
4961
  responseId,
@@ -5594,12 +4970,14 @@ function failRecoveredRowTerminally(row, reason) {
5594
4970
  null,
5595
4971
  requestedModel ? 'user' : 'auto',
5596
4972
  );
4973
+ stmts.setMessageSourceId?.run(row.id, responseId);
5597
4974
  stmts.linkActivityToResponse?.run(responseId, row.id);
5598
4975
  stmts.linkStreamEventsToResponse?.run(responseId, row.id);
5599
4976
  stmts.linkThoughtsToResponse?.run(responseId, row.id);
5600
4977
  stmts.updateConvTime.run(now, row.conversation_id);
4978
+ return true;
5601
4979
  });
5602
- tx();
4980
+ if (!tx()) return;
5603
4981
  io.emit('assistant_message', {
5604
4982
  conversationId: row.conversation_id,
5605
4983
  sourceMessageId: row.id,
@@ -5607,7 +4985,20 @@ function failRecoveredRowTerminally(row, reason) {
5607
4985
  message: { role: 'assistant', text: failureText, model: requestedModel || 'unknown', mode: String(row?.relay_mode || '').trim() || null, timestamp: now },
5608
4986
  });
5609
4987
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
5610
- console.warn(`${runtimeLogPrefix()}RECOVERY LIMIT ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} retries=${retryCount} reason=${reason}`);
4988
+ console.warn(`${runtimeLogPrefix()}${failureRecord ? 'CONSUMED FAILED' : 'RECOVERY LIMIT'} ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} retries=${retryCount} reason=${reason}`);
4989
+ }
4990
+
4991
+ // A row whose prompt the worker's runtime already consumed (queue.consumed_at)
4992
+ // is at-most-once: recovery must never requeue it — a re-delivery would run the
4993
+ // prompt twice — so it fails terminally with the steer-settle wording, naming
4994
+ // the provider's runtime the way the workers' own settle wording does.
4995
+ function failConsumedRowTerminally(row, reason) {
4996
+ const providerType = stmts.getRuntimeSessionByConversation?.get?.(row?.conversation_id)?.provider_type;
4997
+ const failureRecord = buildSteerSettleFailure(row, { agentLabel: steerAgentLabelForProvider(providerType) });
4998
+ failRecoveredRowTerminally(row, reason, {
4999
+ failureRecord: { ...failureRecord, error: 'terminal-error', reason: String(reason || 'recovery') },
5000
+ failureText: `${failureRecord.message} Error code: ${failureRecord.stableCode}. ${failureRecord.guidance}`,
5001
+ });
5611
5002
  }
5612
5003
 
5613
5004
  function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso = null } = {}) {
@@ -5619,7 +5010,9 @@ function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso
5619
5010
  stmts.dropStaleContinuation?.run?.(row.id);
5620
5011
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
5621
5012
  }
5622
- const allRows = stmts.listRecoverableProcessing.all(params);
5013
+ const listedRows = stmts.listRecoverableProcessing.all(params);
5014
+ for (const row of listedRows.filter((entry) => entry?.consumed_at)) failConsumedRowTerminally(row, 'stale-recovery');
5015
+ const allRows = listedRows.filter((entry) => !entry?.consumed_at);
5623
5016
  if (!allRows.length) return [];
5624
5017
  // Enforce the retry budget before requeueing: failing these rows first
5625
5018
  // flips them out of 'processing' so the recovery UPDATE below skips them.
@@ -5873,9 +5266,9 @@ function renderIndexHtmlWithPwaVersion(appConfigOverrides = {}) {
5873
5266
 
5874
5267
  function loadPwaManifestTemplate() {
5875
5268
  const fallback = {
5876
- name: 'Copilot Remote',
5877
- short_name: 'Copilot',
5878
- description: 'Installable Copilot Remote web app with standalone launcher support.',
5269
+ name: 'OAR',
5270
+ short_name: 'OAR',
5271
+ description: 'OAR — Open Agent Relay: drive your local coding agents from any browser.',
5879
5272
  display_override: ['standalone'],
5880
5273
  display: 'standalone',
5881
5274
  background_color: '#161b22',
@@ -5896,9 +5289,15 @@ function loadPwaManifestTemplate() {
5896
5289
 
5897
5290
  function buildScopedPwaManifest({ shared = false } = {}) {
5898
5291
  const manifest = loadPwaManifestTemplate();
5292
+ // The custom name must be baked into every served manifest: Android's WebAPK
5293
+ // update check re-fetches this route outside any page session, so a name the
5294
+ // route doesn't know reverts on the phone with an accept-or-uninstall prompt.
5295
+ const names = resolvePwaManifestNames(getPwaAppName(), manifest.name);
5899
5296
  if (shared) {
5900
5297
  return {
5901
5298
  ...manifest,
5299
+ name: names.name,
5300
+ short_name: names.short_name,
5902
5301
  id: './__copilot_remote_shared__',
5903
5302
  start_url: './',
5904
5303
  scope: './',
@@ -5909,6 +5308,8 @@ function buildScopedPwaManifest({ shared = false } = {}) {
5909
5308
  }
5910
5309
  return {
5911
5310
  ...manifest,
5311
+ name: names.name,
5312
+ short_name: names.short_name,
5912
5313
  id: './__copilot_remote_pwa__',
5913
5314
  start_url: './',
5914
5315
  scope: './',
@@ -5942,6 +5343,30 @@ const sdkSessionImportService = createSdkSessionImportService({
5942
5343
  parseSessionEventsToMessages,
5943
5344
  replaceRetrievableHistory: sessionHistoryRefreshService.replaceRetrievableHistory,
5944
5345
  ensureRuntimeSessionBinding,
5346
+ // A registered/supervised worker means the relay executes this session even
5347
+ // when no queue row is visible (e.g. between turns) — the importer must not
5348
+ // overwrite its history.
5349
+ hasRelayExecutionSignal: (sdkSessionId) => !!(
5350
+ sessionWorkerRegistry?.getWorker?.(sdkSessionId)
5351
+ || sessionWorkerSupervisor?.getWorkerState?.(sdkSessionId)
5352
+ ),
5353
+ logger: console,
5354
+ });
5355
+ // Phase 5A: server-side Copilot model discovery. Once the extension retires,
5356
+ // nothing populates the model catalog on a fresh boot until a worker spawns;
5357
+ // this asks the installed CLI runtime for `listModels()` directly (deferred —
5358
+ // see the listen callback) and feeds updateModelCatalog in-process. It
5359
+ // feature-detects the CLI before spawning anything, so a relay without Copilot
5360
+ // stays silent.
5361
+ const copilotModelDiscoveryService = createCopilotModelDiscoveryService({
5362
+ createClient: () => createInstalledCopilotClient({
5363
+ config,
5364
+ cwd: currentWorkspaceRootPath(),
5365
+ baseDirectory: path.dirname(resolveSessionStateRoot()),
5366
+ logLevel: 'error',
5367
+ }),
5368
+ resolveInstalledPaths: () => resolveInstalledCopilotPaths({ config }),
5369
+ updateModelCatalog,
5945
5370
  logger: console,
5946
5371
  });
5947
5372
  const contextSnapshotService = createContextSnapshotService({
@@ -6325,16 +5750,24 @@ async function recoverUndeliveredSessionWorkerMessage({ pending = null, sessionI
6325
5750
 
6326
5751
  const retryCount = Number(row.retry_count || 0) + 1;
6327
5752
  const nextAttemptAt = addMsIso(Math.min(5_000, computeRetryDelayMs(retryCount)));
5753
+ // The undelivered payload names the exact attempt that was minted for it, so
5754
+ // this requeue can be fenced precisely: if the row has moved on to another
5755
+ // attempt since the failed send, leave it alone.
5756
+ const deliveredAttemptId = String(pending?.message?.attemptId || '').trim() || null;
6328
5757
  const result = db.prepare(`
6329
5758
  UPDATE queue
6330
5759
  SET status = 'pending',
6331
5760
  processing_at = NULL,
5761
+ attempt_id = NULL,
6332
5762
  retry_count = ?,
6333
5763
  next_attempt_at = ?,
6334
5764
  owner_lease_expires_at = NULL
6335
5765
  WHERE id = ?
6336
- AND status = 'processing'
6337
- `).run(retryCount, nextAttemptAt, messageId);
5766
+ AND status = 'processing'${deliveredAttemptId ? `
5767
+ AND attempt_id = ?` : ''}
5768
+ `).run(...(deliveredAttemptId
5769
+ ? [retryCount, nextAttemptAt, messageId, deliveredAttemptId]
5770
+ : [retryCount, nextAttemptAt, messageId]));
6338
5771
  if (result.changes <= 0) return false;
6339
5772
 
6340
5773
  if (requesterSessionId) {
@@ -6356,7 +5789,11 @@ async function recoverUndeliveredSessionWorkerMessage({ pending = null, sessionI
6356
5789
  const WORKER_DEATH_GRACE_MS = 15_000;
6357
5790
 
6358
5791
  function isWorkerSessionProcessAlive(sessionId, hintPid = null) {
6359
- if (sessionWorkerWebSocketService?.hasWorkerSocket?.(sessionId)) return true;
5792
+ // A merely-retained socket is not proof of life: a dead worker's socket
5793
+ // lingers until its close event lands, and counting it stalled recovery.
5794
+ // The socket only counts with a recent heartbeat or a PID that probes
5795
+ // alive; otherwise fall through to the process probes below.
5796
+ if (sessionWorkerWebSocketService?.hasLiveWorkerSocket?.(sessionId)) return true;
6360
5797
  try {
6361
5798
  if (sessionWorkerProcessInspector?.findProcessForSession?.(sessionId)) return true;
6362
5799
  } catch {}
@@ -6381,6 +5818,10 @@ function recoverDeadWorkerProcessingRows(sessionId, reason) {
6381
5818
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
6382
5819
  continue;
6383
5820
  }
5821
+ if (row.consumed_at) {
5822
+ failConsumedRowTerminally(row, `worker-died-${reason}`);
5823
+ continue;
5824
+ }
6384
5825
  if (Number(row.retry_count || 0) + 1 >= MAX_REQUEUE_RETRIES) {
6385
5826
  failRecoveredRowTerminally(row, `worker-died-${reason}`);
6386
5827
  continue;
@@ -6423,11 +5864,15 @@ function handleWorkerSocketClosed({ sessionId, pid = null } = {}) {
6423
5864
  const sid = String(sessionId || '').trim();
6424
5865
  if (!sid) return;
6425
5866
  const owned = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
6426
- if (!owned.length) return;
5867
+ // A dead worker's steering snapshot must not keep holding the composer even
5868
+ // when it owned no rows (the hold can outlive them, e.g. a continuation's).
5869
+ const heldSteering = Boolean(sessionWorkerRegistry?.getWorker?.(sid)?.steering);
5870
+ if (!owned.length && !heldSteering) return;
6427
5871
  const timer = setTimeout(() => {
6428
5872
  try {
6429
5873
  if (runtimeShutdownStarted) return;
6430
5874
  if (isWorkerSessionProcessAlive(sid, pid)) return; // reconnect or socket flap
5875
+ sessionWorkerRegistry?.clearSteering?.(sid);
6431
5876
  const stillOwned = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
6432
5877
  if (!stillOwned.length) return;
6433
5878
  console.warn(`${runtimeLogPrefix()}WORKER DIED session=${sid.slice(0, 8)} rows=${stillOwned.length} trigger=socket-close — recovering owned turns`);
@@ -6605,6 +6050,10 @@ const windowsAutostartService = createWindowsAutostartService({
6605
6050
  packageRoot: REPO_ROOT,
6606
6051
  configPath: CONFIG_PATH,
6607
6052
  });
6053
+ const windowsBootAutostartService = createWindowsBootAutostartService({
6054
+ packageRoot: REPO_ROOT,
6055
+ configPath: CONFIG_PATH,
6056
+ });
6608
6057
 
6609
6058
  const gitChangesService = createGitChangesService();
6610
6059
  const sharedRouteDeps = {
@@ -6718,6 +6167,7 @@ const sharedRouteDeps = {
6718
6167
  previewCardsForResponse,
6719
6168
  sanitizeActivityText,
6720
6169
  inFlightStateForConversation,
6170
+ resolveLiveTurnQueueRow,
6721
6171
  backgroundTaskStore,
6722
6172
  // A closure, not the registry itself: this deps object is built before the
6723
6173
  // preview lane is constructed further down the file.
@@ -6741,6 +6191,7 @@ const sharedRouteDeps = {
6741
6191
  readContextFromSessionEvents,
6742
6192
  sessionHistoryRefreshService,
6743
6193
  sdkSessionImportService,
6194
+ copilotModelDiscoveryService,
6744
6195
  readSessionTranscriptMessages,
6745
6196
  readSessionUsageSummary,
6746
6197
  collectOrphanedUploadsFromConversation,
@@ -6783,15 +6234,24 @@ const sharedRouteDeps = {
6783
6234
  setTurnCeilingMinutes,
6784
6235
  getBackgroundTaskTimeoutMinutes,
6785
6236
  setBackgroundTaskTimeoutMinutes,
6237
+ getFeatureFlagsSettingsState,
6238
+ setFeatureFlagSetting,
6239
+ getPwaAppName,
6240
+ setPwaAppName,
6786
6241
  requestRelayShutdown,
6787
6242
  markSharedViewerPresence,
6788
6243
  getSharedWatcherCount,
6789
6244
  statusEventService,
6790
6245
  claudeAuthService,
6791
6246
  cliInstallService,
6247
+ updateCheckService,
6248
+ updateInstallService,
6249
+ buildUpdateStatePayload,
6250
+ runningVersion: RUNNING_VERSION,
6792
6251
  grokAuthService,
6793
6252
  imageOperationService,
6794
6253
  windowsAutostartService,
6254
+ windowsBootAutostartService,
6795
6255
  pushDispatchService,
6796
6256
  };
6797
6257
  registerMessagesRoutes(app, sharedRouteDeps);
@@ -6895,6 +6355,8 @@ if (sweptUploads > 0) {
6895
6355
  }
6896
6356
  runtimeTimers.uploadSweep = setInterval(sweepUnreferencedUploadBlobs, 6 * 60 * 60 * 1000);
6897
6357
  if (typeof runtimeTimers.uploadSweep.unref === 'function') runtimeTimers.uploadSweep.unref();
6358
+ // Arms the 12h update poller only when the user opted in; otherwise silent.
6359
+ updateCheckService?.startIfEnabled();
6898
6360
  const runtimeBindingsBootstrapped = bootstrapRuntimeSessionBindings();
6899
6361
  if (runtimeBindingsBootstrapped > 0) {
6900
6362
  console.log(`${runtimeLogPrefix()}Runtime sessions bootstrapped: ${runtimeBindingsBootstrapped}`);
@@ -6959,7 +6421,10 @@ function ensureRuntimeSessionBinding(
6959
6421
  const normalizedModel = String(model || '').trim() || null;
6960
6422
  const normalizedSdkSessionId = String(sdkSessionId || '').trim() || null;
6961
6423
  if (normalizedSdkSessionId) {
6962
- stmts.setConvSdkSessionIdIfMissing.run(normalizedConversationId, nowIso, normalizedSdkSessionId);
6424
+ // Statement shape is (sdk_session_id, updated_at, WHERE id): these args
6425
+ // were swapped for as long as sdk session ids equalled conversation ids,
6426
+ // which made the bug invisible — distinct ids made it a silent no-op.
6427
+ stmts.setConvSdkSessionIdIfMissing.run(normalizedSdkSessionId, nowIso, normalizedConversationId);
6963
6428
  }
6964
6429
  const existing = stmts.getRuntimeSessionByConversation.get(normalizedConversationId);
6965
6430
  if (existing?.id) {
@@ -7113,25 +6578,10 @@ function sanitizeRelayQuestionRequest(requestBody) {
7113
6578
  }
7114
6579
  }
7115
6580
 
6581
+ // The allow-list lives in services/relay-question-context.mjs (unit-tested);
6582
+ // this binds it to the relay's own mode normalizer.
7116
6583
  function sanitizeRelayQuestionContext(rawContext) {
7117
- if (!rawContext || typeof rawContext !== 'object' || Array.isArray(rawContext)) return null;
7118
- const context = {};
7119
- if (typeof rawContext.source === 'string' && rawContext.source.trim()) {
7120
- context.source = rawContext.source.trim().slice(0, 64);
7121
- }
7122
- if (typeof rawContext.rationale === 'string' && rawContext.rationale.trim()) {
7123
- context.rationale = rawContext.rationale.trim().slice(0, 240);
7124
- }
7125
- if (typeof rawContext.queueMessageId === 'string' && rawContext.queueMessageId.trim()) {
7126
- context.queueMessageId = rawContext.queueMessageId.trim();
7127
- }
7128
- if (typeof rawContext.conversationId === 'string' && rawContext.conversationId.trim()) {
7129
- context.conversationId = rawContext.conversationId.trim();
7130
- }
7131
- if (typeof rawContext.relayMode === 'string' && rawContext.relayMode.trim()) {
7132
- context.relayMode = normalizeRelayMode(rawContext.relayMode) || DEFAULT_RELAY_MODE;
7133
- }
7134
- return Object.keys(context).length ? context : null;
6584
+ return sanitizeRelayQuestionContextWith(rawContext, { normalizeRelayMode, defaultRelayMode: DEFAULT_RELAY_MODE });
7135
6585
  }
7136
6586
 
7137
6587
 
@@ -7303,9 +6753,19 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
7303
6753
  runtimeShutdownStarted = true;
7304
6754
  console.log(`${runtimeLogPrefix()}Runtime shutdown started (${reason}, exitCode=${runtimeShutdownExitCode})`);
7305
6755
  clearRuntimeTimers();
7306
- void sdkSessionImportService.dispose().catch((error) => {
6756
+ try { updateCheckService?.stop(); } catch {}
6757
+ // Started here (flips the importer's closing flag synchronously) but awaited
6758
+ // below before the transports close: fire-and-forget disposal let a
6759
+ // mid-import sweep keep running while the process exited under it.
6760
+ const importerShutdown = sdkSessionImportService.dispose().catch((error) => {
7307
6761
  console.warn(`${runtimeLogPrefix()}SDK session importer shutdown failed: ${error?.message || error}`);
7308
6762
  });
6763
+ // Same treatment for a model discovery in flight: its dispose() tears the
6764
+ // live client down first, so this settles promptly rather than waiting out
6765
+ // the discovery timeout (the force-exit timer below still caps everything).
6766
+ const discoveryShutdown = copilotModelDiscoveryService.dispose().catch((error) => {
6767
+ console.warn(`${runtimeLogPrefix()}Copilot model discovery shutdown failed: ${error?.message || error}`);
6768
+ });
7309
6769
  try { claudeAuthService.dispose(); } catch (error) {
7310
6770
  console.warn(`${runtimeLogPrefix()}Claude auth service shutdown failed: ${error?.message || error}`);
7311
6771
  }
@@ -7364,7 +6824,12 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
7364
6824
  // inside the 2s force-exit budget above.
7365
6825
  // Deliberately not unref'd: this timer must fire for the process to exit
7366
6826
  // cleanly. The unref'd force-exit timer above is the backstop if it does not.
7367
- setTimeout(closeTransports, SHUTDOWN_SOCKET_FLUSH_MS);
6827
+ // Importer and discovery disposal are awaited first so an in-flight
6828
+ // session import or model discovery settles before the process exits; the
6829
+ // force-exit timer caps how long that can take.
6830
+ setTimeout(() => {
6831
+ void Promise.allSettled([importerShutdown, discoveryShutdown]).then(closeTransports);
6832
+ }, SHUTDOWN_SOCKET_FLUSH_MS);
7368
6833
  });
7369
6834
 
7370
6835
  return runtimeShutdownPromise;
@@ -7460,4 +6925,7 @@ httpServer.listen(config.port, listenHost, () => {
7460
6925
  void sdkSessionImportService.runStartupImport().catch((error) => {
7461
6926
  console.warn(`${runtimeLogPrefix()}SDK session import startup failed: ${error?.message || error}`);
7462
6927
  });
6928
+ // Deferred and unref'd (a few seconds behind the startup import's own CLI
6929
+ // spawn); a relay without an installed Copilot CLI skips it entirely.
6930
+ copilotModelDiscoveryService.scheduleStartupRefresh();
7463
6931
  });