@oar-sh/oar 0.9.23 → 0.9.25

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 (110) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +307 -0
  3. package/README.md +12 -11
  4. package/bin/oar.js +54 -4
  5. package/package.json +11 -7
  6. package/server/agent-sdk-loader.mjs +116 -0
  7. package/server/agent-sdks/claude/entry.mjs +4 -0
  8. package/server/agent-sdks/claude/package-lock.json +1438 -0
  9. package/server/agent-sdks/claude/package.json +13 -0
  10. package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
  11. package/server/agent-sdks/cursor/entry.mjs +3 -0
  12. package/server/agent-sdks/cursor/package-lock.json +171 -0
  13. package/server/agent-sdks/cursor/package.json +11 -0
  14. package/server/agent-sdks/koffi/entry.cjs +2 -0
  15. package/server/agent-sdks/koffi/package-lock.json +368 -0
  16. package/server/agent-sdks/koffi/package.json +10 -0
  17. package/server/claude-worker/claude-preview-tool.mjs +5 -2
  18. package/server/claude-worker/claude-remote-relay-tool.mjs +20 -2
  19. package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
  20. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +13 -8
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
  23. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  24. package/server/db-schema.mjs +19 -0
  25. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  26. package/server/migrations/0039-media-index.mjs +167 -0
  27. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  28. package/server/public/app/agent-sdk-ui.js +349 -0
  29. package/server/public/app/api-client.js +149 -8
  30. package/server/public/app/ask-user-view.js +2 -1
  31. package/server/public/app/attachments-view.js +66 -9
  32. package/server/public/app/bootstrap.js +289 -94
  33. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  34. package/server/public/app/context-media-tab.js +143 -0
  35. package/server/public/app/conversation-view.js +90 -5
  36. package/server/public/app/image-composer-ui.js +518 -0
  37. package/server/public/app/image-turn-placeholder.mjs +233 -0
  38. package/server/public/app/journal-view.js +12 -2
  39. package/server/public/app/media-gallery-model.mjs +202 -0
  40. package/server/public/app/media-gallery-ui.js +524 -0
  41. package/server/public/app/media-view.js +79 -0
  42. package/server/public/app/model-refresh-ui.mjs +413 -0
  43. package/server/public/app/no-conversation-state.mjs +5 -2
  44. package/server/public/app/openai-image-composer.mjs +492 -0
  45. package/server/public/app/openai-image-cost-ui.js +426 -0
  46. package/server/public/app/openai-image-spend.mjs +443 -0
  47. package/server/public/app/plan-usage-view.mjs +38 -6
  48. package/server/public/app/provider-readiness.mjs +67 -4
  49. package/server/public/app/relay-error-ctas.mjs +5 -0
  50. package/server/public/app/session-history-ui.js +2 -0
  51. package/server/public/app/settings-modal.js +171 -0
  52. package/server/public/app/socket-handlers.js +47 -0
  53. package/server/public/app/tab-strip-fade.mjs +1 -1
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +478 -9
  56. package/server/relay-tools.md +1 -0
  57. package/server/repositories/image-conversation-repository.mjs +69 -0
  58. package/server/repositories/media-repository.mjs +305 -0
  59. package/server/routes/agent-sdk-routes.mjs +73 -0
  60. package/server/routes/media-routes.mjs +361 -0
  61. package/server/routes/messages-routes.mjs +1467 -343
  62. package/server/routes/sessions-routes.mjs +596 -123
  63. package/server/server-runtime.mjs +414 -81
  64. package/server/services/about-service.mjs +26 -6
  65. package/server/services/agent-sdk-launch-gate.mjs +124 -0
  66. package/server/services/agent-sdk-paths.mjs +307 -0
  67. package/server/services/agent-sdk-store.mjs +1211 -0
  68. package/server/services/claude-api-settings-service.mjs +54 -17
  69. package/server/services/cli-install-service.mjs +3 -2
  70. package/server/services/copilot-model-discovery-service.mjs +7 -1
  71. package/server/services/database-file.mjs +23 -5
  72. package/server/services/deepseek-settings-service.mjs +58 -20
  73. package/server/services/earlier-database-service.mjs +4 -2
  74. package/server/services/generated-image-cleanup-service.mjs +2 -1
  75. package/server/services/generated-image-files.mjs +110 -0
  76. package/server/services/image-input-paths.mjs +389 -0
  77. package/server/services/image-operation-service.mjs +6 -2
  78. package/server/services/media-extract.mjs +653 -0
  79. package/server/services/media-index-service.mjs +221 -0
  80. package/server/services/model-refresh-scheduler.mjs +153 -0
  81. package/server/services/model-variant-catalog-service.mjs +141 -9
  82. package/server/services/oar-cli-helpers.mjs +72 -9
  83. package/server/services/oar-state-migration-service.mjs +2 -12
  84. package/server/services/oar-state-root.mjs +17 -0
  85. package/server/services/openai-image-preview-turns.mjs +330 -0
  86. package/server/services/openai-image-spend-service.mjs +516 -0
  87. package/server/services/openai-image-stream.mjs +170 -0
  88. package/server/services/plan-usage-openai-image.mjs +148 -0
  89. package/server/services/provider-model-refresh.mjs +326 -0
  90. package/server/services/provider-model-state.mjs +395 -0
  91. package/server/services/relay-singleton-guard.mjs +72 -10
  92. package/server/services/remote-relay-caller-context.mjs +6 -0
  93. package/server/services/remote-relay-dispatcher.mjs +386 -22
  94. package/server/services/remote-relay-registry-service.mjs +3 -1
  95. package/server/services/session-worker-launch-service.mjs +13 -0
  96. package/server/services/session-worker-supervisor-service.mjs +19 -1
  97. package/server/services/session-worker-websocket-service.mjs +15 -0
  98. package/server/services/token-usage-service.mjs +15 -0
  99. package/server/services/update-install-service.mjs +2 -2
  100. package/server/services/worker-launch-note-service.mjs +14 -2
  101. package/server/services/worker-recycle-service.mjs +145 -11
  102. package/shared/image-header.mjs +90 -0
  103. package/shared/model-refresh-schedule.mjs +120 -0
  104. package/shared/openai-image-options.mjs +299 -0
  105. package/shared/openai-image-presets.mjs +175 -0
  106. package/shared/openai-image-previews.mjs +40 -0
  107. package/shared/openai-image-prices.mjs +291 -0
  108. package/shared/openai-image-spend.mjs +90 -0
  109. package/shared/remote-relay-contract.mjs +126 -4
  110. package/shared/worker-runtime/direct-openai-image.mjs +98 -48
@@ -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';
@@ -81,6 +84,7 @@ import { sessionLaunchMutex } from './services/session-launch-mutex-service.mjs'
81
84
  import { createWorkerRecycleService, sweepArchivedWorkers } from './services/worker-recycle-service.mjs';
82
85
  import { createClaudeApiUsageService } from './services/claude-api-usage-service.mjs';
83
86
  import { createTokenUsageService } from './services/token-usage-service.mjs';
87
+ import { createOpenAIImageSpendService } from './services/openai-image-spend-service.mjs';
84
88
  import { createClaudeApiCapWatch, createClaudeApiPlanUsage, createClaudeApiStopNoteLedger } from './services/plan-usage-claude-api.mjs';
85
89
  import { DEEPSEEK_DEFAULT_AUTO_COMPACT_WINDOW, DEFAULT_DEEPSEEK_MODEL, deepseekModelLabel } from '../shared/deepseek-models.mjs';
86
90
  import { createClaudeCloudSessionService } from './services/claude-cloud-session-service.mjs';
