@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
@@ -7,8 +7,8 @@
7
7
  //
8
8
  // One key per relay, stored like the DeepSeek and Cursor keys in the settings
9
9
  // table and never sent to a browser. Next to it: the switch, the default
10
- // model, the discovered models and the ones enabled for the composer, and an
11
- // optional monthly spend cap. Commit attribution, the background command
10
+ // model, the discovered models and which of them are on for the composer
11
+ // (provider-model-state.mjs), and an optional monthly spend cap. Commit attribution, the background command
12
12
  // limit, the effort levels and the compaction window are the Claude tab's, so
13
13
  // they are not stored here.
14
14
  //
@@ -25,6 +25,7 @@ import {
25
25
  isClaudeLongContextModelId,
26
26
  isSafeClaudeModelId,
27
27
  } from '../../shared/model-id.mjs';
28
+ import { createProviderModelStateStore } from './provider-model-state.mjs';
28
29
  import { DEFAULT_CLAUDE_REASONING_EFFORTS, withClaudeUltracodeTier } from './provider-reasoning-effort.mjs';
29
30
 
30
31
  export const CLAUDE_API_PROVIDER_TYPE = 'claude-api';
@@ -34,6 +35,8 @@ export const CLAUDE_API_KEY_SETTING_KEY = 'claude_api_key';
34
35
  export const CLAUDE_API_ENABLED_SETTING_KEY = 'claude_api_enabled';
35
36
  export const CLAUDE_API_MODEL_SETTING_KEY = 'claude_api_model';
36
37
  export const CLAUDE_API_MODELS_SETTING_KEY = 'claude_api_models';
38
+ // The allowlist before 0.9.24: read once to build the model map, then left
39
+ // as it is so an older version still finds it.
37
40
  export const CLAUDE_API_ENABLED_MODELS_SETTING_KEY = 'claude_api_enabled_models';
38
41
  export const CLAUDE_API_MONTHLY_CAP_SETTING_KEY = 'claude_api_monthly_cap_usd';
39
42
  // The derived default a new key replaced, kept until that key's models are
@@ -180,8 +183,8 @@ function normalizeStoredModel(entry) {
180
183
  }
181
184
 
182
185
  /**
183
- * Reads `{ apiKey?, enabled?, model?, enabledModels?, monthlyCapUsd?,
184
- * remove? }` from a request body. The key is never echoed in an error, and a
186
+ * Reads `{ apiKey?, enabled?, model?, enabledModels?, autoEnableNew?,
187
+ * monthlyCapUsd?, remove? }` from a request body. The key is never echoed in an error, and a
185
188
  * Claude login token or an Admin key is refused by its prefix.
186
189
  */
