@oar-sh/oar 0.9.23 → 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 (59) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +54 -4
  4. package/package.json +9 -7
  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-preview-tool.mjs +5 -2
  17. package/server/claude-worker/claude-remote-relay-tool.mjs +5 -2
  18. package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +7 -4
  20. package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
  21. package/server/public/app/agent-sdk-ui.js +349 -0
  22. package/server/public/app/api-client.js +91 -8
  23. package/server/public/app/bootstrap.js +204 -69
  24. package/server/public/app/model-refresh-ui.mjs +413 -0
  25. package/server/public/app/no-conversation-state.mjs +5 -2
  26. package/server/public/app/provider-readiness.mjs +67 -4
  27. package/server/public/app/settings-modal.js +167 -0
  28. package/server/public/app/socket-handlers.js +21 -0
  29. package/server/public/index.html +95 -2
  30. package/server/routes/agent-sdk-routes.mjs +73 -0
  31. package/server/routes/messages-routes.mjs +91 -17
  32. package/server/routes/sessions-routes.mjs +235 -109
  33. package/server/server-runtime.mjs +395 -81
  34. package/server/services/about-service.mjs +26 -6
  35. package/server/services/agent-sdk-launch-gate.mjs +124 -0
  36. package/server/services/agent-sdk-paths.mjs +307 -0
  37. package/server/services/agent-sdk-store.mjs +1211 -0
  38. package/server/services/claude-api-settings-service.mjs +54 -17
  39. package/server/services/cli-install-service.mjs +3 -2
  40. package/server/services/copilot-model-discovery-service.mjs +7 -1
  41. package/server/services/database-file.mjs +17 -1
  42. package/server/services/deepseek-settings-service.mjs +58 -20
  43. package/server/services/earlier-database-service.mjs +4 -2
  44. package/server/services/model-refresh-scheduler.mjs +153 -0
  45. package/server/services/model-variant-catalog-service.mjs +141 -9
  46. package/server/services/oar-cli-helpers.mjs +72 -9
  47. package/server/services/oar-state-migration-service.mjs +2 -12
  48. package/server/services/oar-state-root.mjs +17 -0
  49. package/server/services/provider-model-refresh.mjs +326 -0
  50. package/server/services/provider-model-state.mjs +395 -0
  51. package/server/services/relay-singleton-guard.mjs +72 -10
  52. package/server/services/session-worker-launch-service.mjs +13 -0
  53. package/server/services/session-worker-supervisor-service.mjs +19 -1
  54. package/server/services/session-worker-websocket-service.mjs +15 -0
  55. package/server/services/update-install-service.mjs +2 -2
  56. package/server/services/worker-launch-note-service.mjs +14 -2
  57. package/server/services/worker-recycle-service.mjs +145 -11
  58. package/shared/model-refresh-schedule.mjs +120 -0
  59. package/shared/worker-runtime/direct-openai-image.mjs +8 -4
@@ -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 {
@@ -375,6 +383,29 @@ for (const dir of UPLOAD_READ_DIRS.slice(1)) {
375
383
  } catch {}
376
384
  }
377
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
+
378
409
  async function refreshOpenAIProviderModels() {
379
410
  const settings = getOpenAIProviderSettings();
380
411
  if (!settings.enabled) return { ok: false, models: [], error: 'OpenAI API key is not configured' };
@@ -410,7 +441,8 @@ async function refreshOpenAIProviderModels() {
410
441
  .filter((modelId) => isOpenAIModelId(modelId)),
411
442
  ])).filter(Boolean);
412
443
  stmts.upsertAppSetting.run(OPENAI_MODELS_SETTING_KEY, JSON.stringify(models), new Date().toISOString());
413
- return { ok: true, models, error: null };
444
+ const { added } = providerModelState.reconcile('openai', { availableModels: getOpenAIProviderSettings().availableModels });
445
+ return { ok: true, models, newModels: added, error: null };
414
446
  }
415
447
 