@@ -88,6 +92,10 @@ import { createClaudeCloudRepoService, listRecentCloudSourcesFromStatements } fr
88
92
  import { createClaudeAccountUsageService } from './services/claude-account-usage-service.mjs';
89
93
  import { createGitRemoteService } from './services/git-remote-service.mjs';
90
94
  import { createCliInstallService, writeCliBinariesToConfigFile } from './services/cli-install-service.mjs';
95
+ import { createAgentSdkStore } from './services/agent-sdk-store.mjs';
96
+ import { createAgentSdkLaunchGate, staleWorkerLosesAgentSdk } from './services/agent-sdk-launch-gate.mjs';
97
+ import { agentSdkFromCheckout } from './services/agent-sdk-paths.mjs';
98
+ import { agentSdkDirEnvName, AgentSdkMissingError, loadAgentSdk, resolveAgentSdkSource, setAgentSdkStore } from './agent-sdk-loader.mjs';
91
99
  import { createGrokAuthService } from './services/grok-auth-service.mjs';
92
100
  import { sweepUnreferencedUploads, UNREFERENCED_UPLOADS_QUERY } from './services/upload-sweep.mjs';
93
101
  import webpush from 'web-push';
@@ -123,6 +131,7 @@ import { createActiveDeviceTracker } from './services/push-active-device-service
123
131
  import { registerPushRoutes } from './routes/push-routes.mjs';
124
132
  import { registerAboutRoutes } from './routes/about-routes.mjs';
125
133
  import { registerRelayRestartRoutes } from './routes/relay-restart-routes.mjs';
134
+ import { registerAgentSdkRoutes } from './routes/agent-sdk-routes.mjs';
126
135
  import { createAboutService } from './services/about-service.mjs';
127
136
  import { resolveBuildId } from '../shared/build-id.mjs';
128
137
  import { createImageOperationService } from './services/image-operation-service.mjs';
@@ -137,7 +146,7 @@ import { createPlanUsageService } from './services/plan-usage-service.mjs';
137
146
  import { splitFreeAndBufferTokens } from './services/context-usage-view.mjs';
138
147
  import { normalizeCursorAllowanceSettings } from './services/plan-usage-cursor.mjs';
139
148
  import { normalizeGrokAllowanceSettings } from './services/plan-usage-grok.mjs';
140
- import { createGrokBillingUsageFetcher } from './services/grok-billing-usage.mjs';
149
+ import { createGrokBillingUsageFetcher, readGrokCliAuthKey } from './services/grok-billing-usage.mjs';
141
150
  import { createCursorDashboardUsageFetcher, readCursorIdeSessionToken } from './services/cursor-dashboard-usage.mjs';
142
151
  import { fetchPersonalBillingUsage } from './services/github-billing-usage.mjs';
143
152
  import { createSessionTranscriptService } from './services/session-transcript-service.mjs';
@@ -163,7 +172,7 @@ import { createRelayCliLauncherService } from './services/relay-cli-launcher-ser
163
172
  import { createSessionWorkerRegistry, normalizeWorkerKind } from './services/session-worker-registry-service.mjs';
164
173
  import { createSessionWorkerSupervisor } from './services/session-worker-supervisor-service.mjs';
165
174
  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';
175
+ import { agentSdkForWorkerKind, applyClaudeApiProviderEnvironment, applyClaudeCloudProviderEnvironment, applyClaudeProviderEnvironment, applyCopilotSdkProviderEnvironment, applyCursorProviderEnvironment, applyDeepSeekProviderEnvironment, applyGrokProviderEnvironment, applyOpenAIProviderEnvironment, copilotSdkEngineUnavailableReason, killTmuxSession, launchSessionCli, resolveWorkerKind } from './services/session-worker-launch-service.mjs';
167
176
  import { createSshTunnelManager } from './services/ssh-tunnel-manager-service.mjs';
168
177
  import { createCloudflaredTunnelManager } from './services/cloudflared-tunnel-service.mjs';
169
178
  import {
@@ -375,6 +384,29 @@ for (const dir of UPLOAD_READ_DIRS.slice(1)) {
375
384
  } catch {}
376
385
  }
377
386
 
387
+ // Which models of OpenAI, Claude, Cursor and Grok are on (the DeepSeek and
388
+ // Claude API services keep their maps the same way).
389
+ const providerModelState = createProviderModelStateStore({
390
+ readSetting: readAppSettingValue,
391
+ writeSetting: (key, value, stamp) => stmts.upsertAppSetting.run(key, value, stamp),
392
+ });
393
+ // When each provider's models were last refreshed, by anything, and the new
394
+ // models the next models_updated announces (provider-model-refresh.mjs).
395
+ const modelRefreshLedger = createModelRefreshLedger({
396
+ readSetting: readAppSettingValue,
397
+ writeSetting: (key, value, stamp) => stmts.upsertAppSetting.run(key, value, stamp),
398
+ // What a provider's refresh error may echo: its key or token, cut out
399
+ // before the error is stored, shown in Settings or logged.
400
+ secretsFor: (provider) => ({
401
+ openai: () => [readAppSettingValue(OPENAI_API_KEY_SETTING_KEY)],
402
+ cursor: () => [readAppSettingValue(CURSOR_API_KEY_SETTING_KEY), readCursorDashboardSessionToken()],
403
+ deepseek: () => [readAppSettingValue(DEEPSEEK_API_KEY_SETTING_KEY)],
404
+ 'claude-api': () => [readAppSettingValue(CLAUDE_API_KEY_SETTING_KEY)],
405
+ grok: () => [process.env.XAI_API_KEY, readGrokCliAuthKey()?.key],
406
+ claude: () => [process.env.ANTHROPIC_API_KEY],
407
+ }[provider]?.() || []),
408
+ });
409
+
378
410
  async function refreshOpenAIProviderModels() {
379
411
  const settings = getOpenAIProviderSettings();
380
412
  if (!settings.enabled) return { ok: false, models: [], error: 'OpenAI API key is not configured' };
@@ -410,7 +442,8 @@ async function refreshOpenAIProviderModels() {
410
442
  .filter((modelId) => isOpenAIModelId(modelId)),
411
443
  ])).filter(Boolean);
412
444
  stmts.upsertAppSetting.run(OPENAI_MODELS_SETTING_KEY, JSON.stringify(models), new Date().toISOString());
413
- return { ok: true, models, error: null };
445
+ const { added } = providerModelState.reconcile('openai', { availableModels: getOpenAIProviderSettings().availableModels });
446
+ return { ok: true, models, newModels: added, error: null };
414
447
  }
415
448
 
