@oar-sh/oar 0.9.22 → 0.9.24

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 (68) hide show
  1. package/CHANGELOG.md +258 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +82 -4
  4. package/package.json +16 -15
  5. package/server/agent-sdk-loader.mjs +116 -0
  6. package/server/agent-sdks/claude/entry.mjs +4 -0
  7. package/server/agent-sdks/claude/package-lock.json +1438 -0
  8. package/server/agent-sdks/claude/package.json +13 -0
  9. package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
  10. package/server/agent-sdks/cursor/entry.mjs +3 -0
  11. package/server/agent-sdks/cursor/package-lock.json +171 -0
  12. package/server/agent-sdks/cursor/package.json +11 -0
  13. package/server/agent-sdks/koffi/entry.cjs +2 -0
  14. package/server/agent-sdks/koffi/package-lock.json +368 -0
  15. package/server/agent-sdks/koffi/package.json +10 -0
  16. package/server/claude-worker/claude-cli-pinned-env.mjs +4 -1
  17. package/server/claude-worker/claude-preview-tool.mjs +5 -2
  18. package/server/claude-worker/claude-remote-relay-tool.mjs +5 -2
  19. package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +7 -4
  21. package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
  22. package/server/public/app/agent-sdk-ui.js +349 -0
  23. package/server/public/app/api-client.js +91 -8
  24. package/server/public/app/bootstrap.js +204 -69
  25. package/server/public/app/model-refresh-ui.mjs +413 -0
  26. package/server/public/app/no-conversation-state.mjs +5 -2
  27. package/server/public/app/provider-readiness.mjs +67 -4
  28. package/server/public/app/settings-modal.js +167 -0
  29. package/server/public/app/socket-handlers.js +21 -0
  30. package/server/public/index.html +95 -2
  31. package/server/routes/agent-sdk-routes.mjs +73 -0
  32. package/server/routes/messages-routes.mjs +102 -37
  33. package/server/routes/sessions-routes.mjs +236 -110
  34. package/server/server-runtime.mjs +416 -121
  35. package/server/services/about-service.mjs +26 -6
  36. package/server/services/agent-sdk-launch-gate.mjs +124 -0
  37. package/server/services/agent-sdk-paths.mjs +307 -0
  38. package/server/services/agent-sdk-store.mjs +1211 -0
  39. package/server/services/claude-api-settings-service.mjs +54 -17
  40. package/server/services/cli-install-service.mjs +3 -2
  41. package/server/services/copilot-model-discovery-service.mjs +7 -1
  42. package/server/services/database-file.mjs +17 -1
  43. package/server/services/deepseek-settings-service.mjs +58 -20
  44. package/server/services/earlier-database-service.mjs +5 -3
  45. package/server/services/file-serving.mjs +13 -0
  46. package/server/services/model-refresh-scheduler.mjs +153 -0
  47. package/server/services/model-variant-catalog-service.mjs +141 -9
  48. package/server/services/native-addon-build-service.mjs +224 -0
  49. package/server/services/oar-cli-helpers.mjs +76 -10
  50. package/server/services/oar-state-migration-service.mjs +2 -12
  51. package/server/services/oar-state-root.mjs +17 -0
  52. package/server/services/provider-model-refresh.mjs +326 -0
  53. package/server/services/provider-model-state.mjs +395 -0
  54. package/server/services/relay-console-log.mjs +2 -3
  55. package/server/services/relay-singleton-guard.mjs +72 -10
  56. package/server/services/remote-relay-loopback.mjs +3 -3
  57. package/server/services/sdk-session-sync-service.mjs +1 -1
  58. package/server/services/session-worker-launch-service.mjs +13 -0
  59. package/server/services/session-worker-supervisor-service.mjs +19 -1
  60. package/server/services/session-worker-websocket-service.mjs +15 -0
  61. package/server/services/update-install-service.mjs +34 -3
  62. package/server/services/worker-launch-note-service.mjs +14 -2
  63. package/server/services/worker-recycle-service.mjs +145 -11
  64. package/shared/model-refresh-schedule.mjs +120 -0
  65. package/shared/worker-runtime/direct-openai-image.mjs +8 -4
  66. package/server/services/package-data-rescue-preinstall.mjs +0 -22
  67. package/server/services/package-data-rescue.mjs +0 -139
  68. package/server/tty-console-bootstrap.mjs +0 -75
@@ -4,7 +4,7 @@ import express from 'express';
4
4
  import http from 'http';
5
5
  import { Server } from 'socket.io';
6
6
  import { WebSocketServer } from 'ws';
7
- import { v4 as uuidv4 } from 'uuid';
7
+ import { randomUUID as uuidv4 } from 'crypto';
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
10
  import { execFile, execFileSync, spawn } from 'child_process';
@@ -25,7 +25,7 @@ import {
25
25
  } from './services/workspace-root-path-policy.mjs';
26
26
  import { stopSessionWorkerProcesses } from './services/session-worker-stop-service.mjs';
27
27
  import { createDetachedWorkerWatch } from './services/detached-worker-watch-service.mjs';
28
- import { applyCdnNoStoreHeaders } from './services/file-serving.mjs';
28
+ import { applyCdnNoStoreHeaders, sendOwnFile } from './services/file-serving.mjs';
29
29
  import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
30
30
  import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
31
31
  import { PLAN_BOARD_APPROVE_ONLY, planBoardShownActions } from '../shared/plan-board-actions.mjs';
@@ -68,12 +68,15 @@ import { createClaudeCloudCredentials } from '../shared/claude-cloud/credentials
68
68
  import { createClaudeCloudClient } from '../shared/claude-cloud/api-client.mjs';
69
69
  import { resolveClaudeCloudBaseUrl } from '../shared/claude-cloud/base-url.mjs';
70
70
  import { createClaudeCloudSettingsService } from './services/claude-cloud-settings-service.mjs';
71
- import { createDeepSeekSettingsService } from './services/deepseek-settings-service.mjs';
71
+ import { DEEPSEEK_API_KEY_SETTING_KEY, createDeepSeekSettingsService } from './services/deepseek-settings-service.mjs';
72
72
  import { createWorkerPostRedaction } from './services/worker-secret-redaction.mjs';
73
73
  import { createDeepSeekBalanceService } from './services/deepseek-balance-service.mjs';
74
74
  import { deepseekCardCurrency } from './services/plan-usage-deepseek.mjs';
75
75
  import { createDeepSeekUsageImportService } from './services/deepseek-usage-import.mjs';
76
- import { createClaudeApiSettingsService } from './services/claude-api-settings-service.mjs';
76
+ import { CLAUDE_API_KEY_SETTING_KEY, createClaudeApiSettingsService } from './services/claude-api-settings-service.mjs';
77
+ import { createProviderModelStateStore } from './services/provider-model-state.mjs';
78
+ import { buildProviderRefreshTable, createModelRefreshLedger, createModelRefreshSettings, refreshProviderModels } from './services/provider-model-refresh.mjs';
79
+ import { createModelRefreshScheduler } from './services/model-refresh-scheduler.mjs';
77
80
  import { createClaudeBillingSwitchService, normalizeClaudeBilling } from './services/claude-billing-switch-service.mjs';
78
81
  import { createClaudeBillingSwapService } from './services/claude-billing-swap-service.mjs';
79
82
  import { createWorkerReplacement, createWorkerReplacementFence } from './services/worker-replacement.mjs';
@@ -88,6 +91,10 @@ import { createClaudeCloudRepoService, listRecentCloudSourcesFromStatements } fr
88
91
  import { createClaudeAccountUsageService } from './services/claude-account-usage-service.mjs';
89
92
  import { createGitRemoteService } from './services/git-remote-service.mjs';
90
93
  import { createCliInstallService, writeCliBinariesToConfigFile } from './services/cli-install-service.mjs';
94
+ import { createAgentSdkStore } from './services/agent-sdk-store.mjs';
95
+ import { createAgentSdkLaunchGate, staleWorkerLosesAgentSdk } from './services/agent-sdk-launch-gate.mjs';
96
+ import { agentSdkFromCheckout } from './services/agent-sdk-paths.mjs';
97
+ import { agentSdkDirEnvName, AgentSdkMissingError, loadAgentSdk, resolveAgentSdkSource, setAgentSdkStore } from './agent-sdk-loader.mjs';
91
98
  import { createGrokAuthService } from './services/grok-auth-service.mjs';
92
99
  import { sweepUnreferencedUploads, UNREFERENCED_UPLOADS_QUERY } from './services/upload-sweep.mjs';
93
100
  import webpush from 'web-push';
@@ -123,6 +130,7 @@ import { createActiveDeviceTracker } from './services/push-active-device-service
123
130
  import { registerPushRoutes } from './routes/push-routes.mjs';
