@oar-sh/oar 0.9.23 → 0.9.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +54 -4
  4. package/package.json +9 -7
  5. package/server/agent-sdk-loader.mjs +116 -0
  6. package/server/agent-sdks/claude/entry.mjs +4 -0
  7. package/server/agent-sdks/claude/package-lock.json +1438 -0
  8. package/server/agent-sdks/claude/package.json +13 -0
  9. package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
  10. package/server/agent-sdks/cursor/entry.mjs +3 -0
  11. package/server/agent-sdks/cursor/package-lock.json +171 -0
  12. package/server/agent-sdks/cursor/package.json +11 -0
  13. package/server/agent-sdks/koffi/entry.cjs +2 -0
  14. package/server/agent-sdks/koffi/package-lock.json +368 -0
  15. package/server/agent-sdks/koffi/package.json +10 -0
  16. package/server/claude-worker/claude-preview-tool.mjs +5 -2
  17. package/server/claude-worker/claude-remote-relay-tool.mjs +5 -2
  18. package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +7 -4
  20. package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
  21. package/server/public/app/agent-sdk-ui.js +349 -0
  22. package/server/public/app/api-client.js +91 -8
  23. package/server/public/app/bootstrap.js +204 -69
  24. package/server/public/app/model-refresh-ui.mjs +413 -0
  25. package/server/public/app/no-conversation-state.mjs +5 -2
  26. package/server/public/app/provider-readiness.mjs +67 -4
  27. package/server/public/app/settings-modal.js +167 -0
  28. package/server/public/app/socket-handlers.js +21 -0
  29. package/server/public/index.html +95 -2
  30. package/server/routes/agent-sdk-routes.mjs +73 -0
  31. package/server/routes/messages-routes.mjs +91 -17
  32. package/server/routes/sessions-routes.mjs +235 -109
  33. package/server/server-runtime.mjs +395 -81
  34. package/server/services/about-service.mjs +26 -6
  35. package/server/services/agent-sdk-launch-gate.mjs +124 -0
  36. package/server/services/agent-sdk-paths.mjs +307 -0
  37. package/server/services/agent-sdk-store.mjs +1211 -0
  38. package/server/services/claude-api-settings-service.mjs +54 -17
  39. package/server/services/cli-install-service.mjs +3 -2
  40. package/server/services/copilot-model-discovery-service.mjs +7 -1
  41. package/server/services/database-file.mjs +17 -1
  42. package/server/services/deepseek-settings-service.mjs +58 -20
  43. package/server/services/earlier-database-service.mjs +4 -2
  44. package/server/services/model-refresh-scheduler.mjs +153 -0
  45. package/server/services/model-variant-catalog-service.mjs +141 -9
  46. package/server/services/oar-cli-helpers.mjs +72 -9
  47. package/server/services/oar-state-migration-service.mjs +2 -12
  48. package/server/services/oar-state-root.mjs +17 -0
  49. package/server/services/provider-model-refresh.mjs +326 -0
  50. package/server/services/provider-model-state.mjs +395 -0
  51. package/server/services/relay-singleton-guard.mjs +72 -10
  52. package/server/services/session-worker-launch-service.mjs +13 -0
  53. package/server/services/session-worker-supervisor-service.mjs +19 -1
  54. package/server/services/session-worker-websocket-service.mjs +15 -0
  55. package/server/services/update-install-service.mjs +2 -2
  56. package/server/services/worker-launch-note-service.mjs +14 -2
  57. package/server/services/worker-recycle-service.mjs +145 -11
  58. package/shared/model-refresh-schedule.mjs +120 -0
  59. package/shared/worker-runtime/direct-openai-image.mjs +8 -4
@@ -155,6 +155,8 @@ import { deepseekStartingBalanceNote } from '../services/plan-usage-deepseek.mjs
155
155
  import { appendTokenSections } from '../services/plan-usage-tokens.mjs';
156
156
  import { DEEPSEEK_USAGE_CHECKPOINTS_TABLE } from '../migrations/0024-deepseek-usage-totals.mjs';
157
157
  import { claudeProjectDirSlug } from '../services/claude-session-root-service.mjs';
158
+ import { buildProviderRefreshTable, refreshProviderModels } from '../services/provider-model-refresh.mjs';
159
+ import { MODEL_REFRESH_INTERVAL_HOURS_CHOICES, MODEL_REFRESH_PROVIDERS, DEFAULT_MODEL_REFRESH_AUTO, DEFAULT_MODEL_REFRESH_INTERVAL_HOURS } from '../../shared/model-refresh-schedule.mjs';
158
160
 
159
161
  export { mapUsageSnapshotRow };
160
162
 