416
449
  function getOpenAIProviderSettings() {
@@ -427,12 +460,21 @@ function getOpenAIProviderSettings() {
427
460
  } catch {
428
461
  models = [];
429
462
  }
463
+ const availableModels = Array.from(new Set([model, ...models]));
464
+ // Every discovered model was offered before the model map: an upgrade
465
+ // keeps them all on.
466
+ const enabledModels = providerModelState.resolve('openai', { availableModels, defaultModel: model });
430
467
  return {
431
468
  configured: !!apiKey,
432
469
  enabled,
433
470
  apiKey,
434
471
  model,
435
- models: Array.from(new Set([model, ...models])),
472
+ models: enabledModels.models,
473
+ availableModels,
474
+ enabledModels: enabledModels.models,
475
+ autoEnableNew: enabledModels.autoEnableNew,
476
+ fallback: enabledModels.fallback,
477
+ unseenModels: enabledModels.unseen,
436
478
  baseUrl: readAppSettingValue(OPENAI_BASE_URL_SETTING_KEY) || DEFAULT_OPENAI_BASE_URL,
437
479
  providerType: 'openai',
438
480
  };
@@ -443,6 +485,7 @@ function setOpenAIProviderSettings(update = {}) {
443
485
  const apiKey = payload.apiKey;
444
486
  const model = payload.model;
445
487
  const enabled = payload.enabled;
488
+ const enabledModels = payload.enabledModels;
446
489
  const remove = payload.remove === true;
447
490
  const hasBaseUrl = Object.prototype.hasOwnProperty.call(payload, 'baseUrl');
448
491
  const baseUrl = hasBaseUrl ? payload.baseUrl : '';
@@ -481,6 +524,7 @@ function setOpenAIProviderSettings(update = {}) {
481
524
  activeQueueConversationCount,
482
525
  };
483
526
  }
527
+ // The model map stays: a key added again finds its models as they were.
484
528
  stmts.deleteAppSetting.run(OPENAI_API_KEY_SETTING_KEY);
485
529
  stmts.deleteAppSetting.run(OPENAI_MODELS_SETTING_KEY);
486
530
  stmts.upsertAppSetting.run(OPENAI_ENABLED_SETTING_KEY, 'false', nowIso);
@@ -504,6 +548,21 @@ function setOpenAIProviderSettings(update = {}) {
504
548
  stmts.deleteAppSetting.run(OPENAI_BASE_URL_SETTING_KEY);
505
549
  }
506
550
  }
551
+ if (Array.isArray(enabledModels) || typeof payload.autoEnableNew === 'boolean') {
552
+ // The models Select Models showed are set as ticked; an empty list
553
+ // leaves the default model alone, never all.
554
+ providerModelState.select('openai', {
555
+ enabledModels: Array.isArray(enabledModels)
556
+ ? enabledModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeProviderModelId(value))
557
+ : undefined,
558
+ // The rows the browser drew; an older page sends none.
559
+ shownModels: Array.isArray(payload.shownModels)
560
+ ? payload.shownModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeProviderModelId(value))
561
+ : getOpenAIProviderSettings().availableModels,
562
+ defaultModel: normalizedModel,
563
+ autoEnableNew: payload.autoEnableNew,
564
+ });
565
+ }
507
566
  }
508
567
  const current = getOpenAIProviderSettings();
509
568
  return {
@@ -511,6 +570,9 @@ function setOpenAIProviderSettings(update = {}) {
511
570
  configured: current.configured,
512
571
  enabled: current.enabled,
513
572
  model: current.model,
573
+ models: current.models,
574
+ availableModels: current.availableModels,
575
+ autoEnableNew: current.autoEnableNew,
514
576
  baseUrl: current.baseUrl,
515
577
  };
516
578
  }
@@ -554,15 +616,14 @@ function getClaudeProviderSettings() {
554
616
  model,
555
617
  ...(discovered.length ? discovered : DEFAULT_CLAUDE_MODELS),
556
618
  ])).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);
619
+ // The models that are on (Select Models and the model map) drive the
620
+ // catalog and composers; the default model always is.
621
+ const enabledModels = providerModelState.resolve('claude', {
622
+ availableModels,
623
+ defaultModel: model,
624
+ legacyAllowlist: () => readClaudeModelListSetting(CLAUDE_ENABLED_MODELS_SETTING_KEY),
625
+ });
626
+ const models = enabledModels.models;
566
627
  const storedEfforts = readClaudeModelEffortsSetting();
567
628
  const effortsByModel = {};
568
629
  for (const availableModel of availableModels) {
@@ -586,6 +647,9 @@ function getClaudeProviderSettings() {
586
647
  models,
587
648
  availableModels,
588
649
  enabledModels: models,
650
+ autoEnableNew: enabledModels.autoEnableNew,
651
+ fallback: enabledModels.fallback,
652
+ unseenModels: enabledModels.unseen,
589
653
  effortsByModel,
590
654
  modelsDiscovered,
591
655
  attributionMode: normalizeClaudeAttributionMode(readAppSettingValue(CLAUDE_ATTRIBUTION_SETTING_KEY)) || DEFAULT_CLAUDE_ATTRIBUTION_MODE,
@@ -644,15 +708,20 @@ function setClaudeProviderSettings(update = {}) {
644
708
  stmts.deleteAppSetting.run(CLAUDE_MODELS_SETTING_KEY);
645
709
  }
646
710
  }
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
- }
711
+ if (Array.isArray(enabledModels) || typeof payload.autoEnableNew === 'boolean') {
712
+ // The models Select Models showed are set as ticked; an empty list
713
+ // leaves the default model alone, never all.
714
+ providerModelState.select('claude', {
715
+ enabledModels: Array.isArray(enabledModels)
716
+ ? enabledModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeClaudeModelId(value))
717
+ : undefined,
718
+ // The rows the browser drew; an older page sends none.
719
+ shownModels: Array.isArray(payload.shownModels)
720
+ ? payload.shownModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeClaudeModelId(value))
721
+ : getClaudeProviderSettings().availableModels,
722
+ defaultModel: normalizedModel,
723
+ autoEnableNew: payload.autoEnableNew,
724
+ });
656
725
  }
657
726
  const current = getClaudeProviderSettings();
658
727
  return {
@@ -662,6 +731,7 @@ function setClaudeProviderSettings(update = {}) {
662
731
  model: current.model,
663
732
  models: current.models,
664
733
  availableModels: current.availableModels,
734
+ autoEnableNew: current.autoEnableNew,
665
735
  attributionMode: current.attributionMode,
666
736
  };
667
737
  }