124
131
  import { registerAboutRoutes } from './routes/about-routes.mjs';
125
132
  import { registerRelayRestartRoutes } from './routes/relay-restart-routes.mjs';
133
+ import { registerAgentSdkRoutes } from './routes/agent-sdk-routes.mjs';
126
134
  import { createAboutService } from './services/about-service.mjs';
127
135
  import { resolveBuildId } from '../shared/build-id.mjs';
128
136
  import { createImageOperationService } from './services/image-operation-service.mjs';
@@ -137,7 +145,7 @@ import { createPlanUsageService } from './services/plan-usage-service.mjs';
137
145
  import { splitFreeAndBufferTokens } from './services/context-usage-view.mjs';
138
146
  import { normalizeCursorAllowanceSettings } from './services/plan-usage-cursor.mjs';
139
147
  import { normalizeGrokAllowanceSettings } from './services/plan-usage-grok.mjs';
140
- import { createGrokBillingUsageFetcher } from './services/grok-billing-usage.mjs';
148
+ import { createGrokBillingUsageFetcher, readGrokCliAuthKey } from './services/grok-billing-usage.mjs';
141
149
  import { createCursorDashboardUsageFetcher, readCursorIdeSessionToken } from './services/cursor-dashboard-usage.mjs';
142
150
  import { fetchPersonalBillingUsage } from './services/github-billing-usage.mjs';
143
151
  import { createSessionTranscriptService } from './services/session-transcript-service.mjs';
@@ -163,7 +171,7 @@ import { createRelayCliLauncherService } from './services/relay-cli-launcher-ser
163
171
  import { createSessionWorkerRegistry, normalizeWorkerKind } from './services/session-worker-registry-service.mjs';
164
172
  import { createSessionWorkerSupervisor } from './services/session-worker-supervisor-service.mjs';
165
173
  import { createSessionWorkerProcessInspector } from './services/session-worker-process-service.mjs';
166
- import { applyClaudeApiProviderEnvironment, applyClaudeCloudProviderEnvironment, applyClaudeProviderEnvironment, applyCopilotSdkProviderEnvironment, applyCursorProviderEnvironment, applyDeepSeekProviderEnvironment, applyGrokProviderEnvironment, applyOpenAIProviderEnvironment, copilotSdkEngineUnavailableReason, killTmuxSession, launchSessionCli } from './services/session-worker-launch-service.mjs';
174
+ import { agentSdkForWorkerKind, applyClaudeApiProviderEnvironment, applyClaudeCloudProviderEnvironment, applyClaudeProviderEnvironment, applyCopilotSdkProviderEnvironment, applyCursorProviderEnvironment, applyDeepSeekProviderEnvironment, applyGrokProviderEnvironment, applyOpenAIProviderEnvironment, copilotSdkEngineUnavailableReason, killTmuxSession, launchSessionCli, resolveWorkerKind } from './services/session-worker-launch-service.mjs';
167
175
  import { createSshTunnelManager } from './services/ssh-tunnel-manager-service.mjs';
168
176
  import { createCloudflaredTunnelManager } from './services/cloudflared-tunnel-service.mjs';
169
177
  import {
@@ -196,8 +204,8 @@ import {
196
204
  resolveDefaultSessionWorkspaceRootState as resolveDefaultSessionWorkspaceRootStateFromService,
197
205
  resolveLaunchWorkspaceRootPath,
198
206
  } from './services/workspace-root-defaults-service.mjs';
199
- import { maybeStartTtyConsole } from './tty-console-bootstrap.mjs';
200
207
  import { installRelayConsoleLog, resolveRelayConsoleLogPath } from './services/relay-console-log.mjs';
208
+ import { isMissingSqliteBindingError, missingSqliteBindingLines } from './services/native-addon-build-service.mjs';
201
209
  import {
202
210
  computeFeatureFlagState,
203
211
  FEATURE_REGISTRY,
@@ -327,13 +335,15 @@ const PWA_VERSION_PLACEHOLDER = /const __PWA_VERSION = '[^']*';/;
327
335
  // after a restart or an incident.
328
336
  const relayConsoleLogPath = resolveRelayConsoleLogPath({ env: process.env, serverDir: __dirname });
329
337
  const relayConsoleLog = installRelayConsoleLog({ logPath: relayConsoleLogPath });
330
- const ttyConsoleRuntime = await maybeStartTtyConsole({
331
- serverDir: __dirname,
332
- logsDir: path.join(__dirname, 'logs'),
333
- logger: console,
334
- });
335
- // The terminal console takes the console's methods over when it starts.
336
- relayConsoleLog?.rewrap();
338
+ // Where better-sqlite3 ships no binary (Termux, 32-bit ARM) the binding is
339
+ // built by `oar build-native`; until then say so instead of a stack trace.
340
+ try {
341
+ new Database(':memory:').close();
342
+ } catch (error) {
343
+ if (!isMissingSqliteBindingError(error)) throw error;
344
+ for (const line of missingSqliteBindingLines({ error })) console.error(`[startup] ${line}`);
345
+ process.exit(1);
346
+ }
337
347
  // TEMP and TMP for this relay and everything it starts (normalizeTempEnv).
338
348
  for (const change of normalizeTempEnv()) console.log(`[startup] ${change} (quotes taken off, or the folder is not there)`);
339
349
 
@@ -373,6 +383,29 @@ for (const dir of UPLOAD_READ_DIRS.slice(1)) {
373
383
  } catch {}
374
384
  }
375
385
 
386
+ // Which models of OpenAI, Claude, Cursor and Grok are on (the DeepSeek and
387
+ // Claude API services keep their maps the same way).
388
+ const providerModelState = createProviderModelStateStore({
389
+ readSetting: readAppSettingValue,
390
+ writeSetting: (key, value, stamp) => stmts.upsertAppSetting.run(key, value, stamp),
391
+ });
392
+ // When each provider's models were last refreshed, by anything, and the new
393
+ // models the next models_updated announces (provider-model-refresh.mjs).
394
+ const modelRefreshLedger = createModelRefreshLedger({
395
+ readSetting: readAppSettingValue,
396
+ writeSetting: (key, value, stamp) => stmts.upsertAppSetting.run(key, value, stamp),
397
+ // What a provider's refresh error may echo: its key or token, cut out
398
+ // before the error is stored, shown in Settings or logged.
399
+ secretsFor: (provider) => ({
400
+ openai: () => [readAppSettingValue(OPENAI_API_KEY_SETTING_KEY)],
401
+ cursor: () => [readAppSettingValue(CURSOR_API_KEY_SETTING_KEY), readCursorDashboardSessionToken()],
402
+ deepseek: () => [readAppSettingValue(DEEPSEEK_API_KEY_SETTING_KEY)],
403
+ 'claude-api': () => [readAppSettingValue(CLAUDE_API_KEY_SETTING_KEY)],
404
+ grok: () => [process.env.XAI_API_KEY, readGrokCliAuthKey()?.key],
405
+ claude: () => [process.env.ANTHROPIC_API_KEY],
406
+ }[provider]?.() || []),
407
+ });
408
+
376
409
  async function refreshOpenAIProviderModels() {
377
410
  const settings = getOpenAIProviderSettings();
378
411
  if (!settings.enabled) return { ok: false, models: [], error: 'OpenAI API key is not configured' };
@@ -408,7 +441,8 @@ async function refreshOpenAIProviderModels() {
408
441
  .filter((modelId) => isOpenAIModelId(modelId)),
409
442
  ])).filter(Boolean);
410
443
  stmts.upsertAppSetting.run(OPENAI_MODELS_SETTING_KEY, JSON.stringify(models), new Date().toISOString());
411
- return { ok: true, models, error: null };
444
+ const { added } = providerModelState.reconcile('openai', { availableModels: getOpenAIProviderSettings().availableModels });
445
+ return { ok: true, models, newModels: added, error: null };
412
446
  }
413
447
 