@@ -305,7 +307,9 @@ export function parseOpenAISettingsUpdateRequest(body = {}) {
305
307
  const hasEnabled = typeof payload.enabled === 'boolean';
306
308
  const hasBaseUrl = Object.prototype.hasOwnProperty.call(payload, 'baseUrl');
307
309
  const baseUrl = hasBaseUrl ? String(payload.baseUrl || '').trim() : undefined;
308
- if (!remove && !hasModel && !apiKey && !hasEnabled && !hasBaseUrl) {
310
+ const hasEnabledModels = Array.isArray(payload.enabledModels);
311
+ const hasAutoEnableNew = typeof payload.autoEnableNew === 'boolean';
312
+ if (!remove && !hasModel && !apiKey && !hasEnabled && !hasBaseUrl && !hasEnabledModels && !hasAutoEnableNew) {
309
313
  return { ok: false, error: 'No OpenAI settings update provided' };
310
314
  }
311
315
  if (hasModel && !isSafeProviderModelId(model)) {
@@ -326,6 +330,9 @@ export function parseOpenAISettingsUpdateRequest(body = {}) {
326
330
  apiKey,
327
331
  ...(hasModel ? { model } : {}),
328
332
  ...(hasBaseUrl ? { baseUrl } : {}),
333
+ ...(hasEnabledModels ? { enabledModels: payload.enabledModels } : {}),
334
+ ...(hasAutoEnableNew ? { autoEnableNew: payload.autoEnableNew } : {}),
335
+ ...(Array.isArray(payload.shownModels) ? { shownModels: payload.shownModels } : {}),
329
336
  enabled: hasEnabled ? payload.enabled : (apiKey ? true : undefined),
330
337
  };
331
338
  }
@@ -337,13 +344,14 @@ export function parseClaudeSettingsUpdateRequest(body = {}) {
337
344
  const model = hasModel ? (String(payload.model || '').trim() || 'claude-sonnet-5') : undefined;
338
345
  const hasModels = Array.isArray(payload.models);
339
346
  const hasEnabledModels = Array.isArray(payload.enabledModels);
347
+ const hasAutoEnableNew = typeof payload.autoEnableNew === 'boolean';
340
348
  const hasAttributionMode = Object.prototype.hasOwnProperty.call(payload, 'attributionMode');
341
349
  const attributionMode = hasAttributionMode ? normalizeClaudeAttributionMode(payload.attributionMode) : undefined;
342
350
  const hasBackgroundCommandLimit = Object.prototype.hasOwnProperty.call(payload, 'backgroundCommandLimitMinutes');
343
351
  const backgroundCommandLimit = hasBackgroundCommandLimit
344
352
  ? parseClaudeBackgroundCommandLimitUpdate(payload.backgroundCommandLimitMinutes)
345
353
  : null;
346
- if (!hasEnabled && !hasModel && !hasModels && !hasEnabledModels && !hasAttributionMode && !hasBackgroundCommandLimit) {
354
+ if (!hasEnabled && !hasModel && !hasModels && !hasEnabledModels && !hasAutoEnableNew && !hasAttributionMode && !hasBackgroundCommandLimit) {
347
355
  return { ok: false, error: 'No Claude settings update provided' };
348
356
  }
349
357
  if (backgroundCommandLimit && !backgroundCommandLimit.ok) {
@@ -361,6 +369,8 @@ export function parseClaudeSettingsUpdateRequest(body = {}) {
361
369
  ...(hasModel ? { model } : {}),
362
370
  ...(hasModels ? { models: payload.models } : {}),
363
371
  ...(hasEnabledModels ? { enabledModels: payload.enabledModels } : {}),
372
+ ...(hasAutoEnableNew ? { autoEnableNew: payload.autoEnableNew } : {}),
373
+ ...(Array.isArray(payload.shownModels) ? { shownModels: payload.shownModels } : {}),
364
374
  ...(hasAttributionMode ? { attributionMode } : {}),
365
375
  // Clamped to 30 min … 8 h and snapped to 30-minute steps.
366
376
  ...(backgroundCommandLimit ? { backgroundCommandLimitMinutes: backgroundCommandLimit.minutes } : {}),
@@ -459,7 +469,8 @@ export function parseCursorSettingsUpdateRequest(body = {}) {
459
469
  const apiKey = String(payload.apiKey || '').trim();
460
470
  const hasEnabled = typeof payload.enabled === 'boolean';
461
471
  const hasEnabledModels = Array.isArray(payload.enabledModels);
462
- if (!remove && !hasModel && !apiKey && !hasEnabled && !hasEnabledModels) {
472
+ const hasAutoEnableNew = typeof payload.autoEnableNew === 'boolean';
473
+ if (!remove && !hasModel && !apiKey && !hasEnabled && !hasEnabledModels && !hasAutoEnableNew) {
463
474
  return { ok: false, error: 'No Cursor settings update provided' };
464
475
  }
465
476
  if (hasModel && !isSafeCursorModelId(model)) {
@@ -480,6 +491,8 @@ export function parseCursorSettingsUpdateRequest(body = {}) {
480
491
  apiKey,
481
492
  ...(hasModel ? { model } : {}),
482
493
  ...(hasEnabledModels ? { enabledModels: payload.enabledModels } : {}),
494
+ ...(hasAutoEnableNew ? { autoEnableNew: payload.autoEnableNew } : {}),
495
+ ...(Array.isArray(payload.shownModels) ? { shownModels: payload.shownModels } : {}),
483
496
  enabled: hasEnabled ? payload.enabled : (apiKey ? true : undefined),
484
497
  };
485
498
  }
@@ -491,7 +504,8 @@ export function parseGrokSettingsUpdateRequest(body = {}) {
491
504
  const model = hasModel ? (String(payload.model || '').trim() || 'grok-4.5') : undefined;
492
505
  const hasModels = Array.isArray(payload.models);
493
506
  const hasEnabledModels = Array.isArray(payload.enabledModels);
494
- if (!hasEnabled && !hasModel && !hasModels && !hasEnabledModels) {
507
+ const hasAutoEnableNew = typeof payload.autoEnableNew === 'boolean';
508
+ if (!hasEnabled && !hasModel && !hasModels && !hasEnabledModels && !hasAutoEnableNew) {
495
509
  return { ok: false, error: 'No Grok settings update provided' };
496
510
  }
497
511
  if (hasModel && !isSafeGrokModelId(model)) {
@@ -503,6 +517,8 @@ export function parseGrokSettingsUpdateRequest(body = {}) {
503
517
  ...(hasModel ? { model } : {}),
504
518
  ...(hasModels ? { models: payload.models } : {}),
505
519
  ...(hasEnabledModels ? { enabledModels: payload.enabledModels } : {}),
520
+ ...(hasAutoEnableNew ? { autoEnableNew: payload.autoEnableNew } : {}),
521
+ ...(Array.isArray(payload.shownModels) ? { shownModels: payload.shownModels } : {}),
506
522
  };
507
523
  }
508
524
 
@@ -2204,6 +2220,22 @@ export function registerSessionsRoutes(app, deps) {
2204
2220
  listModelVariantRows,
2205
2221
  refreshModelVariantCatalogFromCli,
2206
2222
  setEnabledModelVariants,
2223
+ // Copilot's model map (model-variant-catalog-service.mjs) and the other
2224
+ // providers' (provider-model-state.mjs): the switch, the "New" flags.
2225
+ getCopilotModelState = () => ({ autoEnableNew: false, unseen: [] }),
2226
+ setCopilotAutoEnableNew = null,
2227
+ markCopilotModelsSeen = () => false,
2228
+ markProviderModelsSeen = () => false,
2229
+ // When each provider was last refreshed and the new models the next
2230
+ // models_updated carries (provider-model-refresh.mjs), the background
2231
+ // check's settings and the check itself. Absent in most tests.
2232
+ modelRefreshLedger = null,
2233
+ modelRefreshSettings = null,
2234
+ modelRefreshScheduler = null,
2235
+ // Whether Claude and Grok are set up for the background check (their CLI
2236
+ // or their login); absent, switched on is enough.
2237
+ isClaudeModelRefreshSetUp = () => true,
2238
+ isGrokModelRefreshSetUp = () => true,
2207
2239
  SUPPORTED_REASONING_EFFORTS,
2208
2240
  buildRelayReadyBannerData,
2209
2241
  workspaceRootPayload,
@@ -2664,10 +2696,62 @@ export function registerSessionsRoutes(app, deps) {
2664
2696
  );
2665
2697
  }
2666
2698
 
2699
+ // The providers the Refresh button and the background check ask, with
2700
+ // their enablement and refresh functions (provider-model-refresh.mjs).
2701
+ const providerRefreshTable = buildProviderRefreshTable({
2702
+ getOpenAIProviderSettings,
2703
+ refreshOpenAIProviderModels,
2704
+ getClaudeProviderSettings,
2705
+ refreshClaudeProviderModels,
2706
+ getCursorProviderSettings,
2707
+ refreshCursorProviderModels,
2708
+ getGrokProviderSettings,
2709
+ refreshGrokProviderModels,
2710
+ deepseekSettingsService,
2711
+ claudeApiSettingsService,
2712
+ copilotModelDiscoveryService,
2713
+ isClaudeSetUp: isClaudeModelRefreshSetUp,
2714
+ isGrokSetUp: isGrokModelRefreshSetUp,
2715
+ });
2716
+
2717
+ /** Models flagged new on the enabled providers: the Select Models menu dot. */
2718
+ function countUnseenNewModels() {
2719
+ let count = 0;
2720
+ for (const settings of [
2721
+ getOpenAIProviderSettings(),
2722
+ getClaudeProviderSettings(),
2723
+ getCursorProviderSettings(),
2724
+ getGrokProviderSettings(),
2725
+ getDeepSeekProviderSettings(),
2726
+ getClaudeApiProviderSettings(),
2727
+ ]) {
2728
+ if (settings?.enabled === true && Array.isArray(settings.unseenModels)) count += settings.unseenModels.length;
2729
+ }
2730
+ const copilot = getCopilotModelState();
2731
+ return count + (Array.isArray(copilot?.unseen) ? copilot.unseen.length : 0);
2732
+ }
2733
+
2734
+ /**
2735
+ * What models_updated carries: the catalog, the menu dot's count and, once,
2736
+ * the models a refresh found for the first time (the toast).
2737
+ */
2738
+ function modelsUpdatedPayload(modelState, openAISettings = getOpenAIProviderSettings()) {
2739
+ const newModels = modelRefreshLedger?.takeNewModels?.() || [];
2740
+ return {
2741
+ ...buildModelCatalogWithProviders(modelState, openAISettings),
2742
+ unseenNewModelCount: countUnseenNewModels(),
2743
+ ...(newModels.length ? { newModels } : {}),
2744
+ };
2745
+ }
2746
+
2667
2747
  // A discovery that lands outside any route (the deferred boot refresh) still
2668
2748
  // has to reach connected pickers the way a snapshot POST does.
2669
2749
  copilotModelDiscoveryService?.setOnCatalogUpdated?.(() => {
2670
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
2750
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
2751
+ });
2752
+ // So does a background check that changed a provider's models.
2753
+ modelRefreshScheduler?.setOnModelsChanged?.(() => {
2754
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
2671
2755
  });
2672
2756
 
2673
2757
  // The client address as Express derives it through `trust proxy`: a
@@ -7152,8 +7236,12 @@ export function registerSessionsRoutes(app, deps) {
7152
7236
  runtimeSessionId: runtimeSession?.id || null,
7153
7237
  status: workerState?.status || 'ready',
7154
7238
  });
7155
- const pendingDepth = Number(queueCounts?.().pendingCount || 0);
7156
- sessionWorkerSupervisor?.markIdle?.(ownerSessionId, pendingDepth);
7239
+ // A deferred start (the agent SDK still installs) has no worker to
7240
+ // call idle: it launches when the install ends.
7241
+ if (!ensureResult.deferred) {
7242
+ const pendingDepth = Number(queueCounts?.().pendingCount || 0);
7243
+ sessionWorkerSupervisor?.markIdle?.(ownerSessionId, pendingDepth);
7244
+ }
7157
7245
  } else if (ownerSessionId) {
7158
7246
  // Same state as the 409 above: the conversation is committed, only the
7159
7247
  // worker is missing, so the client gets what it needs to open it.
@@ -7409,7 +7497,7 @@ export function registerSessionsRoutes(app, deps) {
7409
7497
  baseUrl: String(currentSettings?.baseUrl || 'https://api.openai.com/v1').trim() || 'https://api.openai.com/v1',
7410
7498
  reconciliation,
7411
7499
  };
7412
- io.emit('models_updated', buildModelCatalogWithProviders(
7500
+ io.emit('models_updated', modelsUpdatedPayload(
7413
7501
  getModelCatalogState(),
7414
7502
  currentSettings,
7415
7503
  ));
@@ -7523,7 +7611,7 @@ export function registerSessionsRoutes(app, deps) {
7523
7611
  ...claudeBackgroundCommandLimitSettingsFields(currentSettings),
7524
7612
  reconciliation,
7525
7613
  };
7526
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
7614
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
7527
7615
  io.emit('claude_settings_updated', settingsPayload);
7528
7616
  const reconciliationFailures = Array.isArray(reconciliation?.failedConversations)
7529
7617
  ? reconciliation.failedConversations
@@ -7646,7 +7734,7 @@ export function registerSessionsRoutes(app, deps) {
7646
7734
  console.log(`[claude-auth] model refresh after login failed: ${error?.message || error}`);
7647
7735
  }
7648
7736
  const settings = getClaudeProviderSettings();
7649
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
7737
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
7650
7738
  io.emit('claude_settings_updated', {
7651
7739
  configured: settings?.configured === true,
7652
7740
  enabled: settings?.enabled === true,
@@ -7738,7 +7826,7 @@ export function registerSessionsRoutes(app, deps) {
7738
7826
  console.log(`[cli-install] Grok model refresh after install failed: ${error?.message || error}`);
7739
7827
  }
7740
7828
  const settings = getGrokProviderSettings();
7741
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
7829
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
7742
7830
  io.emit('grok_settings_updated', {
7743
7831
  configured: settings?.configured === true,
7744
7832
  enabled: settings?.enabled === true,
@@ -7755,7 +7843,7 @@ export function registerSessionsRoutes(app, deps) {
7755
7843
  console.log(`[cli-install] Claude model refresh after install failed: ${error?.message || error}`);
7756
7844
  }
7757
7845
  const settings = getClaudeProviderSettings();
7758
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
7846
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
7759
7847
  io.emit('claude_settings_updated', {
7760
7848
  configured: settings?.configured === true,
7761
7849
  enabled: settings?.enabled === true,
@@ -7939,7 +8027,7 @@ export function registerSessionsRoutes(app, deps) {
7939
8027
  console.log(`[grok-auth] model refresh after login failed: ${error?.message || error}`);
7940
8028
  }
7941
8029
  const settings = getGrokProviderSettings();
7942
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8030
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
7943
8031
  io.emit('grok_settings_updated', {
7944
8032
  configured: settings?.configured === true,
7945
8033
  enabled: settings?.enabled === true,
@@ -8040,7 +8128,7 @@ export function registerSessionsRoutes(app, deps) {
8040
8128
  availableModels: Array.isArray(currentSettings?.availableModels) ? currentSettings.availableModels : [],
8041
8129
  reconciliation,
8042
8130
  };
8043
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8131
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
8044
8132
  io.emit('grok_settings_updated', settingsPayload);
8045
8133
  const reconciliationFailures = Array.isArray(reconciliation?.failedConversations)
8046
8134
  ? reconciliation.failedConversations
@@ -8176,7 +8264,7 @@ export function registerSessionsRoutes(app, deps) {
8176
8264
  availableModels: Array.isArray(currentSettings?.availableModels) ? currentSettings.availableModels : [],
8177
8265
  reconciliation,
8178
8266
  };
8179
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8267
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
8180
8268
  io.emit('cursor_settings_updated', settingsPayload);
8181
8269
  const reconciliationFailures = Array.isArray(reconciliation?.failedConversations)
8182
8270
  ? reconciliation.failedConversations
@@ -8270,7 +8358,7 @@ export function registerSessionsRoutes(app, deps) {
8270
8358
  failedConversations,
8271
8359
  },
8272
8360
  };
8273
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8361
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
8274
8362
  io.emit('deepseek_settings_updated', settingsPayload);
8275
8363
  return res.json({
8276
8364
  ok: true,
@@ -8324,7 +8412,7 @@ export function registerSessionsRoutes(app, deps) {
8324
8412
  failedConversations,
8325
8413
  },
8326
8414
  };
8327
- io.emit('models_updated', buildModelCatalogWithProviders(getModelCatalogState()));
8415
+ io.emit('models_updated', modelsUpdatedPayload(getModelCatalogState()));
8328
8416
  io.emit('claude_api_settings_updated', settingsPayload);
8329
8417
  try { claudeApiStopLedger?.clearAll?.(); } catch {}
8330
8418
  try { checkClaudeApiCap('settings'); } catch {}
@@ -8910,9 +8998,19 @@ export function registerSessionsRoutes(app, deps) {
8910
8998
  });
8911
8999
  });
8912
9000
 
9001
+ // Each provider tab's switch, "New" tags and fallback warning.
9002
+ function modelStateFields(settings) {
9003
+ return {
9004
+ autoEnableNew: settings?.autoEnableNew !== false,
9005
+ unseen: Array.isArray(settings?.unseenModels) ? settings.unseenModels : [],
9006
+ fallback: settings?.fallback === true,
9007
+ };
9008
+ }
9009
+
8913
9010
  function buildModelVariantCatalogPayloadForRoute() {
8914
9011
  const rows = typeof listModelVariantRows === 'function' ? listModelVariantRows() : [];
8915
9012
  const modelState = getModelCatalogState();
9013
+ const openAISettings = getOpenAIProviderSettings();
8916
9014
  const claudeSettings = getClaudeProviderSettings();
8917
9015
  const cursorSettings = getCursorProviderSettings();
8918
9016
  const grokSettings = getGrokProviderSettings();
@@ -8926,11 +9024,21 @@ export function registerSessionsRoutes(app, deps) {
8926
9024
  contextLimitsByModel: modelState.contextLimitsByModel || {},
8927
9025
  modelMetadataByModel: modelState.modelMetadataByModel || {},
8928
9026
  }),
9027
+ copilotModelState: getCopilotModelState(),
9028
+ openaiModels: openAISettings?.enabled === true
9029
+ ? {
9030
+ defaultModel: openAISettings.model,
9031
+ availableModels: Array.isArray(openAISettings.availableModels) ? openAISettings.availableModels : [],
9032
+ enabledModels: Array.isArray(openAISettings.models) ? openAISettings.models : [],
9033
+ ...modelStateFields(openAISettings),
9034
+ }
9035
+ : null,
8929
9036
  claudeModels: claudeSettings?.enabled === true
8930
9037
  ? {
8931
9038
  defaultModel: claudeSettings.model,
8932
9039
  availableModels: Array.isArray(claudeSettings.availableModels) ? claudeSettings.availableModels : [],
8933
9040
  enabledModels: Array.isArray(claudeSettings.models) ? claudeSettings.models : [],
9041
+ ...modelStateFields(claudeSettings),
8934
9042
  }
8935
9043
  : null,
8936
9044
  cursorModels: cursorSettings?.enabled === true
@@ -8938,6 +9046,7 @@ export function registerSessionsRoutes(app, deps) {
8938
9046
  defaultModel: cursorSettings.model,
8939
9047
  availableModels: Array.isArray(cursorSettings.availableModels) ? cursorSettings.availableModels : [],
8940
9048
  enabledModels: Array.isArray(cursorSettings.models) ? cursorSettings.models : [],
9049
+ ...modelStateFields(cursorSettings),
8941
9050
  }
8942
9051
  : null,
8943
9052
  grokModels: grokSettings?.enabled === true
@@ -8945,6 +9054,7 @@ export function registerSessionsRoutes(app, deps) {
8945
9054
  defaultModel: grokSettings.model,
8946
9055
  availableModels: Array.isArray(grokSettings.availableModels) ? grokSettings.availableModels : [],
8947
9056
  enabledModels: Array.isArray(grokSettings.models) ? grokSettings.models : [],
9057
+ ...modelStateFields(grokSettings),
8948
9058
  }
8949
9059
  : null,
8950
9060
  deepseekModels: deepseekSettings?.enabled === true
@@ -8952,6 +9062,7 @@ export function registerSessionsRoutes(app, deps) {
8952
9062
  defaultModel: deepseekSettings.model,
8953
9063
  availableModels: Array.isArray(deepseekSettings.availableModels) ? deepseekSettings.availableModels : [],
8954
9064
  enabledModels: Array.isArray(deepseekSettings.models) ? deepseekSettings.models : [],
9065
+ ...modelStateFields(deepseekSettings),
8955
9066
  }
8956
9067
  : null,
8957
9068
  // The Select Models → Claude API tab: its own list, "[1m]" ids included
@@ -8962,6 +9073,7 @@ export function registerSessionsRoutes(app, deps) {
8962
9073
  availableModels: Array.isArray(claudeApiSettings.availableModels) ? claudeApiSettings.availableModels : [],
8963
9074
  enabledModels: Array.isArray(claudeApiSettings.models) ? claudeApiSettings.models : [],
8964
9075
  discovered: claudeApiSettings.discovered === true,
9076
+ ...modelStateFields(claudeApiSettings),
8965
9077
  }
8966
9078
  : null,
8967
9079
  };
@@ -8978,101 +9090,28 @@ export function registerSessionsRoutes(app, deps) {
8978
9090
  return res.status(501).json({ error: 'Model refresh is unavailable' });
8979
9091
  }
8980
9092
  try {
8981
- const openAISettings = getOpenAIProviderSettings();
8982
- const claudeSettings = getClaudeProviderSettings();
8983
- const cursorSettings = getCursorProviderSettings();
8984
- const grokSettings = getGrokProviderSettings();
8985
- const deepseekSettings = getDeepSeekProviderSettings();
8986
- const claudeApiSettings = getClaudeApiProviderSettings();
8987
- const refreshTasks = [
9093
+ // The help-text CLI refresh beside every enabled provider's discovery
9094
+ // (provider-model-refresh.mjs; Copilot discovery gates itself on an
9095
+ // installed CLI). Only a failed CLI refresh fails the request.
9096
+ const [cliRefresh, providerRefresh] = await Promise.allSettled([
8988
9097
  refreshModelVariantCatalogFromCli(),
8989
- openAISettings?.enabled === true
8990
- ? refreshOpenAIProviderModels()
8991
- : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8992
- claudeSettings?.enabled === true
8993
- ? refreshClaudeProviderModels()
8994
- : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8995
- cursorSettings?.enabled === true
8996
- ? refreshCursorProviderModels()
8997
- : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
8998
- grokSettings?.enabled === true
8999
- ? refreshGrokProviderModels()
9000
- : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
9001
- // Copilot SDK discovery self-gates on an installed CLI (skipped:true
9002
- // when there is none), so unlike the providers above it has no
9003
- // enablement flag to check here.
9004
- typeof copilotModelDiscoveryService?.refresh === 'function'
9005
- ? copilotModelDiscoveryService.refresh('manual-refresh')
9006
- : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
9007
- deepseekSettings?.enabled === true && deepseekSettingsService
9008
- ? deepseekSettingsService.refreshModels()
9009
- : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
9010
- claudeApiSettings?.enabled === true && claudeApiSettingsService
9011
- ? claudeApiSettingsService.refreshModels()
9012
- : Promise.resolve({ ok: true, skipped: true, models: [], error: null }),
9013
- ];
9014
- const [
9015
- cliRefresh, openAIRefresh, claudeRefresh, cursorRefresh, grokRefresh, copilotDiscoveryRefresh, deepseekRefresh, claudeApiRefresh,
9016
- ] = await Promise.allSettled(refreshTasks);
9098
+ refreshProviderModels({ table: providerRefreshTable, reason: 'manual-refresh' }),
9099
+ ]);
9017
9100
  if (cliRefresh.status === 'rejected') throw cliRefresh.reason;
9018
- const openAIModelDiscovery = openAIRefresh.status === 'fulfilled'
9019
- ? openAIRefresh.value
9020
- : {
9021
- ok: false,
9022
- models: Array.isArray(openAISettings?.models) ? openAISettings.models : [],
9023
- error: openAIRefresh.reason?.message || 'OpenAI model discovery failed',
9024
- };
9025
- const claudeModelDiscovery = claudeRefresh.status === 'fulfilled'
9026
- ? claudeRefresh.value
9027
- : {
9028
- ok: false,
9029
- models: Array.isArray(claudeSettings?.models) ? claudeSettings.models : [],
9030
- error: claudeRefresh.reason?.message || 'Claude model discovery failed',
9031
- };
9032
- const cursorModelDiscovery = cursorRefresh.status === 'fulfilled'
9033
- ? cursorRefresh.value
9034
- : {
9035
- ok: false,
9036
- models: Array.isArray(cursorSettings?.models) ? cursorSettings.models : [],
9037
- error: cursorRefresh.reason?.message || 'Cursor model discovery failed',
9038
- };
9039
- const grokModelDiscovery = grokRefresh.status === 'fulfilled'
9040
- ? grokRefresh.value
9041
- : {
9042
- ok: false,
9043
- models: Array.isArray(grokSettings?.models) ? grokSettings.models : [],
9044
- error: grokRefresh.reason?.message || 'Grok model discovery failed',
9045
- };
9046
- const deepseekModelDiscovery = deepseekRefresh.status === 'fulfilled'
9047
- ? deepseekRefresh.value
9048
- : {
9049
- ok: false,
9050
- models: Array.isArray(deepseekSettings?.models) ? deepseekSettings.models : [],
9051
- error: deepseekRefresh.reason?.message || 'DeepSeek model discovery failed',
9052
- };
9053
- const claudeApiModelDiscovery = claudeApiRefresh.status === 'fulfilled'
9054
- ? claudeApiRefresh.value
9055
- : {
9056
- ok: false,
9057
- models: Array.isArray(claudeApiSettings?.models) ? claudeApiSettings.models : [],
9058
- error: claudeApiRefresh.reason?.message || 'Claude API model discovery failed',
9059
- };
9060
- const copilotModelDiscovery = copilotDiscoveryRefresh.status === 'fulfilled'
9061
- ? copilotDiscoveryRefresh.value
9062
- : { ok: false, models: [], error: copilotDiscoveryRefresh.reason?.message || 'Copilot model discovery failed' };
9063
- io.emit('models_updated', buildModelCatalogWithProviders(
9101
+ const results = providerRefresh.status === 'fulfilled' ? providerRefresh.value.results : {};
9102
+ io.emit('models_updated', modelsUpdatedPayload(
9064
9103
  getModelCatalogState(),
9065
9104
  ));
9066
9105
  return res.json({
9067
9106
  ok: true,
9068
9107
  ...buildModelVariantCatalogPayloadForRoute(),
9069
- openAIModelDiscovery,
9070
- claudeModelDiscovery,
9071
- cursorModelDiscovery,
9072
- grokModelDiscovery,
9073
- deepseekModelDiscovery,
9074
- claudeApiModelDiscovery,
9075
- copilotModelDiscovery,
9108
+ openAIModelDiscovery: results.openai,
9109
+ claudeModelDiscovery: results.claude,
9110
+ cursorModelDiscovery: results.cursor,
9111
+ grokModelDiscovery: results.grok,
9112
+ deepseekModelDiscovery: results.deepseek,
9113
+ claudeApiModelDiscovery: results['claude-api'],
9114
+ copilotModelDiscovery: results.github,
9076
9115
  });
9077
9116
  } catch (error) {
9078
9117
  return res.status(500).json({
@@ -9087,11 +9126,28 @@ export function registerSessionsRoutes(app, deps) {
9087
9126
  if (typeof setEnabledModelVariants !== 'function') {
9088
9127
  return res.status(501).json({ error: 'Model variant updates are unavailable' });
9089
9128
  }
9090
- const enabledVariantIds = Array.isArray(req.body?.enabledVariantIds)
9129
+ const hasVariantIds = Array.isArray(req.body?.enabledVariantIds);
9130
+ const autoEnableNew = req.body?.autoEnableNew;
9131
+ if (autoEnableNew !== undefined && typeof autoEnableNew !== 'boolean') {
9132
+ return res.status(400).json({ error: 'autoEnableNew must be true or false' });
9133
+ }
9134
+ if (!hasVariantIds && autoEnableNew === undefined) {
9135
+ return res.status(400).json({ error: 'No model variant update provided' });
9136
+ }
9137
+ const enabledVariantIds = hasVariantIds
9091
9138
  ? req.body.enabledVariantIds.map((value) => String(value || '').trim()).filter(Boolean)
9092
9139
  : [];
9093
- setEnabledModelVariants(enabledVariantIds);
9094
- io.emit('models_updated', buildModelCatalogWithProviders(
9140
+ // Every row off would leave the pickers with the fallback model only.
9141
+ if (hasVariantIds && !enabledVariantIds.length) {
9142
+ return res.status(400).json({ error: 'Select at least one model variant' });
9143
+ }
9144
+ // The rows the browser drew: only those change (an older page sends none).
9145
+ const shownVariantIds = Array.isArray(req.body?.shownVariantIds)
9146
+ ? req.body.shownVariantIds.map((value) => String(value || '').trim()).filter(Boolean)
9147
+ : null;
9148
+ if (hasVariantIds) setEnabledModelVariants(enabledVariantIds, shownVariantIds ? { shownVariantIds } : undefined);
9149
+ if (typeof autoEnableNew === 'boolean') setCopilotAutoEnableNew?.(autoEnableNew);
9150
+ io.emit('models_updated', modelsUpdatedPayload(
9095
9151
  getModelCatalogState(),
9096
9152
  ));
9097
9153
  return res.json({
@@ -9100,6 +9156,74 @@ export function registerSessionsRoutes(app, deps) {
9100
9156
  });
9101
9157
  });
9102
9158
 
9159
+ // Select Models was opened: every provider's "New" flags clear.
9160
+ app.post('/api/model-variants/seen', auth, (req, res) => {
9161
+ ensureSessionId(req, res);
9162
+ const markers = [
9163
+ () => markCopilotModelsSeen(),
9164
+ () => markProviderModelsSeen('openai'),
9165
+ () => markProviderModelsSeen('claude'),
9166
+ () => markProviderModelsSeen('cursor'),
9167
+ () => markProviderModelsSeen('grok'),
9168
+ () => deepseekSettingsService?.markModelsSeen?.() === true,
9169
+ () => claudeApiSettingsService?.markModelsSeen?.() === true,
9170
+ ];
9171
+ let changed = false;
9172
+ for (const mark of markers) {
9173
+ try {
9174
+ if (mark() === true) changed = true;
9175
+ } catch {
9176
+ /* one provider's map does not hold up the others */
9177
+ }
9178
+ }
9179
+ if (changed) {
9180
+ io.emit('models_updated', modelsUpdatedPayload(
9181
+ getModelCatalogState(),
9182
+ ));
9183
+ }
9184
+ return res.json({ ok: true, unseenNewModelCount: countUnseenNewModels() });
9185
+ });
9186
+
9187
+ // "Check for new models" (Settings → General → Models): the switch, the
9188
+ // interval, the providers left out, and each enabled provider's last check.
9189
+ function modelRefreshSettingsPayload(settings) {
9190
+ const last = modelRefreshLedger?.readLast?.() || {};
9191
+ const lastOf = (id) => (last[id] ? { at: last[id].at, ok: last[id].ok, error: last[id].error } : null);
9192
+ const ask = (id, gate) => {
9193
+ try {
9194
+ return providerRefreshTable[id]?.[gate]?.() === true;
9195
+ } catch {
9196
+ return false;
9197
+ }
9198
+ };
9199
+ // Every switched-on provider; one that is not set up is not checked and
9200
+ // says so.
9201
+ const providers = MODEL_REFRESH_PROVIDERS
9202
+ .filter(({ id }) => ask(id, 'isEnabled'))
9203
+ .map(({ id, label }) => ({ id, label, setUp: ask(id, 'isSetUp'), last: lastOf(id) }));
9204
+ return {
9205
+ auto: settings.auto,
9206
+ intervalHours: settings.intervalHours,
9207
+ intervalChoices: [...MODEL_REFRESH_INTERVAL_HOURS_CHOICES],
9208
+ default: { auto: DEFAULT_MODEL_REFRESH_AUTO, intervalHours: DEFAULT_MODEL_REFRESH_INTERVAL_HOURS },
9209
+ excluded: settings.excluded,
9210
+ providers,
9211
+ last: Object.fromEntries(Object.keys(last).map((id) => [id, { at: last[id].at, ok: last[id].ok }])),
9212
+ };
9213
+ }
9214
+
9215
+ app.get('/api/model-refresh-settings', auth, (_req, res) => {
9216
+ if (!modelRefreshSettings) return res.status(501).json({ error: 'Model refresh settings are unavailable' });
9217
+ return res.json(modelRefreshSettingsPayload(modelRefreshSettings.get()));
9218
+ });
9219
+
9220
+ app.post('/api/model-refresh-settings', auth, (req, res) => {
9221
+ if (!modelRefreshSettings) return res.status(501).json({ error: 'Model refresh settings are unavailable' });
9222
+ const result = modelRefreshSettings.update(req.body);
9223
+ if (!result.ok) return res.status(400).json({ error: result.error });
9224
+ return res.json(modelRefreshSettingsPayload(result.settings));
9225
+ });
9226
+
9103
9227
  app.get('/api/models', auth, (req, res) => {
9104
9228
  ensureSessionId(req, res);
9105
9229
  const modelState = buildModelCatalogWithProviders(
@@ -9121,6 +9245,8 @@ export function registerSessionsRoutes(app, deps) {
9121
9245
  claudeContextTiersByModel: modelState.claudeContextTiersByModel || {},
9122
9246
  claudeApiContextTiersByModel: modelState.claudeApiContextTiersByModel || {},
9123
9247
  providersByModel: modelState.providersByModel || {},
9248
+ // Models flagged new in Select Models: the menu item's dot.
9249
+ unseenNewModelCount: countUnseenNewModels(),
9124
9250
  stale: modelState.stale,
9125
9251
  metadataValid: modelState.metadataValid === true,
9126
9252
  reasoningMetadataValid: modelState.reasoningMetadataValid === true,
@@ -9142,7 +9268,7 @@ export function registerSessionsRoutes(app, deps) {
9142
9268
  source: source || 'relay-extension',
9143
9269
  error,
9144
9270
  });
9145
- io.emit('models_updated', buildModelCatalogWithProviders(
9271
+ io.emit('models_updated', modelsUpdatedPayload(
9146
9272
  getModelCatalogState(),
9147
9273
  ));
9148
9274
  res.json({