@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
@@ -37,8 +37,16 @@ import {
37
37
  updateSubagentRecordRetentionSetting as requestSubagentRecordRetentionSetting,
38
38
  loadFeatureFlagSettings,
39
39
  updateFeatureFlagSetting as requestFeatureFlagSetting,
40
+ loadModelRefreshSettings,
41
+ updateModelRefreshSettings as requestModelRefreshSettings,
40
42
  requestRelayRestart,
41
43
  } from './api-client.js';
44
+ import {
45
+ formatModelRefreshIntervalLabel,
46
+ formatModelRefreshLastChecked,
47
+ nextModelRefreshExcluded,
48
+ normalizeModelRefreshSettings,
49
+ } from './model-refresh-ui.mjs';
42
50
  import { DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES } from './background-task-timeout-options.mjs';
43
51
  import {
44
52
  DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES,
@@ -77,6 +85,7 @@ export {
77
85
  } from './claude-api-settings-ui.js';
78
86
  import { openClaudeCloudSettingsSection } from './claude-cloud-settings-ui.js';
79
87
  import { refreshCliInstallSections } from './cli-install-ui.js';
88
+ import { refreshAgentSdkRows } from './agent-sdk-ui.js';
80
89
  import { refreshUpdateSection } from './update-ui.js';
81
90
  import { refreshRemoteRelaysSection } from './remote-relays-settings.js';
82
91
  import { refreshAgentSessionsSection } from './agent-sessions-settings-ui.js';
@@ -399,6 +408,161 @@ export async function updateSubagentRecordRetentionSetting(value) {
399
408
  }
400
409
  }
401
410
 