416
448
  function getOpenAIProviderSettings() {
@@ -427,12 +459,21 @@ function getOpenAIProviderSettings() {
427
459
  } catch {
428
460
  models = [];
429
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 });
430
466
  return {
431
467
  configured: !!apiKey,
432
468
  enabled,
433
469
  apiKey,
434
470
  model,
435
- 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,
436
477
  baseUrl: readAppSettingValue(OPENAI_BASE_URL_SETTING_KEY) || DEFAULT_OPENAI_BASE_URL,
437
478
  providerType: 'openai',
438
479
  };
@@ -443,6 +484,7 @@ function setOpenAIProviderSettings(update = {}) {
443
484
  const apiKey = payload.apiKey;
444
485
  const model = payload.model;
445
486
  const enabled = payload.enabled;
487
+ const enabledModels = payload.enabledModels;
446
488
  const remove = payload.remove === true;
447
489
  const hasBaseUrl = Object.prototype.hasOwnProperty.call(payload, 'baseUrl');
448
490
  const baseUrl = hasBaseUrl ? payload.baseUrl : '';
@@ -481,6 +523,7 @@ function setOpenAIProviderSettings(update = {}) {
481
523
  activeQueueConversationCount,
482
524
  };
483
525
  }
526
+ // The model map stays: a key added again finds its models as they were.
484
527
  stmts.deleteAppSetting.run(OPENAI_API_KEY_SETTING_KEY);
485
528
  stmts.deleteAppSetting.run(OPENAI_MODELS_SETTING_KEY);
486
529
  stmts.upsertAppSetting.run(OPENAI_ENABLED_SETTING_KEY, 'false', nowIso);
@@ -504,6 +547,21 @@ function setOpenAIProviderSettings(update = {}) {
504
547
  stmts.deleteAppSetting.run(OPENAI_BASE_URL_SETTING_KEY);
505
548
  }
506
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
+ }
507
565
  }
508
566
  const current = getOpenAIProviderSettings();
509
567
  return {
@@ -511,6 +569,9 @@ function setOpenAIProviderSettings(update = {}) {
511
569
  configured: current.configured,
512
570
  enabled: current.enabled,
513
571
  model: current.model,
572
+ models: current.models,
573
+ availableModels: current.availableModels,
574
+ autoEnableNew: current.autoEnableNew,
514
575
  baseUrl: current.baseUrl,
515
576
  };
516
577
  }
@@ -554,15 +615,14 @@ function getClaudeProviderSettings() {
554
615
  model,
555
616
  ...(discovered.length ? discovered : DEFAULT_CLAUDE_MODELS),
556
617
  ])).filter(Boolean);
557
- const availableSet = new Set(availableModels);
558
- const enabledSelection = readClaudeModelListSetting(CLAUDE_ENABLED_MODELS_SETTING_KEY)
559
- .filter((modelId) => availableSet.has(modelId));
560
- // The enabled subset (chosen in the Select Models modal) drives the model
561
- // catalog and composers; an empty selection means "all available".
562
- const models = Array.from(new Set([
563
- model,
564
- ...(enabledSelection.length ? enabledSelection : availableModels),
565
- ])).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;
566
626
  const storedEfforts = readClaudeModelEffortsSetting();
567
627
  const effortsByModel = {};
