@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
@@ -1,4 +1,4 @@
1
- import { createSdkMcpServer, query } from '@anthropic-ai/claude-agent-sdk';
1
+ import { loadAgentSdk } from '../agent-sdk-loader.mjs';
2
2
 
3
3
  import { formatClaudeBackgroundCommandLimitLabel } from '../../shared/claude-background-command-limit.mjs';
4
4
  import { parseThinkingDisplay } from '../../shared/claude-thinking.mjs';
@@ -9,6 +9,9 @@ import { createPreviewToolDefinition } from './claude-preview-tool.mjs';
9
9
  import { createRemoteRelayToolDefinition } from './claude-remote-relay-tool.mjs';
10
10
  import { CLAUDE_LIVE_FLAG_SETTINGS_CODE, CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE } from './claude-turn-failure.mjs';
11
11
 
12
+ // The SDK the relay installed for this worker (agent-sdk-loader.mjs).
13
+ const { createSdkMcpServer, query } = await loadAgentSdk('claude');
14
+
12
15
  export { CLAUDE_LIVE_FLAG_SETTINGS_CODE };
13
16
 
14
17
  // The name the in-process MCP server is registered under; it prefixes every
@@ -5025,15 +5025,18 @@ export function createCopilotSdkSessionRunner({
5025
5025
  dbg('image turn', message.id, result.via, result.outcome);
5026
5026
  } catch (error) {
5027
5027
  if (isOpenAIImageTerminalError(error)) {
5028
- dbg('image turn refused by OpenAI', message.id, error.message);
5028
+ // A refusal by OpenAI, or a reply the relay could not process after
5029
+ // OpenAI answered 2xx; the codes are the ones the relay's image
5030
+ // operation uses for the same two cases.
5031
+ const code = error.relayProcessing === true ? 'image-reply-unprocessed' : 'image-provider-rejected';
5032
+ dbg(error.relayProcessing === true ? 'image reply not processed' : 'image turn refused by OpenAI', message.id, error.message);
5029
5033
  await publishResponse(message, {
5030
5034
  text: error.message,
5031
5035
  model: null,
5032
5036
  terminalError: {
5033
5037
  kind: 'openai-image-failed',
5034
- // The code the relay's image operation uses for the same refusal.
5035
- code: 'image-provider-rejected',
5036
- stableCode: 'image-provider-rejected',
5038
+ code,
5039
+ stableCode: code,
5037
5040
  message: error.message,
5038
5041
  ...(error.detail ? { detail: String(error.detail) } : {}),
5039
5042
  failedAt: new Date().toISOString(),
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs';
3
3
 
4
+ import { loadAgentSdk } from '../agent-sdk-loader.mjs';
5
+
4
6
  import {
5
7
  createTurnLiveness,
6
8
  createTurnStalledError,
@@ -18,7 +20,7 @@ import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '
18
20
  */
19
21
 
20
22
  async function defaultAgentFactory() {
21
- const { Agent } = await import('@cursor/sdk');
23
+ const { Agent } = await loadAgentSdk('cursor');
22
24
  return {
23
25
  create: (options) => Agent.create(options),
24
26
  resume: (agentId, options) => Agent.resume(agentId, options),
@@ -30,7 +32,7 @@ async function defaultAgentFactory() {
30
32
  // `storeFactoryImpl(storePath, { cwd })` with the real store rooted in the
31
33
  // storePath's directory.
32
34
  async function defaultStoreFactory() {
33
- const { SqliteLocalAgentStore } = await import('@cursor/sdk/sqlite');
35
+ const { SqliteLocalAgentStore } = await loadAgentSdk('cursor', { entry: 'sqlite' });
34
36
  return (storePath, { cwd } = {}) =>
35
37
  SqliteLocalAgentStore.open({
36
38
  workspaceRef: cwd || path.dirname(storePath),
@@ -497,7 +499,7 @@ async function defaultModelsList({ apiKey }) {
497
499
  // The namespace form is the only working call shape: `Cursor` has a private
498
500
  // constructor and `models` is a static member, so the old client-instance
499
501
  // fallback could only ever throw a TypeError that masked the real error.
500
- const sdk = await import('@cursor/sdk');
502
+ const sdk = await loadAgentSdk('cursor');
501
503
  return sdk.Cursor.models.list({ apiKey });
502
504
  }
503
505
 
@@ -0,0 +1,349 @@
1
+ // Agent SDK rows inside Settings: the Claude Agent SDK in the Claude, Claude
2
+ // API and DeepSeek tabs, the Cursor SDK in the Cursor tab, and the Windows
3
+ // helper (koffi, the relay's own lock check) under General.
4
+ //
5
+ // The relay installs these on demand from pins it ships (agent-sdk-store.mjs),
6
+ // so a row is mostly a window on the store: its state, the pinned and the
7
+ // installed version, the live npm log, the last error and the next automatic
8
+ // retry, and the one button that state calls for. Nothing here composes a
9
+ // command; the request carries an SDK id and the relay looks it up.
10
+ //
11
+ // Every visible state comes from the `agent_sdk_state` broadcast (the same
12
+ // payload as GET /api/agent-sdks/status), so an install survives closing the
13
+ // modal or being started by a first chat or another device. Rendering reads
14
+ // the latest payload and never assumes a local step sequence; the log is
15
+ // redrawn from the whole buffer whenever `logSeq` moves.
16
+ //
17
+ // The markup holds only empty mounts (`data-agent-sdk="<id>"`); this module
18
+ // draws the row into each. One SDK can have several mounts (Claude's), so the
19
+ // parts are found by `data-part`, not by id. The log is host process output:
20
+ // it goes in with textContent, and the little that goes through innerHTML is
21
+ // static and escaped with escHtml anyway.
22
+
23
+ import { escHtml } from './store.js';
24
+ import { cancelAgentSdkInstall, getAgentSdkStatus, startAgentSdkInstall } from './api-client.js';
25
+ import { updateProviderReadinessInputs } from './provider-readiness.mjs';
26
+
27
+ export const AGENT_SDK_IDS = Object.freeze(['claude', 'cursor', 'koffi']);
28
+ const STATES = Object.freeze(['missing', 'installing', 'ready', 'error', 'unsupported']);
29
+ const ACTIONS = Object.freeze(['install', 'retry', 'reinstall', 'cancel']);
30
+ const ACTION_LABELS = Object.freeze({ install: 'Install', retry: 'Retry', reinstall: 'Reinstall', cancel: 'Cancel' });
31
+ // Until the first payload names them.
32
+ const DEFAULT_LABELS = Object.freeze({ claude: 'Claude Agent SDK', cursor: 'Cursor SDK', koffi: 'Windows helper' });
33
+ const STATE_WORDS = Object.freeze({
34
+ missing: 'Not installed',
35
+ installing: 'Installing…',
36
+ ready: 'Installed',
37
+ error: 'Install failed',
38
+ unsupported: 'Not available on this platform',
39
+ });
40
+ const LOG_WORDS = Object.freeze({ installing: 'Running', ready: 'Finished', error: 'Failed' });
41
+
42
+ let sdkState = { root: null, host: null, sdks: {} };
43
+ let loadFailed = false;
44
+ // A request the relay refused before any broadcast, on the row that sent it;
45
+ // the SDK's next transition clears it.
46
+ let localErrors = {};
47
+ const requestsInFlight = new Set();
48
+ // One-shot per failed install: its log opens once, and a later redraw of the
49
+ // same payload does not fight the user closing it.
50
+ const expandedFailures = new Map();
51
+ let statusRefresh = null;
52
+
53
+ function normalizeId(raw) {
54
+ const value = String(raw || '').trim().toLowerCase();
55
+ return AGENT_SDK_IDS.includes(value) ? value : '';
56
+ }
57
+
58
+ function normalizeState(raw) {
59
+ const value = String(raw || '').trim().toLowerCase();
60
+ return STATES.includes(value) ? value : 'missing';
61
+ }
62
+
63
+ function labelOf(id, status) {
64
+ return String(status?.label || '').trim() || DEFAULT_LABELS[id] || id;
65
+ }
66
+
67
+ /** The Windows helper only where it can exist; the SDK rows always. */
68
+ export function isAgentSdkRowShown(id, snapshot) {
69
+ if (normalizeId(id) !== 'koffi') return true;
70
+ const status = snapshot?.sdks?.koffi;
71
+ if (!status || typeof status !== 'object') return false;
72
+ if (status.support) return status.support !== 'no';
73
+ return snapshot?.host?.platform === 'win32';
74
+ }
75
+
76
+ /** "Installed · pinned 0.3.1 · installed 0.3.1", with whose install it waits for. */
77
+ export function agentSdkStatusText(status) {
78
+ if (!status || typeof status !== 'object') return 'Checking…';
79
+ const state = normalizeState(status.state);
80
+ const heldBy = Number(status.heldBy) || 0;
81
+ const parts = [state === 'installing' && heldBy
82
+ ? `Installing by another OAR process (pid ${heldBy})…`
83
+ : STATE_WORDS[state]];
84
+ const pinned = String(status.pinned || '').trim();
85
+ const installed = String(status.installed || '').trim();
86
+ if (pinned) parts.push(`pinned ${pinned}`);
87
+ if (installed) parts.push(`installed ${installed}`);
88
+ else if (state !== 'unsupported') parts.push('none installed');
89
+ return parts.join(' · ');
90
+ }
91
+
92
+ export function agentSdkStatusState(status) {
93
+ if (!status || typeof status !== 'object') return 'pending';
94
+ const state = normalizeState(status.state);
95
+ if (state === 'ready') return 'active';
96
+ if (state === 'error' && status.kind !== 'cancelled') return 'error';
97
+ if (state === 'installing') return 'pending';
98
+ return 'unconfigured';
99
+ }
100
+
101
+ function formatRetryTime(iso) {
102
+ const at = new Date(iso);
103
+ if (Number.isNaN(at.getTime())) return '';
104
+ try {
105
+ return at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
106
+ } catch {
107
+ return at.toISOString().slice(11, 16);
108
+ }
109
+ }
110
+
111
+ /** The line under the row: the error, the next automatic retry. */
112
+ export function agentSdkNoteText(status) {
113
+ if (!status || typeof status !== 'object') return '';
114
+ const state = normalizeState(status.state);
115
+ const parts = [];
116
+ // "Not available" is the whole story already in the status line.
117
+ const error = String(status.error || '').trim();
118
+ if (error && state === 'error') parts.push(error);
119
+ if (status.retryAt && state === 'error') {
120
+ const time = formatRetryTime(status.retryAt);
121
+ if (time) parts.push(`Next automatic retry at ${time}.`);
122
+ }
123
+ return parts.join(' ');
124
+ }
125
+
126
+ /** The buttons a row offers, in order. */
127
+ export function agentSdkActions(status, { reinstall = true } = {}) {
128
+ if (!status || typeof status !== 'object') return [];
129
+ switch (normalizeState(status.state)) {
130
+ case 'missing': return ['install'];
131
+ case 'installing': return ['cancel'];
132
+ case 'error': return ['retry'];
133
+ case 'ready': return reinstall ? ['reinstall'] : [];
134
+ default: return [];
135
+ }
136
+ }
137
+
138
+ export function agentSdkLogSummary(status) {
139
+ const word = status?.kind === 'cancelled' ? 'Cancelled' : (LOG_WORDS[normalizeState(status?.state)] || 'Install');
140
+ return `${word} · install log`;
141
+ }
142
+
143
+ function part(mount, name) {
144
+ return mount.querySelector(`[data-part="${name}"]`);
145
+ }
146
+
147
+ function setHidden(element, hidden) {
148
+ if (element) element.hidden = !!hidden;
149
+ }
150
+
151
+ function mountSkeleton(mount, id) {
152
+ if (mount.dataset.agentSdkMounted === '1') return;
153
+ mount.dataset.agentSdkMounted = '1';
154
+ const buttons = ACTIONS.map((action) => (
155
+ `<button type="button" class="summary-btn" data-action="${escHtml(action)}" hidden>${escHtml(ACTION_LABELS[action])}</button>`
156
+ )).join('');
157
+ mount.innerHTML = `
158
+ <div class="settings-row" style="margin-bottom: 0;">
159
+ <span class="cli-install-label" data-part="label">${escHtml(DEFAULT_LABELS[id])}</span>
160
+ <div class="settings-help settings-provider-status" data-part="status" style="flex: 1 1 auto;">Checking…</div>
161
+ <div class="settings-inline-actions">${buttons}</div>
162
+ </div>
163
+ <div class="settings-help settings-provider-status settings-gap-xs" data-part="note" hidden></div>
164
+ <details class="cli-install-log" data-part="log-details" hidden>
165
+ <summary data-part="log-summary">Install log</summary>
166
+ <pre data-part="log"></pre>
167
+ </details>
168
+ `;
169
+ for (const button of mount.querySelectorAll('button[data-action]')) {
170
+ button.addEventListener('click', () => { void runAgentSdkAction(id, button.dataset.action); });
171
+ }
172
+ }
173
+
174
+ function renderMount(mount, { expandFailure = false } = {}) {
175
+ const id = normalizeId(mount.dataset.agentSdk);
176
+ if (!id) return;
177
+ mountSkeleton(mount, id);
178
+ const status = sdkState.sdks?.[id] || null;
179
+ const shown = isAgentSdkRowShown(id, sdkState);
180
+ setHidden(mount, !shown);
181
+ if (!shown) return;
182
+
183
+ const label = part(mount, 'label');
184
+ if (label) label.textContent = labelOf(id, status);
185
+
186
+ const statusLine = part(mount, 'status');
187
+ if (statusLine) {
188
+ // The same words as the store's error, which the note shows already.
189
+ const localError = localErrors[id] && localErrors[id] !== String(status?.error || '').trim() ? localErrors[id] : '';
190
+ statusLine.textContent = localError
191
+ || (!status && loadFailed ? 'Unable to load the SDK status.' : agentSdkStatusText(status));
192
+ statusLine.dataset.state = localError || (!status && loadFailed) ? 'error' : agentSdkStatusState(status);
193
+ }
194
+
195
+ // The Windows helper has nothing to reinstall: its state and a Retry.
196
+ const offered = agentSdkActions(status, { reinstall: id !== 'koffi' });
197
+ const busy = requestsInFlight.has(id);
198
+ for (const button of mount.querySelectorAll('button[data-action]')) {
199
+ const action = button.dataset.action;
200
+ setHidden(button, !offered.includes(action));
201
+ // canInstall is false while spawns are off or the folder is unusable;
202
+ // Cancel needs nothing but the install it stops.
203
+ button.disabled = busy || (action !== 'cancel' && status?.canInstall === false);
204
+ }
205
+
206
+ const note = part(mount, 'note');
207
+ const noteText = agentSdkNoteText(status);
208
+ if (note) {
209
+ note.textContent = noteText;
210
+ note.dataset.state = noteText && status?.kind !== 'cancelled' ? 'error' : '';
211
+ }
212
+ setHidden(note, !noteText);
213
+
214
+ const details = part(mount, 'log-details');
215
+ const log = String(status?.log || '');
216
+ const showLog = !!status && (normalizeState(status.state) === 'installing' || log.length > 0);
217
+ setHidden(details, !showLog);
218
+ const summary = part(mount, 'log-summary');
219
+ if (summary && status) summary.textContent = agentSdkLogSummary(status);
220
+ const logBox = part(mount, 'log');
221
+ // Keyed on the install, not on logSeq alone: a new install starts with an
222
+ // empty buffer.
223
+ const logKey = `${status?.startedAt || ''}|${status?.logSeq || 0}`;
224
+ if (logBox && showLog && mount.dataset.logKey !== logKey) {
225
+ logBox.textContent = log;
226
+ logBox.scrollTop = logBox.scrollHeight;
227
+ mount.dataset.logKey = logKey;
228
+ }
229
+ if (details && showLog && expandFailure) details.open = true;
230
+ }
231
+
232
+ export function renderAgentSdkRows() {
233
+ if (typeof document === 'undefined') return;
234
+ const expand = new Set();
235
+ for (const id of AGENT_SDK_IDS) {
236
+ const status = sdkState.sdks?.[id];
237
+ if (!status || normalizeState(status.state) !== 'error' || !status.log) continue;
238
+ const signature = `${status.startedAt || ''}|${status.finishedAt || ''}`;
239
+ if (expandedFailures.get(id) === signature) continue;
240
+ expandedFailures.set(id, signature);
241
+ if (status.kind !== 'cancelled') expand.add(id);
242
+ }
243
+ for (const mount of document.querySelectorAll('[data-agent-sdk]')) {
244
+ renderMount(mount, { expandFailure: expand.has(normalizeId(mount.dataset.agentSdk)) });
245
+ }
246
+ }
247
+
248
+ // A broadcast can outrun the HTTP response that started it. Within one
249
+ // install (same startedAt) logSeq only grows, so an older snapshot never
250
+ // regresses the log or the state.
251
+ function isStaleStatus(current, incoming) {
252
+ if (!current || !incoming) return false;
253
+ if (current.startedAt && incoming.startedAt && incoming.startedAt < current.startedAt) return true;
254
+ if (!current.startedAt || current.startedAt !== incoming.startedAt) return false;
255
+ const currentSeq = Number(current.logSeq || 0);
256
+ const incomingSeq = Number(incoming.logSeq || 0);
257
+ if (incomingSeq < currentSeq) return true;
258
+ // Equal: a settled install is not reopened by the slower answer that
259
+ // snapshotted its start.
260
+ return incomingSeq === currentSeq
261
+ && normalizeState(current.state) !== 'installing'
262
+ && normalizeState(incoming.state) === 'installing';
263
+ }
264
+
265
+ /**
266
+ * Single entry point for every payload (GET, POST answers, the broadcast).
267
+ * It also feeds provider readiness, so a failed SDK shows up wherever that is
268
+ * asked.
269
+ */
270
+ export function applyAgentSdkState(payload) {
271
+ if (!payload || typeof payload !== 'object' || !payload.sdks || typeof payload.sdks !== 'object') {
272
+ renderAgentSdkRows();
273
+ return sdkState;
274
+ }
275
+ const sdks = { ...sdkState.sdks };
276
+ for (const id of AGENT_SDK_IDS) {
277
+ const incoming = payload.sdks[id];
278
+ if (!incoming || typeof incoming !== 'object' || isStaleStatus(sdks[id], incoming)) continue;
279
+ const before = sdks[id];
280
+ if (!before || before.state !== incoming.state || before.startedAt !== incoming.startedAt) delete localErrors[id];
281
+ sdks[id] = incoming;
282
+ }
283
+ sdkState = {
284
+ root: typeof payload.root === 'string' ? payload.root : sdkState.root,
285
+ host: payload.host && typeof payload.host === 'object' ? payload.host : sdkState.host,
286
+ sdks,
287
+ };
288
+ loadFailed = false;
289
+ updateProviderReadinessInputs({ agentSdks: sdkState });
290
+ renderAgentSdkRows();
291
+ return sdkState;
292
+ }
293
+
294
+ export function getAgentSdkState() {
295
+ return sdkState;
296
+ }
297
+
298
+ /** Reads the rows; a second caller joins the read in flight. */
299
+ export function refreshAgentSdkRows() {
300
+ if (statusRefresh) return statusRefresh;
301
+ const promise = (async () => {
302
+ try {
303
+ const payload = await getAgentSdkStatus();
304
+ if (!payload) {
305
+ loadFailed = true;
306
+ renderAgentSdkRows();
307
+ return null;
308
+ }
309
+ return applyAgentSdkState(payload);
310
+ } finally {
311
+ if (statusRefresh === promise) statusRefresh = null;
312
+ }
313
+ })();
314
+ statusRefresh = promise;
315
+ return promise;
316
+ }
317
+
318
+ /** Install, Retry, Reinstall or Cancel, from a row's button. */
319
+ export async function runAgentSdkAction(sdk, action) {
320
+ const id = normalizeId(sdk);
321
+ const wanted = ACTIONS.includes(action) ? action : '';
322
+ if (!id || !wanted || requestsInFlight.has(id)) return;
323
+ requestsInFlight.add(id);
324
+ delete localErrors[id];
325
+ renderAgentSdkRows();
326
+ try {
327
+ const payload = wanted === 'cancel'
328
+ ? await cancelAgentSdkInstall(id)
329
+ : await startAgentSdkInstall(id, { reinstall: wanted === 'reinstall' });
330
+ applyAgentSdkState(payload);
331
+ } catch (error) {
332
+ localErrors = { ...localErrors, [id]: String(error?.message || 'The SDK request failed.') };
333
+ // The refusal carried the rows, but as a throw: read them again.
334
+ await refreshAgentSdkRows().catch(() => {});
335
+ } finally {
336
+ requestsInFlight.delete(id);
337
+ renderAgentSdkRows();
338
+ }
339
+ }
340
+
341
+ /** Tests only. */
342
+ export function resetAgentSdkUiForTests() {
343
+ sdkState = { root: null, host: null, sdks: {} };
344
+ loadFailed = false;
345
+ localErrors = {};
346
+ requestsInFlight.clear();
347
+ expandedFailures.clear();
348
+ statusRefresh = null;
349
+ }
@@ -331,6 +331,8 @@ export async function updateClaudeSettings({
331
331
  enabled = undefined,
332
332
  model = undefined,
333
333
  enabledModels = undefined,
334
+ shownModels = undefined,
335
+ autoEnableNew = undefined,
334
336
  attributionMode = undefined,
335
337
  backgroundCommandLimitMinutes = undefined,
336
338
  } = {}) {
@@ -338,6 +340,8 @@ export async function updateClaudeSettings({
338
340
  if (typeof enabled === 'boolean') payload.enabled = enabled;
339
341
  if (typeof model === 'string' && model.trim()) payload.model = model.trim();
340
342
  if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
343
+ if (Array.isArray(shownModels)) payload.shownModels = shownModels;
344
+ if (typeof autoEnableNew === 'boolean') payload.autoEnableNew = autoEnableNew;
341
345
  if (typeof attributionMode === 'string' && attributionMode.trim()) payload.attributionMode = attributionMode.trim();
342
346
  if (Number.isFinite(backgroundCommandLimitMinutes)) payload.backgroundCommandLimitMinutes = backgroundCommandLimitMinutes;
343
347
  return settingsRequest('/api/settings/claude', payload, 'Failed to update Claude settings');
@@ -393,6 +397,25 @@ export async function cancelCliInstall() {
393
397
  return settingsRequest('/api/cli/install/cancel', null, 'Failed to cancel the CLI install');
394
398
  }
395
399
 
400
+ // Agent SDKs the relay installs on demand. As for the CLIs, the body carries
401
+ // only an SDK id, which the relay looks up in its own table.
402
+ export async function getAgentSdkStatus() {
403
+ return apiFetch('/api/agent-sdks/status');
404
+ }
405
+
406
+ export async function startAgentSdkInstall(sdk, { reinstall = false } = {}) {
407
+ return settingsRequest(
408
+ '/api/agent-sdks/install',
409
+ { sdk: String(sdk || ''), reinstall: reinstall === true },
410
+ 'Failed to start the SDK install',
411
+ );
412
+ }
413
+
414
+ // Stops a running install, and dismisses a failure otherwise.
415
+ export async function cancelAgentSdkInstall(sdk) {
416
+ return settingsRequest('/api/agent-sdks/install/cancel', { sdk: String(sdk || '') }, 'Failed to cancel the SDK install');
417
+ }
418
+
396
419
  // Grok account auth (Sign in / Sign out). One route shorter than Claude's: the
397
420
  // device code rides in the authorize URL and the CLI polls x.ai itself, so
398
421
  // nothing is ever pasted back through the relay.
@@ -424,11 +447,15 @@ export async function updateGrokSettings({
424
447
  enabled = undefined,
425
448
  model = undefined,
426
449
  enabledModels = undefined,
450
+ shownModels = undefined,
451
+ autoEnableNew = undefined,
427
452
  } = {}) {
428
453
  const payload = {};
429
454
  if (typeof enabled === 'boolean') payload.enabled = enabled;
430
455
  if (typeof model === 'string' && model.trim()) payload.model = model.trim();
431
456
  if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
457
+ if (Array.isArray(shownModels)) payload.shownModels = shownModels;
458
+ if (typeof autoEnableNew === 'boolean') payload.autoEnableNew = autoEnableNew;
432
459
  return settingsRequest('/api/settings/grok', payload, 'Failed to update Grok settings');
433
460
  }
434
461
 
@@ -493,18 +520,26 @@ export async function loadClaudeCloudBranches(repo) {
493
520
 
494
521
  export async function updateOpenAISettings({
495
522
  apiKey = '',
496
- model = 'gpt-4o',
523
+ model = undefined,
497
524
  baseUrl = undefined,
498
525
  enabled = undefined,
526
+ enabledModels = undefined,
527
+ shownModels = undefined,
528
+ autoEnableNew = undefined,
499
529
  remove = false,
500
530
  } = {}) {
501
531
  const payload = {
502
532
  apiKey: String(apiKey || '').trim(),
503
- model: String(model || '').trim() || 'gpt-4o',
504
533
  remove: remove === true,
505
534
  };
535
+ // Every Settings call names the model; the Select Models save does not, and
536
+ // must not reset the default model to gpt-4o.
537
+ if (model !== undefined) payload.model = String(model || '').trim() || 'gpt-4o';
506
538
  if (typeof baseUrl === 'string') payload.baseUrl = String(baseUrl).trim();
507
539
  if (typeof enabled === 'boolean') payload.enabled = enabled;
540
+ if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
541
+ if (Array.isArray(shownModels)) payload.shownModels = shownModels;
542
+ if (typeof autoEnableNew === 'boolean') payload.autoEnableNew = autoEnableNew;
508
543
  return settingsRequest('/api/settings/openai', payload, 'Failed to update OpenAI settings');
509
544
  }
510
545
 
@@ -517,6 +552,8 @@ export async function updateCursorSettings({
517
552
  model = undefined,
518
553
  enabled = undefined,
519
554
  enabledModels = undefined,
555
+ shownModels = undefined,
556
+ autoEnableNew = undefined,
520
557
  remove = false,
521
558
  } = {}) {
522
559
  const payload = {
@@ -528,6 +565,8 @@ export async function updateCursorSettings({
528
565
  if (typeof model === 'string' && model.trim()) payload.model = model.trim();
529
566
  if (typeof enabled === 'boolean') payload.enabled = enabled;
530
567
  if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
568
+ if (Array.isArray(shownModels)) payload.shownModels = shownModels;
569
+ if (typeof autoEnableNew === 'boolean') payload.autoEnableNew = autoEnableNew;
531
570
  return settingsRequest('/api/settings/cursor', payload, 'Failed to update Cursor settings');
532
571
  }
533
572
 
@@ -546,6 +585,8 @@ export async function updateDeepSeekSettings({
546
585
  model = undefined,
547
586
  enabled = undefined,
548
587
  enabledModels = undefined,
588
+ shownModels = undefined,
589
+ autoEnableNew = undefined,
549
590
  baseUrl = undefined,
550
591
  attribution = undefined,
551
592
  monthlyBudget = undefined,
@@ -563,6 +604,8 @@ export async function updateDeepSeekSettings({
563
604
  if (typeof model === 'string' && model.trim()) payload.model = model.trim();
564
605
  if (typeof enabled === 'boolean') payload.enabled = enabled;
565
606
  if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
607
+ if (Array.isArray(shownModels)) payload.shownModels = shownModels;
608
+ if (typeof autoEnableNew === 'boolean') payload.autoEnableNew = autoEnableNew;
566
609
  if (baseUrl === null || typeof baseUrl === 'string') payload.baseUrl = baseUrl === null ? null : String(baseUrl).trim();
567
610
  if (typeof attribution === 'string' && attribution.trim()) payload.attribution = attribution.trim();
568
611
  if (monthlyBudget === null || (typeof monthlyBudget === 'number' && Number.isFinite(monthlyBudget))) payload.monthlyBudget = monthlyBudget;
@@ -590,6 +633,8 @@ export async function updateClaudeApiSettings({
590
633
  model = undefined,
591
634
  enabled = undefined,
592
635
  enabledModels = undefined,
636
+ shownModels = undefined,
637
+ autoEnableNew = undefined,
593
638
  monthlyCapUsd = undefined,
594
639
  remove = false,
595
640
  } = {}) {
@@ -602,6 +647,8 @@ export async function updateClaudeApiSettings({
602
647
  if (typeof model === 'string' && model.trim()) payload.model = model.trim();
603
648
  if (typeof enabled === 'boolean') payload.enabled = enabled;
604
649
  if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
650
+ if (Array.isArray(shownModels)) payload.shownModels = shownModels;
651
+ if (typeof autoEnableNew === 'boolean') payload.autoEnableNew = autoEnableNew;
605
652
  if (monthlyCapUsd === null || (typeof monthlyCapUsd === 'number' && Number.isFinite(monthlyCapUsd))) payload.monthlyCapUsd = monthlyCapUsd;
606
653
  }
607
654
  return settingsRequest('/api/settings/claude-api', payload, 'Failed to update Claude API settings');
@@ -940,14 +987,50 @@ export async function refreshModelVariantCatalog() {
940
987
  return apiFetch('/api/model-variants/refresh', { method: 'POST' });
941
988
  }
942
989
 
943
- export async function saveEnabledModelVariants(enabledVariantIds = []) {
990
+ /**
991
+ * Copilot's selection and its "Turn new models on automatically". A null
992
+ * list leaves the selection alone (only the switch changes): an empty one is
993
+ * refused, at least one variant stays on. `shownVariantIds` names the rows
994
+ * the save could see: the relay turns only those on or off.
995
+ */
996
+ export async function saveEnabledModelVariants(enabledVariantIds = [], { autoEnableNew = undefined, shownVariantIds = undefined } = {}) {
997
+ const payload = {};
998
+ if (enabledVariantIds !== null) {
999
+ payload.enabledVariantIds = Array.isArray(enabledVariantIds)
1000
+ ? enabledVariantIds.map((value) => String(value || '').trim()).filter(Boolean)
1001
+ : [];
1002
+ }
1003
+ if (Array.isArray(shownVariantIds)) {
1004
+ payload.shownVariantIds = shownVariantIds.map((value) => String(value || '').trim()).filter(Boolean);
1005
+ }
1006
+ if (typeof autoEnableNew === 'boolean') payload.autoEnableNew = autoEnableNew;
944
1007
  return apiFetch('/api/model-variants', {
945
1008
  method: 'PATCH',
946
- body: JSON.stringify({
947
- enabledVariantIds: Array.isArray(enabledVariantIds)
948
- ? enabledVariantIds.map((value) => String(value || '').trim()).filter(Boolean)
949
- : [],
950
- }),
1009
+ body: JSON.stringify(payload),
1010
+ });
1011
+ }
1012
+
1013
+ // Opening Select Models clears every provider's "New" tags and the menu dot;
1014
+ // answers `{ ok, unseenNewModelCount }`.
1015
+ export async function markModelsSeen() {
1016
+ return apiFetch('/api/model-variants/seen', { method: 'POST' });
1017
+ }
1018
+
1019
+ // Settings › General › Models: the background check for new models. A failed
1020
+ // read throws with the relay's reason, which the section shows.
1021
+ export async function loadModelRefreshSettings() {
1022
+ return apiFetchOrThrow('/api/model-refresh-settings');
1023
+ }
1024
+
1025
+ /** Any of `{ auto, intervalHours, excluded }`; a refused value throws with the relay's reason. */
1026
+ export async function updateModelRefreshSettings(patch = {}) {
1027
+ const payload = {};
1028
+ if (typeof patch.auto === 'boolean') payload.auto = patch.auto;
1029
+ if (patch.intervalHours !== undefined) payload.intervalHours = Number(patch.intervalHours);
1030
+ if (Array.isArray(patch.excluded)) payload.excluded = patch.excluded.map((value) => String(value || '').trim()).filter(Boolean);
1031
+ return apiFetchOrThrow('/api/model-refresh-settings', {
1032
+ method: 'POST',
1033
+ body: JSON.stringify(payload),
951
1034
  });
952
1035
  }
953
1036