@@ -709,7 +779,7 @@ async function refreshClaudeProviderModels() {
709
779
  let claudeQuery = null;
710
780
  let releaseInputGate = () => {};
711
781
  try {
712
- const { query } = await import('@anthropic-ai/claude-agent-sdk');
782
+ const { query } = await loadAgentSdk('claude');
713
783
  const inputGate = new Promise((resolve) => { releaseInputGate = resolve; });
714
784
  claudeQuery = query({
715
785
  // Streaming-input mode with a gated (never-yielding) prompt keeps the
@@ -762,8 +832,13 @@ async function refreshClaudeProviderModels() {
762
832
  const mergedEfforts = mergeClaudeModelEfforts(readClaudeModelEffortsSetting(), effortsByModel);
763
833
  stmts.upsertAppSetting.run(CLAUDE_MODELS_SETTING_KEY, JSON.stringify(models), nowIso);
764
834
  stmts.upsertAppSetting.run(CLAUDE_MODEL_EFFORTS_SETTING_KEY, JSON.stringify(mergedEfforts), nowIso);
765
- return { ok: true, models, error: null };
835
+ const { added } = providerModelState.reconcile('claude', { availableModels: getClaudeProviderSettings().availableModels });
836
+ return { ok: true, models, newModels: added, error: null };
766
837
  } catch (error) {
838
+ // No SDK installed yet: nothing is asked, the stored catalogue stays.
839
+ if (error instanceof AgentSdkMissingError) {
840
+ return { ok: false, models: settings.models, sdkMissing: true, error: `Claude model discovery waits for the SDK: ${error.message}` };
841
+ }
767
842
  return {
768
843
  ok: false,
769
844
  models: settings.models,
@@ -827,24 +902,19 @@ function getCursorProviderSettings() {
827
902
  models = [];
828
903
  }
829
904
  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);
905
+ // The models that are on (Select Models and the model map) drive the
906
+ // catalog and composers; the default model always is.
907
+ const enabledModels = providerModelState.resolve('cursor', {
908
+ availableModels,
909
+ defaultModel: model,
910
+ legacyAllowlist: () => {
911
+ const parsed = JSON.parse(readAppSettingValue(CURSOR_ENABLED_MODELS_SETTING_KEY) || '[]');
912
+ return Array.isArray(parsed)
913
+ ? parsed.map((value) => String(value || '').trim()).filter((modelId) => isSafeCursorModelId(modelId))
914
+ : [];
915
+ },
916
+ });
917
+ const resolvedModels = enabledModels.models;
848
918
  const storedEfforts = readCursorModelEffortsSetting();
849
919
  const storedReasoningOff = readCursorModelReasoningOffSetting();
850
920
  const effortsByModel = {};
@@ -868,6 +938,9 @@ function getCursorProviderSettings() {
868
938
  models: resolvedModels,
869
939
  availableModels,
870
940
  enabledModels: resolvedModels,
941
+ autoEnableNew: enabledModels.autoEnableNew,
942
+ fallback: enabledModels.fallback,
943
+ unseenModels: enabledModels.unseen,
871
944
  effortsByModel,
872
945
  reasoningOffByModel,
873
946
  // Pre-effort installs have models but no efforts setting; the settings
@@ -1109,9 +1182,9 @@ function setCursorProviderSettings(update = {}) {
1109
1182
  activeQueueConversationCount,
1110
1183
  };
1111
1184
  }
1185
+ // The model map stays: a key added again finds its models as they were.
1112
1186
  stmts.deleteAppSetting.run(CURSOR_API_KEY_SETTING_KEY);
1113
1187
  stmts.deleteAppSetting.run(CURSOR_MODELS_SETTING_KEY);
1114
- stmts.deleteAppSetting.run(CURSOR_ENABLED_MODELS_SETTING_KEY);
1115
1188
  stmts.deleteAppSetting.run(CURSOR_MODEL_EFFORTS_SETTING_KEY);
1116
1189
  stmts.deleteAppSetting.run(CURSOR_MODEL_REASONING_OFF_SETTING_KEY);
1117
1190
  stmts.upsertAppSetting.run(CURSOR_ENABLED_SETTING_KEY, 'false', nowIso);
@@ -1129,15 +1202,20 @@ function setCursorProviderSettings(update = {}) {
1129
1202
  if (nextEnabled && !hasApiKey) return { ok: false, error: 'Cursor API key is not configured' };
1130
1203
  stmts.upsertAppSetting.run(CURSOR_ENABLED_SETTING_KEY, nextEnabled ? 'true' : 'false', nowIso);
1131
1204
  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
- }
1205
+ if (Array.isArray(enabledModels) || typeof payload.autoEnableNew === 'boolean') {
1206
+ // The models Select Models showed are set as ticked; an empty list
1207
+ // leaves the default model alone, never all.
1208
+ providerModelState.select('cursor', {
1209
+ enabledModels: Array.isArray(enabledModels)
1210
+ ? enabledModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeCursorModelId(value))
1211
+ : undefined,
1212
+ // The rows the browser drew; an older page sends none.
1213
+ shownModels: Array.isArray(payload.shownModels)
1214
+ ? payload.shownModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeCursorModelId(value))
1215
+ : getCursorProviderSettings().availableModels,
1216
+ defaultModel: normalizedModel,
1217
+ autoEnableNew: payload.autoEnableNew,
1218
+ });
1141
1219
  }
1142
1220
  }
1143
1221
  const current = getCursorProviderSettings();
@@ -1148,6 +1226,7 @@ function setCursorProviderSettings(update = {}) {
1148
1226
  model: current.model,
1149
1227
  models: current.models,
1150
1228
  availableModels: current.availableModels,
1229
+ autoEnableNew: current.autoEnableNew,
1151
1230
  };
1152
1231
  }
1153
1232
 
@@ -1204,7 +1283,7 @@ async function refreshCursorProviderModels() {
1204
1283
  return { ok: false, models: settings.models, error: 'Cursor API key is not configured' };
1205
1284
  }
1206
1285
  try {
1207
- const mod = await import('@cursor/sdk');
1286
+ const mod = await loadAgentSdk('cursor');
1208
1287
  // The namespace form is the only working call shape: `Cursor` has a
1209
1288
  // private constructor and `models` is static, so the old client-instance
1210
1289
  // fallback could only throw a TypeError that replaced the real error.
@@ -1243,8 +1322,12 @@ async function refreshCursorProviderModels() {
1243
1322
  stmts.upsertAppSetting.run(CURSOR_MODELS_SETTING_KEY, JSON.stringify(models), nowIso);
1244
1323
  stmts.upsertAppSetting.run(CURSOR_MODEL_EFFORTS_SETTING_KEY, JSON.stringify(effortsByModel), nowIso);
1245
1324
  stmts.upsertAppSetting.run(CURSOR_MODEL_REASONING_OFF_SETTING_KEY, JSON.stringify(reasoningOffByModel), nowIso);
1246
- return { ok: true, models, error: null };
1325
+ const { added } = providerModelState.reconcile('cursor', { availableModels: getCursorProviderSettings().availableModels });
1326
+ return { ok: true, models, newModels: added, error: null };
1247
1327
  } catch (error) {
1328
+ if (error instanceof AgentSdkMissingError) {
1329
+ return { ok: false, models: settings.models, sdkMissing: true, error: `Cursor model discovery waits for the SDK: ${error.message}` };
1330
+ }
1248
1331
  return {
1249
1332
  ok: false,
1250
1333
  models: settings.models,
@@ -1294,13 +1377,12 @@ function getGrokProviderSettings() {
1294
1377
  model,
1295
1378
  ...(discovered.length ? discovered : DEFAULT_GROK_MODELS),
1296
1379
  ])).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);
1380
+ const enabledModels = providerModelState.resolve('grok', {
1381
+ availableModels,
1382
+ defaultModel: model,
1383
+ legacyAllowlist: () => readGrokModelListSetting(GROK_ENABLED_MODELS_SETTING_KEY),
1384
+ });
1385
+ const models = enabledModels.models;
1304
1386
  const storedEfforts = readGrokModelEffortsSetting();
1305
1387
  const effortsByModel = {};
1306
1388
  for (const availableModel of availableModels) {
@@ -1319,6 +1401,9 @@ function getGrokProviderSettings() {
1319
1401
  models,
1320
1402
  availableModels,
1321
1403
  enabledModels: models,
1404
+ autoEnableNew: enabledModels.autoEnableNew,
1405
+ fallback: enabledModels.fallback,
1406
+ unseenModels: enabledModels.unseen,
1322
1407
  effortsByModel,
1323
1408
  modelsDiscovered,
1324
1409
  providerType: 'grok',
@@ -1353,15 +1438,20 @@ function setGrokProviderSettings(update = {}) {
1353
1438
  stmts.deleteAppSetting?.run?.(GROK_MODELS_SETTING_KEY);
1354
1439
  }
1355
1440
  }
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
- }
1441
+ if (Array.isArray(enabledModels) || typeof payload.autoEnableNew === 'boolean') {
1442
+ // The models Select Models showed are set as ticked; an empty list
1443
+ // leaves the default model alone, never all.
1444
+ providerModelState.select('grok', {
1445
+ enabledModels: Array.isArray(enabledModels)
1446
+ ? enabledModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeGrokModelId(value))
1447
+ : undefined,
1448
+ // The rows the browser drew; an older page sends none.
1449
+ shownModels: Array.isArray(payload.shownModels)
1450
+ ? payload.shownModels.map((value) => String(value || '').trim()).filter((value) => value && isSafeGrokModelId(value))
1451
+ : getGrokProviderSettings().availableModels,
1452
+ defaultModel: normalizedModel,
1453
+ autoEnableNew: payload.autoEnableNew,
1454
+ });
1365
1455
  }
