@oar-sh/oar 0.9.22 → 0.9.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +258 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +82 -4
  4. package/package.json +16 -15
  5. package/server/agent-sdk-loader.mjs +116 -0
  6. package/server/agent-sdks/claude/entry.mjs +4 -0
  7. package/server/agent-sdks/claude/package-lock.json +1438 -0
  8. package/server/agent-sdks/claude/package.json +13 -0
  9. package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
  10. package/server/agent-sdks/cursor/entry.mjs +3 -0
  11. package/server/agent-sdks/cursor/package-lock.json +171 -0
  12. package/server/agent-sdks/cursor/package.json +11 -0
  13. package/server/agent-sdks/koffi/entry.cjs +2 -0
  14. package/server/agent-sdks/koffi/package-lock.json +368 -0
  15. package/server/agent-sdks/koffi/package.json +10 -0
  16. package/server/claude-worker/claude-cli-pinned-env.mjs +4 -1
  17. package/server/claude-worker/claude-preview-tool.mjs +5 -2
  18. package/server/claude-worker/claude-remote-relay-tool.mjs +5 -2
  19. package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +7 -4
  21. package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
  22. package/server/public/app/agent-sdk-ui.js +349 -0
  23. package/server/public/app/api-client.js +91 -8
  24. package/server/public/app/bootstrap.js +204 -69
  25. package/server/public/app/model-refresh-ui.mjs +413 -0
  26. package/server/public/app/no-conversation-state.mjs +5 -2
  27. package/server/public/app/provider-readiness.mjs +67 -4
  28. package/server/public/app/settings-modal.js +167 -0
  29. package/server/public/app/socket-handlers.js +21 -0
  30. package/server/public/index.html +95 -2
  31. package/server/routes/agent-sdk-routes.mjs +73 -0
  32. package/server/routes/messages-routes.mjs +102 -37
  33. package/server/routes/sessions-routes.mjs +236 -110
  34. package/server/server-runtime.mjs +416 -121
  35. package/server/services/about-service.mjs +26 -6
  36. package/server/services/agent-sdk-launch-gate.mjs +124 -0
  37. package/server/services/agent-sdk-paths.mjs +307 -0
  38. package/server/services/agent-sdk-store.mjs +1211 -0
  39. package/server/services/claude-api-settings-service.mjs +54 -17
  40. package/server/services/cli-install-service.mjs +3 -2
  41. package/server/services/copilot-model-discovery-service.mjs +7 -1
  42. package/server/services/database-file.mjs +17 -1
  43. package/server/services/deepseek-settings-service.mjs +58 -20
  44. package/server/services/earlier-database-service.mjs +5 -3
  45. package/server/services/file-serving.mjs +13 -0
  46. package/server/services/model-refresh-scheduler.mjs +153 -0
  47. package/server/services/model-variant-catalog-service.mjs +141 -9
  48. package/server/services/native-addon-build-service.mjs +224 -0
  49. package/server/services/oar-cli-helpers.mjs +76 -10
  50. package/server/services/oar-state-migration-service.mjs +2 -12
  51. package/server/services/oar-state-root.mjs +17 -0
  52. package/server/services/provider-model-refresh.mjs +326 -0
  53. package/server/services/provider-model-state.mjs +395 -0
  54. package/server/services/relay-console-log.mjs +2 -3
  55. package/server/services/relay-singleton-guard.mjs +72 -10
  56. package/server/services/remote-relay-loopback.mjs +3 -3
  57. package/server/services/sdk-session-sync-service.mjs +1 -1
  58. package/server/services/session-worker-launch-service.mjs +13 -0
  59. package/server/services/session-worker-supervisor-service.mjs +19 -1
  60. package/server/services/session-worker-websocket-service.mjs +15 -0
  61. package/server/services/update-install-service.mjs +34 -3
  62. package/server/services/worker-launch-note-service.mjs +14 -2
  63. package/server/services/worker-recycle-service.mjs +145 -11
  64. package/shared/model-refresh-schedule.mjs +120 -0
  65. package/shared/worker-runtime/direct-openai-image.mjs +8 -4
  66. package/server/services/package-data-rescue-preinstall.mjs +0 -22
  67. package/server/services/package-data-rescue.mjs +0 -139
  68. package/server/tty-console-bootstrap.mjs +0 -75