187
190
  export function parseClaudeApiSettingsUpdateRequest(body = {}) {
@@ -212,6 +215,14 @@ export function parseClaudeApiSettingsUpdateRequest(body = {}) {
212
215
  if (!Array.isArray(payload.enabledModels)) return { ok: false, error: 'enabledModels must be a list' };
213
216
  update.enabledModels = uniqueModels(payload.enabledModels);
214
217
  }
218
+ if (hasOwn(payload, 'shownModels')) {
219
+ if (!Array.isArray(payload.shownModels)) return { ok: false, error: 'shownModels must be a list' };
220
+ update.shownModels = uniqueModels(payload.shownModels);
221
+ }
222
+ if (hasOwn(payload, 'autoEnableNew')) {
223
+ if (typeof payload.autoEnableNew !== 'boolean') return { ok: false, error: 'autoEnableNew must be true or false' };
224
+ update.autoEnableNew = payload.autoEnableNew;
225
+ }
215
226
  if (hasOwn(payload, 'monthlyCapUsd')) {
216
227
  const cap = parseClaudeApiMonthlyCap(payload.monthlyCapUsd);
217
228
  if (!cap.ok) return { ok: false, error: cap.error };
@@ -240,7 +251,11 @@ export function createClaudeApiSettingsService({
240
251
  // Where OAR_ANTHROPIC_API_BASE_URL is read (a loopback fake API in tests).
241
252
  env = process.env,
242
253
  now = () => new Date(),
254
+ // (result) after each discovery: the model refresh ledger's clock.
255
+ onModelsRefreshed = () => {},
243
256
  } = {}) {
257
+ const modelState = createProviderModelStateStore({ readSetting, writeSetting, now });
258
+
244
259
  function readClaudeSettings() {
245
260
  try {
246
261
  const settings = getClaudeProviderSettings();
@@ -321,10 +336,13 @@ export function createClaudeApiSettingsService({
321
336
  const model = stored && isUsableModelId(stored) ? stored : defaultModelFor(offered, claudeSettings, carried);
322
337
  const availableModels = uniqueModels([model, ...offered]);
323
338
  const availableKeys = new Set(availableModels.map((id) => id.toLowerCase()));
324
- const storedEnabled = uniqueModels(readJsonList(CLAUDE_API_ENABLED_MODELS_SETTING_KEY))
325
- .filter((entry) => availableKeys.has(entry.toLowerCase()));
326
- // The default model always stays enabled; with no choice stored, all are.
327
- const models = storedEnabled.length ? uniqueModels([model, ...storedEnabled]) : availableModels;
339
+ // The default model always stays on; the others as the model map says.
340
+ const enabled = modelState.resolve(CLAUDE_API_PROVIDER_TYPE, {
341
+ availableModels,
342
+ defaultModel: model,
343
+ legacyAllowlist: () => uniqueModels(readJsonList(CLAUDE_API_ENABLED_MODELS_SETTING_KEY)),
344
+ });
345
+ const models = uniqueModels(enabled.models);
328
346
 
329
347
  const detailsByBase = new Map(discovered.map((entry) => [entry.id.toLowerCase(), entry]));
330
348
  const claudeEfforts = claudeSettings?.effortsByModel && typeof claudeSettings.effortsByModel === 'object'
@@ -381,6 +399,9 @@ export function createClaudeApiSettingsService({
381
399
  models,
382
400
  availableModels,
383
401
  enabledModels: models,
402
+ autoEnableNew: enabled.autoEnableNew,
403
+ fallback: enabled.fallback,
404
+ unseenModels: enabled.unseen,
384
405
  discovered: discovered.length > 0,
385
406
  effortsByModel,
386
407
  contextWindowsByModel,
@@ -463,9 +484,9 @@ export function createClaudeApiSettingsService({
463
484
  activeQueueConversationCount: counts.queued,
464
485
  };
465
486
  }
487
+ // The model map stays: a key added again finds its models as they were.
466
488
  deleteSetting(CLAUDE_API_KEY_SETTING_KEY);
467
489
  deleteSetting(CLAUDE_API_MODELS_SETTING_KEY);
468
- deleteSetting(CLAUDE_API_ENABLED_MODELS_SETTING_KEY);
469
490
  deleteSetting(CLAUDE_API_CARRIED_MODEL_SETTING_KEY);
470
491
  writeSetting(CLAUDE_API_ENABLED_SETTING_KEY, 'false', stamp);
471
492
  return {
@@ -498,12 +519,16 @@ export function createClaudeApiSettingsService({
498
519
  if (parsed.model) writeSetting(CLAUDE_API_MODEL_SETTING_KEY, parsed.model, stamp);
499
520
  else deleteSetting(CLAUDE_API_MODEL_SETTING_KEY);
500
521
  }
501
- if (hasOwn(parsed, 'enabledModels')) {
502
- if (parsed.enabledModels.length) {
503
- writeSetting(CLAUDE_API_ENABLED_MODELS_SETTING_KEY, JSON.stringify(parsed.enabledModels), stamp);
504
- } else {
505
- deleteSetting(CLAUDE_API_ENABLED_MODELS_SETTING_KEY);
506
- }
522
+ if (hasOwn(parsed, 'enabledModels') || hasOwn(parsed, 'autoEnableNew')) {
523
+ // The models Select Models showed are set as ticked; an empty list
524
+ // leaves the default model alone, never all.
525
+ modelState.select(CLAUDE_API_PROVIDER_TYPE, {
526
+ enabledModels: parsed.enabledModels,
527
+ // The rows the browser drew; an older page sends none.
528
+ shownModels: parsed.shownModels || previous.availableModels,
529
+ defaultModel: getSettings().model,
530
+ autoEnableNew: parsed.autoEnableNew,
531
+ });
507
532
  }
508
533
  if (hasOwn(parsed, 'monthlyCapUsd')) {
509
534
  if (parsed.monthlyCapUsd === null) deleteSetting(CLAUDE_API_MONTHLY_CAP_SETTING_KEY);
@@ -530,6 +555,12 @@ export function createClaudeApiSettingsService({
530
555
  * new key's.
531
556
  */
532
557
  async function refreshModels() {
558
+ const result = await discoverModels();
559
+ try { onModelsRefreshed(result); } catch { /* the clock is best-effort */ }
560
+ return result;
561
+ }
562
+
563
+ async function discoverModels() {
533
564
  const settings = getSettings();
534
565
  if (!settings.configured) return { ok: false, models: settings.availableModels, error: 'Claude API key is not configured' };
535
566
  const redact = (text) => String(text ?? '').split(settings.apiKey).join('[redacted]');
@@ -560,7 +591,9 @@ export function createClaudeApiSettingsService({
560
591
  writeSetting?.(CLAUDE_API_MODELS_SETTING_KEY, JSON.stringify(models), now().toISOString());
561
592
  // The key's own list now decides the default.
562
593
  deleteSetting?.(CLAUDE_API_CARRIED_MODEL_SETTING_KEY);
563
- return { ok: true, models: getSettings().availableModels, error: null };
594
+ const availableModels = getSettings().availableModels;
595
+ const { added } = modelState.reconcile(CLAUDE_API_PROVIDER_TYPE, { availableModels });
596
+ return { ok: true, models: availableModels, newModels: added, error: null };
564
597
  } catch (error) {
565
598
  return {
566
599
  ok: false,
@@ -571,5 +604,9 @@ export function createClaudeApiSettingsService({
571
604
  }
572
605
  }
573
606
 
574
- return { getSettings, describe, update, refreshModels, contextWindowFor };
607
+ function markModelsSeen() {
608
+ return modelState.markSeen(CLAUDE_API_PROVIDER_TYPE);
609
+ }
610
+
611
+ return { getSettings, describe, update, refreshModels, contextWindowFor, markModelsSeen };
575
612
  }
@@ -70,8 +70,9 @@ const GENERIC_VERSION_PATTERN = /(\d+\.\d+(?:\.\d+)*)/;
70
70
  /**
71
71
  * The frozen descriptor table: the only place an install command exists.
72
72
  *
73
- * Cursor is deliberately absent — `@cursor/sdk` is a pure npm package and the
74
- * relay never invokes a `cursor-agent` binary, so an install button there would
73
+ * Cursor is deliberately absent — `@cursor/sdk` is an npm package the relay
74
+ * installs on demand (agent-sdk-store.mjs) and the relay never invokes a
75
+ * `cursor-agent` binary, so an install button there would
75
76
  * install a CLI nothing runs (plan §2.4). Adding it back later is a data change
76
77
  * here, not a code change.
77
78
  *
@@ -44,6 +44,9 @@ export function createCopilotModelDiscoveryService({
44
44
  updateModelCatalog,
45
45
  timeoutMs = DEFAULT_COPILOT_MODEL_DISCOVERY_TIMEOUT_MS,
46
46
  bootDelayMs = DEFAULT_COPILOT_MODEL_DISCOVERY_BOOT_DELAY_MS,
47
+ // (result, reason) after each refresh that got past the CLI check: the
48
+ // model refresh ledger's clock (the boot refresh included).
49
+ onRefreshed = () => {},
47
50
  logger = console,
48
51
  } = {}) {
49
52
  if (typeof createClient !== 'function' || typeof updateModelCatalog !== 'function') {
@@ -151,7 +154,10 @@ export function createCopilotModelDiscoveryService({
151
154
  }
152
155
  activeRefresh = null;
153
156
  }
154
- })();
157
+ })().then((result) => {
158
+ try { onRefreshed(result, reason); } catch { /* the clock is best-effort */ }
159
+ return result;
160
+ });
155
161
  return activeRefresh;
156
162
  }
157
163
 
@@ -27,7 +27,23 @@ const CHECK_DIR_PREFIX = '.database-file-check-';
27
27
  // one's work, not no rows: every start writes these settings (seen on a
28
28
  // real start of 0.9.19 on an empty folder). A later boot-time key goes here
29
29
  // deliberately; any other key makes the start refuse.
30
- export const STARTUP_SETTING_KEYS = Object.freeze(['push_vapid_public_key', 'push_vapid_private_key', 'relay_last_exit']);
30
+ // Since 0.9.24 a start also writes each provider's model map as it finds
31
+ // it (provider-model-state.mjs) and the last model refresh. A version that
32
+ // runs on copilot.db writes none of them; a start of this one, which the
33
+ // browser test uses as its stand-in, writes several.
34
+ export const STARTUP_SETTING_KEYS = Object.freeze([
35
+ 'push_vapid_public_key',
36
+ 'push_vapid_private_key',
37
+ 'relay_last_exit',
38
+ 'copilot_model_state',
39
+ 'openai_model_state',
40
+ 'claude_model_state',
41
+ 'cursor_model_state',
42
+ 'grok_model_state',
43
+ 'deepseek_model_state',
44
+ 'claude_api_model_state',
45
+ 'model_refresh_last',
46
+ ]);
31
47
  // And the tables such a start fills that hold no one's work: the model
32
48
  // catalog and its refresh state, the restart orchestrator's one row. (The
33
49
  // shadow tables of the message search are left out too: they follow
@@ -4,10 +4,11 @@
4
4
  //
5
5
  // One API key per relay, stored like the OpenAI and Cursor keys in the
6
6
  // settings table and never sent to the browser. Next to it: the switch, the
7
- // default model, the discovered models and the ones enabled for the
8
- // composer, an optional base URL, and the commit attribution of DeepSeek
9
- // chats. Discovery asks DeepSeek's `GET /models` (shared/deepseek-api.mjs);
10
- // when it has not run or failed, the built-in list stands in.
7
+ // default model, the discovered models and which of them are on for the
8
+ // composer (provider-model-state.mjs), an optional base URL, and the commit
9
+ // attribution of DeepSeek chats. Discovery asks DeepSeek's `GET /models`
10
+ // (shared/deepseek-api.mjs); when it has not run or failed, the built-in list
11
+ // stands in.
11
12
 
12
13
  import { createDeepSeekApiClient, normalizeDeepSeekBaseUrl } from '../../shared/deepseek-api.mjs';
13
14
  import {
@@ -22,11 +23,14 @@ import {
22
23
  } from '../../shared/deepseek-models.mjs';
23
24
  import { DEEPSEEK_PRICE_CURRENCIES } from '../../shared/deepseek-pricing.mjs';
24
25
  import { isSafeDeepSeekModelId } from '../../shared/model-id.mjs';
26
+ import { createProviderModelStateStore } from './provider-model-state.mjs';
25
27
 
26
28
  export const DEEPSEEK_API_KEY_SETTING_KEY = 'deepseek_api_key';
27
29
  export const DEEPSEEK_ENABLED_SETTING_KEY = 'deepseek_enabled';
28
30
  export const DEEPSEEK_MODEL_SETTING_KEY = 'deepseek_model';
29
31
  export const DEEPSEEK_MODELS_SETTING_KEY = 'deepseek_models';
32
+ // The allowlist before 0.9.24: read once to build the model map, then left
33
+ // as it is so an older version still finds it.
30
34
  export const DEEPSEEK_ENABLED_MODELS_SETTING_KEY = 'deepseek_enabled_models';
31
35
  export const DEEPSEEK_BASE_URL_SETTING_KEY = 'deepseek_base_url';
32
36
  export const DEEPSEEK_ATTRIBUTION_SETTING_KEY = 'deepseek_attribution';
@@ -95,9 +99,9 @@ function parseMonthlyBudgetCurrency(value) {
95
99
  }
96
100
 
97
101
  /**
98
- * Reads `{ apiKey?, enabled?, model?, enabledModels?, baseUrl?, attribution?,
99
- * monthlyBudget?, monthlyBudgetCurrency?, startingBalance?,
100
- * startingBalanceEdited?, remove? }` from a
102
+ * Reads `{ apiKey?, enabled?, model?, enabledModels?, autoEnableNew?,
103
+ * baseUrl?, attribution?, monthlyBudget?, monthlyBudgetCurrency?,
104
+ * startingBalance?, startingBalanceEdited?, remove? }` from a
101
105
  * request body. The
102
106
  * key is never echoed in an error.
103
107
  */
@@ -124,6 +128,14 @@ export function parseDeepSeekSettingsUpdateRequest(body = {}) {
124
128
  if (!Array.isArray(payload.enabledModels)) return { ok: false, error: 'enabledModels must be a list' };
125
129
  update.enabledModels = uniqueModels(payload.enabledModels);
126
130
  }
131
+ if (hasOwn(payload, 'shownModels')) {
132
+ if (!Array.isArray(payload.shownModels)) return { ok: false, error: 'shownModels must be a list' };
133
+ update.shownModels = uniqueModels(payload.shownModels);
134
+ }
135
+ if (hasOwn(payload, 'autoEnableNew')) {
136
+ if (typeof payload.autoEnableNew !== 'boolean') return { ok: false, error: 'autoEnableNew must be true or false' };
137
+ update.autoEnableNew = payload.autoEnableNew;
138
+ }
127
139
  if (hasOwn(payload, 'baseUrl')) {
128
140
  const normalized = normalizeDeepSeekBaseUrl(payload.baseUrl === null ? '' : payload.baseUrl);
129
141
  if (!normalized.ok) return { ok: false, error: normalized.error };
@@ -168,7 +180,11 @@ export function createDeepSeekSettingsService({
168
180
  listProviderCandidates = () => [],
169
181
  fetchImpl = globalThis.fetch,
170
182
  now = () => new Date(),
183
+ // (result) after each discovery: the model refresh ledger's clock.
184
+ onModelsRefreshed = () => {},
171
185
  } = {}) {
186
+ const modelState = createProviderModelStateStore({ readSetting, writeSetting, now });
187
+
172
188
  function readList(settingKey) {
173
189
  try {
174
190
  const parsed = JSON.parse(readSetting(settingKey) || '[]');
@@ -220,10 +236,13 @@ export function createDeepSeekSettingsService({
220
236
  const model = readModel();
221
237
  const discovered = readList(DEEPSEEK_MODELS_SETTING_KEY);
222
238
  const availableModels = uniqueModels([model, ...(discovered.length ? discovered : DEFAULT_DEEPSEEK_MODELS)]);
223
- const storedEnabled = readList(DEEPSEEK_ENABLED_MODELS_SETTING_KEY)
224
- .filter((entry) => availableModels.some((candidate) => candidate.toLowerCase() === entry.toLowerCase()));
225
- // The default model always stays enabled; with no choice stored, all are.
226
- const models = storedEnabled.length ? uniqueModels([model, ...storedEnabled]) : availableModels;
239
+ // The default model always stays on; the others as the model map says.
240
+ const enabled = modelState.resolve(DEEPSEEK_PROVIDER_TYPE, {
241
+ availableModels,
242
+ defaultModel: model,
243
+ legacyAllowlist: () => readList(DEEPSEEK_ENABLED_MODELS_SETTING_KEY),
244
+ });
245
+ const models = uniqueModels(enabled.models);
227
246
  return {
228
247
  configured: !!apiKey,
229
248
  enabled: !!apiKey && (enabledSetting === '' || enabledSetting === 'true'),
@@ -231,6 +250,9 @@ export function createDeepSeekSettingsService({
231
250
  model,
232
251
  models,
233
252
  availableModels,
253
+ autoEnableNew: enabled.autoEnableNew,
254
+ fallback: enabled.fallback,
255
+ unseenModels: enabled.unseen,
234
256
  discovered: discovered.length > 0,
235
257
  baseUrl: readBaseUrl(),
236
258
  attribution: readAttribution(),
@@ -291,9 +313,9 @@ export function createDeepSeekSettingsService({
291
313
  activeQueueConversationCount: counts.queued,
292
314
  };
293
315
  }
316
+ // The model map stays: a key added again finds its models as they were.
294
317
  deleteSetting(DEEPSEEK_API_KEY_SETTING_KEY);
295
318
  deleteSetting(DEEPSEEK_MODELS_SETTING_KEY);
296
- deleteSetting(DEEPSEEK_ENABLED_MODELS_SETTING_KEY);
297
319
  writeSetting(DEEPSEEK_ENABLED_SETTING_KEY, 'false', stamp);
298
320
  return { ok: true, removed: true, keyChanged: previous.configured, discover: false, settings: describe() };
299
321
  }
@@ -315,12 +337,16 @@ export function createDeepSeekSettingsService({
315
337
  }
316
338
  if (hasOwn(parsed, 'enabled') || keyChanged) writeSetting(DEEPSEEK_ENABLED_SETTING_KEY, nextEnabled ? 'true' : 'false', stamp);
317
339
  if (hasOwn(parsed, 'model')) writeSetting(DEEPSEEK_MODEL_SETTING_KEY, parsed.model, stamp);
318
- if (hasOwn(parsed, 'enabledModels')) {
319
- if (parsed.enabledModels.length) {
320
- writeSetting(DEEPSEEK_ENABLED_MODELS_SETTING_KEY, JSON.stringify(parsed.enabledModels), stamp);
321
- } else {
322
- deleteSetting(DEEPSEEK_ENABLED_MODELS_SETTING_KEY);
323
- }
340
+ if (hasOwn(parsed, 'enabledModels') || hasOwn(parsed, 'autoEnableNew')) {
341
+ // The models Select Models showed are set as ticked; an empty list
342
+ // leaves the default model alone, never all.
343
+ modelState.select(DEEPSEEK_PROVIDER_TYPE, {
344
+ enabledModels: parsed.enabledModels,
345
+ // The rows the browser drew; an older page sends none.
346
+ shownModels: parsed.shownModels || previous.availableModels,
347
+ defaultModel: readModel(),
348
+ autoEnableNew: parsed.autoEnableNew,
349
+ });
324
350
  }
325
351
  if (hasOwn(parsed, 'attribution')) writeSetting(DEEPSEEK_ATTRIBUTION_SETTING_KEY, parsed.attribution, stamp);
326
352
  if (hasOwn(parsed, 'monthlyBudget')) {
@@ -361,6 +387,12 @@ export function createDeepSeekSettingsService({
361
387
  * failure keeps what was stored, so the pickers never empty out.
362
388
  */
363
389
  async function refreshModels() {
390
+ const result = await discoverModels();
391
+ try { onModelsRefreshed(result); } catch { /* the clock is best-effort */ }
392
+ return result;
393
+ }
394
+
395
+ async function discoverModels() {
364
396
  const settings = getSettings();
365
397
  if (!settings.configured) return { ok: false, models: settings.availableModels, error: 'DeepSeek API key is not configured' };
366
398
  try {
@@ -370,11 +402,17 @@ export function createDeepSeekSettingsService({
370
402
  return { ok: false, models: settings.availableModels, error: 'DeepSeek listed no DeepSeek models' };
371
403
  }
372
404
  writeSetting?.(DEEPSEEK_MODELS_SETTING_KEY, JSON.stringify(models), now().toISOString());
373
- return { ok: true, models: getSettings().availableModels, error: null };
405
+ const availableModels = getSettings().availableModels;
406
+ const { added } = modelState.reconcile(DEEPSEEK_PROVIDER_TYPE, { availableModels });
407
+ return { ok: true, models: availableModels, newModels: added, error: null };
374
408
  } catch (error) {
375
409
  return { ok: false, models: settings.availableModels, error: error?.message || 'DeepSeek model discovery failed', code: error?.code || null };
376
410
  }
377
411
  }
378
412
 
379
- return { getSettings, describe, update, refreshModels };
413
+ function markModelsSeen() {
414
+ return modelState.markSeen(DEEPSEEK_PROVIDER_TYPE);
415
+ }
416
+
417
+ return { getSettings, describe, update, refreshModels, markModelsSeen };
380
418
  }
@@ -9,10 +9,10 @@ import {
9
9
  } from './database-file.mjs';
10
10
  import { resolveOarRoot } from './oar-state-migration-service.mjs';
11
11
  import { resolveStateLayout } from './oar-state-layout.mjs';
12
- import { defaultIsProcessAlive, lockHolderMayRun, processStartedAt as defaultProcessStartedAt } from './relay-singleton-guard.mjs';
12
+ import { defaultIsProcessAlive, lockHolderMayRun, processImageName, processStartedAt as defaultProcessStartedAt } from './relay-singleton-guard.mjs';
13
13
 
14
14
  const SHA256_NAME = /^[a-f0-9]{64}$/;
15
- // The install-time rescue's copies (package-data-rescue.mjs), in the data folder.
15
+ // The copies the install-time rescue of 0.9.14 to 0.9.22 made, in the data folder.
16
16
  export const RESCUE_DIR_NAME = 'rescued-package-data';
17
17
  // What was looked at already: never offered again unless it changed.
18
18
  export const CHECKED_FILE = 'earlier-databases.json';
@@ -94,6 +94,8 @@ function adopt({
94
94
  platform = process.platform,
95
95
  isProcessAlive = defaultIsProcessAlive,
96
96
  processStartedAt = (pid) => defaultProcessStartedAt(pid, { platform }),
97
+ // What a pid runs, when its start cannot be told (Windows without koffi).
98
+ imageNameOf = processImageName,
97
99
  bootTime = Date.now() - os.uptime() * 1000,
98
100
  now = () => new Date(),
99
101
  clock = Date.now,
@@ -234,7 +236,7 @@ function adopt({
234
236
  fsImpl,
235
237
  rename,
236
238
  exists,
237
- lockMayRun: (lock) => lockHolderMayRun({ lock, isProcessAlive, startedAtOf: processStartedAt, bootTime }),
239
+ lockMayRun: (lock) => lockHolderMayRun({ lock, isProcessAlive, startedAtOf: processStartedAt, bootTime, platform, imageNameOf }),
238
240
  normalize,
239
241
  progress,
240
242
  });
@@ -60,6 +60,19 @@ export function withFileVersion(url, version) {
60
60
  return appendUrlParam(String(url || ''), 'v', String(version || ''));
61
61
  }
62
62
 
63
+ /**
64
+ * One of the relay's own files (sw.js, the socket.io client) through
65
+ * res.sendFile, rooted at its folder. Given a bare absolute path, express 5's
66
+ * send refuses any path with a dot folder in it (dotfiles 'ignore' looks at
67
+ * every segment without a root): a 404 for an install under ~/.oar, nvm or
68
+ * ~/.npm-global. With the folder as root only the file name is looked at.
69
+ */
70
+ export function sendOwnFile(res, absolutePath, next) {
71
+ res.sendFile(path.basename(absolutePath), { root: path.dirname(absolutePath) }, (error) => {
72
+ if (error) next(error);
73
+ });
74
+ }
75
+
63
76
  /**
64
77
  * `cacheControl` replaces the no-store default for content whose URL names
65
78
  * its bytes (an upload addressed by its hash): such an answer may be kept.
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+
3
+ // The background "Check for new models" (Settings → General → Models).
4
+ //
5
+ // A minute after boot, then every quarter of an hour, the scheduler asks
6
+ // which providers are due and refreshes those through the same helper as the
7
+ // Select Models Refresh button (provider-model-refresh.mjs), with reason
8
+ // `background`. A provider is due when the check is on, the provider is not
9
+ // left out, it is enabled and set up, its last successful refresh (by
10
+ // anything: the button, a save, a login, the boot) is at least the interval
11
+ // old, and its last failure at least an hour old. A provider never refreshed
12
+ // is due at the first tick, which is how an overdue provider is checked after
13
+ // boot. Time and timers are injected so a test can drive it with a fake clock.
14
+
15
+ import {
16
+ MODEL_REFRESH_FAILURE_RETRY_MS,
17
+ MODEL_REFRESH_FIRST_TICK_MS,
18
+ MODEL_REFRESH_PROVIDERS,
19
+ MODEL_REFRESH_TICK_MS,
20
+ } from '../../shared/model-refresh-schedule.mjs';
21
+ import { modelRefreshErrorSummary } from './provider-model-refresh.mjs';
22
+
23
+ const HOUR_MS = 60 * 60 * 1000;
24
+
25
+ function timeOf(value) {
26
+ const ms = Date.parse(String(value || ''));
27
+ return Number.isFinite(ms) ? ms : null;
28
+ }
29
+
30
+ function notInFuture(ms, nowMs) {
31
+ return ms !== null && ms > nowMs ? null : ms;
32
+ }
33
+
34
+ /**
35
+ * The providers due at `nowMs`, in the canonical order. `settings` is
36
+ * `{ auto, intervalHours, excluded }`, `last` the ledger's entries, `table`
37
+ * the provider table (buildProviderRefreshTable).
38
+ */
39
+ export function dueModelRefreshProviders({ settings = {}, last = {}, table = {}, nowMs = Date.now() } = {}) {
40
+ if (settings.auto === false) return [];
41
+ const intervalMs = Math.max(1, Number(settings.intervalHours) || 24) * HOUR_MS;
42
+ const excluded = new Set(Array.isArray(settings.excluded) ? settings.excluded : []);
43
+ const due = [];
44
+ for (const { id } of MODEL_REFRESH_PROVIDERS) {
45
+ const entry = table[id];
46
+ if (!entry || excluded.has(id) || typeof entry.refresh !== 'function') continue;
47
+ let setUp = false;
48
+ try {
49
+ setUp = entry.isSetUp?.() === true;
50
+ } catch {
51
+ setUp = false;
52
+ }
53
+ if (!setUp) continue;
54
+ // A stamp later than now (a clock set back, a hand edit) cannot be
55
+ // trusted: the provider is due rather than skipped until then.
56
+ const lastOk = notInFuture(timeOf(last[id]?.lastOkAt), nowMs);
57
+ const lastFailure = notInFuture(timeOf(last[id]?.lastFailureAt), nowMs);
58
+ if (lastOk !== null && nowMs - lastOk < intervalMs) continue;
59
+ if (lastFailure !== null && nowMs - lastFailure < MODEL_REFRESH_FAILURE_RETRY_MS) continue;
60
+ due.push(id);
61
+ }
62
+ return due;
63
+ }
64
+
65
+ export function createModelRefreshScheduler({
66
+ // () => { auto, intervalHours, excluded }
67
+ getSettings = () => ({ auto: true, intervalHours: 24, excluded: [] }),
68
+ // () => { <provider>: { lastOkAt, lastFailureAt } } (the ledger)
69
+ getLast = () => ({}),
70
+ // () => the provider table (buildProviderRefreshTable)
71
+ getTable = () => ({}),
72
+ // ({ providers, reason }) => Promise<{ results, changed }>
73
+ refreshProviders = async () => ({ results: {}, changed: false }),
74
+ now = () => Date.now(),
75
+ setTimeoutFn = setTimeout,
76
+ clearTimeoutFn = clearTimeout,
77
+ firstTickMs = MODEL_REFRESH_FIRST_TICK_MS,
78
+ tickMs = MODEL_REFRESH_TICK_MS,
79
+ logger = console,
80
+ } = {}) {
81
+ let timer = null;
82
+ let running = null;
83
+ let disposed = false;
84
+ // Set by the routes layer, which owns the models_updated payload.
85
+ let onModelsChanged = () => {};
86
+
87
+ function schedule(delayMs) {
88
+ if (disposed) return;
89
+ if (timer) clearTimeoutFn(timer);
90
+ timer = setTimeoutFn(() => {
91
+ timer = null;
92
+ void tick().finally(() => schedule(tickMs));
93
+ }, Math.max(0, Number(delayMs) || 0));
94
+ timer?.unref?.();
95
+ }
96
+
97
+ /** One look: refreshes what is due. Never rejects; one run at a time. */
98
+ async function tick() {
99
+ if (disposed) return { providers: [], results: {}, changed: false };
100
+ if (running) return running;
101
+ // Cleared in a .finally of its own: a run with nothing due settles
102
+ // before the assignment would otherwise have happened.
103
+ running = (async () => {
104
+ let providers = [];
105
+ try {
106
+ providers = dueModelRefreshProviders({ settings: getSettings(), last: getLast(), table: getTable(), nowMs: now() });
107
+ if (!providers.length) return { providers, results: {}, changed: false };
108
+ logger.log?.(`[model-refresh] background model refresh: ${providers.join(', ')}`);
109
+ const { results = {}, changed = false } = await refreshProviders({ providers, reason: 'background' }) || {};
110
+ const failed = providers.filter((id) => results[id] && results[id].ok !== true && results[id].skipped !== true);
111
+ if (failed.length) {
112
+ // The provider and the kind of failure only: an answer's body may
113
+ // echo what was sent with the request.
114
+ logger.warn?.(`[model-refresh] background model refresh failed for ${failed.map((id) => `${id} (${modelRefreshErrorSummary(results[id].error)})`).join(', ')}`);
115
+ }
116
+ if (changed && !disposed) {
117
+ try {
118
+ onModelsChanged();
119
+ } catch (error) {
120
+ logger.warn?.(`[model-refresh] models-changed listener failed: ${error?.message || error}`);
121
+ }
122
+ }
123
+ return { providers, results, changed };
124
+ } catch (error) {
125
+ logger.warn?.(`[model-refresh] background model refresh failed: ${modelRefreshErrorSummary(error?.message || error)}`);
126
+ return { providers, results: {}, changed: false };
127
+ }
128
+ })().finally(() => {
129
+ running = null;
130
+ });
131
+ return running;
132
+ }
133
+
134
+ function start() {
135
+ if (disposed || timer) return;
136
+ schedule(firstTickMs);
137
+ }
138
+
139
+ function setOnModelsChanged(listener) {
140
+ onModelsChanged = typeof listener === 'function' ? listener : () => {};
141
+ }
142
+
143
+ async function dispose() {
144
+ disposed = true;
145
+ if (timer) {
146
+ clearTimeoutFn(timer);
147
+ timer = null;
148
+ }
149
+ try { await running; } catch { /* tick() never rejects */ }
150
+ }
151
+
152
+ return { start, tick, dispose, setOnModelsChanged, isStarted: () => timer !== null };
153
+ }