1366
1456
  const current = getGrokProviderSettings();
1367
1457
  return {
@@ -1371,6 +1461,7 @@ function setGrokProviderSettings(update = {}) {
1371
1461
  model: current.model,
1372
1462
  models: current.models,
1373
1463
  availableModels: current.availableModels,
1464
+ autoEnableNew: current.autoEnableNew,
1374
1465
  };
1375
1466
  }
1376
1467
 
@@ -1452,7 +1543,8 @@ async function refreshGrokProviderModels() {
1452
1543
  const models = Array.from(new Set([settings.model, ...discovered])).filter(Boolean);
1453
1544
  stmts.upsertAppSetting.run(GROK_MODELS_SETTING_KEY, JSON.stringify(models), nowIso);
1454
1545
  stmts.upsertAppSetting.run(GROK_MODEL_EFFORTS_SETTING_KEY, JSON.stringify(effortsByModel), nowIso);
1455
- return { ok: true, models, error: null };
1546
+ const { added } = providerModelState.reconcile('grok', { availableModels: getGrokProviderSettings().availableModels });
1547
+ return { ok: true, models, newModels: added, error: null };
1456
1548
  } catch (error) {
1457
1549
  return {
1458
1550
  ok: false,
@@ -1509,6 +1601,8 @@ const FALLBACK_COPILOT_ENGINE = 'extension';
1509
1601
  const CLAUDE_ENABLED_SETTING_KEY = 'claude_enabled';
1510
1602
  const CLAUDE_MODEL_SETTING_KEY = 'claude_model';
1511
1603
  const CLAUDE_MODELS_SETTING_KEY = 'claude_models';
1604
+ // The allowlists before 0.9.24 (Claude, Cursor, Grok): read once to build
1605
+ // the model map, then left as they are so an older version still finds them.
1512
1606
  const CLAUDE_ENABLED_MODELS_SETTING_KEY = 'claude_enabled_models';
1513
1607
  const CLAUDE_MODEL_EFFORTS_SETTING_KEY = 'claude_model_efforts';
1514
1608
  // oar | vanilla | off — what commits and PRs made in Claude sessions say
@@ -2678,6 +2772,10 @@ const modelVariantCatalog = createModelVariantCatalogService({
2678
2772
  curatedModelIds: CURATED_MODEL_IDS,
2679
2773
  autoModelSentinel: AUTO_MODEL_SENTINEL,
2680
2774
  runCopilotCliCommand,
2775
+ modelStateStore: providerModelState,
2776
+ onNewModels: (baseModelIds, on) => modelRefreshLedger.noteNewModels('github', baseModelIds.map((id) => ({ id, on }))),
2777
+ // A worker's snapshot read the runtime's list: a Copilot model refresh.
2778
+ onAuthoritativeSnapshot: () => modelRefreshLedger.record('github', { ok: true }),
2681
2779
  });
2682
2780
  const {
2683
2781
  SUPPORTED_REASONING_EFFORTS,
@@ -2990,6 +3088,7 @@ const deepseekSettingsService = createDeepSeekSettingsService({
2990
3088
  },
2991
3089
  deleteSetting: (key) => stmts.deleteAppSetting.run(key),
2992
3090
  listProviderCandidates: () => stmts.listRuntimeSessionProviderCandidates?.all?.() || [],
3091
+ onModelsRefreshed: (result) => modelRefreshLedger.record('deepseek', result),
2993
3092
  });
2994
3093
  function getDeepSeekProviderSettings() {
2995
3094
  return deepseekSettingsService.getSettings();
@@ -3007,6 +3106,7 @@ const claudeApiSettingsService = createClaudeApiSettingsService({
3007
3106
  listProviderCandidates: () => stmts.listRuntimeSessionProviderCandidates?.all?.() || [],
3008
3107
  countLiveKeyWorkers: () => (sessionWorkerRegistry?.listWorkers?.() || []).filter((worker) => worker?.kind === 'claude-api').length,
3009
3108
  getClaudeProviderSettings: () => getClaudeProviderSettings(),
3109
+ onModelsRefreshed: (result) => modelRefreshLedger.record('claude-api', result),
3010
3110
  });
3011
3111
  function getClaudeApiProviderSettings() {
3012
3112
  return claudeApiSettingsService.getSettings();
@@ -3096,6 +3196,44 @@ try {
3096
3196
  console.warn(`[cli-install] failed to apply persisted CLI bindings: ${error?.message || error}`);
3097
3197
  }
3098
3198
 
3199
+ // The agent SDKs (and koffi on Windows) the relay installs on demand
3200
+ // (agent-sdk-store.mjs). Registered with the loader, so this process takes
3201
+ // them from the store, never from a worker folder in its environment.
3202
+ const agentSdkStore = createAgentSdkStore({
3203
+ fromCheckout: (sdk) => agentSdkFromCheckout(sdk),
3204
+ readSetting: readAppSettingValue,
3205
+ writeSetting: (key, value) => stmts.upsertAppSetting.run(key, value, new Date().toISOString()),
3206
+ });
3207
+ setAgentSdkStore(agentSdkStore);
3208
+ // A provider that was just set up gets its SDK at once, in the
3209
+ // background; a failure shows in its Settings tab and on the first chat.
3210
+ function installAgentSdkForSetup(sdk, reason) {
3211
+ if (resolveAgentSdkSource(sdk)) return;
3212
+ void agentSdkStore.ensure(sdk, { reason, retryFailed: true }).catch((error) => {
3213
+ console.warn(`[agent-sdk] ${sdk} install after ${reason} failed: ${error?.message || error}`);
3214
+ });
3215
+ }
3216
+ claudeAuthService.onLoginSuccess?.(() => installAgentSdkForSetup('claude', 'Claude login'));
3217
+ // The SDKs whose providers are in use: enabled, logged in, or with a
3218
+ // conversation that is not archived. koffi wherever the store supports it.
3219
+ function agentSdksInUse() {
3220
+ const inUse = new Set(['koffi']);
3221
+ const providerSdk = { claude: 'claude', 'claude-api': 'claude', deepseek: 'claude', cursor: 'cursor' };
3222
+ try {
3223
+ if (getClaudeProviderSettings()?.enabled === true || claudeAuthService.getCachedStatus?.()?.loggedIn === true) inUse.add('claude');
3224
+ if (claudeApiSettingsService.getSettings?.()?.enabled === true || deepseekSettingsService.getSettings?.()?.enabled === true) inUse.add('claude');
3225
+ if (getCursorProviderSettings()?.enabled === true) inUse.add('cursor');
3226
+ 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();
3227
+ for (const row of rows) {
3228
+ const sdk = providerSdk[String(row?.provider || '').trim().toLowerCase()];
3229
+ if (sdk) inUse.add(sdk);
3230
+ }
3231
+ } catch (error) {
3232
+ console.warn(`[agent-sdk] could not tell which providers are in use: ${error?.message || error}`);
3233
+ }
3234
+ return [...inUse];
3235
+ }
3236
+
3099
3237
  // ─── Web Push ─────────────────────────────────────────────────────────────────
3100
3238
  // VAPID keys are generated once and persisted in app_settings; regenerating
3101
3239
  // them would invalidate every stored subscription.
@@ -3383,6 +3521,20 @@ const relayCliLauncherService = createRelayCliLauncherService({
3383
3521
  workerScope,
3384
3522
  });
3385
3523
 
3524
+ // The launch gate (agent-sdk-launch-gate.mjs): never waits for an install.
3525
+ // A session it held back gets its launch back when the install ends or the
3526
+ // SDK turns ready later: the restart schedule cleared, the queue woken.
3527
+ const agentSdkLaunchGate = createAgentSdkLaunchGate({
3528
+ store: agentSdkStore,
3529
+ sdkForWorkerKind: agentSdkForWorkerKind,
3530
+ resolveSource: (sdk) => resolveAgentSdkSource(sdk),
3531
+ noteInstalling: (sdkSessionId, { label }) => workerLaunchNoteService.handle({ type: 'installing', sdkSessionId, lifecycle: { sdkLabel: label } }),
3532
+ onSessionsReleased: ({ sessions }) => {
3533
+ for (const sdkSessionId of sessions) sessionWorkerSupervisor.clearRestartSchedule?.(sdkSessionId);
3534
+ wakeSessionWorkerQueue('agent-sdk-ready');
3535
+ },
3536
+ });
3537
+
3386
3538
  async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true } = {}) {
3387
3539
  const normalizedTargetSessionId = String(targetSessionId || '').trim();
3388
3540
  if (!normalizedTargetSessionId) {
@@ -3413,6 +3565,11 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
3413
3565
  }
3414
3566
  const resolvedWorkspaceRoot = resolveLaunchWorkspaceRootForSession(normalizedTargetSessionId);
3415
3567
  const workerLaunchEnv = buildSessionWorkerLaunchEnvForSession(normalizedTargetSessionId);
3568
+ // Synchronous: nothing between the gate and the launch can start a
3569
+ // second worker for the session.
3570
+ const agentSdkDir = agentSdkLaunchGate.check(normalizedTargetSessionId, resolveWorkerKind(workerLaunchEnv));
3571
+ if (agentSdkDir.deferred) return { deferred: agentSdkDir.deferred };
3572
+ if (agentSdkDir.dir) workerLaunchEnv[agentSdkDirEnvName(agentSdkDir.sdk)] = agentSdkDir.dir;
3416
3573
  const launched = await launchSessionCli({
3417
3574
  workerScope,
3418
3575
  targetSessionId: normalizedTargetSessionId,
@@ -3427,6 +3584,9 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
3427
3584
  log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
3428
3585
  });
3429
3586
  const workerPid = Number.isInteger(Number(launched?.pid)) ? Number(launched.pid) : null;
3587
+ // The worker leases its folder when it loads the SDK; this covers the
3588
+ // seconds before, so no clean-up takes it in between.
3589
+ if (agentSdkDir.dir && workerPid) agentSdkStore.lease(agentSdkDir.dir, workerPid);
3430
3590
  const workerId = `worker-${normalizedTargetSessionId.slice(0, 8)}`;
3431
3591
  const launchMode = String(launched?.launchMode || 'detached').trim();
3432
3592
  const tmuxSessionName = String(launched?.tmuxSessionName || '').trim();
@@ -5648,6 +5808,16 @@ const claudeApiUsageService = createClaudeApiUsageService({ db, logger: console
5648
5808
  // Tokens per turn and per day for every provider, booked from the
5649
5809
  // workers' reports; the Tokens sections in Check Usage and About read it.
5650
5810
  const tokenUsageService = createTokenUsageService({ db, logger: console });
5811
+ // OpenAI image spend against its daily cap: one ledger of the tries in
5812
+ // flight for the image routes and the settings route alike. Its events go
5813
+ // to the owner's clients once the socket server is up.
5814
+ const openAIImageSpend = createOpenAIImageSpendService({
5815
+ tokenUsageService,
5816
+ readSetting: readAppSettingValue,
5817
+ db,
5818
+ emit: (event, payload) => io?.emit?.(event, payload),
5819
+ logger: console,
5820
+ });
5651
5821
  // This month's spend on the key against the cap, and the Check Usage card
5652
5822
  // (services/plan-usage-claude-api.mjs), over the same local days.
5653
5823
  const claudeApiPlanUsage = createClaudeApiPlanUsage({
@@ -6259,6 +6429,7 @@ const copilotModelDiscoveryService = createCopilotModelDiscoveryService({
6259
6429
  }),
6260
6430
  resolveInstalledPaths: () => resolveInstalledCopilotPaths({ config }),
6261
6431
  updateModelCatalog,
6432
+ onRefreshed: (result) => modelRefreshLedger.record('github', result),
6262
6433
  logger: console,
6263
6434
  });
6264
6435
  const contextSnapshotService = createContextSnapshotService({
@@ -7085,9 +7256,51 @@ const workerRecycleService = createWorkerRecycleService({
7085
7256
  // The stop and the launch under the per-session launch mutex the Change
7086
7257
  // CWD relaunch and the launch routes take (sessions-routes.mjs).
7087
7258
  runExclusive: (sid, fn) => sessionLaunchMutex.runExclusive(sid, fn),
7259
+ // The kinds the launch gate gives an agent SDK: once stale, recycled
7260
+ // without waiting for their CLI child, and fenced until then.
7261
+ isAgentSdkWorker: (worker) => staleWorkerLosesAgentSdk(worker?.kind, { sdkForWorkerKind: agentSdkForWorkerKind, fromCheckout: (sdk) => agentSdkFromCheckout(sdk) }),
7088
7262
  logger: console,
7089
7263
  });
7090
7264
 
7265
+ // A worker's hello, before the hello's own request for work: the registry
7266
+ // learns the process, its kind and its build id (a relay that just started
7267
+ // has no entry yet, and the delivery would register the process without its
7268
+ // build id), and the recycle sweeps, so a stale worker on an agent SDK is
7269
+ // fenced before anything is handed to it.
7270
+ function noteSessionWorkerHello({ sessionId, pid, kind = null, buildId = null } = {}) {
7271
+ if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED !== true || runtimeShutdownStarted) return;
7272
+ const sid = String(sessionId || '').trim();
7273
+ if (!sid) return;
7274
+ // A killed session's worker is registered by nothing, as the delivery
7275
+ // leaves it unregistered.
7276
+ if (sessionWorkerSupervisor?.isKillBlocked?.(sid) !== true) {
7277
+ const statedPid = Number.isInteger(Number(pid)) && Number(pid) > 0 ? Number(pid) : null;
7278
+ let worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
7279
+ // No entry, or one with no process behind it (a launch the agent SDK
7280
+ // gate deferred leaves 'new'): the process saying hello is the session's
7281
+ // worker, ready, as its first heartbeat would make it (/api/heartbeat).
7282
+ if (!worker || worker.status === 'new' || (!worker.workerId && !worker.pid)) {
7283
+ worker = sessionWorkerRegistry?.upsertWorker?.({
7284
+ ...(worker || {}),
7285
+ sdkSessionId: sid,
7286
+ workerId: worker?.workerId || `worker-${sid.slice(0, 8)}`,
7287
+ status: 'ready',
7288
+ pid: statedPid || worker?.pid || null,
7289
+ queueDepth: Math.max(0, Number(queueCounts?.().pendingCount || 0)),
7290
+ }) || null;
7291
+ } else if (statedPid && worker.pid !== statedPid) {
7292
+ worker = sessionWorkerRegistry?.upsertWorker?.({ ...worker, sdkSessionId: sid, pid: statedPid, status: worker.status || 'ready' }) || null;
7293
+ }
7294
+ // Stated for the process just registered, in an upsert of its own: the
7295
+ // registry drops a build id that comes with a new pid and equals the old
7296
+ // entry's (the old process's, spread in).
7297
+ if (worker && ((kind && worker.kind !== kind) || (buildId && worker.buildId !== buildId))) {
7298
+ sessionWorkerRegistry?.upsertWorker?.({ ...worker, ...(kind ? { kind } : {}), ...(buildId ? { buildId } : {}) });
7299
+ }
7300
+ }
7301
+ void workerRecycleService.sweepNow();
7302
+ }
7303
+
7091
7304
  // Once at start, after the restart's own worker reconnects settled: what
7092
7305
  // still runs for an archived conversation is stopped and stays stopped.
7093
7306
  // Through the recycle's replacement, one session at a time; never throws.
@@ -7322,6 +7535,9 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
7322
7535
  const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
7323
7536
  if (worker && worker.buildId !== buildId) sessionWorkerRegistry?.upsertWorker?.({ ...worker, buildId });
7324
7537
  },
7538
+ // Registers the process and runs the recycle's sweep before the hello's
7539
+ // request for work (noteSessionWorkerHello).
7540
+ onWorkerHello: noteSessionWorkerHello,
7325
7541
  // Sent in server.hello, so a worker on other code can say so in its log.
7326
7542
  buildId: BUILD_ID,
7327
7543
  isWorkerProcessAlive: (pid) => isProcessAlive(Number(pid)),
@@ -7710,6 +7926,85 @@ const remoteRelayDispatcher = createRemoteRelayDispatcher({
7710
7926
  logger: console,
7711
7927
  });
7712
7928
 
7929
+ // Every refresh of a provider's models resets its clock in the ledger,
7930
+ // whoever asks; the background check refreshes what is due through the same
7931
+ // table as the Select Models Refresh button.
7932
+ const trackedModelRefreshers = {
7933
+ refreshOpenAIProviderModels: modelRefreshLedger.track('openai', refreshOpenAIProviderModels),
7934
+ refreshClaudeProviderModels: modelRefreshLedger.track('claude', refreshClaudeProviderModels),
7935
+ refreshCursorProviderModels: modelRefreshLedger.track('cursor', refreshCursorProviderModels),
7936
+ refreshGrokProviderModels: modelRefreshLedger.track('grok', refreshGrokProviderModels),
7937
+ };
7938
+ const modelRefreshSettings = createModelRefreshSettings({
7939
+ readSetting: readAppSettingValue,
7940
+ writeSetting: (key, value, stamp) => stmts.upsertAppSetting.run(key, value, stamp),
7941
+ });
7942
+ // Claude and Grok are set up when their CLI is installed or their login is
7943
+ // there: a file lookup and the last status, never a CLI started to ask.
7944
+ function isClaudeModelRefreshSetUp() {
7945
+ // Discovery runs on the SDK: none installed yet, nothing to ask.
7946
+ if (!resolveAgentSdkSource('claude')) return false;
7947
+ return !!cliInstallService.resolveCliBinary('claude')
7948
+ || claudeCloudCredentials.describe().hasToken === true
7949
+ || claudeAuthService.getCachedStatus?.()?.loggedIn === true;
7950
+ }
7951
+ function isGrokModelRefreshSetUp() {
7952
+ return !!String(process.env.GROK_CLI_COMMAND || process.env.GROK_COMMAND || '').trim()
7953
+ || !!cliInstallService.resolveCliBinary('grok')
7954
+ || !!readGrokCliAuthKey();
7955
+ }
7956
+ const modelRefreshTable = buildProviderRefreshTable({
7957
+ getOpenAIProviderSettings,
7958
+ getClaudeProviderSettings,
7959
+ getCursorProviderSettings,
7960
+ getGrokProviderSettings,
7961
+ ...trackedModelRefreshers,
7962
+ deepseekSettingsService,
7963
+ claudeApiSettingsService,
7964
+ copilotModelDiscoveryService,
7965
+ isClaudeSetUp: isClaudeModelRefreshSetUp,
7966
+ isGrokSetUp: isGrokModelRefreshSetUp,
7967
+ });
7968
+ // A freshly installed SDK fills its provider's catalogue at once, as a
7969
+ // fresh CLI install does.
7970
+ agentSdkStore.onReady(({ id } = {}) => {
7971
+ // A session the gate held back, or whose launch gave up on a failed
7972
+ // install, delivers its queued message now.
7973
+ agentSdkLaunchGate.onReady(id);
7974
+ const providers = { claude: ['claude'], cursor: ['cursor'] }[id];
7975
+ if (!providers) return;
7976
+ void refreshProviderModels({ table: modelRefreshTable, providers, reason: 'agent-sdk-installed', gate: 'isSetUp' }).catch((error) => {
7977
+ console.warn(`[agent-sdk] model refresh after the ${id} install failed: ${error?.message || error}`);
7978
+ });
7979
+ });
7980
+ const modelRefreshScheduler = createModelRefreshScheduler({
7981
+ getSettings: () => modelRefreshSettings.get(),
7982
+ getLast: () => modelRefreshLedger.readLast(),
7983
+ getTable: () => modelRefreshTable,
7984
+ refreshProviders: ({ providers, reason }) => refreshProviderModels({ table: modelRefreshTable, providers, reason, gate: 'isSetUp' }),
7985
+ logger: console,
7986
+ });
7987
+
7988
+ // The same for the settings routes: a save that leaves the provider enabled
7989
+ // installs its SDK in the background.
7990
+ function withAgentSdkSetup(setter, sdk, isEnabled, reason) {
7991
+ return (...args) => {
7992
+ const result = setter(...args);
7993
+ try { if (result?.ok !== false && isEnabled()) installAgentSdkForSetup(sdk, reason); } catch {}
7994
+ return result;
7995
+ };
7996
+ }
7997
+ function withAgentSdkUpdate(service, reason) {
7998
+ if (!service) return service;
7999
+ return Object.assign(Object.create(service), {
8000
+ update: (...args) => {
8001
+ const result = service.update(...args);
8002
+ try { if (result?.ok !== false && service.getSettings?.()?.enabled === true) installAgentSdkForSetup('claude', reason); } catch {}
8003
+ return result;
8004
+ },
8005
+ });
8006
+ }
8007
+
7713
8008
  const sharedRouteDeps = {
7714
8009
  auth,
7715
8010
  isTrulyLocalRequest: localRequestCheck.isTrulyLocal,
@@ -7766,27 +8061,27 @@ const sharedRouteDeps = {
7766
8061
  setDefaultSessionWorkspaceRootPath,
7767
8062
  getOpenAIProviderSettings,
7768
8063
  setOpenAIProviderSettings,
7769
- refreshOpenAIProviderModels,
8064
+ refreshOpenAIProviderModels: trackedModelRefreshers.refreshOpenAIProviderModels,
7770
8065
  getCopilotProviderSettings,
7771
8066
  setCopilotProviderSettings,
7772
8067
  getClaudeProviderSettings,
7773
- setClaudeProviderSettings,
7774
- refreshClaudeProviderModels,
8068
+ setClaudeProviderSettings: withAgentSdkSetup(setClaudeProviderSettings, 'claude', () => getClaudeProviderSettings()?.enabled === true, 'Claude enabled'),
8069
+ refreshClaudeProviderModels: trackedModelRefreshers.refreshClaudeProviderModels,
7775
8070
  getCursorProviderSettings,
7776
- setCursorProviderSettings,
7777
- refreshCursorProviderModels,
8071
+ setCursorProviderSettings: withAgentSdkSetup(setCursorProviderSettings, 'cursor', () => getCursorProviderSettings()?.enabled === true, 'Cursor key saved'),
8072
+ refreshCursorProviderModels: trackedModelRefreshers.refreshCursorProviderModels,
7778
8073
  getGrokProviderSettings,
7779
8074
  setGrokProviderSettings,
7780
- refreshGrokProviderModels,
8075
+ refreshGrokProviderModels: trackedModelRefreshers.refreshGrokProviderModels,
7781
8076
  getClaudeCloudProviderSettings,
7782
8077
  claudeCloudSettingsService,
7783
8078
  claudeCloudSessionService,
7784
8079
  getDeepSeekProviderSettings,
7785
- deepseekSettingsService,
8080
+ deepseekSettingsService: withAgentSdkUpdate(deepseekSettingsService, 'DeepSeek set up'),
7786
8081
  deepseekBalanceService,
7787
8082
  deepseekUsageImportService,
7788
8083
  getClaudeApiProviderSettings,
7789
- claudeApiSettingsService,
8084
+ claudeApiSettingsService: withAgentSdkUpdate(claudeApiSettingsService, 'Claude API set up'),
7790
8085
  claudeCloudRepoService,
7791
8086
  // The login's state for the workers' nudge route (never the token).
7792
8087
  claudeCloudLoginStatus: { describe: () => claudeCloudCredentials.describe() },
@@ -7807,6 +8102,15 @@ const sharedRouteDeps = {
7807
8102
  listModelVariantRows,
7808
8103
  refreshModelVariantCatalogFromCli,
7809
8104
  setEnabledModelVariants,
8105
+ getCopilotModelState: modelVariantCatalog.getCopilotModelState,
8106
+ setCopilotAutoEnableNew: modelVariantCatalog.setCopilotAutoEnableNew,
8107
+ markCopilotModelsSeen: modelVariantCatalog.markCopilotModelsSeen,
8108
+ markProviderModelsSeen: (provider) => providerModelState.markSeen(provider),
8109
+ modelRefreshLedger,
8110
+ modelRefreshSettings,
8111
+ modelRefreshScheduler,
8112
+ isClaudeModelRefreshSetUp,
8113
+ isGrokModelRefreshSetUp,
7810
8114
  parseModelVariantSelection,
7811
8115
  SUPPORTED_REASONING_EFFORTS,
7812
8116
  buildRelayReadyBannerData,
@@ -7882,6 +8186,11 @@ const sharedRouteDeps = {
7882
8186
  path,
7883
8187
  uploadsDir: UPLOAD_DIR,
7884
8188
  uploadReadDirs: UPLOAD_READ_DIRS,
8189
+ // Never read for a send's imagePaths, even inside the session's folder.
8190
+ imageInputDenyPaths: {
8191
+ dirs: [DATA_DIR],
8192
+ files: [DB_PATH, `${DB_PATH}-wal`, `${DB_PATH}-shm`, `${DB_PATH}-journal`, CONFIG_PATH],
8193
+ },
7885
8194
  fetchUsageSummary,
7886
8195
  fetchCopilotBillingUsage,
7887
8196
  fetchGrokBillingUsage: createGrokBillingUsageFetcher(),
@@ -7938,6 +8247,9 @@ const sharedRouteDeps = {
7938
8247
  claudeBillingSwitchService,
7939
8248
  claudeApiUsageService,
7940
8249
  tokenUsageService,
8250
+ openAIImageSpend,
8251
+ getOpenAIImageSpendState: (options) => openAIImageSpend.buildState(options),
8252
+ notifyOpenAIImageSpendChanged: () => openAIImageSpend.notifyImageSpendChanged(),
7941
8253
  claudeApiClaimCheck,
7942
8254
  onClaudeBillingFence: handleClaudeBillingFence,
7943
8255
  claudeApiPlanUsage,
@@ -7968,6 +8280,7 @@ const sharedRouteDeps = {
7968
8280
  shareRepository,
7969
8281
  };
7970
8282
  registerMessagesRoutes(app, sharedRouteDeps);
8283
+ registerAgentSdkRoutes(app, { auth, io, agentSdkStore });
7971
8284
  registerSessionsRoutes(app, sharedRouteDeps);
7972
8285
  registerConversationGroupRoutes(app, sharedRouteDeps);
7973
8286
  registerAskUserRoutes(app, sharedRouteDeps);
@@ -8054,6 +8367,7 @@ registerAboutRoutes(app, {
8054
8367
  isRelayCliOnline: () => runtimeState.cliOnline === true,
8055
8368
  listWorkers: () => sessionWorkerRegistry.listWorkers(),
8056
8369
  getUpdateState: () => buildUpdateStatePayload(),
8370
+ getAgentSdkStatus: () => agentSdkStore.snapshot(),
8057
8371
  describeBackups: () => describeBackups({ layout: STATE_LAYOUT, serverDir: __dirname, dataDir: DATA_DIR, config, sizes: false }),
8058
8372
  }),
8059
8373
  });
@@ -8700,12 +9014,17 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
8700
9014
  const discoveryShutdown = copilotModelDiscoveryService.dispose().catch((error) => {
8701
9015
  console.warn(`${runtimeLogPrefix()}Copilot model discovery shutdown failed: ${error?.message || error}`);
8702
9016
  });
9017
+ // Stops the timer at once; a check in flight ends with the discoveries above.
9018
+ void modelRefreshScheduler.dispose();
8703
9019
  try { claudeAuthService.dispose(); } catch (error) {
8704
9020
  console.warn(`${runtimeLogPrefix()}Claude auth service shutdown failed: ${error?.message || error}`);
8705
9021
  }
8706
9022
  try { cliInstallService.dispose(); } catch (error) {
8707
9023
  console.warn(`${runtimeLogPrefix()}CLI install service shutdown failed: ${error?.message || error}`);
8708
9024
  }
9025
+ try { agentSdkStore.dispose(); } catch (error) {
9026
+ console.warn(`${runtimeLogPrefix()}agent SDK store shutdown failed: ${error?.message || error}`);
9027
+ }
8709
9028
  try { grokAuthService.dispose(); } catch (error) {
8710
9029
  console.warn(`${runtimeLogPrefix()}Grok auth service shutdown failed: ${error?.message || error}`);
8711
9030
  }
@@ -8880,6 +9199,20 @@ httpServer.listen(config.port, listenHost, () => {
8880
9199
  // Deferred and unref'd (a few seconds behind the startup import's own CLI
8881
9200
  // spawn); a relay without an installed Copilot CLI skips it entirely.
8882
9201
  copilotModelDiscoveryService.scheduleStartupRefresh();
9202
+ // "Check for new models": a first look a minute in (overdue providers),
9203
+ // then every quarter of an hour; unref'd.
9204
+ modelRefreshScheduler.start();
9205
+ // At start: the SDKs of the providers in use, and koffi on
9206
+ // Windows, installed in the background when their pinned folder is
9207
+ // missing (after an update: the new pins); the start never waits for it.
9208
+ // A git checkout runs the devDependencies it has installed and leaves
9209
+ // the store alone for those.
9210
+ const storeSdks = agentSdkStore.ids().filter((sdk) => !agentSdkFromCheckout(sdk));
9211
+ if (storeSdks.length) {
9212
+ void agentSdkStore.reconcile({ inUse: agentSdksInUse().filter((sdk) => storeSdks.includes(sdk)) }).catch((error) => {
9213
+ console.warn(`[agent-sdk] start-up check failed: ${error?.message || error}`);
9214
+ });
9215
+ }
8883
9216
  // Archived conversations' workers: stopped once, 30 s in, so the workers
8884
9217
  // that reconnect after the restart and the first prime have settled. With
8885
9218
  // routing off the relay launches no workers, so there is nothing to sweep.