@@ -79,8 +79,10 @@ import {
79
79
  loadModelVariantCatalog,
80
80
  refreshModelVariantCatalog,
81
81
  saveEnabledModelVariants,
82
+ markModelsSeen,
82
83
  updateClaudeSettings,
83
84
  updateCursorSettings,
85
+ updateOpenAISettings,
84
86
  updateGrokSettings,
85
87
  updateDeepSeekSettings,
86
88
  updateClaudeApiSettings,
@@ -192,6 +194,24 @@ import {
192
194
  modelSelectorOptionsEqual,
193
195
  splitModelVariantId,
194
196
  } from './model-selector-options.mjs';
197
+ import {
198
+ applyUnsavedModelTicks,
199
+ buildOpenAIModelRows,
200
+ collectUnsavedModelTicks,
201
+ isUnseenModelRow,
202
+ mergeUnseenByTab,
203
+ modelCheckboxSelection,
204
+ modelRefreshBlockFields,
205
+ modelRowSdkModelId,
206
+ modelSelectionSignature,
207
+ modelTabAutoEnableNew,
208
+ modelTabFallbackModel,
209
+ normalizeCopilotModelState,
210
+ renderModelAutoEnableRowHtml,
211
+ renderModelFallbackWarningHtml,
212
+ renderModelVariantCheckboxHtml,
213
+ renderNewModelChipHtml,
214
+ } from './model-refresh-ui.mjs';
195
215
  import {
196
216
  buildContextTierOptions,
197
217
  resolveContextTierValue,
@@ -384,6 +404,8 @@ import {
384
404
  updateTaskStallMinutesSetting,
385
405
  updateRestartResumeSettingFromToggle,
386
406
  updateSubagentRecordRetentionSetting,
407
+ updateModelRefreshAutoSetting,
408
+ updateModelRefreshIntervalSetting,
387
409
  } from './settings-modal.js';
388
410
  import {
389
411
  togglePushOnThisDevice,
@@ -486,6 +508,7 @@ import {
486
508
  runCliInstall,
487
509
  cancelCliInstallRun,
488
510
  } from './cli-install-ui.js';
511
+ import { refreshAgentSdkRows } from './agent-sdk-ui.js';
489
512
  import {
490
513
  updateAutoCheckSetting,
491
514
  checkForUpdatesNow,
@@ -613,6 +636,15 @@ let modelVariantCatalogState = {
613
636
  reasoningEfforts: [],
614
637
  };
615
638
  let modelVariantCatalogProviderTab = 'copilot';
639
+ // The unseen ids each Select Models tab tagged "New" since the modal opened:
640
+ // opening it marks them seen, and the re-render that follows keeps the tags.
641
+ let selectModelsHeldUnseen = new Map();
642
+ // modelSelectionSignature of the catalog the open Select Models was last
643
+ // rendered from: a models_updated that changes none of it skips the re-render.
644
+ let selectModelsRenderedSignature = '';
645
+ // New models no Select Models has shown yet (GET /api/models, models_updated):
646
+ // the dot on the menu entry.
647
+ let unseenNewModelCount = 0;
616
648
  let suppressConversationPreferenceSync = false;
617
649
  let conversationPreferenceWriteVersion = 0;
618
650
  let latestQueueStatus = {
@@ -1845,10 +1877,24 @@ function rebuildModelSelectorOptionsForProvider() {
1845
1877
  return true;
1846
1878
  }
1847
1879
 
1880
+ function syncSelectModelsMenuDot(count) {
1881
+ if (Number.isFinite(Number(count))) unseenNewModelCount = Math.max(0, Math.trunc(Number(count)));
1882
+ const button = document.getElementById('chat-menu-select-models');
1883
+ const dot = document.getElementById('chat-menu-select-models-dot');
1884
+ if (dot) dot.hidden = unseenNewModelCount === 0;
1885
+ // The dot is decorative; the name says it.
1886
+ if (unseenNewModelCount > 0) button?.setAttribute('aria-label', 'Select Models (new models)');
1887
+ else button?.removeAttribute('aria-label');
1888
+ }
1889
+
1848
1890
  function updateModelCatalogState(payload) {
1849
1891
  // Shared readers never load the catalog, so this would only surface
1850
1892
  // "metadata unavailable" warnings for a picker they cannot see.
1851
1893
  if (isSharedReaderMode()) return;
1894
+ // Before the early returns below: a deferred catalog still moves the dot.
1895
+ if (payload && Object.prototype.hasOwnProperty.call(payload, 'unseenNewModelCount')) {
1896
+ syncSelectModelsMenuDot(payload.unseenNewModelCount);
1897
+ }
1852
1898
  // The catalog's `source` is the Copilot readiness signal (provider-readiness.mjs).
1853
1899
  updateProviderReadinessInputs({ catalog: payload && typeof payload === 'object' ? payload : null });
1854
1900
  const select = document.getElementById('model-select');
@@ -2619,6 +2665,7 @@ function applyModelVariantCatalogState(payload) {
2619
2665
  enabledModels: Array.isArray(payload.claudeModels.enabledModels)
2620
2666
  ? payload.claudeModels.enabledModels.map((value) => String(value || '').trim()).filter(Boolean)
2621
2667
  : [],
2668
+ ...modelRefreshBlockFields(payload.claudeModels),
2622
2669
  }
2623
2670
  : null,
2624
2671
  cursorModels: payload?.cursorModels && typeof payload.cursorModels === 'object'
@@ -2630,6 +2677,7 @@ function applyModelVariantCatalogState(payload) {
2630
2677
  enabledModels: Array.isArray(payload.cursorModels.enabledModels)
2631
2678
  ? payload.cursorModels.enabledModels.map((value) => String(value || '').trim()).filter(Boolean)
2632
2679
  : [],
2680
+ ...modelRefreshBlockFields(payload.cursorModels),
2633
2681
  }
2634
2682
  : null,
2635
2683
  grokModels: payload?.grokModels && typeof payload.grokModels === 'object'
@@ -2641,6 +2689,7 @@ function applyModelVariantCatalogState(payload) {
2641
2689
  enabledModels: Array.isArray(payload.grokModels.enabledModels)
2642
2690
  ? payload.grokModels.enabledModels.map((value) => String(value || '').trim()).filter(Boolean)
2643
2691
  : [],
2692
+ ...modelRefreshBlockFields(payload.grokModels),
2644
2693
  }
2645
2694
  : null,
2646
2695
  deepseekModels: payload?.deepseekModels && typeof payload.deepseekModels === 'object'
@@ -2652,6 +2701,7 @@ function applyModelVariantCatalogState(payload) {
2652
2701
  enabledModels: Array.isArray(payload.deepseekModels.enabledModels)
2653
2702
  ? payload.deepseekModels.enabledModels.map((value) => String(value || '').trim()).filter(Boolean)
2654
2703
  : [],
2704
+ ...modelRefreshBlockFields(payload.deepseekModels),
2655
2705
  }
2656
2706
  : null,
2657
2707
  claudeApiModels: payload?.claudeApiModels && typeof payload.claudeApiModels === 'object'
@@ -2663,8 +2713,24 @@ function applyModelVariantCatalogState(payload) {
2663
2713
  enabledModels: Array.isArray(payload.claudeApiModels.enabledModels)
2664
2714
  ? payload.claudeApiModels.enabledModels.map((value) => String(value || '').trim()).filter(Boolean)
2665
2715
  : [],
2716
+ ...modelRefreshBlockFields(payload.claudeApiModels),
2666
2717
  }
2667
2718
  : null,
2719
+ // OpenAI BYOK's selection, like Cursor's; null from a relay without one
2720
+ // (the OpenAI tab then lists its models as managed in settings).
2721
+ openaiModels: payload?.openaiModels && typeof payload.openaiModels === 'object'
2722
+ ? {
2723
+ defaultModel: String(payload.openaiModels.defaultModel || '').trim(),
2724
+ availableModels: Array.isArray(payload.openaiModels.availableModels)
2725
+ ? payload.openaiModels.availableModels.map((value) => String(value || '').trim()).filter(Boolean)
2726
+ : [],
2727
+ enabledModels: Array.isArray(payload.openaiModels.enabledModels)
2728
+ ? payload.openaiModels.enabledModels.map((value) => String(value || '').trim()).filter(Boolean)
2729
+ : [],
2730
+ ...modelRefreshBlockFields(payload.openaiModels),
2731
+ }
2732
+ : null,
2733
+ copilotModelState: normalizeCopilotModelState(payload?.copilotModelState),
2668
2734
  };
2669
2735
  }
2670
2736
 
@@ -2711,17 +2777,37 @@ async function reconcileOpenModelVariantModal() {
2711
2777
  const payload = await loadModelVariantCatalog();
2712
2778
  if (!payload) return;
2713
2779
  applyModelVariantCatalogState(payload);
2780
+ // Another device opening or saving Select Models emits models_updated too:
2781
+ // when nothing this modal shows changed, the ticks not saved yet stay as
2782
+ // they are; otherwise they are carried across the re-render.
2783
+ if (modelSelectionSignature(modelVariantCatalogState) === selectModelsRenderedSignature) return;
2784
+ const checkboxes = () => Array.from(document.querySelectorAll(
2785
+ '#summary-modal-body .model-variant-checkbox, #summary-modal-body .model-auto-enable-toggle',
2786
+ ));
2787
+ const unsavedTicks = collectUnsavedModelTicks(checkboxes());
2714
2788
  renderModelVariantCatalogBody();
2789
+ applyUnsavedModelTicks(checkboxes(), unsavedTicks);
2790
+ }
2791
+
2792
+ // Shown now: the "New" tags and the menu dot are cleared on the relay (fire
2793
+ // and forget). The open modal keeps its tags (selectModelsHeldUnseen).
2794
+ function markSelectModelsSeen() {
2795
+ void markModelsSeen().then((result) => {
2796
+ if (result) syncSelectModelsMenuDot(Number(result.unseenNewModelCount) || 0);
2797
+ }).catch(() => {});
2715
2798
  }
2716
2799
 
2717
2800
  function renderModelVariantCatalogBody() {
2718
2801
  const providersByModel = modelCatalogState.providersByModel || {};
2802
+ const openaiCatalog = modelVariantCatalogState.openaiModels;
2719
2803
  const variantBelongsToTab = (entry, tab) => {
2720
2804
  const activeTab = String(tab || 'copilot').trim().toLowerCase();
2721
2805
  const baseModelId = String(entry?.baseModelId || '').trim().toLowerCase();
2722
2806
  const entryProvider = String(entry?.provider || '').trim().toLowerCase();
2723
2807
  const providers = modelProvidersForId(baseModelId, providersByModel);
2724
- const hasOpenAIByok = providers.includes('openai-byok');
2808
+ // With its own selection, the OpenAI tab lists exactly its own rows; a
2809
+ // Copilot row of the same model stays on the Copilot tab.
2810
+ const hasOpenAIByok = !openaiCatalog && providers.includes('openai-byok');
2725
2811
  // Each tab lists only rows sourced from that runtime: the Claude SDK tab
2726
2812
  // shows Claude-SDK rows exclusively, the Cursor SDK tab Cursor rows, the
2727
2813
  // Grok tab Grok rows, and Copilot never shows rows that a different runtime
@@ -2780,7 +2866,8 @@ function renderModelVariantCatalogBody() {
2780
2866
  .map((entry) => String(entry.baseModelId || '').trim().toLowerCase())
2781
2867
  .filter(Boolean),
2782
2868
  );
2783
- for (const [modelId, providers] of Object.entries(modelCatalogState.providersByModel || {})) {
2869
+ // A relay without an OpenAI selection: its models are listed, managed in settings.
2870
+ for (const [modelId, providers] of Object.entries(openaiCatalog ? {} : (modelCatalogState.providersByModel || {}))) {
2784
2871
  const baseModelId = String(modelId || '').trim().toLowerCase();
2785
2872
  if (!baseModelId) continue;
2786
2873
  const providerList = Array.isArray(providers) ? providers : [];
@@ -2801,6 +2888,9 @@ function renderModelVariantCatalogBody() {
2801
2888
  grouped.set('openai-byok', providerRows);
2802
2889
  openAIBaseModelsAlreadyListed.add(baseModelId);
2803
2890
  }
2891
+ if (openaiCatalog?.availableModels?.length) {
2892
+ grouped.set('openai-byok', buildOpenAIModelRows(openaiCatalog, humanizeModelLabel));
2893
+ }
2804
2894
  const claudeCatalog = modelVariantCatalogState.claudeModels;
2805
2895
  if (claudeCatalog?.availableModels?.length) {
2806
2896
  const claudeEnabledSet = new Set(claudeCatalog.enabledModels || []);
@@ -2973,6 +3063,16 @@ function renderModelVariantCatalogBody() {
2973
3063
  else if (hasClaudeApiTab) modelVariantCatalogProviderTab = 'claude-api';
2974
3064
  }
2975
3065
  const visibleProviderOrder = providerOrder.filter((providerKey) => providerBelongsToTab(providerKey, modelVariantCatalogProviderTab));
3066
+ selectModelsHeldUnseen = mergeUnseenByTab(selectModelsHeldUnseen, modelVariantCatalogState);
3067
+ const tabUnseenIds = selectModelsHeldUnseen.get(modelVariantCatalogProviderTab) || new Set();
3068
+ const tabAutoEnableNew = modelTabAutoEnableNew(modelVariantCatalogState, modelVariantCatalogProviderTab);
3069
+ // A relay from before the switch sends none: no row then.
3070
+ const autoEnableRowHtml = visibleProviderOrder.length && tabAutoEnableNew !== null
3071
+ ? renderModelAutoEnableRowHtml(modelVariantCatalogProviderTab, tabAutoEnableNew)
3072
+ : '';
3073
+ const fallbackWarningHtml = renderModelFallbackWarningHtml(
3074
+ modelTabFallbackModel(modelVariantCatalogState, modelVariantCatalogProviderTab),
3075
+ );
2976
3076
  const providerTabButtons = `
2977
3077
  <div class="summary-tab-strip" style="display:flex;flex-wrap:wrap;gap:calc(4px * var(--ui-scale)) calc(5px * var(--ui-scale));align-items:center;">
2978
3078
  <button type="button" class="summary-btn model-provider-tab${modelVariantCatalogProviderTab === 'copilot' ? ' active' : ''}" data-model-provider-tab="copilot">Copilot</button>
@@ -3034,13 +3134,10 @@ function renderModelVariantCatalogBody() {
3034
3134
  : '';
3035
3135
  const variantRowsHtml = variantRows.map((row) => {
3036
3136
  const selectable = row.selectable !== false;
3037
- // SDK-provider rows (Claude/Cursor/Grok/DeepSeek) carry their own enabled
3038
- // flag; only Copilot variant rows read the enabledVariantIds selection.
3039
- const sdkProviderModelId = row.claudeModelId || row.cursorModelId || row.grokModelId || row.deepseekModelId
3040
- || row.claudeApiModelId || '';
3041
- const checked = sdkProviderModelId
3042
- ? row.enabled === true
3043
- : (selectable && selected.has(row.variantId));
3137
+ // SDK-provider rows (Claude/Cursor/Grok/DeepSeek/OpenAI) carry their own
3138
+ // enabled flag; only Copilot variant rows read the enabledVariantIds
3139
+ // selection (renderModelVariantCheckboxHtml).
3140
+ const sdkProviderModelId = modelRowSdkModelId(row);
3044
3141
  const effortChip = row.reasoningEffort
3045
3142
  ? ` <span class="model-reasoning-chip">${escHtml(row.reasoningEffort)}</span>`
3046
3143
  : '';
@@ -3053,20 +3150,12 @@ function renderModelVariantCatalogBody() {
3053
3150
  ? ' <span class="model-reasoning-chip">default model</span>'
3054
3151
  : ' <span class="model-reasoning-chip">managed in settings</span>')
3055
3152
  : '';
3056
- const sdkModelAttr = row.claudeModelId
3057
- ? ` data-claude-model="${escHtml(row.claudeModelId)}"`
3058
- : (row.cursorModelId
3059
- ? ` data-cursor-model="${escHtml(row.cursorModelId)}"`
3060
- : (row.grokModelId
3061
- ? ` data-grok-model="${escHtml(row.grokModelId)}"`
3062
- : (row.deepseekModelId
3063
- ? ` data-deepseek-model="${escHtml(row.deepseekModelId)}"`
3064
- : (row.claudeApiModelId ? ` data-claude-api-model="${escHtml(row.claudeApiModelId)}"` : ''))));
3153
+ const newChip = isUnseenModelRow(row, tabUnseenIds) ? renderNewModelChipHtml() : '';
3065
3154
  return `
3066
3155
  <label class="model-variant-row">
3067
- <input class="model-variant-checkbox" type="checkbox" data-selectable="${selectable ? '1' : '0'}" data-variant-id="${escHtml(row.variantId)}"${sdkModelAttr} ${checked ? 'checked' : ''} ${selectable ? '' : 'disabled'}>
3156
+ ${renderModelVariantCheckboxHtml(row, selected)}
3068
3157
  <span class="model-variant-row-copy">
3069
- <span class="model-variant-row-title"${row.claudeApiModelId ? ` title="${escHtml(row.claudeApiModelId)}"` : ''}>${escHtml(row.claudeApiModelId ? row.label : row.variantId)}${effortChip}${statusChip}${fixedChip}</span>
3158
+ <span class="model-variant-row-title"${row.claudeApiModelId ? ` title="${escHtml(row.claudeApiModelId)}"` : ''}>${escHtml(row.claudeApiModelId ? row.label : row.variantId)}${effortChip}${statusChip}${fixedChip}${newChip}</span>
3070
3159
  </span>
3071
3160
  </label>
3072
3161
  `;
@@ -3095,6 +3184,8 @@ function renderModelVariantCatalogBody() {
3095
3184
  const bodyHtml = `
3096
3185
  <div style="display:flex;flex-direction:column;gap:calc(5px * var(--ui-scale))">
3097
3186
  ${providerTabButtons}
3187
+ ${autoEnableRowHtml}
3188
+ ${fallbackWarningHtml}
3098
3189
  <div style="font-size:0.8rem;color:var(--muted)">
3099
3190
  Saved globally for this relay. Refreshed: <strong>${escHtml(refreshedLabel)}</strong>
3100
3191
  </div>
@@ -3108,6 +3199,7 @@ function renderModelVariantCatalogBody() {
3108
3199
  <div style="display:grid;gap:calc(5px * var(--ui-scale))">${groupsHtml || '<div style="color:var(--muted)">No models discovered yet. Click Refresh.</div>'}</div>
3109
3200
  </div>
3110
3201
  `;
3202
+ selectModelsRenderedSignature = modelSelectionSignature(modelVariantCatalogState);
3111
3203
  renderSummaryModalContent({
3112
3204
  title: '🤗 Select Models',
3113
3205
  subtitle: "Choose model variants shown under Model in the composer's ⚙ settings",
@@ -3117,7 +3209,10 @@ function renderModelVariantCatalogBody() {
3117
3209
  if (!refreshed) throw new Error('Failed to refresh model variants');
3118
3210
  reportOpenAIModelDiscoveryFailure(refreshed);
3119
3211
  applyModelVariantCatalogState(refreshed);
3212
+ // The render holds the refresh's unseen ids with the tags already
3213
+ // shown; the models it found are seen now too, as on open.
3120
3214
  renderModelVariantCatalogBody();
3215
+ markSelectModelsSeen();
3121
3216
  await refreshModelCatalog(true);
3122
3217
  },
3123
3218
  kind: 'select-models',
@@ -3143,8 +3238,9 @@ function renderModelVariantCatalogBody() {
3143
3238
  }
3144
3239
  }
3145
3240
 
3146
- async function openSelectModelsModal() {
3147
- modelVariantCatalogProviderTab = 'copilot';
3241
+ async function openSelectModelsModal(tab = 'copilot') {
3242
+ modelVariantCatalogProviderTab = String(tab || '').trim().toLowerCase() || 'copilot';
3243
+ selectModelsHeldUnseen = new Map();
3148
3244
  openSummaryModal({
3149
3245
  title: '🤗 Select Models',
3150
3246
  subtitle: 'Loading…',
@@ -3157,6 +3253,8 @@ async function openSelectModelsModal() {
3157
3253
  if (!payload) throw new Error('Failed to load model variants');
3158
3254
  applyModelVariantCatalogState(payload);
3159
3255
  renderModelVariantCatalogBody();
3256
+ // This modal keeps its tags until it is opened again.
3257
+ markSelectModelsSeen();
3160
3258
  } catch (error) {
3161
3259
  renderSummaryModalContent({
3162
3260
  title: '🤗 Select Models',
@@ -3172,44 +3270,39 @@ async function openSelectModelsModal() {
3172
3270
  async function saveSelectedModelsFromModal() {
3173
3271
  const body = document.getElementById('summary-modal-body');
3174
3272
  if (!body) return;
3175
- const claudeCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-claude-model]'));
3176
- const selectedClaudeModels = claudeCheckboxes
3177
- .filter((input) => input.checked)
3178
- .map((input) => String(input.getAttribute('data-claude-model') || '').trim())
3179
- .filter(Boolean);
3180
- const cursorCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-cursor-model]'));
3181
- const selectedCursorModels = cursorCheckboxes
3182
- .filter((input) => input.checked)
3183
- .map((input) => String(input.getAttribute('data-cursor-model') || '').trim())
3184
- .filter(Boolean);
3185
- const grokCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-grok-model]'));
3186
- const selectedGrokModels = grokCheckboxes
3187
- .filter((input) => input.checked)
3188
- .map((input) => String(input.getAttribute('data-grok-model') || '').trim())
3189
- .filter(Boolean);
3190
- const deepseekCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-deepseek-model]'));
3191
- const selectedDeepSeekModels = deepseekCheckboxes
3192
- .filter((input) => input.checked)
3193
- .map((input) => String(input.getAttribute('data-deepseek-model') || '').trim())
3194
- .filter(Boolean);
3195
- const claudeApiCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-claude-api-model]'));
3196
- const selectedClaudeApiModels = claudeApiCheckboxes
3197
- .filter((input) => input.checked)
3198
- .map((input) => String(input.getAttribute('data-claude-api-model') || '').trim())
3199
- .filter(Boolean);
3273
+ // Each provider's rendered rows: `enabled` the ticked model ids, `shown`
3274
+ // every rendered one (the locked default model too). Read from the DOM,
3275
+ // never the catalog state: a models_updated may already hold a model this
3276
+ // render does not show, and the relay changes only the shown ids.
3277
+ const providerSelection = (attribute) => {
3278
+ const inputs = Array.from(body.querySelectorAll(`.model-variant-checkbox[${attribute}]`));
3279
+ return { inputs, ...modelCheckboxSelection(inputs, attribute) };
3280
+ };
3281
+ const claudeSelection = providerSelection('data-claude-model');
3282
+ const cursorSelection = providerSelection('data-cursor-model');
3283
+ const grokSelection = providerSelection('data-grok-model');
3284
+ const deepseekSelection = providerSelection('data-deepseek-model');
3285
+ const claudeApiSelection = providerSelection('data-claude-api-model');
3286
+ const openaiSelection = providerSelection('data-openai-model');
3287
+ // The rendered tab's "Turn new models on automatically"; undefined for
3288
+ // every other tab, whose switch the save leaves alone.
3289
+ const autoEnableToggle = body.querySelector('.model-auto-enable-toggle[data-auto-enable-tab]');
3290
+ const autoEnableNewFor = (tab) => (
3291
+ autoEnableToggle && autoEnableToggle.getAttribute('data-auto-enable-tab') === tab
3292
+ ? autoEnableToggle.checked === true
3293
+ : undefined
3294
+ );
3200
3295
  // Only the active provider tab's rows are in the DOM, but the PATCH
3201
3296
  // replaces the WHOLE enabled set — so the save must only change rows the
3202
3297
  // user could actually see. Off-screen variants keep their stored state,
3203
3298
  // otherwise saving from e.g. the OpenAI tab silently disables every
3204
3299
  // Copilot-only vendor group (Anthropic, Google, …).
3205
- const renderedVariantCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-selectable="1"]:not([data-claude-model]):not([data-cursor-model]):not([data-grok-model]):not([data-deepseek-model]):not([data-claude-api-model])'));
3206
- const renderedVariantIds = new Set(renderedVariantCheckboxes
3207
- .map((input) => String(input.getAttribute('data-variant-id') || '').trim())
3208
- .filter(Boolean));
3209
- const checkedVariantIds = renderedVariantCheckboxes
3210
- .filter((input) => input.checked)
3211
- .map((input) => String(input.getAttribute('data-variant-id') || '').trim())
3212
- .filter(Boolean);
3300
+ const renderedVariantCheckboxes = Array.from(body.querySelectorAll('.model-variant-checkbox[data-selectable="1"]:not([data-claude-model]):not([data-cursor-model]):not([data-grok-model]):not([data-deepseek-model]):not([data-claude-api-model]):not([data-openai-model])'));
3301
+ const {
3302
+ enabled: checkedVariantIds,
3303
+ shown: shownVariantIds,
3304
+ } = modelCheckboxSelection(renderedVariantCheckboxes, 'data-variant-id');
3305
+ const renderedVariantIds = new Set(shownVariantIds);
3213
3306
  const checkedVariantSet = new Set(checkedVariantIds);
3214
3307
  const selectedVariantIds = (modelVariantCatalogState.enabledVariantIds || [])
3215
3308
  .filter((variantId) => !renderedVariantIds.has(variantId) || checkedVariantSet.has(variantId));
@@ -3223,38 +3316,73 @@ async function saveSelectedModelsFromModal() {
3223
3316
  }
3224
3317
  setSummaryModalLoading(true);
3225
3318
  try {
3226
- if (hasVariantRows) {
3227
- const saved = await saveEnabledModelVariants(selectedVariantIds);
3319
+ const copilotAutoEnableNew = autoEnableNewFor('copilot');
3320
+ if (hasVariantRows || copilotAutoEnableNew !== undefined) {
3321
+ // Without rows to save, only the switch: a null list keeps the selection.
3322
+ const saved = await saveEnabledModelVariants(hasVariantRows ? selectedVariantIds : null, {
3323
+ autoEnableNew: copilotAutoEnableNew,
3324
+ shownVariantIds: hasVariantRows ? shownVariantIds : undefined,
3325
+ });
3228
3326
  if (!saved) throw new Error('Failed to save model selection');
3229
3327
  applyModelVariantCatalogState(saved);
3230
3328
  }
3231
- if (claudeCheckboxes.length) {
3232
- const savedClaude = await updateClaudeSettings({ enabledModels: selectedClaudeModels });
3329
+ if (claudeSelection.inputs.length) {
3330
+ const savedClaude = await updateClaudeSettings({
3331
+ enabledModels: claudeSelection.enabled,
3332
+ shownModels: claudeSelection.shown,
3333
+ autoEnableNew: autoEnableNewFor('anthropic'),
3334
+ });
3233
3335
  if (!savedClaude) throw new Error('Failed to save Claude model selection');
3234
3336
  applyClaudeSettingsState(savedClaude);
3235
3337
  }
3236
- if (cursorCheckboxes.length) {
3237
- const savedCursor = await updateCursorSettings({ enabledModels: selectedCursorModels });
3338
+ if (cursorSelection.inputs.length) {
3339
+ const savedCursor = await updateCursorSettings({
3340
+ enabledModels: cursorSelection.enabled,
3341
+ shownModels: cursorSelection.shown,
3342
+ autoEnableNew: autoEnableNewFor('cursor'),
3343
+ });
3238
3344
  if (!savedCursor) throw new Error('Failed to save Cursor model selection');
3239
3345
  applyCursorSettingsState(savedCursor);
3240
3346
  }
3241
- if (grokCheckboxes.length) {
3242
- const savedGrok = await updateGrokSettings({ enabledModels: selectedGrokModels });
3347
+ if (grokSelection.inputs.length) {
3348
+ const savedGrok = await updateGrokSettings({
3349
+ enabledModels: grokSelection.enabled,
3350
+ shownModels: grokSelection.shown,
3351
+ autoEnableNew: autoEnableNewFor('grok'),
3352
+ });
3243
3353
  if (!savedGrok) throw new Error('Failed to save Grok model selection');
3244
3354
  applyGrokSettingsState(savedGrok);
3245
3355
  }
3246
- if (deepseekCheckboxes.length) {
3247
- const savedDeepSeek = await updateDeepSeekSettings({ enabledModels: selectedDeepSeekModels });
3356
+ if (deepseekSelection.inputs.length) {
3357
+ const savedDeepSeek = await updateDeepSeekSettings({
3358
+ enabledModels: deepseekSelection.enabled,
3359
+ shownModels: deepseekSelection.shown,
3360
+ autoEnableNew: autoEnableNewFor('deepseek'),
3361
+ });
3248
3362
  if (!savedDeepSeek) throw new Error('Failed to save DeepSeek model selection');
3249
3363
  applyDeepSeekSettingsState(savedDeepSeek);
3250
3364
  }
3251
- if (claudeApiCheckboxes.length) {
3252
- const savedClaudeApi = await updateClaudeApiSettings({ enabledModels: selectedClaudeApiModels });
3365
+ if (claudeApiSelection.inputs.length) {
3366
+ const savedClaudeApi = await updateClaudeApiSettings({
3367
+ enabledModels: claudeApiSelection.enabled,
3368
+ shownModels: claudeApiSelection.shown,
3369
+ autoEnableNew: autoEnableNewFor('claude-api'),
3370
+ });
3253
3371
  if (!savedClaudeApi) throw new Error('Failed to save Claude API model selection');
3254
3372
  applyClaudeApiSettingsState(savedClaudeApi);
3255
3373
  }
3256
- if (claudeCheckboxes.length || cursorCheckboxes.length || grokCheckboxes.length || deepseekCheckboxes.length
3257
- || claudeApiCheckboxes.length) {
3374
+ if (openaiSelection.inputs.length) {
3375
+ // No model named: the selection only, the default model stays.
3376
+ const savedOpenAI = await updateOpenAISettings({
3377
+ enabledModels: openaiSelection.enabled,
3378
+ shownModels: openaiSelection.shown,
3379
+ autoEnableNew: autoEnableNewFor('openai'),
3380
+ });
3381
+ if (!savedOpenAI) throw new Error('Failed to save OpenAI model selection');
3382
+ applyOpenAISettingsState(savedOpenAI);
3383
+ }
3384
+ if ([claudeSelection, cursorSelection, grokSelection, deepseekSelection, claudeApiSelection, openaiSelection]
3385
+ .some((selection) => selection.inputs.length)) {
3258
3386
  const refreshedCatalog = await loadModelVariantCatalog();
3259
3387
  if (refreshedCatalog) applyModelVariantCatalogState(refreshedCatalog);
3260
3388
  }
@@ -5288,6 +5416,7 @@ initSocketHandlers({
5288
5416
  refreshModelCatalog,
5289
5417
  updateModelCatalogState,
5290
5418
  reconcileOpenModelVariantModal,
5419
+ openSelectModelsModal,
5291
5420
  applyConversationWorkspaceRootUpdate,
5292
5421
  applyConversationTitleUpdate,
5293
5422
  syncChatTitleControls,
@@ -5730,6 +5859,10 @@ async function initApp() {
5730
5859
  void refreshClaudeCloudSettingsState().then((state) => {
5731
5860
  updateProviderReadinessInputs({ claudeCloud: state || null });
5732
5861
  });
5862
+ // Not awaited either: a provider whose SDK failed to install is not ready,
5863
+ // and the read passes the snapshot to provider readiness itself. A missing
5864
+ // answer changes nothing.
5865
+ void refreshAgentSdkRows();
5733
5866
  await refreshModelCatalog(true);
5734
5867
  initFullscreenButton();
5735
5868
  initInstallButton();
@@ -5973,6 +6106,8 @@ window.previewTaskStallMinutesSetting = previewTaskStallMinutesSetting;
5973
6106
  window.updateTaskStallMinutesSetting = updateTaskStallMinutesSetting;
5974
6107
  window.updateRestartResumeSettingFromToggle = updateRestartResumeSettingFromToggle;
5975
6108
  window.updateSubagentRecordRetentionSetting = updateSubagentRecordRetentionSetting;
6109
+ window.updateModelRefreshAutoSetting = updateModelRefreshAutoSetting;
6110
+ window.updateModelRefreshIntervalSetting = updateModelRefreshIntervalSetting;
5976
6111
  window.togglePushOnThisDevice = togglePushOnThisDevice;
5977
6112
  window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
5978
6113
  window.openSettingsModal = openSettingsModal;