568
628
  for (const availableModel of availableModels) {
@@ -586,6 +646,9 @@ function getClaudeProviderSettings() {
586
646
  models,
587
647
  availableModels,
588
648
  enabledModels: models,
649
+ autoEnableNew: enabledModels.autoEnableNew,
650
+ fallback: enabledModels.fallback,
651
+ unseenModels: enabledModels.unseen,
589
652
  effortsByModel,
590
653
  modelsDiscovered,
591
654
  attributionMode: normalizeClaudeAttributionMode(readAppSettingValue(CLAUDE_ATTRIBUTION_SETTING_KEY)) || DEFAULT_CLAUDE_ATTRIBUTION_MODE,
@@ -644,15 +707,20 @@ function setClaudeProviderSettings(update = {}) {
644
707
  stmts.deleteAppSetting.run(CLAUDE_MODELS_SETTING_KEY);
645
708
  }
646
709
  }
647
- if (Array.isArray(enabledModels)) {
648
- const normalizedEnabledModels = enabledModels
649
- .map((value) => String(value || '').trim())
650
- .filter((value) => value && isSafeClaudeModelId(value));
651
- if (normalizedEnabledModels.length) {
652
- stmts.upsertAppSetting.run(CLAUDE_ENABLED_MODELS_SETTING_KEY, JSON.stringify(normalizedEnabledModels), nowIso);
653
- } else {
654
- stmts.deleteAppSetting.run(CLAUDE_ENABLED_MODELS_SETTING_KEY);
655
- }
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
+ });
656
724
  }
657
725
  const current = getClaudeProviderSettings();
658
726
  return {
@@ -662,6 +730,7 @@ function setClaudeProviderSettings(update = {}) {
662
730
  model: current.model,
663
731
  models: current.models,
664
732
  availableModels: current.availableModels,
733
+ autoEnableNew: current.autoEnableNew,
665
734
  attributionMode: current.attributionMode,
666
735
  };
667
736
  }