414
448
  function getOpenAIProviderSettings() {
@@ -425,12 +459,21 @@ function getOpenAIProviderSettings() {
425
459
  } catch {
426
460
  models = [];
427
461
  }
462
+ const availableModels = Array.from(new Set([model, ...models]));
463
+ // Every discovered model was offered before the model map: an upgrade
464
+ // keeps them all on.
465
+ const enabledModels = providerModelState.resolve('openai', { availableModels, defaultModel: model });
428
466
  return {
429
467
  configured: !!apiKey,
430
468
  enabled,
431
469
  apiKey,
432
470
  model,
433
- models: Array.from(new Set([model, ...models])),
471
+ models: enabledModels.models,
472
+ availableModels,
473
+ enabledModels: enabledModels.models,
474
+ autoEnableNew: enabledModels.autoEnableNew,
475
+ fallback: enabledModels.fallback,
476
+ unseenModels: enabledModels.unseen,
434
477
  baseUrl: readAppSettingValue(OPENAI_BASE_URL_SETTING_KEY) || DEFAULT_OPENAI_BASE_URL,
435
478
  providerType: 'openai',
436
479
  };
@@ -441,6 +484,7 @@ function setOpenAIProviderSettings(update = {}) {
441
484
  const apiKey = payload.apiKey;
442
485
  const model = payload.model;
443
486
  const enabled = payload.enabled;
487
+ const enabledModels = payload.enabledModels;
444
488
  const remove = payload.remove === true;
445
489
  const hasBaseUrl = Object.prototype.hasOwnProperty.call(payload, 'baseUrl');
446
490
  const baseUrl = hasBaseUrl ? payload.baseUrl : '';
@@ -479,6 +523,7 @@ function setOpenAIProviderSettings(update = {}) {
479
523
  activeQueueConversationCount,
480
524
  };
481
525
  }
526
+ // The model map stays: a key added again finds its models as they were.
482
527
  stmts.deleteAppSetting.run(OPENAI_API_KEY_SETTING_KEY);
483
528
  stmts.deleteAppSetting.run(OPENAI_MODELS_SETTING_KEY);
484
529
  stmts.upsertAppSetting.run(OPENAI_ENABLED_SETTING_KEY, 'false', nowIso);
@@ -502,6 +547,21 @@ function setOpenAIProviderSettings(update = {}) {
502
547
  stmts.deleteAppSetting.run(OPENAI_BASE_URL_SETTING_KEY);
503
548
  }
504
549
  }
550
+ if (Array.isArray(enabledModels) || typeof payload.autoEnableNew === 'boolean') {
551
+ // The models Select Models showed are set as ticked; an empty list
552
+ // leaves the default model alone, never all.
553
+ providerModelState.select('openai', {
554
+ enabledModels: Array.isArray(enabledModels)
555
+ ? enabledModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeProviderModelId(value))
556
+ : undefined,
557
+ // The rows the browser drew; an older page sends none.
558
+ shownModels: Array.isArray(payload.shownModels)
559
+ ? payload.shownModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeProviderModelId(value))
560
+ : getOpenAIProviderSettings().availableModels,
561
+ defaultModel: normalizedModel,
562
+ autoEnableNew: payload.autoEnableNew,
563
+ });
564
+ }
505
565
  }
506
566
  const current = getOpenAIProviderSettings();
507
567
  return {
@@ -509,6 +569,9 @@ function setOpenAIProviderSettings(update = {}) {
509
569
  configured: current.configured,
510
570
  enabled: current.enabled,
511
571
  model: current.model,
572
+ models: current.models,
573
+ availableModels: current.availableModels,
574
+ autoEnableNew: current.autoEnableNew,
512
575
  baseUrl: current.baseUrl,
513
576
  };
514
577
  }
@@ -552,15 +615,14 @@ function getClaudeProviderSettings() {
552
615
  model,
553
616
  ...(discovered.length ? discovered : DEFAULT_CLAUDE_MODELS),
554
617
  ])).filter(Boolean);
555
- const availableSet = new Set(availableModels);
556
- const enabledSelection = readClaudeModelListSetting(CLAUDE_ENABLED_MODELS_SETTING_KEY)
557
- .filter((modelId) => availableSet.has(modelId));
558
- // The enabled subset (chosen in the Select Models modal) drives the model
559
- // catalog and composers; an empty selection means "all available".
560
- const models = Array.from(new Set([
561
- model,
562
- ...(enabledSelection.length ? enabledSelection : availableModels),
563
- ])).filter(Boolean);
618
+ // The models that are on (Select Models and the model map) drive the
619
+ // catalog and composers; the default model always is.
620
+ const enabledModels = providerModelState.resolve('claude', {
621
+ availableModels,
622
+ defaultModel: model,
623
+ legacyAllowlist: () => readClaudeModelListSetting(CLAUDE_ENABLED_MODELS_SETTING_KEY),
624
+ });
625
+ const models = enabledModels.models;
564
626
  const storedEfforts = readClaudeModelEffortsSetting();
565
627
  const effortsByModel = {};
566
628
  for (const availableModel of availableModels) {
@@ -584,6 +646,9 @@ function getClaudeProviderSettings() {
584
646
  models,
585
647
  availableModels,
586
648
  enabledModels: models,
649
+ autoEnableNew: enabledModels.autoEnableNew,
650
+ fallback: enabledModels.fallback,
651
+ unseenModels: enabledModels.unseen,
587
652
  effortsByModel,
588
653
  modelsDiscovered,
589
654
  attributionMode: normalizeClaudeAttributionMode(readAppSettingValue(CLAUDE_ATTRIBUTION_SETTING_KEY)) || DEFAULT_CLAUDE_ATTRIBUTION_MODE,
@@ -642,15 +707,20 @@ function setClaudeProviderSettings(update = {}) {
642
707
  stmts.deleteAppSetting.run(CLAUDE_MODELS_SETTING_KEY);
643
708
  }
644
709
  }
645
- if (Array.isArray(enabledModels)) {
646
- const normalizedEnabledModels = enabledModels
647
- .map((value) => String(value || '').trim())
648
- .filter((value) => value && isSafeClaudeModelId(value));
649
- if (normalizedEnabledModels.length) {
650
- stmts.upsertAppSetting.run(CLAUDE_ENABLED_MODELS_SETTING_KEY, JSON.stringify(normalizedEnabledModels), nowIso);
651
- } else {
652
- stmts.deleteAppSetting.run(CLAUDE_ENABLED_MODELS_SETTING_KEY);
653
- }
710
+ if (Array.isArray(enabledModels) || typeof payload.autoEnableNew === 'boolean') {
711
+ // The models Select Models showed are set as ticked; an empty list
712
+ // leaves the default model alone, never all.
713
+ providerModelState.select('claude', {
714
+ enabledModels: Array.isArray(enabledModels)
715
+ ? enabledModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeClaudeModelId(value))
716
+ : undefined,
717
+ // The rows the browser drew; an older page sends none.
718
+ shownModels: Array.isArray(payload.shownModels)
719
+ ? payload.shownModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeClaudeModelId(value))
720
+ : getClaudeProviderSettings().availableModels,
721
+ defaultModel: normalizedModel,
722
+ autoEnableNew: payload.autoEnableNew,
723
+ });
654
724
  }
655
725
  const current = getClaudeProviderSettings();
656
726
  return {
@@ -660,6 +730,7 @@ function setClaudeProviderSettings(update = {}) {
660
730
  model: current.model,
661
731
  models: current.models,
662
732
  availableModels: current.availableModels,
733
+ autoEnableNew: current.autoEnableNew,
663
734
  attributionMode: current.attributionMode,
664
735
  };
665
736
  }