411
+ // "Check for new models" (Settings › General › Models): a relay setting, on
412
+ // every 24 h by default, with one checkbox per enabled provider to leave it
413
+ // out (server/services/model-refresh-scheduler.mjs). Starts at the relay's
414
+ // defaults until its answer loads.
415
+ let modelRefreshSettings = normalizeModelRefreshSettings(null);
416
+ let modelRefreshUpdateInFlight = false;
417
+ // The provider list waits for the relay's answer: "Loading…" until one
418
+ // arrived, the reason while the last read failed.
419
+ let modelRefreshProvidersLoaded = false;
420
+ let modelRefreshLoadError = '';
421
+
422
+ function syncModelRefreshIntervalOptions(select) {
423
+ const { intervalChoices, intervalHours } = modelRefreshSettings;
424
+ // A stored value outside today's choices still shows as itself.
425
+ const choices = intervalChoices.includes(intervalHours)
426
+ ? intervalChoices
427
+ : [...intervalChoices, intervalHours].sort((a, b) => a - b);
428
+ const current = Array.from(select.options).map((option) => option.value).join(',');
429
+ if (current === choices.join(',')) return;
430
+ select.replaceChildren(...choices.map((hours) => {
431
+ const option = document.createElement('option');
432
+ option.value = String(hours);
433
+ option.textContent = formatModelRefreshIntervalLabel(hours);
434
+ return option;
435
+ }));
436
+ }
437
+
438
+ function renderModelRefreshProviderRows(list) {
439
+ const excluded = new Set(modelRefreshSettings.excluded);
440
+ const disabled = modelRefreshUpdateInFlight || !modelRefreshSettings.auto;
441
+ const now = Date.now();
442
+ const status = (text, className) => {
443
+ const line = document.createElement('div');
444
+ line.className = className;
445
+ line.textContent = text;
446
+ return line;
447
+ };
448
+ let statusLine = null;
449
+ if (modelRefreshLoadError) {
450
+ statusLine = status(`Could not load the providers: ${modelRefreshLoadError}`, 'settings-help model-refresh-providers-error');
451
+ } else if (!modelRefreshProvidersLoaded) {
452
+ statusLine = status('Loading…', 'settings-help model-refresh-providers-loading');
453
+ } else if (!modelRefreshSettings.providers.length) {
454
+ statusLine = status('No provider is switched on.', 'settings-help');
455
+ }
456
+ // After a failed read, the providers of an earlier answer stay below it.
457
+ const providers = modelRefreshProvidersLoaded ? modelRefreshSettings.providers : [];
458
+ list.replaceChildren(...(statusLine ? [statusLine] : []), ...providers.map((provider) => {
459
+ const inputId = `model-refresh-provider-${provider.id.toLowerCase().replace(/[^a-z0-9-]/g, '-')}`;
460
+ const row = document.createElement('div');
461
+ row.className = 'settings-row model-refresh-provider-row';
462
+ const label = document.createElement('label');
463
+ label.htmlFor = inputId;
464
+ const name = document.createElement('span');
465
+ name.textContent = `Check ${provider.label}`;
466
+ const last = document.createElement('span');
467
+ last.className = 'model-refresh-provider-last';
468
+ // An enabled provider without its key or CLI is not checked until it is.
469
+ last.textContent = provider.setUp ? formatModelRefreshLastChecked(provider.last, now) : 'Not set up';
470
+ label.append(name, last);
471
+ const input = document.createElement('input');
472
+ input.id = inputId;
473
+ input.type = 'checkbox';
474
+ input.dataset.modelRefreshProvider = provider.id;
475
+ input.setAttribute('aria-label', `Check ${provider.label} for new models`);
476
+ input.checked = !excluded.has(provider.id);
477
+ input.disabled = disabled;
478
+ input.addEventListener('change', () => {
479
+ void updateModelRefreshProviderSetting(provider.id, input.checked);
480
+ });
481
+ row.append(label, input);
482
+ return row;
483
+ }));
484
+ }
485
+
486
+ function syncModelRefreshSection() {
487
+ const toggle = document.getElementById('model-refresh-auto-toggle');
488
+ if (toggle instanceof HTMLInputElement) {
489
+ toggle.checked = modelRefreshSettings.auto;
490
+ toggle.disabled = modelRefreshUpdateInFlight;
491
+ }
492
+ const select = document.getElementById('model-refresh-interval-select');
493
+ if (select && select.tagName === 'SELECT') {
494
+ syncModelRefreshIntervalOptions(select);
495
+ select.value = String(modelRefreshSettings.intervalHours);
496
+ // Nothing to time while the check is off.
497
+ select.disabled = modelRefreshUpdateInFlight || !modelRefreshSettings.auto;
498
+ }
499
+ const list = document.getElementById('model-refresh-providers');
500
+ if (list) renderModelRefreshProviderRows(list);
501
+ }
502
+
503
+ export async function refreshModelRefreshSettings() {
504
+ // A new read: "Loading…" again until it answers, or the earlier list.
505
+ modelRefreshLoadError = '';
506
+ syncModelRefreshSection();
507
+ try {
508
+ const result = await loadModelRefreshSettings();
509
+ if (!result || typeof result !== 'object') throw new Error('The relay did not answer.');
510
+ modelRefreshSettings = normalizeModelRefreshSettings(result);
511
+ modelRefreshProvidersLoaded = true;
512
+ modelRefreshLoadError = '';
513
+ } catch (error) {
514
+ // Keep the last known value; the setting is not critical.
515
+ modelRefreshLoadError = String(error?.message || '').trim() || 'The relay did not answer.';
516
+ } finally {
517
+ syncModelRefreshSection();
518
+ }
519
+ }
520
+
521
+ async function saveModelRefreshSettings(patch, describe) {
522
+ if (modelRefreshUpdateInFlight) {
523
+ syncModelRefreshSection();
524
+ return;
525
+ }
526
+ modelRefreshUpdateInFlight = true;
527
+ syncModelRefreshSection();
528
+ try {
529
+ const result = await requestModelRefreshSettings(patch);
530
+ if (!result) throw new Error('Failed to update the check for new models.');
531
+ modelRefreshSettings = normalizeModelRefreshSettings(result);
532
+ modelRefreshProvidersLoaded = true;
533
+ modelRefreshLoadError = '';
534
+ showTransientRelayNotice(describe(modelRefreshSettings));
535
+ } catch (error) {
536
+ alert(error?.message || 'Failed to update the check for new models.');
537
+ } finally {
538
+ modelRefreshUpdateInFlight = false;
539
+ syncModelRefreshSection();
540
+ }
541
+ }
542
+
543
+ export function updateModelRefreshAutoSetting(checked) {
544
+ return saveModelRefreshSettings({ auto: checked === true }, (settings) => (settings.auto
545
+ ? `The relay checks for new models ${formatModelRefreshIntervalLabel(settings.intervalHours).toLowerCase()}.`
546
+ : 'The relay no longer checks for new models by itself.'));
547
+ }
548
+
549
+ export function updateModelRefreshIntervalSetting(value) {
550
+ return saveModelRefreshSettings({ intervalHours: Math.round(Number(value)) }, (settings) => (
551
+ `The relay checks for new models ${formatModelRefreshIntervalLabel(settings.intervalHours).toLowerCase()}.`
552
+ ));
553
+ }
554
+
555
+ export function updateModelRefreshProviderSetting(providerId, checked) {
556
+ const id = String(providerId || '').trim();
557
+ const label = modelRefreshSettings.providers.find((provider) => provider.id === id)?.label || id;
558
+ return saveModelRefreshSettings(
559
+ { excluded: nextModelRefreshExcluded(modelRefreshSettings.excluded, id, checked === true) },
560
+ (settings) => (settings.excluded.includes(id)
561
+ ? `${label} is no longer checked for new models.`
562
+ : `${label} is checked for new models.`),
563
+ );
564
+ }
565
+
402
566
  export async function updateBackgroundTaskTimeoutSetting(value) {
403
567
  if (backgroundTaskTimeoutUpdateInFlight) {
404
568
  syncBackgroundTaskTimeoutSlider();
@@ -1966,6 +2130,8 @@ export function openSettingsModal(tab, providerTab) {
1966
2130
  // in the meantime should show up the moment the panel opens. Sockets keep it
1967
2131
  // current from here on.
1968
2132
  void refreshCliInstallSections({ force: true });
2133
+ // The agent SDK rows: a read of the store's state, nothing spawned.
2134
+ void refreshAgentSdkRows();
1969
2135
  grokSettingsInputsDirty = false;
1970
2136
  ensureGrokSettingsInputTracking();
1971
2137
  void syncGrokSettingsInputs();
@@ -1989,6 +2155,7 @@ export function openSettingsModal(tab, providerTab) {
1989
2155
  void refreshRestartResumeSetting();
1990
2156
  syncSubagentRecordRetentionSelect();
1991
2157
  void refreshSubagentRecordRetentionSetting();
2158
+ void refreshModelRefreshSettings();
1992
2159
  void refreshRelayRestartMaxWaitSetting();
1993
2160
  void refreshPushSettingsSection();
1994
2161
  void refreshPreviewsSection();
@@ -31,7 +31,9 @@ import {
31
31
  refreshSummaryModal,
32
32
  summaryModalState,
33
33
  IS_SHARED_VIEW,
34
+ showTransientRelayNotice,
34
35
  } from './store.js';
36
+ import { newModelsToastFor } from './model-refresh-ui.mjs';
35
37
  import { scheduleContextUsageRefresh } from './api-client.js';
36
38
  import { capFinishedActivityEntries, capLiveActivityEntries, normalizeTurnSeq } from './activity-replay-state.mjs';
37
39
  import { liveActivityCap } from './live-activity-cap.mjs';
@@ -66,6 +68,7 @@ import { applyClaudeCloudSettingsState, refreshClaudeCloudSettingsState } from '
66
68
  import { applyClaudeCloudSessionEvent } from './claude-cloud-conversation-ui.js';
67
69
  import { updateProviderReadinessInputs } from './provider-readiness.mjs';
68
70
  import { applyCliInstallState } from './cli-install-ui.js';
71
+ import { applyAgentSdkState } from './agent-sdk-ui.js';
69
72
  import { renderUpdateSection } from './update-ui.js';
70
73
  import { applyHostSuspendState, applyRelayShutdownState } from './host-suspend-ui.js';
71
74
  import { applyClaudeUsageLimit, applyConversationBilling, applyUsageLimitPause } from './usage-limit-ui.js';
@@ -150,6 +153,7 @@ let deps = null;
150
153
  * @property {(force?: boolean) => (void | Promise<void>)} refreshModelCatalog
151
154
  * @property {(payload?: object) => void} updateModelCatalogState
152
155
  * @property {() => (void | Promise<void>)} reconcileOpenModelVariantModal
156
+ * @property {(tab?: string) => (void | Promise<void>)} [openSelectModelsModal]
153
157
  * @property {(payload?: object) => void} applyConversationWorkspaceRootUpdate
154
158
  * @property {(conversationId: string, title: string, updatedAt?: string | number | null) => void} applyConversationTitleUpdate
155
159
  * @property {() => void} syncChatTitleControls
@@ -414,6 +418,18 @@ export async function connectSocket(overrideDeps) {
414
418
  socket.on('models_updated', (payload) => {
415
419
  updateModelCatalogState(payload || {});
416
420
  void reconcileOpenModelVariantModal();
421
+ // A refresh that found models never seen before: one toast per event, on
422
+ // the clients connected now (the others get the menu dot and the tags).
423
+ const toast = newModelsToastFor(payload, { sharedView: IS_SHARED_VIEW });
424
+ if (toast) {
425
+ const { tab } = toast;
426
+ showTransientRelayNotice(toast.text, 10000, {
427
+ actionLabel: 'Show',
428
+ onAction: () => {
429
+ void Promise.resolve(deps?.openSelectModelsModal?.(tab)).catch(() => {});
430
+ },
431
+ });
432
+ }
417
433
  });
418
434
  // Each settings event also feeds provider readiness (provider-readiness.mjs),
419
435
  // so a Settings save changes the empty screen without a reload.
@@ -457,6 +473,11 @@ export async function connectSocket(overrideDeps) {
457
473
  socket.on('cli_install_state', (payload) => {
458
474
  applyCliInstallState(payload || null);
459
475
  });
476
+ // Agent SDK installs, the same whole-buffer contract. It also feeds
477
+ // provider readiness (agent-sdk-ui.js passes the snapshot on).
478
+ socket.on('agent_sdk_state', (payload) => {
479
+ applyAgentSdkState(payload || null);
480
+ });
460
481
  // Self-update transitions AND npm log chunks; same whole-buffer contract as
461
482
  // cli_install_state, so a client connecting mid-update renders the full log.
462
483
  socket.on('update_state', (payload) => {
@@ -1673,6 +1673,15 @@
1673
1673
  background: transparent;
1674
1674
  border-color: transparent;
1675
1675
  }
1676
+ /* Select Models: new models found that the modal has not shown yet. */
1677
+ .chat-menu-new-dot {
1678
+ width: calc(8px * var(--ui-scale));
1679
+ height: calc(8px * var(--ui-scale));
1680
+ border-radius: 50%;
1681
+ background: var(--accent);
1682
+ flex-shrink: 0;
1683
+ }
1684
+ .chat-menu-new-dot[hidden] { display: none; }
1676
1685
  .chat-actions-menu-meta {
1677
1686
  display: inline-flex;
1678
1687
  align-items: center;
@@ -1954,6 +1963,17 @@
1954
1963
  .settings-auth-row { gap: calc(5px * var(--ui-scale)); align-items: center; width: 100%; margin-top: calc(4px * var(--ui-scale)); }
1955
1964
  .settings-auth-row-wrap { flex-wrap: wrap; row-gap: calc(4px * var(--ui-scale)); }
1956
1965
  .settings-radio-stack { display: flex; flex-direction: column; gap: calc(4px * var(--ui-scale)); }
1966
+ /* Models: one "Check <provider>" row per provider, its last check under
1967
+ the name in the help text's colour. */
1968
+ #settings-modal .settings-row.model-refresh-provider-row:has(> input[type="checkbox"]) > label {
1969
+ flex-direction: column; align-items: flex-start; justify-content: center;
1970
+ }
1971
+ .model-refresh-provider-last {
1972
+ color: var(--muted);
1973
+ font-size: var(--fs-meta-sm);
1974
+ font-weight: normal;
1975
+ overflow-wrap: anywhere;
1976
+ }
1957
1977
  /* These rows are laid out as flex and hidden from script via `.hidden = true`.
1958
1978
  Their `display: flex` lives HERE rather than in a style="" attribute on
1959
1979
  purpose: an inline declaration outranks every selector-based rule (short of
@@ -5933,6 +5953,42 @@
5933
5953
  border-color: var(--warn, #f7c873);
5934
5954
  color: var(--warn, #f7c873);
5935
5955
  }
5956
+ .model-reasoning-chip-new {
5957
+ border-color: var(--accent);
5958
+ color: var(--accent);
5959
+ }
5960
+ /* "Turn new models on automatically" at the top of a provider tab. */
5961
+ .model-auto-enable-row {
5962
+ display: flex;
5963
+ align-items: center;
5964
+ gap: calc(5px * var(--ui-scale));
5965
+ padding: calc(4px * var(--ui-scale)) calc(7px * var(--ui-scale));
5966
+ border: 1px solid var(--border);
5967
+ border-radius: calc(10px * var(--ui-scale));
5968
+ background: var(--bg3);
5969
+ font-size: 0.84rem;
5970
+ min-height: calc(30px * var(--ui-scale));
5971
+ cursor: pointer;
5972
+ }
5973
+ .model-auto-enable-toggle {
5974
+ width: calc(18px * var(--ui-scale));
5975
+ height: calc(18px * var(--ui-scale));
5976
+ flex-shrink: 0;
5977
+ accent-color: var(--accent);
5978
+ }
5979
+ .model-auto-enable-copy {
5980
+ display: flex;
5981
+ flex-direction: column;
5982
+ gap: calc(2px * var(--ui-scale));
5983
+ min-width: 0;
5984
+ }
5985
+ .model-auto-enable-title {
5986
+ font-weight: 600;
5987
+ }
5988
+ .model-auto-enable-help {
5989
+ color: var(--muted);
5990
+ font-size: 0.78rem;
5991
+ }
5936
5992
  .model-variant-list {
5937
5993
  display: flex;
5938
5994
  flex-direction: column;
@@ -7907,7 +7963,8 @@
7907
7963
  min-height: calc(32px * var(--ui-scale));
7908
7964
  padding: calc(5px * var(--ui-scale)) calc(4px * var(--ui-scale));
7909
7965
  }
7910
- .model-variant-checkbox {
7966
+ .model-variant-checkbox,
7967
+ .model-auto-enable-toggle {
7911
7968
  width: calc(22px * var(--ui-scale));
7912
7969
  height: calc(22px * var(--ui-scale));
7913
7970
  }
@@ -8226,7 +8283,7 @@
8226
8283
  <button id="chat-menu-group" class="chat-actions-menu-item" type="button" role="menuitem">🧩 Session group…</button>
8227
8284
  <button id="chat-menu-refresh-history" class="chat-actions-menu-item" type="button" role="menuitem" hidden disabled aria-hidden="true" tabindex="-1" style="display:none">🔄️ Refresh history</button>
8228
8285
  <button id="chat-menu-inspect-tmux" class="chat-actions-menu-item" type="button" role="menuitem">🖥️ Inspect tmux console</button>
8229
- <button id="chat-menu-select-models" class="chat-actions-menu-item" type="button" role="menuitem">🤗 Select Models</button>
8286
+ <button id="chat-menu-select-models" class="chat-actions-menu-item" type="button" role="menuitem">🤗 Select Models<span id="chat-menu-select-models-dot" class="chat-menu-new-dot" aria-hidden="true" hidden></span></button>
8230
8287
  <button id="chat-menu-change-cwd" class="chat-actions-menu-item" type="button" role="menuitem">🗂️ Change CWD</button>
8231
8288
  <button id="chat-menu-git-changes" class="chat-actions-menu-item" type="button" role="menuitem">🌿 Git changes</button>
8232
8289
  <button id="chat-menu-open-cloud-session" class="chat-actions-menu-item" type="button" role="menuitem" hidden>☁️ Open on claude.ai</button>
@@ -8663,6 +8720,31 @@
8663
8720
  </div>
8664
8721
  <div class="settings-help settings-gap-xs">A sub-agent's record keeps every tool call's full input and the first 4 KB of each result. After this many days the relay removes them from finished agents, once a day; the thoughts, the reply, notes and the one-line summary of each call stay. Always keeps everything. A setting of this relay.</div>
8665
8722
  </section>
8723
+ <section class="settings-section" id="settings-section-models" aria-labelledby="settings-section-models-title">
8724
+ <h3 class="settings-section-title" id="settings-section-models-title">Models</h3>
8725
+ <div class="settings-row settings-gap">
8726
+ <label for="model-refresh-auto-toggle">Check for new models</label>
8727
+ <input
8728
+ id="model-refresh-auto-toggle"
8729
+ type="checkbox"
8730
+ onchange="updateModelRefreshAutoSetting(this.checked)"
8731
+ aria-label="Check for new models"
8732
+ checked
8733
+ >
8734
+ </div>
8735
+ <div class="settings-row settings-gap">
8736
+ <label for="model-refresh-interval-select">How often</label>
8737
+ <select id="model-refresh-interval-select" onchange="updateModelRefreshIntervalSetting(this.value)" aria-label="How often to check for new models">
8738
+ <option value="6">Every 6 h</option>
8739
+ <option value="12">Every 12 h</option>
8740
+ <option value="24" selected>Every 24 h</option>
8741
+ <option value="72">Every 3 days</option>
8742
+ <option value="168">Every 7 days</option>
8743
+ </select>
8744
+ </div>
8745
+ <div id="model-refresh-providers" class="settings-gap" role="group" aria-label="Providers checked for new models"></div>
8746
+ <div class="settings-help settings-gap-xs">The relay asks each switched-on provider for its model list this often. A provider whose list was refreshed in the meantime (Refresh in Select Models, a settings save, a sign-in) waits a full interval again, and a failed check is tried again after an hour at the earliest. Whether a new model starts on is set per provider in Select Models. A setting of this relay.</div>
8747
+ </section>
8666
8748
  <section class="settings-section" id="settings-section-relay" aria-labelledby="settings-section-relay-title">
8667
8749
  <h3 class="settings-section-title" id="settings-section-relay-title">This relay</h3>
8668
8750
  <div class="settings-row settings-row-column settings-gap">
@@ -8734,6 +8816,9 @@
8734
8816
  <code id="windows-autostart-manual-command" style="display: block; margin-top: calc(4px * var(--ui-scale)); user-select: all;"></code>
8735
8817
  </div>
8736
8818
  </div>
8819
+ <!-- The Windows helper (koffi) the relay's lock check reads process start
8820
+ times with; agent-sdk-ui.js draws the row, on a Windows relay only. -->
8821
+ <div id="agent-sdk-koffi-row" class="settings-gap" data-agent-sdk="koffi" hidden></div>
8737
8822
  </section>
8738
8823
  <section class="settings-section" id="settings-section-updates" aria-labelledby="settings-section-updates-title">
8739
8824
  <h3 class="settings-section-title" id="settings-section-updates-title">Updates</h3>
@@ -8980,6 +9065,8 @@
8980
9065
  <pre id="cli-claude-log"></pre>
8981
9066
  </details>
8982
9067
  </div>
9068
+ <!-- Agent SDK row; agent-sdk-ui.js draws it. -->
9069
+ <div id="agent-sdk-claude-row" data-agent-sdk="claude" style="margin-bottom: calc(6px * var(--ui-scale));"></div>
8983
9070
  <!-- Claude account auth UI (Relogin / Logout); claude-auth-ui.js drives the state. -->
8984
9071
  <div id="claude-auth-section">
8985
9072
  <div class="settings-row" style="margin-bottom: 0;">
@@ -9069,6 +9156,8 @@
9069
9156
  data-settings-provider-panel="claude-api"
9070
9157
  hidden
9071
9158
  >
9159
+ <!-- Claude API chats run on the Claude Agent SDK; agent-sdk-ui.js draws the row. -->
9160
+ <div id="agent-sdk-claude-api-row" data-agent-sdk="claude"></div>
9072
9161
  <div class="settings-row settings-row-column settings-gap">
9073
9162
  <label for="claude-api-key-input">Claude API</label>
9074
9163
  <div class="settings-row settings-row-full settings-row-lead">
@@ -9274,6 +9363,8 @@
9274
9363
  data-settings-provider-panel="cursor"
9275
9364
  hidden
9276
9365
  >
9366
+ <!-- Agent SDK row; agent-sdk-ui.js draws it. -->
9367
+ <div id="agent-sdk-cursor-row" data-agent-sdk="cursor"></div>
9277
9368
  <div class="settings-row settings-row-column settings-gap">
9278
9369
  <label for="cursor-api-key-input">Cursor SDK</label>
9279
9370
  <div class="settings-row settings-row-full settings-row-lead">
@@ -9367,6 +9458,8 @@
9367
9458
  data-settings-provider-panel="deepseek"
9368
9459
  hidden
9369
9460
  >
9461
+ <!-- DeepSeek chats run on the Claude Agent SDK; agent-sdk-ui.js draws the row. -->
9462
+ <div id="agent-sdk-deepseek-row" data-agent-sdk="claude"></div>
9370
9463
  <div class="settings-row settings-row-column settings-gap">
9371
9464
  <label for="deepseek-api-key-input">DeepSeek</label>
9372
9465
  <div class="settings-row settings-row-full settings-row-lead">
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ // The agent SDKs the relay installs on demand (agent-sdk-store.mjs): their
4
+ // status, and Install / Reinstall / Cancel from Settings. A request body only
5
+ // ever carries an SDK id, looked up through the store's frozen table; an
6
+ // unknown id is a 400 before anything runs. The store itself refuses a spawn
7
+ // while COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN is set (503), and that answer is
8
+ // passed through as it is.
9
+
10
+ const UNKNOWN_SDK_ERROR = 'Unknown agent SDK';
11
+
12
+ export function registerAgentSdkRoutes(app, { auth, io = null, agentSdkStore = null } = {}) {
13
+ const store = agentSdkStore;
14
+
15
+ // Every transition and log flush (four a second at most, the store
16
+ // coalesces them) goes to every client, as cli_install_state does.
17
+ if (store && io && typeof store.subscribe === 'function') {
18
+ store.subscribe((snapshot) => {
19
+ io.emit('agent_sdk_state', snapshot);
20
+ });
21
+ }
22
+
23
+ const unavailable = (res) => res.status(503).json({ error: 'Agent SDKs are unavailable on this relay' });
24
+
25
+ // A string the store knows, or null. Only a string: String(['claude'])
26
+ // would otherwise read as a valid id.
27
+ const knownId = (raw) => (typeof raw === 'string' && store.status(raw) ? raw : null);
28
+
29
+ app.get('/api/agent-sdks/status', auth, (_req, res) => {
30
+ if (!store) return unavailable(res);
31
+ return res.json(store.snapshot());
32
+ });
33
+
34
+ app.post('/api/agent-sdks/install', auth, (req, res) => {
35
+ if (!store) return unavailable(res);
36
+ const sdk = knownId(req.body?.sdk);
37
+ if (!sdk) return res.status(400).json({ error: UNKNOWN_SDK_ERROR, ...store.snapshot() });
38
+ let result;
39
+ try {
40
+ result = store.install(sdk, { force: req.body?.reinstall === true });
41
+ } catch (error) {
42
+ return res.status(500).json({ error: `The install could not start: ${String(error?.message || error)}`, ...store.snapshot() });
43
+ }
44
+ if (!result?.ok) {
45
+ return res.status(Number(result?.statusCode) || 400).json({
46
+ error: result?.error || 'Failed to start the agent SDK install',
47
+ ...store.snapshot(),
48
+ });
49
+ }
50
+ // The rows ride along so the caller renders without a second round trip;
51
+ // the install itself finishes over the socket.
52
+ return res.json({
53
+ ok: true,
54
+ reused: result.reused === true,
55
+ ready: result.ready === true,
56
+ ...store.snapshot(),
57
+ });
58
+ });
59
+
60
+ app.post('/api/agent-sdks/install/cancel', auth, (req, res) => {
61
+ if (!store) return unavailable(res);
62
+ const sdk = knownId(req.body?.sdk);
63
+ if (!sdk) return res.status(400).json({ error: UNKNOWN_SDK_ERROR, ...store.snapshot() });
64
+ const result = store.cancel(sdk);
65
+ if (!result?.ok) {
66
+ return res.status(Number(result?.statusCode) || 400).json({
67
+ error: result?.error || 'Failed to cancel the agent SDK install',
68
+ ...store.snapshot(),
69
+ });
70
+ }
71
+ return res.json({ ok: true, ...store.snapshot() });
72
+ });
73
+ }