@@ -709,7 +778,7 @@ async function refreshClaudeProviderModels() {
709
778
  let claudeQuery = null;
710
779
  let releaseInputGate = () => {};
711
780
  try {
712
- const { query } = await import('@anthropic-ai/claude-agent-sdk');
781
+ const { query } = await loadAgentSdk('claude');
713
782
  const inputGate = new Promise((resolve) => { releaseInputGate = resolve; });
714
783
  claudeQuery = query({
715
784
  // Streaming-input mode with a gated (never-yielding) prompt keeps the
@@ -762,8 +831,13 @@ async function refreshClaudeProviderModels() {
762
831
  const mergedEfforts = mergeClaudeModelEfforts(readClaudeModelEffortsSetting(), effortsByModel);
763
832
  stmts.upsertAppSetting.run(CLAUDE_MODELS_SETTING_KEY, JSON.stringify(models), nowIso);
764
833
  stmts.upsertAppSetting.run(CLAUDE_MODEL_EFFORTS_SETTING_KEY, JSON.stringify(mergedEfforts), nowIso);
765
- return { ok: true, models, error: null };
834
+ const { added } = providerModelState.reconcile('claude', { availableModels: getClaudeProviderSettings().availableModels });
835
+ return { ok: true, models, newModels: added, error: null };
766
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
+ }
767
841
  return {
768
842
  ok: false,
769
843
  models: settings.models,
@@ -827,24 +901,19 @@ function getCursorProviderSettings() {
827
901
  models = [];
828
902
  }
829
903
  const availableModels = Array.from(new Set([model, ...(models.length ? models : DEFAULT_CURSOR_MODELS)]));
830
- const availableSet = new Set(availableModels);
831
- let enabledSelection = [];
832
- try {
833
- const parsed = JSON.parse(readAppSettingValue(CURSOR_ENABLED_MODELS_SETTING_KEY) || '[]');
834
- if (Array.isArray(parsed)) {
835
- enabledSelection = parsed
836
- .map((value) => String(value || '').trim())
837
- .filter((modelId) => isSafeCursorModelId(modelId) && availableSet.has(modelId));
838
- }
839
- } catch {
840
- enabledSelection = [];
841
- }
842
- // The enabled subset (chosen in the Select Models modal) drives the model
843
- // catalog and composers; an empty selection means "all available".
844
- const resolvedModels = Array.from(new Set([
845
- model,
846
- ...(enabledSelection.length ? enabledSelection : availableModels),
847
- ])).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;
848
917
  const storedEfforts = readCursorModelEffortsSetting();
849
918
  const storedReasoningOff = readCursorModelReasoningOffSetting();
850
919
  const effortsByModel = {};
@@ -868,6 +937,9 @@ function getCursorProviderSettings() {
868
937
  models: resolvedModels,
869
938
  availableModels,
870
939
  enabledModels: resolvedModels,
940
+ autoEnableNew: enabledModels.autoEnableNew,
941
+ fallback: enabledModels.fallback,
942
+ unseenModels: enabledModels.unseen,
871
943
  effortsByModel,
872
944
  reasoningOffByModel,
873
945
  // Pre-effort installs have models but no efforts setting; the settings
@@ -1109,9 +1181,9 @@ function setCursorProviderSettings(update = {}) {
1109
1181
  activeQueueConversationCount,
1110
1182
  };
1111
1183
  }
1184
+ // The model map stays: a key added again finds its models as they were.
1112
1185
  stmts.deleteAppSetting.run(CURSOR_API_KEY_SETTING_KEY);
1113
1186
  stmts.deleteAppSetting.run(CURSOR_MODELS_SETTING_KEY);
1114
- stmts.deleteAppSetting.run(CURSOR_ENABLED_MODELS_SETTING_KEY);
1115
1187
  stmts.deleteAppSetting.run(CURSOR_MODEL_EFFORTS_SETTING_KEY);
1116
1188
  stmts.deleteAppSetting.run(CURSOR_MODEL_REASONING_OFF_SETTING_KEY);
1117
1189
  stmts.upsertAppSetting.run(CURSOR_ENABLED_SETTING_KEY, 'false', nowIso);
@@ -1129,15 +1201,20 @@ function setCursorProviderSettings(update = {}) {
1129
1201
  if (nextEnabled && !hasApiKey) return { ok: false, error: 'Cursor API key is not configured' };
1130
1202
  stmts.upsertAppSetting.run(CURSOR_ENABLED_SETTING_KEY, nextEnabled ? 'true' : 'false', nowIso);
1131
1203
  stmts.upsertAppSetting.run(CURSOR_MODEL_SETTING_KEY, normalizedModel, nowIso);
1132
- if (Array.isArray(enabledModels)) {
1133
- const normalizedEnabledModels = enabledModels
1134
- .map((value) => String(value || '').trim())
1135
- .filter((value) => value && isSafeCursorModelId(value));
1136
- if (normalizedEnabledModels.length) {
1137
- stmts.upsertAppSetting.run(CURSOR_ENABLED_MODELS_SETTING_KEY, JSON.stringify(normalizedEnabledModels), nowIso);
1138
- } else {
1139
- stmts.deleteAppSetting.run(CURSOR_ENABLED_MODELS_SETTING_KEY);
1140
- }
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
+ });
1141
1218
  }
1142
1219
  }
1143
1220
  const current = getCursorProviderSettings();
@@ -1148,6 +1225,7 @@ function setCursorProviderSettings(update = {}) {
1148
1225
  model: current.model,
1149
1226
  models: current.models,
1150
1227
  availableModels: current.availableModels,
1228
+ autoEnableNew: current.autoEnableNew,
1151
1229
  };
1152
1230
  }
1153
1231
 
@@ -1204,7 +1282,7 @@ async function refreshCursorProviderModels() {
1204
1282
  return { ok: false, models: settings.models, error: 'Cursor API key is not configured' };
1205
1283
  }
1206
1284
  try {
1207
- const mod = await import('@cursor/sdk');
1285
+ const mod = await loadAgentSdk('cursor');
1208
1286
  // The namespace form is the only working call shape: `Cursor` has a
1209
1287
  // private constructor and `models` is static, so the old client-instance
1210
1288
  // fallback could only throw a TypeError that replaced the real error.
@@ -1243,8 +1321,12 @@ async function refreshCursorProviderModels() {
1243
1321
  stmts.upsertAppSetting.run(CURSOR_MODELS_SETTING_KEY, JSON.stringify(models), nowIso);
1244
1322
  stmts.upsertAppSetting.run(CURSOR_MODEL_EFFORTS_SETTING_KEY, JSON.stringify(effortsByModel), nowIso);
1245
1323
  stmts.upsertAppSetting.run(CURSOR_MODEL_REASONING_OFF_SETTING_KEY, JSON.stringify(reasoningOffByModel), nowIso);
1246
- return { ok: true, models, error: null };
1324
+ const { added } = providerModelState.reconcile('cursor', { availableModels: getCursorProviderSettings().availableModels });
1325
+ return { ok: true, models, newModels: added, error: null };
1247
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
+ }
1248
1330
  return {
1249
1331
  ok: false,
1250
1332
  models: settings.models,
@@ -1294,13 +1376,12 @@ function getGrokProviderSettings() {
1294
1376
  model,
1295
1377
  ...(discovered.length ? discovered : DEFAULT_GROK_MODELS),
1296
1378
  ])).filter(Boolean);
1297
- const availableSet = new Set(availableModels);
1298
- const enabledSelection = readGrokModelListSetting(GROK_ENABLED_MODELS_SETTING_KEY)
1299
- .filter((modelId) => availableSet.has(modelId));
1300
- const models = Array.from(new Set([
1301
- model,
1302
- ...(enabledSelection.length ? enabledSelection : availableModels),
1303
- ])).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;
1304
1385
  const storedEfforts = readGrokModelEffortsSetting();
1305
1386
  const effortsByModel = {};
1306
1387
  for (const availableModel of availableModels) {
@@ -1319,6 +1400,9 @@ function getGrokProviderSettings() {
1319
1400
  models,
1320
1401
  availableModels,
1321
1402
  enabledModels: models,
1403
+ autoEnableNew: enabledModels.autoEnableNew,
1404
+ fallback: enabledModels.fallback,
1405
+ unseenModels: enabledModels.unseen,
1322
1406
  effortsByModel,
1323
1407
  modelsDiscovered,
1324
1408
  providerType: 'grok',
@@ -1353,15 +1437,20 @@ function setGrokProviderSettings(update = {}) {
1353
1437
  stmts.deleteAppSetting?.run?.(GROK_MODELS_SETTING_KEY);
1354
1438
  }
1355
1439
  }
1356
- if (Array.isArray(enabledModels)) {
1357
- const normalizedEnabledModels = enabledModels
1358
- .map((value) => String(value || '').trim())
1359
- .filter((value) => value && isSafeGrokModelId(value));
1360
- if (normalizedEnabledModels.length) {
1361
- stmts.upsertAppSetting.run(GROK_ENABLED_MODELS_SETTING_KEY, JSON.stringify(normalizedEnabledModels), nowIso);
1362
- } else {
1363
- stmts.deleteAppSetting?.run?.(GROK_ENABLED_MODELS_SETTING_KEY);
1364
- }
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
+ });
1365
1454
  }
1366
1455
  const current = getGrokProviderSettings();
1367
1456
  return {
@@ -1371,6 +1460,7 @@ function setGrokProviderSettings(update = {}) {
1371
1460
  model: current.model,
1372
1461
  models: current.models,
1373
1462
  availableModels: current.availableModels,
1463
+ autoEnableNew: current.autoEnableNew,
1374
1464
  };
1375
1465
  }
1376
1466
 
@@ -1452,7 +1542,8 @@ async function refreshGrokProviderModels() {
1452
1542
  const models = Array.from(new Set([settings.model, ...discovered])).filter(Boolean);
1453
1543
  stmts.upsertAppSetting.run(GROK_MODELS_SETTING_KEY, JSON.stringify(models), nowIso);
1454
1544
  stmts.upsertAppSetting.run(GROK_MODEL_EFFORTS_SETTING_KEY, JSON.stringify(effortsByModel), nowIso);
1455
- return { ok: true, models, error: null };
1545
+ const { added } = providerModelState.reconcile('grok', { availableModels: getGrokProviderSettings().availableModels });
1546
+ return { ok: true, models, newModels: added, error: null };
1456
1547
  } catch (error) {
1457
1548
  return {
1458
1549
  ok: false,
@@ -1509,6 +1600,8 @@ const FALLBACK_COPILOT_ENGINE = 'extension';
1509
1600
  const CLAUDE_ENABLED_SETTING_KEY = 'claude_enabled';
1510
1601
  const CLAUDE_MODEL_SETTING_KEY = 'claude_model';
1511
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.
1512
1605
  const CLAUDE_ENABLED_MODELS_SETTING_KEY = 'claude_enabled_models';
1513
1606
  const CLAUDE_MODEL_EFFORTS_SETTING_KEY = 'claude_model_efforts';
1514
1607
  // oar | vanilla | off — what commits and PRs made in Claude sessions say
@@ -2678,6 +2771,10 @@ const modelVariantCatalog = createModelVariantCatalogService({
2678
2771
  curatedModelIds: CURATED_MODEL_IDS,
2679
2772
  autoModelSentinel: AUTO_MODEL_SENTINEL,
2680
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 }),
2681
2778
  });
2682
2779
  const {
2683
2780
  SUPPORTED_REASONING_EFFORTS,
@@ -2990,6 +3087,7 @@ const deepseekSettingsService = createDeepSeekSettingsService({
2990
3087
  },
2991
3088
  deleteSetting: (key) => stmts.deleteAppSetting.run(key),
2992
3089
  listProviderCandidates: () => stmts.listRuntimeSessionProviderCandidates?.all?.() || [],
3090
+ onModelsRefreshed: (result) => modelRefreshLedger.record('deepseek', result),
2993
3091
  });
2994
3092
  function getDeepSeekProviderSettings() {
2995
3093
  return deepseekSettingsService.getSettings();
@@ -3007,6 +3105,7 @@ const claudeApiSettingsService = createClaudeApiSettingsService({
3007
3105
  listProviderCandidates: () => stmts.listRuntimeSessionProviderCandidates?.all?.() || [],
3008
3106
  countLiveKeyWorkers: () => (sessionWorkerRegistry?.listWorkers?.() || []).filter((worker) => worker?.kind === 'claude-api').length,
3009
3107
  getClaudeProviderSettings: () => getClaudeProviderSettings(),
3108
+ onModelsRefreshed: (result) => modelRefreshLedger.record('claude-api', result),
3010
3109
  });
3011
3110
  function getClaudeApiProviderSettings() {
3012
3111
  return claudeApiSettingsService.getSettings();
@@ -3096,6 +3195,44 @@ try {
3096
3195
  console.warn(`[cli-install] failed to apply persisted CLI bindings: ${error?.message || error}`);
3097
3196
  }
3098
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
+
3099
3236
  // ─── Web Push ─────────────────────────────────────────────────────────────────
3100
3237
  // VAPID keys are generated once and persisted in app_settings; regenerating
3101
3238
  // them would invalidate every stored subscription.
@@ -3383,6 +3520,20 @@ const relayCliLauncherService = createRelayCliLauncherService({
3383
3520
  workerScope,
3384
3521
  });
3385
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
+
3386
3537
  async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true } = {}) {
3387
3538
  const normalizedTargetSessionId = String(targetSessionId || '').trim();
3388
3539
  if (!normalizedTargetSessionId) {
@@ -3413,6 +3564,11 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
3413
3564
  }
3414
3565
  const resolvedWorkspaceRoot = resolveLaunchWorkspaceRootForSession(normalizedTargetSessionId);
3415
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;
3416
3572
  const launched = await launchSessionCli({
3417
3573
  workerScope,
3418
3574
  targetSessionId: normalizedTargetSessionId,
@@ -3427,6 +3583,9 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
3427
3583
  log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
3428
3584
  });
3429
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);
3430
3589
  const workerId = `worker-${normalizedTargetSessionId.slice(0, 8)}`;
3431
3590
  const launchMode = String(launched?.launchMode || 'detached').trim();
3432
3591
  const tmuxSessionName = String(launched?.tmuxSessionName || '').trim();
@@ -6259,6 +6418,7 @@ const copilotModelDiscoveryService = createCopilotModelDiscoveryService({
6259
6418
  }),
6260
6419
  resolveInstalledPaths: () => resolveInstalledCopilotPaths({ config }),
6261
6420
  updateModelCatalog,
6421
+ onRefreshed: (result) => modelRefreshLedger.record('github', result),
6262
6422
  logger: console,
6263
6423
  });