@@ -707,7 +778,7 @@ async function refreshClaudeProviderModels() {
707
778
  let claudeQuery = null;
708
779
  let releaseInputGate = () => {};
709
780
  try {
710
- const { query } = await import('@anthropic-ai/claude-agent-sdk');
781
+ const { query } = await loadAgentSdk('claude');
711
782
  const inputGate = new Promise((resolve) => { releaseInputGate = resolve; });
712
783
  claudeQuery = query({
713
784
  // Streaming-input mode with a gated (never-yielding) prompt keeps the
@@ -760,8 +831,13 @@ async function refreshClaudeProviderModels() {
760
831
  const mergedEfforts = mergeClaudeModelEfforts(readClaudeModelEffortsSetting(), effortsByModel);
761
832
  stmts.upsertAppSetting.run(CLAUDE_MODELS_SETTING_KEY, JSON.stringify(models), nowIso);
762
833
  stmts.upsertAppSetting.run(CLAUDE_MODEL_EFFORTS_SETTING_KEY, JSON.stringify(mergedEfforts), nowIso);
763
- return { ok: true, models, error: null };
834
+ const { added } = providerModelState.reconcile('claude', { availableModels: getClaudeProviderSettings().availableModels });
835
+ return { ok: true, models, newModels: added, error: null };
764
836
  } catch (error) {
837
+ // No SDK installed yet: nothing is asked, the stored catalogue stays.
838
+ if (error instanceof AgentSdkMissingError) {
839
+ return { ok: false, models: settings.models, sdkMissing: true, error: `Claude model discovery waits for the SDK: ${error.message}` };
840
+ }
765
841
  return {
766
842
  ok: false,
767
843
  models: settings.models,
@@ -825,24 +901,19 @@ function getCursorProviderSettings() {
825
901
  models = [];
826
902
  }
827
903
  const availableModels = Array.from(new Set([model, ...(models.length ? models : DEFAULT_CURSOR_MODELS)]));
828
- const availableSet = new Set(availableModels);
829
- let enabledSelection = [];
830
- try {
831
- const parsed = JSON.parse(readAppSettingValue(CURSOR_ENABLED_MODELS_SETTING_KEY) || '[]');
832
- if (Array.isArray(parsed)) {
833
- enabledSelection = parsed
834
- .map((value) => String(value || '').trim())
835
- .filter((modelId) => isSafeCursorModelId(modelId) && availableSet.has(modelId));
836
- }
837
- } catch {
838
- enabledSelection = [];
839
- }
840
- // The enabled subset (chosen in the Select Models modal) drives the model
841
- // catalog and composers; an empty selection means "all available".
842
- const resolvedModels = Array.from(new Set([
843
- model,
844
- ...(enabledSelection.length ? enabledSelection : availableModels),
845
- ])).filter(Boolean);
904
+ // The models that are on (Select Models and the model map) drive the
905
+ // catalog and composers; the default model always is.
906
+ const enabledModels = providerModelState.resolve('cursor', {
907
+ availableModels,
908
+ defaultModel: model,
909
+ legacyAllowlist: () => {
910
+ const parsed = JSON.parse(readAppSettingValue(CURSOR_ENABLED_MODELS_SETTING_KEY) || '[]');
911
+ return Array.isArray(parsed)
912
+ ? parsed.map((value) => String(value || '').trim()).filter((modelId) => isSafeCursorModelId(modelId))
913
+ : [];
914
+ },
915
+ });
916
+ const resolvedModels = enabledModels.models;
846
917
  const storedEfforts = readCursorModelEffortsSetting();
847
918
  const storedReasoningOff = readCursorModelReasoningOffSetting();
848
919
  const effortsByModel = {};
@@ -866,6 +937,9 @@ function getCursorProviderSettings() {
866
937
  models: resolvedModels,
867
938
  availableModels,
868
939
  enabledModels: resolvedModels,
940
+ autoEnableNew: enabledModels.autoEnableNew,
941
+ fallback: enabledModels.fallback,
942
+ unseenModels: enabledModels.unseen,
869
943
  effortsByModel,
870
944
  reasoningOffByModel,
871
945
  // Pre-effort installs have models but no efforts setting; the settings
@@ -1107,9 +1181,9 @@ function setCursorProviderSettings(update = {}) {
1107
1181
  activeQueueConversationCount,
1108
1182
  };
1109
1183
  }
1184
+ // The model map stays: a key added again finds its models as they were.
1110
1185
  stmts.deleteAppSetting.run(CURSOR_API_KEY_SETTING_KEY);
1111
1186
  stmts.deleteAppSetting.run(CURSOR_MODELS_SETTING_KEY);
1112
- stmts.deleteAppSetting.run(CURSOR_ENABLED_MODELS_SETTING_KEY);
1113
1187
  stmts.deleteAppSetting.run(CURSOR_MODEL_EFFORTS_SETTING_KEY);
1114
1188
  stmts.deleteAppSetting.run(CURSOR_MODEL_REASONING_OFF_SETTING_KEY);
1115
1189
  stmts.upsertAppSetting.run(CURSOR_ENABLED_SETTING_KEY, 'false', nowIso);
@@ -1127,15 +1201,20 @@ function setCursorProviderSettings(update = {}) {
1127
1201
  if (nextEnabled && !hasApiKey) return { ok: false, error: 'Cursor API key is not configured' };
1128
1202
  stmts.upsertAppSetting.run(CURSOR_ENABLED_SETTING_KEY, nextEnabled ? 'true' : 'false', nowIso);
1129
1203
  stmts.upsertAppSetting.run(CURSOR_MODEL_SETTING_KEY, normalizedModel, nowIso);
1130
- if (Array.isArray(enabledModels)) {
1131
- const normalizedEnabledModels = enabledModels
1132
- .map((value) => String(value || '').trim())
1133
- .filter((value) => value && isSafeCursorModelId(value));
1134
- if (normalizedEnabledModels.length) {
1135
- stmts.upsertAppSetting.run(CURSOR_ENABLED_MODELS_SETTING_KEY, JSON.stringify(normalizedEnabledModels), nowIso);
1136
- } else {
1137
- stmts.deleteAppSetting.run(CURSOR_ENABLED_MODELS_SETTING_KEY);
1138
- }
1204
+ if (Array.isArray(enabledModels) || typeof payload.autoEnableNew === 'boolean') {
1205
+ // The models Select Models showed are set as ticked; an empty list
1206
+ // leaves the default model alone, never all.
1207
+ providerModelState.select('cursor', {
1208
+ enabledModels: Array.isArray(enabledModels)
1209
+ ? enabledModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeCursorModelId(value))
1210
+ : undefined,
1211
+ // The rows the browser drew; an older page sends none.
1212
+ shownModels: Array.isArray(payload.shownModels)
1213
+ ? payload.shownModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeCursorModelId(value))
1214
+ : getCursorProviderSettings().availableModels,
1215
+ defaultModel: normalizedModel,
1216
+ autoEnableNew: payload.autoEnableNew,
1217
+ });
1139
1218
  }
1140
1219
  }
1141
1220
  const current = getCursorProviderSettings();
@@ -1146,6 +1225,7 @@ function setCursorProviderSettings(update = {}) {
1146
1225
  model: current.model,
1147
1226
  models: current.models,
1148
1227
  availableModels: current.availableModels,
1228
+ autoEnableNew: current.autoEnableNew,
1149
1229
  };
1150
1230
  }
1151
1231
 
@@ -1202,7 +1282,7 @@ async function refreshCursorProviderModels() {
1202
1282
  return { ok: false, models: settings.models, error: 'Cursor API key is not configured' };
1203
1283
  }
1204
1284
  try {
1205
- const mod = await import('@cursor/sdk');
1285
+ const mod = await loadAgentSdk('cursor');
1206
1286
  // The namespace form is the only working call shape: `Cursor` has a
1207
1287
  // private constructor and `models` is static, so the old client-instance
1208
1288
  // fallback could only throw a TypeError that replaced the real error.
@@ -1241,8 +1321,12 @@ async function refreshCursorProviderModels() {
1241
1321
  stmts.upsertAppSetting.run(CURSOR_MODELS_SETTING_KEY, JSON.stringify(models), nowIso);
1242
1322
  stmts.upsertAppSetting.run(CURSOR_MODEL_EFFORTS_SETTING_KEY, JSON.stringify(effortsByModel), nowIso);
1243
1323
  stmts.upsertAppSetting.run(CURSOR_MODEL_REASONING_OFF_SETTING_KEY, JSON.stringify(reasoningOffByModel), nowIso);
1244
- return { ok: true, models, error: null };
1324
+ const { added } = providerModelState.reconcile('cursor', { availableModels: getCursorProviderSettings().availableModels });
1325
+ return { ok: true, models, newModels: added, error: null };
1245
1326
  } catch (error) {
1327
+ if (error instanceof AgentSdkMissingError) {
1328
+ return { ok: false, models: settings.models, sdkMissing: true, error: `Cursor model discovery waits for the SDK: ${error.message}` };
1329
+ }
1246
1330
  return {
1247
1331
  ok: false,
1248
1332
  models: settings.models,
@@ -1292,13 +1376,12 @@ function getGrokProviderSettings() {
1292
1376
  model,
1293
1377
  ...(discovered.length ? discovered : DEFAULT_GROK_MODELS),
1294
1378
  ])).filter(Boolean);
1295
- const availableSet = new Set(availableModels);
1296
- const enabledSelection = readGrokModelListSetting(GROK_ENABLED_MODELS_SETTING_KEY)
1297
- .filter((modelId) => availableSet.has(modelId));
1298
- const models = Array.from(new Set([
1299
- model,
1300
- ...(enabledSelection.length ? enabledSelection : availableModels),
1301
- ])).filter(Boolean);
1379
+ const enabledModels = providerModelState.resolve('grok', {
1380
+ availableModels,
1381
+ defaultModel: model,
1382
+ legacyAllowlist: () => readGrokModelListSetting(GROK_ENABLED_MODELS_SETTING_KEY),
1383
+ });
1384
+ const models = enabledModels.models;
1302
1385
  const storedEfforts = readGrokModelEffortsSetting();
1303
1386
  const effortsByModel = {};
1304
1387
  for (const availableModel of availableModels) {
@@ -1317,6 +1400,9 @@ function getGrokProviderSettings() {
1317
1400
  models,
1318
1401
  availableModels,
1319
1402
  enabledModels: models,
1403
+ autoEnableNew: enabledModels.autoEnableNew,
1404
+ fallback: enabledModels.fallback,
1405
+ unseenModels: enabledModels.unseen,
1320
1406
  effortsByModel,
1321
1407
  modelsDiscovered,
1322
1408
  providerType: 'grok',
@@ -1351,15 +1437,20 @@ function setGrokProviderSettings(update = {}) {
1351
1437
  stmts.deleteAppSetting?.run?.(GROK_MODELS_SETTING_KEY);
1352
1438
  }
1353
1439
  }
1354
- if (Array.isArray(enabledModels)) {
1355
- const normalizedEnabledModels = enabledModels
1356
- .map((value) => String(value || '').trim())
1357
- .filter((value) => value && isSafeGrokModelId(value));
1358
- if (normalizedEnabledModels.length) {
1359
- stmts.upsertAppSetting.run(GROK_ENABLED_MODELS_SETTING_KEY, JSON.stringify(normalizedEnabledModels), nowIso);
1360
- } else {
1361
- stmts.deleteAppSetting?.run?.(GROK_ENABLED_MODELS_SETTING_KEY);
1362
- }
1440
+ if (Array.isArray(enabledModels) || typeof payload.autoEnableNew === 'boolean') {
1441
+ // The models Select Models showed are set as ticked; an empty list
1442
+ // leaves the default model alone, never all.
1443
+ providerModelState.select('grok', {
1444
+ enabledModels: Array.isArray(enabledModels)
1445
+ ? enabledModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeGrokModelId(value))
1446
+ : undefined,
1447
+ // The rows the browser drew; an older page sends none.
1448
+ shownModels: Array.isArray(payload.shownModels)
1449
+ ? payload.shownModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeGrokModelId(value))
1450
+ : getGrokProviderSettings().availableModels,
1451
+ defaultModel: normalizedModel,
1452
+ autoEnableNew: payload.autoEnableNew,
1453
+ });
1363
1454
  }
1364
1455
  const current = getGrokProviderSettings();
1365
1456
  return {
@@ -1369,6 +1460,7 @@ function setGrokProviderSettings(update = {}) {
1369
1460
  model: current.model,
1370
1461
  models: current.models,
1371
1462
  availableModels: current.availableModels,
1463
+ autoEnableNew: current.autoEnableNew,
1372
1464
  };
1373
1465
  }
1374
1466
 
@@ -1450,7 +1542,8 @@ async function refreshGrokProviderModels() {
1450
1542
  const models = Array.from(new Set([settings.model, ...discovered])).filter(Boolean);
1451
1543
  stmts.upsertAppSetting.run(GROK_MODELS_SETTING_KEY, JSON.stringify(models), nowIso);
1452
1544
  stmts.upsertAppSetting.run(GROK_MODEL_EFFORTS_SETTING_KEY, JSON.stringify(effortsByModel), nowIso);
1453
- return { ok: true, models, error: null };
1545
+ const { added } = providerModelState.reconcile('grok', { availableModels: getGrokProviderSettings().availableModels });
1546
+ return { ok: true, models, newModels: added, error: null };
1454
1547
  } catch (error) {
1455
1548
  return {
1456
1549
  ok: false,
@@ -1507,6 +1600,8 @@ const FALLBACK_COPILOT_ENGINE = 'extension';
1507
1600
  const CLAUDE_ENABLED_SETTING_KEY = 'claude_enabled';
1508
1601
  const CLAUDE_MODEL_SETTING_KEY = 'claude_model';
1509
1602
  const CLAUDE_MODELS_SETTING_KEY = 'claude_models';
1603
+ // The allowlists before 0.9.24 (Claude, Cursor, Grok): read once to build
1604
+ // the model map, then left as they are so an older version still finds them.
1510
1605
  const CLAUDE_ENABLED_MODELS_SETTING_KEY = 'claude_enabled_models';
1511
1606
  const CLAUDE_MODEL_EFFORTS_SETTING_KEY = 'claude_model_efforts';
1512
1607
  // oar | vanilla | off — what commits and PRs made in Claude sessions say
@@ -2112,7 +2207,6 @@ function workspaceRootPayload() {
2112
2207
  }
2113
2208
 
2114
2209
  const ACTIVE_SESSION_CWD_STATUSES = new Set(['starting', 'ready', 'processing']);
2115
- const ACTIVE_TTY_CLI_WORKER_STATUSES = new Set(['starting', 'ready', 'processing']);
2116
2210
 
2117
2211
  function normalizeConversationWorkspaceRootPath(candidatePath) {
2118
2212
  const normalized = normalizeWorkspaceRootPath(candidatePath);
@@ -2677,6 +2771,10 @@ const modelVariantCatalog = createModelVariantCatalogService({
2677
2771
  curatedModelIds: CURATED_MODEL_IDS,
2678
2772
  autoModelSentinel: AUTO_MODEL_SENTINEL,
2679
2773
  runCopilotCliCommand,
2774
+ modelStateStore: providerModelState,
2775
+ onNewModels: (baseModelIds, on) => modelRefreshLedger.noteNewModels('github', baseModelIds.map((id) => ({ id, on }))),
2776
+ // A worker's snapshot read the runtime's list: a Copilot model refresh.
2777
+ onAuthoritativeSnapshot: () => modelRefreshLedger.record('github', { ok: true }),
2680
2778
  });
2681
2779
  const {
2682
2780
  SUPPORTED_REASONING_EFFORTS,
@@ -2989,6 +3087,7 @@ const deepseekSettingsService = createDeepSeekSettingsService({
2989
3087
  },
2990
3088
  deleteSetting: (key) => stmts.deleteAppSetting.run(key),
2991
3089
  listProviderCandidates: () => stmts.listRuntimeSessionProviderCandidates?.all?.() || [],
3090
+ onModelsRefreshed: (result) => modelRefreshLedger.record('deepseek', result),
2992
3091
  });
2993
3092
  function getDeepSeekProviderSettings() {
2994
3093
  return deepseekSettingsService.getSettings();
@@ -3006,6 +3105,7 @@ const claudeApiSettingsService = createClaudeApiSettingsService({
3006
3105
  listProviderCandidates: () => stmts.listRuntimeSessionProviderCandidates?.all?.() || [],
3007
3106
  countLiveKeyWorkers: () => (sessionWorkerRegistry?.listWorkers?.() || []).filter((worker) => worker?.kind === 'claude-api').length,
3008
3107
  getClaudeProviderSettings: () => getClaudeProviderSettings(),
3108
+ onModelsRefreshed: (result) => modelRefreshLedger.record('claude-api', result),
3009
3109
  });
3010
3110
  function getClaudeApiProviderSettings() {
3011
3111
  return claudeApiSettingsService.getSettings();
@@ -3095,6 +3195,44 @@ try {
3095
3195
  console.warn(`[cli-install] failed to apply persisted CLI bindings: ${error?.message || error}`);
3096
3196
  }
3097
3197
 
3198
+ // The agent SDKs (and koffi on Windows) the relay installs on demand
3199
+ // (agent-sdk-store.mjs). Registered with the loader, so this process takes
3200
+ // them from the store, never from a worker folder in its environment.
3201
+ const agentSdkStore = createAgentSdkStore({
3202
+ fromCheckout: (sdk) => agentSdkFromCheckout(sdk),
3203
+ readSetting: readAppSettingValue,
3204
+ writeSetting: (key, value) => stmts.upsertAppSetting.run(key, value, new Date().toISOString()),
3205
+ });
3206
+ setAgentSdkStore(agentSdkStore);
3207
+ // A provider that was just set up gets its SDK at once, in the
3208
+ // background; a failure shows in its Settings tab and on the first chat.
3209
+ function installAgentSdkForSetup(sdk, reason) {
3210
+ if (resolveAgentSdkSource(sdk)) return;
3211
+ void agentSdkStore.ensure(sdk, { reason, retryFailed: true }).catch((error) => {
3212
+ console.warn(`[agent-sdk] ${sdk} install after ${reason} failed: ${error?.message || error}`);
3213
+ });
3214
+ }
3215
+ claudeAuthService.onLoginSuccess?.(() => installAgentSdkForSetup('claude', 'Claude login'));
3216
+ // The SDKs whose providers are in use: enabled, logged in, or with a
3217
+ // conversation that is not archived. koffi wherever the store supports it.
3218
+ function agentSdksInUse() {
3219
+ const inUse = new Set(['koffi']);
3220
+ const providerSdk = { claude: 'claude', 'claude-api': 'claude', deepseek: 'claude', cursor: 'cursor' };
3221
+ try {
3222
+ if (getClaudeProviderSettings()?.enabled === true || claudeAuthService.getCachedStatus?.()?.loggedIn === true) inUse.add('claude');
3223
+ if (claudeApiSettingsService.getSettings?.()?.enabled === true || deepseekSettingsService.getSettings?.()?.enabled === true) inUse.add('claude');
3224
+ if (getCursorProviderSettings()?.enabled === true) inUse.add('cursor');
3225
+ const rows = db.prepare(`SELECT DISTINCT r.provider_type AS provider FROM runtime_sessions r JOIN conversations c ON c.id = r.conversation_id WHERE c.archived = 0`).all();
3226
+ for (const row of rows) {
3227
+ const sdk = providerSdk[String(row?.provider || '').trim().toLowerCase()];
3228
+ if (sdk) inUse.add(sdk);
3229
+ }
3230
+ } catch (error) {
3231
+ console.warn(`[agent-sdk] could not tell which providers are in use: ${error?.message || error}`);
3232
+ }
3233
+ return [...inUse];
3234
+ }
3235
+
3098
3236
  // ─── Web Push ─────────────────────────────────────────────────────────────────
3099
3237
  // VAPID keys are generated once and persisted in app_settings; regenerating
3100
3238
  // them would invalidate every stored subscription.
@@ -3382,6 +3520,20 @@ const relayCliLauncherService = createRelayCliLauncherService({
3382
3520
  workerScope,
3383
3521
  });
3384
3522
 
3523
+ // The launch gate (agent-sdk-launch-gate.mjs): never waits for an install.
3524
+ // A session it held back gets its launch back when the install ends or the
3525
+ // SDK turns ready later: the restart schedule cleared, the queue woken.
3526
+ const agentSdkLaunchGate = createAgentSdkLaunchGate({
3527
+ store: agentSdkStore,
3528
+ sdkForWorkerKind: agentSdkForWorkerKind,
3529
+ resolveSource: (sdk) => resolveAgentSdkSource(sdk),
3530
+ noteInstalling: (sdkSessionId, { label }) => workerLaunchNoteService.handle({ type: 'installing', sdkSessionId, lifecycle: { sdkLabel: label } }),
3531
+ onSessionsReleased: ({ sessions }) => {
3532
+ for (const sdkSessionId of sessions) sessionWorkerSupervisor.clearRestartSchedule?.(sdkSessionId);
3533
+ wakeSessionWorkerQueue('agent-sdk-ready');
3534
+ },
3535
+ });
3536
+
3385
3537
  async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true } = {}) {
3386
3538
  const normalizedTargetSessionId = String(targetSessionId || '').trim();
3387
3539
  if (!normalizedTargetSessionId) {
@@ -3412,6 +3564,11 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
3412
3564
  }
3413
3565
  const resolvedWorkspaceRoot = resolveLaunchWorkspaceRootForSession(normalizedTargetSessionId);
3414
3566
  const workerLaunchEnv = buildSessionWorkerLaunchEnvForSession(normalizedTargetSessionId);
3567
+ // Synchronous: nothing between the gate and the launch can start a
3568
+ // second worker for the session.
3569
+ const agentSdkDir = agentSdkLaunchGate.check(normalizedTargetSessionId, resolveWorkerKind(workerLaunchEnv));
3570
+ if (agentSdkDir.deferred) return { deferred: agentSdkDir.deferred };
3571
+ if (agentSdkDir.dir) workerLaunchEnv[agentSdkDirEnvName(agentSdkDir.sdk)] = agentSdkDir.dir;
3415
3572
  const launched = await launchSessionCli({
3416
3573
  workerScope,
3417
3574
  targetSessionId: normalizedTargetSessionId,
@@ -3426,6 +3583,9 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
3426
3583
  log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
3427
3584
  });
3428
3585
  const workerPid = Number.isInteger(Number(launched?.pid)) ? Number(launched.pid) : null;
3586
+ // The worker leases its folder when it loads the SDK; this covers the
3587
+ // seconds before, so no clean-up takes it in between.
3588
+ if (agentSdkDir.dir && workerPid) agentSdkStore.lease(agentSdkDir.dir, workerPid);
3429
3589
  const workerId = `worker-${normalizedTargetSessionId.slice(0, 8)}`;
3430
3590
  const launchMode = String(launched?.launchMode || 'detached').trim();
3431
3591
  const tmuxSessionName = String(launched?.tmuxSessionName || '').trim();
@@ -3760,27 +3920,6 @@ function queueCounts() {
3760
3920
  return { pendingCount: map.pending || 0, processingCount: map.processing || 0, parkedCount: map.parked || 0 };
3761
3921
  }
3762
3922
 
3763
- function countActiveCliWorkers() {
3764
- const workers = sessionWorkerRegistry?.listWorkers?.() || [];
3765
- return workers.reduce((acc, worker) => {
3766
- const status = String(worker?.status || '').trim().toLowerCase();
3767
- return ACTIVE_TTY_CLI_WORKER_STATUSES.has(status) ? acc + 1 : acc;
3768
- }, 0);
3769
- }
3770
-
3771
- function attachTtyConsoleRuntimeTitle(runtime) {
3772
- const addConsoleTitle = runtime?.tty?.addConsoleTitle;
3773
- if (typeof addConsoleTitle !== 'function') return;
3774
- addConsoleTitle(() => {
3775
- const counts = queueCounts();
3776
- const connectedClients = Number(io?.sockets?.sockets?.size || 0);
3777
- const activeCliWorkers = countActiveCliWorkers();
3778
- const cliState = cliOnline ? 'online' : 'offline';
3779
- return `Clients: ${connectedClients} :: Pen: ${counts.pendingCount} :: Proc: ${counts.processingCount} :: parked: ${counts.parkedCount} :: CLIs: ${activeCliWorkers} :: CLI: ${cliState}`;
3780
- });
3781
- }
3782
- attachTtyConsoleRuntimeTitle(ttyConsoleRuntime);
3783
-
3784
3923
  // ─── Queued relay restart / shutdown ─────────────────────────────────────────
3785
3924
  // A request waits until no turn is processing and holds new turns meanwhile
3786
3925
  // (services/relay-shutdown-gate.mjs); past the maximum wait it asks instead
@@ -6279,6 +6418,7 @@ const copilotModelDiscoveryService = createCopilotModelDiscoveryService({
6279
6418
  }),
6280
6419
  resolveInstalledPaths: () => resolveInstalledCopilotPaths({ config }),
6281
6420
  updateModelCatalog,
6421
+ onRefreshed: (result) => modelRefreshLedger.record('github', result),
6282
6422
  logger: console,
6283
6423
  });
6284
6424
  const contextSnapshotService = createContextSnapshotService({
@@ -7105,9 +7245,51 @@ const workerRecycleService = createWorkerRecycleService({
7105
7245
  // The stop and the launch under the per-session launch mutex the Change
7106
7246
  // CWD relaunch and the launch routes take (sessions-routes.mjs).
7107
7247
  runExclusive: (sid, fn) => sessionLaunchMutex.runExclusive(sid, fn),
7248
+ // The kinds the launch gate gives an agent SDK: once stale, recycled
7249
+ // without waiting for their CLI child, and fenced until then.
7250
+ isAgentSdkWorker: (worker) => staleWorkerLosesAgentSdk(worker?.kind, { sdkForWorkerKind: agentSdkForWorkerKind, fromCheckout: (sdk) => agentSdkFromCheckout(sdk) }),
7108
7251
  logger: console,
7109
7252
  });
7110
7253
 
7254
+ // A worker's hello, before the hello's own request for work: the registry
7255
+ // learns the process, its kind and its build id (a relay that just started
7256
+ // has no entry yet, and the delivery would register the process without its
7257
+ // build id), and the recycle sweeps, so a stale worker on an agent SDK is
7258
+ // fenced before anything is handed to it.
7259
+ function noteSessionWorkerHello({ sessionId, pid, kind = null, buildId = null } = {}) {
7260
+ if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED !== true || runtimeShutdownStarted) return;
7261
+ const sid = String(sessionId || '').trim();
7262
+ if (!sid) return;
7263
+ // A killed session's worker is registered by nothing, as the delivery
7264
+ // leaves it unregistered.
7265
+ if (sessionWorkerSupervisor?.isKillBlocked?.(sid) !== true) {
7266
+ const statedPid = Number.isInteger(Number(pid)) && Number(pid) > 0 ? Number(pid) : null;
7267
+ let worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
7268
+ // No entry, or one with no process behind it (a launch the agent SDK
7269
+ // gate deferred leaves 'new'): the process saying hello is the session's
7270
+ // worker, ready, as its first heartbeat would make it (/api/heartbeat).
7271
+ if (!worker || worker.status === 'new' || (!worker.workerId && !worker.pid)) {
7272
+ worker = sessionWorkerRegistry?.upsertWorker?.({
7273
+ ...(worker || {}),
7274
+ sdkSessionId: sid,
7275
+ workerId: worker?.workerId || `worker-${sid.slice(0, 8)}`,
7276
+ status: 'ready',
7277
+ pid: statedPid || worker?.pid || null,
7278
+ queueDepth: Math.max(0, Number(queueCounts?.().pendingCount || 0)),
7279
+ }) || null;
7280
+ } else if (statedPid && worker.pid !== statedPid) {
7281
+ worker = sessionWorkerRegistry?.upsertWorker?.({ ...worker, sdkSessionId: sid, pid: statedPid, status: worker.status || 'ready' }) || null;
7282
+ }
7283
+ // Stated for the process just registered, in an upsert of its own: the
7284
+ // registry drops a build id that comes with a new pid and equals the old
7285
+ // entry's (the old process's, spread in).
7286
+ if (worker && ((kind && worker.kind !== kind) || (buildId && worker.buildId !== buildId))) {
7287
+ sessionWorkerRegistry?.upsertWorker?.({ ...worker, ...(kind ? { kind } : {}), ...(buildId ? { buildId } : {}) });
7288
+ }
7289
+ }
7290
+ void workerRecycleService.sweepNow();
7291
+ }
7292
+
7111
7293
  // Once at start, after the restart's own worker reconnects settled: what
7112
7294
  // still runs for an archived conversation is stopped and stays stopped.
7113
7295
  // Through the recycle's replacement, one session at a time; never throws.
@@ -7342,6 +7524,9 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
7342
7524
  const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
7343
7525
  if (worker && worker.buildId !== buildId) sessionWorkerRegistry?.upsertWorker?.({ ...worker, buildId });
7344
7526
  },
7527
+ // Registers the process and runs the recycle's sweep before the hello's
7528
+ // request for work (noteSessionWorkerHello).
7529
+ onWorkerHello: noteSessionWorkerHello,
7345
7530
  // Sent in server.hello, so a worker on other code can say so in its log.
7346
7531
  buildId: BUILD_ID,
7347
7532
  isWorkerProcessAlive: (pid) => isProcessAlive(Number(pid)),
@@ -7430,6 +7615,12 @@ app.use(tunnelWorkerPathGuard.requestMiddleware);
7430
7615
  // Uploads travel as raw bodies on POST /api/upload (express.raw, capped by
7431
7616
  // MAX_UPLOAD_BYTES), so the JSON limit only bounds inline image data URLs.
7432
7617
  app.use(express.json({ limit: '20mb' }));
7618
+ // Express 5 leaves req.body undefined when no parser read a body (a GET, a
7619
+ // POST without a JSON body); the routes read it as express 4 gave it, {}.
7620
+ app.use((req, _res, next) => {
7621
+ if (req.body === undefined) req.body = {};
7622
+ next();
7623
+ });
7433
7624
  app.use((req, _res, next) => {
7434
7625
  stripRequestPathPrefix(req, remotePath);
7435
7626
  next();
@@ -7461,9 +7652,7 @@ app.use((req, res, next) => {
7461
7652
  });
7462
7653
  app.get('/socket.io/socket.io.js', (req, res, next) => {
7463
7654
  res.setHeader('Cache-Control', 'no-store');
7464
- res.sendFile(SOCKET_IO_CLIENT_JS_PATH, (error) => {
7465
- if (error) next(error);
7466
- });
7655
+ sendOwnFile(res, SOCKET_IO_CLIENT_JS_PATH, next);
7467
7656
  });
7468
7657
  app.get('/manifest.webmanifest', (req, res, next) => {
7469
7658
  try {
@@ -7477,9 +7666,7 @@ app.get('/manifest.webmanifest', (req, res, next) => {
7477
7666
  });
7478
7667
  app.get('/sw.js', (req, res, next) => {
7479
7668
  res.setHeader('Cache-Control', 'no-store');
7480
- res.sendFile(PWA_SW_PATH, (error) => {
7481
- if (error) next(error);
7482
- });
7669
+ sendOwnFile(res, PWA_SW_PATH, next);
7483
7670
  });
7484
7671
  app.get(['/', '/index.html'], (req, res, next) => {
7485
7672
  try {
@@ -7554,7 +7741,6 @@ const runtimeState = {
7554
7741
  // Resumed: what was queued while paused goes out now.
7555
7742
  if (wasPaused && !relayPaused) wakeSessionWorkerQueue('relay-resumed');
7556
7743
  },
7557
- get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
7558
7744
  get relayShutdown() { return relayRestartQueue.getState(); },
7559
7745
  // A shutdown or restart waits for the processing turns: no pending row
7560
7746
  // goes out meanwhile (services/relay-restart-queue.mjs).
@@ -7729,6 +7915,85 @@ const remoteRelayDispatcher = createRemoteRelayDispatcher({
7729
7915
  logger: console,
7730
7916
  });
7731
7917
 
7918
+ // Every refresh of a provider's models resets its clock in the ledger,
7919
+ // whoever asks; the background check refreshes what is due through the same
7920
+ // table as the Select Models Refresh button.
7921
+ const trackedModelRefreshers = {
7922
+ refreshOpenAIProviderModels: modelRefreshLedger.track('openai', refreshOpenAIProviderModels),
7923
+ refreshClaudeProviderModels: modelRefreshLedger.track('claude', refreshClaudeProviderModels),
7924
+ refreshCursorProviderModels: modelRefreshLedger.track('cursor', refreshCursorProviderModels),
7925
+ refreshGrokProviderModels: modelRefreshLedger.track('grok', refreshGrokProviderModels),
7926
+ };
7927
+ const modelRefreshSettings = createModelRefreshSettings({
7928
+ readSetting: readAppSettingValue,
7929
+ writeSetting: (key, value, stamp) => stmts.upsertAppSetting.run(key, value, stamp),
7930
+ });
7931
+ // Claude and Grok are set up when their CLI is installed or their login is
7932
+ // there: a file lookup and the last status, never a CLI started to ask.
7933
+ function isClaudeModelRefreshSetUp() {
7934
+ // Discovery runs on the SDK: none installed yet, nothing to ask.
7935
+ if (!resolveAgentSdkSource('claude')) return false;
7936
+ return !!cliInstallService.resolveCliBinary('claude')
7937
+ || claudeCloudCredentials.describe().hasToken === true
7938
+ || claudeAuthService.getCachedStatus?.()?.loggedIn === true;
7939
+ }
7940
+ function isGrokModelRefreshSetUp() {
7941
+ return !!String(process.env.GROK_CLI_COMMAND || process.env.GROK_COMMAND || '').trim()
7942
+ || !!cliInstallService.resolveCliBinary('grok')
7943
+ || !!readGrokCliAuthKey();
7944
+ }
7945
+ const modelRefreshTable = buildProviderRefreshTable({
7946
+ getOpenAIProviderSettings,
7947
+ getClaudeProviderSettings,
7948
+ getCursorProviderSettings,
7949
+ getGrokProviderSettings,
7950
+ ...trackedModelRefreshers,
7951
+ deepseekSettingsService,
7952
+ claudeApiSettingsService,
7953
+ copilotModelDiscoveryService,
7954
+ isClaudeSetUp: isClaudeModelRefreshSetUp,
7955
+ isGrokSetUp: isGrokModelRefreshSetUp,
7956
+ });
7957
+ // A freshly installed SDK fills its provider's catalogue at once, as a
7958
+ // fresh CLI install does.
7959
+ agentSdkStore.onReady(({ id } = {}) => {
7960
+ // A session the gate held back, or whose launch gave up on a failed
7961
+ // install, delivers its queued message now.
7962
+ agentSdkLaunchGate.onReady(id);
7963
+ const providers = { claude: ['claude'], cursor: ['cursor'] }[id];
7964
+ if (!providers) return;
7965
+ void refreshProviderModels({ table: modelRefreshTable, providers, reason: 'agent-sdk-installed', gate: 'isSetUp' }).catch((error) => {
7966
+ console.warn(`[agent-sdk] model refresh after the ${id} install failed: ${error?.message || error}`);
7967
+ });
7968
+ });
7969
+ const modelRefreshScheduler = createModelRefreshScheduler({
7970
+ getSettings: () => modelRefreshSettings.get(),
7971
+ getLast: () => modelRefreshLedger.readLast(),
7972
+ getTable: () => modelRefreshTable,
7973
+ refreshProviders: ({ providers, reason }) => refreshProviderModels({ table: modelRefreshTable, providers, reason, gate: 'isSetUp' }),
7974
+ logger: console,
7975
+ });
7976
+
7977
+ // The same for the settings routes: a save that leaves the provider enabled
7978
+ // installs its SDK in the background.
7979
+ function withAgentSdkSetup(setter, sdk, isEnabled, reason) {
7980
+ return (...args) => {
7981
+ const result = setter(...args);
7982
+ try { if (result?.ok !== false && isEnabled()) installAgentSdkForSetup(sdk, reason); } catch {}
7983
+ return result;
7984
+ };
7985
+ }
7986
+ function withAgentSdkUpdate(service, reason) {
7987
+ if (!service) return service;
7988
+ return Object.assign(Object.create(service), {
7989
+ update: (...args) => {
7990
+ const result = service.update(...args);
7991
+ try { if (result?.ok !== false && service.getSettings?.()?.enabled === true) installAgentSdkForSetup('claude', reason); } catch {}
7992
+ return result;
7993
+ },
7994
+ });
7995
+ }
7996
+
7732
7997
  const sharedRouteDeps = {
7733
7998
  auth,
7734
7999
  isTrulyLocalRequest: localRequestCheck.isTrulyLocal,
@@ -7785,27 +8050,27 @@ const sharedRouteDeps = {
7785
8050
  setDefaultSessionWorkspaceRootPath,
7786
8051
  getOpenAIProviderSettings,
7787
8052
  setOpenAIProviderSettings,
7788
- refreshOpenAIProviderModels,
8053
+ refreshOpenAIProviderModels: trackedModelRefreshers.refreshOpenAIProviderModels,
7789
8054
  getCopilotProviderSettings,
7790
8055
  setCopilotProviderSettings,
7791
8056
  getClaudeProviderSettings,
7792
- setClaudeProviderSettings,
7793
- refreshClaudeProviderModels,
8057
+ setClaudeProviderSettings: withAgentSdkSetup(setClaudeProviderSettings, 'claude', () => getClaudeProviderSettings()?.enabled === true, 'Claude enabled'),
8058
+ refreshClaudeProviderModels: trackedModelRefreshers.refreshClaudeProviderModels,
7794
8059
  getCursorProviderSettings,
7795
- setCursorProviderSettings,
7796
- refreshCursorProviderModels,
8060
+ setCursorProviderSettings: withAgentSdkSetup(setCursorProviderSettings, 'cursor', () => getCursorProviderSettings()?.enabled === true, 'Cursor key saved'),
8061
+ refreshCursorProviderModels: trackedModelRefreshers.refreshCursorProviderModels,
7797
8062
  getGrokProviderSettings,
7798
8063
  setGrokProviderSettings,
7799
- refreshGrokProviderModels,
8064
+ refreshGrokProviderModels: trackedModelRefreshers.refreshGrokProviderModels,
7800
8065
  getClaudeCloudProviderSettings,
7801
8066
  claudeCloudSettingsService,
7802
8067
  claudeCloudSessionService,
7803
8068
  getDeepSeekProviderSettings,
7804
- deepseekSettingsService,
8069
+ deepseekSettingsService: withAgentSdkUpdate(deepseekSettingsService, 'DeepSeek set up'),
7805
8070
  deepseekBalanceService,
7806
8071
  deepseekUsageImportService,
7807
8072
  getClaudeApiProviderSettings,
7808
- claudeApiSettingsService,
8073
+ claudeApiSettingsService: withAgentSdkUpdate(claudeApiSettingsService, 'Claude API set up'),
7809
8074
  claudeCloudRepoService,
7810
8075
  // The login's state for the workers' nudge route (never the token).
7811
8076
  claudeCloudLoginStatus: { describe: () => claudeCloudCredentials.describe() },
@@ -7826,6 +8091,15 @@ const sharedRouteDeps = {
7826
8091
  listModelVariantRows,
7827
8092
  refreshModelVariantCatalogFromCli,
7828
8093
  setEnabledModelVariants,
8094
+ getCopilotModelState: modelVariantCatalog.getCopilotModelState,
8095
+ setCopilotAutoEnableNew: modelVariantCatalog.setCopilotAutoEnableNew,
8096
+ markCopilotModelsSeen: modelVariantCatalog.markCopilotModelsSeen,
8097
+ markProviderModelsSeen: (provider) => providerModelState.markSeen(provider),
8098
+ modelRefreshLedger,
8099
+ modelRefreshSettings,
8100
+ modelRefreshScheduler,
8101
+ isClaudeModelRefreshSetUp,
8102
+ isGrokModelRefreshSetUp,
7829
8103
  parseModelVariantSelection,
7830
8104
  SUPPORTED_REASONING_EFFORTS,
7831
8105
  buildRelayReadyBannerData,
@@ -7987,6 +8261,7 @@ const sharedRouteDeps = {
7987
8261
  shareRepository,
7988
8262
  };
7989
8263
  registerMessagesRoutes(app, sharedRouteDeps);
8264
+ registerAgentSdkRoutes(app, { auth, io, agentSdkStore });
7990
8265
  registerSessionsRoutes(app, sharedRouteDeps);
7991
8266
  registerConversationGroupRoutes(app, sharedRouteDeps);
7992
8267
  registerAskUserRoutes(app, sharedRouteDeps);
@@ -8073,6 +8348,7 @@ registerAboutRoutes(app, {
8073
8348
  isRelayCliOnline: () => runtimeState.cliOnline === true,
8074
8349
  listWorkers: () => sessionWorkerRegistry.listWorkers(),
8075
8350
  getUpdateState: () => buildUpdateStatePayload(),
8351
+ getAgentSdkStatus: () => agentSdkStore.snapshot(),
8076
8352
  describeBackups: () => describeBackups({ layout: STATE_LAYOUT, serverDir: __dirname, dataDir: DATA_DIR, config, sizes: false }),
8077
8353
  }),
8078
8354
  });
@@ -8219,7 +8495,7 @@ if (runtimeBindingsBootstrapped > 0) {
8219
8495
 
8220
8496
  function ts() { return new Date().toISOString().slice(11, 23); }
8221
8497
  function runtimeLogPrefix() {
8222
- return ttyConsoleRuntime?.tty ? '' : `[${ts()}] `;
8498
+ return `[${ts()}] `;
8223
8499
  }
8224
8500
 
8225
8501
  // ─── Auth Middleware ──────────────────────────────────────────────────────────
@@ -8719,12 +8995,17 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
8719
8995
  const discoveryShutdown = copilotModelDiscoveryService.dispose().catch((error) => {
8720
8996
  console.warn(`${runtimeLogPrefix()}Copilot model discovery shutdown failed: ${error?.message || error}`);
8721
8997
  });
8998
+ // Stops the timer at once; a check in flight ends with the discoveries above.
8999
+ void modelRefreshScheduler.dispose();
8722
9000
  try { claudeAuthService.dispose(); } catch (error) {
8723
9001
  console.warn(`${runtimeLogPrefix()}Claude auth service shutdown failed: ${error?.message || error}`);
8724
9002
  }
8725
9003
  try { cliInstallService.dispose(); } catch (error) {
8726
9004
  console.warn(`${runtimeLogPrefix()}CLI install service shutdown failed: ${error?.message || error}`);
8727
9005
  }
9006
+ try { agentSdkStore.dispose(); } catch (error) {
9007
+ console.warn(`${runtimeLogPrefix()}agent SDK store shutdown failed: ${error?.message || error}`);
9008
+ }
8728
9009
  try { grokAuthService.dispose(); } catch (error) {
8729
9010
  console.warn(`${runtimeLogPrefix()}Grok auth service shutdown failed: ${error?.message || error}`);
8730
9011
  }
@@ -8899,6 +9180,20 @@ httpServer.listen(config.port, listenHost, () => {
8899
9180
  // Deferred and unref'd (a few seconds behind the startup import's own CLI
8900
9181
  // spawn); a relay without an installed Copilot CLI skips it entirely.
8901
9182
  copilotModelDiscoveryService.scheduleStartupRefresh();
9183
+ // "Check for new models": a first look a minute in (overdue providers),
9184
+ // then every quarter of an hour; unref'd.
9185
+ modelRefreshScheduler.start();
9186
+ // At start: the SDKs of the providers in use, and koffi on
9187
+ // Windows, installed in the background when their pinned folder is
9188
+ // missing (after an update: the new pins); the start never waits for it.
9189
+ // A git checkout runs the devDependencies it has installed and leaves
9190
+ // the store alone for those.
9191
+ const storeSdks = agentSdkStore.ids().filter((sdk) => !agentSdkFromCheckout(sdk));
9192
+ if (storeSdks.length) {
9193
+ void agentSdkStore.reconcile({ inUse: agentSdksInUse().filter((sdk) => storeSdks.includes(sdk)) }).catch((error) => {
9194
+ console.warn(`[agent-sdk] start-up check failed: ${error?.message || error}`);
9195
+ });
9196
+ }
8902
9197
  // Archived conversations' workers: stopped once, 30 s in, so the workers
8903
9198
  // that reconnect after the restart and the first prime have settled. With
8904
9199
  // routing off the relay launches no workers, so there is nothing to sweep.