6264
6424
  const contextSnapshotService = createContextSnapshotService({
@@ -7085,9 +7245,51 @@ const workerRecycleService = createWorkerRecycleService({
7085
7245
  // The stop and the launch under the per-session launch mutex the Change
7086
7246
  // CWD relaunch and the launch routes take (sessions-routes.mjs).
7087
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) }),
7088
7251
  logger: console,
7089
7252
  });
7090
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
+
7091
7293
  // Once at start, after the restart's own worker reconnects settled: what
7092
7294
  // still runs for an archived conversation is stopped and stays stopped.
7093
7295
  // Through the recycle's replacement, one session at a time; never throws.
@@ -7322,6 +7524,9 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
7322
7524
  const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
7323
7525
  if (worker && worker.buildId !== buildId) sessionWorkerRegistry?.upsertWorker?.({ ...worker, buildId });
7324
7526
  },
7527
+ // Registers the process and runs the recycle's sweep before the hello's
7528
+ // request for work (noteSessionWorkerHello).
7529
+ onWorkerHello: noteSessionWorkerHello,
7325
7530
  // Sent in server.hello, so a worker on other code can say so in its log.
7326
7531
  buildId: BUILD_ID,
7327
7532
  isWorkerProcessAlive: (pid) => isProcessAlive(Number(pid)),
@@ -7710,6 +7915,85 @@ const remoteRelayDispatcher = createRemoteRelayDispatcher({
7710
7915
  logger: console,
7711
7916
  });
7712
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
+
7713
7997
  const sharedRouteDeps = {
7714
7998
  auth,
7715
7999
  isTrulyLocalRequest: localRequestCheck.isTrulyLocal,
@@ -7766,27 +8050,27 @@ const sharedRouteDeps = {
7766
8050
  setDefaultSessionWorkspaceRootPath,
7767
8051
  getOpenAIProviderSettings,
7768
8052
  setOpenAIProviderSettings,
7769
- refreshOpenAIProviderModels,
8053
+ refreshOpenAIProviderModels: trackedModelRefreshers.refreshOpenAIProviderModels,
7770
8054
  getCopilotProviderSettings,
7771
8055
  setCopilotProviderSettings,
7772
8056
  getClaudeProviderSettings,
7773
- setClaudeProviderSettings,
7774
- refreshClaudeProviderModels,
8057
+ setClaudeProviderSettings: withAgentSdkSetup(setClaudeProviderSettings, 'claude', () => getClaudeProviderSettings()?.enabled === true, 'Claude enabled'),
8058
+ refreshClaudeProviderModels: trackedModelRefreshers.refreshClaudeProviderModels,
7775
8059
  getCursorProviderSettings,
7776
- setCursorProviderSettings,
7777
- refreshCursorProviderModels,
8060
+ setCursorProviderSettings: withAgentSdkSetup(setCursorProviderSettings, 'cursor', () => getCursorProviderSettings()?.enabled === true, 'Cursor key saved'),
8061
+ refreshCursorProviderModels: trackedModelRefreshers.refreshCursorProviderModels,
7778
8062
  getGrokProviderSettings,
7779
8063
  setGrokProviderSettings,
7780
- refreshGrokProviderModels,
8064
+ refreshGrokProviderModels: trackedModelRefreshers.refreshGrokProviderModels,
7781
8065
  getClaudeCloudProviderSettings,
7782
8066
  claudeCloudSettingsService,
7783
8067
  claudeCloudSessionService,
7784
8068
  getDeepSeekProviderSettings,
7785
- deepseekSettingsService,
8069
+ deepseekSettingsService: withAgentSdkUpdate(deepseekSettingsService, 'DeepSeek set up'),
7786
8070
  deepseekBalanceService,
7787
8071
  deepseekUsageImportService,
7788
8072
  getClaudeApiProviderSettings,
7789
- claudeApiSettingsService,
8073
+ claudeApiSettingsService: withAgentSdkUpdate(claudeApiSettingsService, 'Claude API set up'),
7790
8074
  claudeCloudRepoService,
7791
8075
  // The login's state for the workers' nudge route (never the token).
7792
8076
  claudeCloudLoginStatus: { describe: () => claudeCloudCredentials.describe() },
@@ -7807,6 +8091,15 @@ const sharedRouteDeps = {
7807
8091
  listModelVariantRows,
7808
8092
  refreshModelVariantCatalogFromCli,
7809
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,
7810
8103
  parseModelVariantSelection,
7811
8104
  SUPPORTED_REASONING_EFFORTS,
7812
8105
  buildRelayReadyBannerData,
@@ -7968,6 +8261,7 @@ const sharedRouteDeps = {
7968
8261
  shareRepository,
7969
8262
  };
7970
8263
  registerMessagesRoutes(app, sharedRouteDeps);
8264
+ registerAgentSdkRoutes(app, { auth, io, agentSdkStore });
7971
8265
  registerSessionsRoutes(app, sharedRouteDeps);
7972
8266
  registerConversationGroupRoutes(app, sharedRouteDeps);
7973
8267
  registerAskUserRoutes(app, sharedRouteDeps);
@@ -8054,6 +8348,7 @@ registerAboutRoutes(app, {
8054
8348
  isRelayCliOnline: () => runtimeState.cliOnline === true,
8055
8349
  listWorkers: () => sessionWorkerRegistry.listWorkers(),
8056
8350
  getUpdateState: () => buildUpdateStatePayload(),
8351
+ getAgentSdkStatus: () => agentSdkStore.snapshot(),
8057
8352
  describeBackups: () => describeBackups({ layout: STATE_LAYOUT, serverDir: __dirname, dataDir: DATA_DIR, config, sizes: false }),
8058
8353
  }),
8059
8354
  });
@@ -8700,12 +8995,17 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
8700
8995
  const discoveryShutdown = copilotModelDiscoveryService.dispose().catch((error) => {
8701
8996
  console.warn(`${runtimeLogPrefix()}Copilot model discovery shutdown failed: ${error?.message || error}`);
8702
8997
  });
8998
+ // Stops the timer at once; a check in flight ends with the discoveries above.
8999
+ void modelRefreshScheduler.dispose();
8703
9000
  try { claudeAuthService.dispose(); } catch (error) {
8704
9001
  console.warn(`${runtimeLogPrefix()}Claude auth service shutdown failed: ${error?.message || error}`);
8705
9002
  }
8706
9003
  try { cliInstallService.dispose(); } catch (error) {
8707
9004
  console.warn(`${runtimeLogPrefix()}CLI install service shutdown failed: ${error?.message || error}`);
8708
9005
  }
9006
+ try { agentSdkStore.dispose(); } catch (error) {
9007
+ console.warn(`${runtimeLogPrefix()}agent SDK store shutdown failed: ${error?.message || error}`);
9008
+ }
8709
9009
  try { grokAuthService.dispose(); } catch (error) {
8710
9010
  console.warn(`${runtimeLogPrefix()}Grok auth service shutdown failed: ${error?.message || error}`);
8711
9011
  }
@@ -8880,6 +9180,20 @@ httpServer.listen(config.port, listenHost, () => {
8880
9180
  // Deferred and unref'd (a few seconds behind the startup import's own CLI
8881
9181
  // spawn); a relay without an installed Copilot CLI skips it entirely.
8882
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
+ }
8883
9197
  // Archived conversations' workers: stopped once, 30 s in, so the workers
8884
9198
  // that reconnect after the restart and the first prime have settled. With
8885
9199
  // routing off the relay launches no workers, so there is nothing to sweep.