@oar-sh/oar 0.9.23 → 0.9.25

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 (110) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +307 -0
  3. package/README.md +12 -11
  4. package/bin/oar.js +54 -4
  5. package/package.json +11 -7
  6. package/server/agent-sdk-loader.mjs +116 -0
  7. package/server/agent-sdks/claude/entry.mjs +4 -0
  8. package/server/agent-sdks/claude/package-lock.json +1438 -0
  9. package/server/agent-sdks/claude/package.json +13 -0
  10. package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
  11. package/server/agent-sdks/cursor/entry.mjs +3 -0
  12. package/server/agent-sdks/cursor/package-lock.json +171 -0
  13. package/server/agent-sdks/cursor/package.json +11 -0
  14. package/server/agent-sdks/koffi/entry.cjs +2 -0
  15. package/server/agent-sdks/koffi/package-lock.json +368 -0
  16. package/server/agent-sdks/koffi/package.json +10 -0
  17. package/server/claude-worker/claude-preview-tool.mjs +5 -2
  18. package/server/claude-worker/claude-remote-relay-tool.mjs +20 -2
  19. package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
  20. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +13 -8
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
  23. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  24. package/server/db-schema.mjs +19 -0
  25. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  26. package/server/migrations/0039-media-index.mjs +167 -0
  27. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  28. package/server/public/app/agent-sdk-ui.js +349 -0
  29. package/server/public/app/api-client.js +149 -8
  30. package/server/public/app/ask-user-view.js +2 -1
  31. package/server/public/app/attachments-view.js +66 -9
  32. package/server/public/app/bootstrap.js +289 -94
  33. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  34. package/server/public/app/context-media-tab.js +143 -0
  35. package/server/public/app/conversation-view.js +90 -5
  36. package/server/public/app/image-composer-ui.js +518 -0
  37. package/server/public/app/image-turn-placeholder.mjs +233 -0
  38. package/server/public/app/journal-view.js +12 -2
  39. package/server/public/app/media-gallery-model.mjs +202 -0
  40. package/server/public/app/media-gallery-ui.js +524 -0
  41. package/server/public/app/media-view.js +79 -0
  42. package/server/public/app/model-refresh-ui.mjs +413 -0
  43. package/server/public/app/no-conversation-state.mjs +5 -2
  44. package/server/public/app/openai-image-composer.mjs +492 -0
  45. package/server/public/app/openai-image-cost-ui.js +426 -0
  46. package/server/public/app/openai-image-spend.mjs +443 -0
  47. package/server/public/app/plan-usage-view.mjs +38 -6
  48. package/server/public/app/provider-readiness.mjs +67 -4
  49. package/server/public/app/relay-error-ctas.mjs +5 -0
  50. package/server/public/app/session-history-ui.js +2 -0
  51. package/server/public/app/settings-modal.js +171 -0
  52. package/server/public/app/socket-handlers.js +47 -0
  53. package/server/public/app/tab-strip-fade.mjs +1 -1
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +478 -9
  56. package/server/relay-tools.md +1 -0
  57. package/server/repositories/image-conversation-repository.mjs +69 -0
  58. package/server/repositories/media-repository.mjs +305 -0
  59. package/server/routes/agent-sdk-routes.mjs +73 -0
  60. package/server/routes/media-routes.mjs +361 -0
  61. package/server/routes/messages-routes.mjs +1467 -343
  62. package/server/routes/sessions-routes.mjs +596 -123
  63. package/server/server-runtime.mjs +414 -81
  64. package/server/services/about-service.mjs +26 -6
  65. package/server/services/agent-sdk-launch-gate.mjs +124 -0
  66. package/server/services/agent-sdk-paths.mjs +307 -0
  67. package/server/services/agent-sdk-store.mjs +1211 -0
  68. package/server/services/claude-api-settings-service.mjs +54 -17
  69. package/server/services/cli-install-service.mjs +3 -2
  70. package/server/services/copilot-model-discovery-service.mjs +7 -1
  71. package/server/services/database-file.mjs +23 -5
  72. package/server/services/deepseek-settings-service.mjs +58 -20
  73. package/server/services/earlier-database-service.mjs +4 -2
  74. package/server/services/generated-image-cleanup-service.mjs +2 -1
  75. package/server/services/generated-image-files.mjs +110 -0
  76. package/server/services/image-input-paths.mjs +389 -0
  77. package/server/services/image-operation-service.mjs +6 -2
  78. package/server/services/media-extract.mjs +653 -0
  79. package/server/services/media-index-service.mjs +221 -0
  80. package/server/services/model-refresh-scheduler.mjs +153 -0
  81. package/server/services/model-variant-catalog-service.mjs +141 -9
  82. package/server/services/oar-cli-helpers.mjs +72 -9
  83. package/server/services/oar-state-migration-service.mjs +2 -12
  84. package/server/services/oar-state-root.mjs +17 -0
  85. package/server/services/openai-image-preview-turns.mjs +330 -0
  86. package/server/services/openai-image-spend-service.mjs +516 -0
  87. package/server/services/openai-image-stream.mjs +170 -0
  88. package/server/services/plan-usage-openai-image.mjs +148 -0
  89. package/server/services/provider-model-refresh.mjs +326 -0
  90. package/server/services/provider-model-state.mjs +395 -0
  91. package/server/services/relay-singleton-guard.mjs +72 -10
  92. package/server/services/remote-relay-caller-context.mjs +6 -0
  93. package/server/services/remote-relay-dispatcher.mjs +386 -22
  94. package/server/services/remote-relay-registry-service.mjs +3 -1
  95. package/server/services/session-worker-launch-service.mjs +13 -0
  96. package/server/services/session-worker-supervisor-service.mjs +19 -1
  97. package/server/services/session-worker-websocket-service.mjs +15 -0
  98. package/server/services/token-usage-service.mjs +15 -0
  99. package/server/services/update-install-service.mjs +2 -2
  100. package/server/services/worker-launch-note-service.mjs +14 -2
  101. package/server/services/worker-recycle-service.mjs +145 -11
  102. package/shared/image-header.mjs +90 -0
  103. package/shared/model-refresh-schedule.mjs +120 -0
  104. package/shared/openai-image-options.mjs +299 -0
  105. package/shared/openai-image-presets.mjs +175 -0
  106. package/shared/openai-image-previews.mjs +40 -0
  107. package/shared/openai-image-prices.mjs +291 -0
  108. package/shared/openai-image-spend.mjs +90 -0
  109. package/shared/remote-relay-contract.mjs +126 -4
  110. package/shared/worker-runtime/direct-openai-image.mjs +98 -48
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { BASE, TOKEN, CLIENT_ID, IS_SHARE_PREVIEW, SHARE_PREVIEW_CONVERSATION_ID, SHARED_CONVERSATION_TOKEN, authHeaders, updateWorkspaceRootHints, applyContextUsageBar, readContextUsageRatio, currentConvId, conversations, setCliOnline, setCloudflaredTunnelState, setActiveRuntimeSessionCount, setRuntimeSessionBindingCount, setContextIndicatorMode, setServerPlatform, setUpdateState } from './store.js';
2
2
  import { liveActivityCap } from './live-activity-cap.mjs';
3
+ import { OPENAI_IMAGE_PRESETS_PATH, OPENAI_IMAGE_SETTINGS_FIELDS, OPENAI_IMAGE_USAGE_PATH } from './openai-image-spend.mjs';
3
4
 
4
5
  // fetch() has no default timeout. Without one, a request issued while the app is
5
6
  // being backgrounded can hang indefinitely on a half-open mobile connection and
@@ -331,6 +332,8 @@ export async function updateClaudeSettings({
331
332
  enabled = undefined,
332
333
  model = undefined,
333
334
  enabledModels = undefined,
335
+ shownModels = undefined,
336
+ autoEnableNew = undefined,
334
337
  attributionMode = undefined,
335
338
  backgroundCommandLimitMinutes = undefined,
336
339
  } = {}) {
@@ -338,6 +341,8 @@ export async function updateClaudeSettings({
338
341
  if (typeof enabled === 'boolean') payload.enabled = enabled;
339
342
  if (typeof model === 'string' && model.trim()) payload.model = model.trim();
340
343
  if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
344
+ if (Array.isArray(shownModels)) payload.shownModels = shownModels;
345
+ if (typeof autoEnableNew === 'boolean') payload.autoEnableNew = autoEnableNew;
341
346
  if (typeof attributionMode === 'string' && attributionMode.trim()) payload.attributionMode = attributionMode.trim();
342
347
  if (Number.isFinite(backgroundCommandLimitMinutes)) payload.backgroundCommandLimitMinutes = backgroundCommandLimitMinutes;
343
348
  return settingsRequest('/api/settings/claude', payload, 'Failed to update Claude settings');
@@ -393,6 +398,25 @@ export async function cancelCliInstall() {
393
398
  return settingsRequest('/api/cli/install/cancel', null, 'Failed to cancel the CLI install');
394
399
  }
395
400
 
401
+ // Agent SDKs the relay installs on demand. As for the CLIs, the body carries
402
+ // only an SDK id, which the relay looks up in its own table.
403
+ export async function getAgentSdkStatus() {
404
+ return apiFetch('/api/agent-sdks/status');
405
+ }
406
+
407
+ export async function startAgentSdkInstall(sdk, { reinstall = false } = {}) {
408
+ return settingsRequest(
409
+ '/api/agent-sdks/install',
410
+ { sdk: String(sdk || ''), reinstall: reinstall === true },
411
+ 'Failed to start the SDK install',
412
+ );
413
+ }
414
+
415
+ // Stops a running install, and dismisses a failure otherwise.
416
+ export async function cancelAgentSdkInstall(sdk) {
417
+ return settingsRequest('/api/agent-sdks/install/cancel', { sdk: String(sdk || '') }, 'Failed to cancel the SDK install');
418
+ }
419
+
396
420
  // Grok account auth (Sign in / Sign out). One route shorter than Claude's: the
397
421
  // device code rides in the authorize URL and the CLI polls x.ai itself, so
398
422
  // nothing is ever pasted back through the relay.
@@ -424,11 +448,15 @@ export async function updateGrokSettings({
424
448
  enabled = undefined,
425
449
  model = undefined,
426
450
  enabledModels = undefined,
451
+ shownModels = undefined,
452
+ autoEnableNew = undefined,
427
453
  } = {}) {
428
454
  const payload = {};
429
455
  if (typeof enabled === 'boolean') payload.enabled = enabled;
430
456
  if (typeof model === 'string' && model.trim()) payload.model = model.trim();
431
457
  if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
458
+ if (Array.isArray(shownModels)) payload.shownModels = shownModels;
459
+ if (typeof autoEnableNew === 'boolean') payload.autoEnableNew = autoEnableNew;
432
460
  return settingsRequest('/api/settings/grok', payload, 'Failed to update Grok settings');
433
461
  }
434
462
 
@@ -493,21 +521,86 @@ export async function loadClaudeCloudBranches(repo) {
493
521
 
494
522
  export async function updateOpenAISettings({
495
523
  apiKey = '',
496
- model = 'gpt-4o',
524
+ model = undefined,
497
525
  baseUrl = undefined,
498
526
  enabled = undefined,
527
+ enabledModels = undefined,
528
+ shownModels = undefined,
529
+ autoEnableNew = undefined,
499
530
  remove = false,
500
531
  } = {}) {
501
532
  const payload = {
502
533
  apiKey: String(apiKey || '').trim(),
503
- model: String(model || '').trim() || 'gpt-4o',
504
534
  remove: remove === true,
505
535
  };
536
+ // Every Settings call names the model; the Select Models save does not, and
537
+ // must not reset the default model to gpt-4o.
538
+ if (model !== undefined) payload.model = String(model || '').trim() || 'gpt-4o';
506
539
  if (typeof baseUrl === 'string') payload.baseUrl = String(baseUrl).trim();
507
540
  if (typeof enabled === 'boolean') payload.enabled = enabled;
541
+ if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
542
+ if (Array.isArray(shownModels)) payload.shownModels = shownModels;
543
+ if (typeof autoEnableNew === 'boolean') payload.autoEnableNew = autoEnableNew;
508
544
  return settingsRequest('/api/settings/openai', payload, 'Failed to update OpenAI settings');
509
545
  }
510
546
 
547
+ /**
548
+ * The daily image cap and today's override (POST /api/settings/openai with
549
+ * only these fields). Throws with the relay's words: a paired relay's
550
+ * request is refused (403).
551
+ */
552
+ export async function updateOpenAIImageSpendSettings({ imageDailyCapUsd = undefined, allowImagesToday = undefined } = {}) {
553
+ const payload = {};
554
+ if (imageDailyCapUsd !== undefined) payload[OPENAI_IMAGE_SETTINGS_FIELDS.dailyCapUsd] = Number(imageDailyCapUsd);
555
+ if (typeof allowImagesToday === 'boolean') payload[OPENAI_IMAGE_SETTINGS_FIELDS.allowToday] = allowImagesToday;
556
+ return settingsRequest('/api/settings/openai', payload, 'Failed to update the image cap');
557
+ }
558
+
559
+ /** Today's image spend; with a conversation, that chat's cost too. Null on any failure. */
560
+ export async function loadOpenAIImageUsage({ conversationId = '' } = {}) {
561
+ const id = String(conversationId || '').trim();
562
+ return quietGet(id ? `${OPENAI_IMAGE_USAGE_PATH}?conversationId=${encodeURIComponent(id)}` : OPENAI_IMAGE_USAGE_PATH);
563
+ }
564
+
565
+ // The presets routes: the relay's `error` and `code` ride on the thrown Error.
566
+ async function imagePresetsRequest(method, path, body = null) {
567
+ if (!networkRequestsEnabled) return null;
568
+ let response;
569
+ try {
570
+ response = await fetch(`${BASE}${path}`, {
571
+ signal: requestTimeoutSignal(),
572
+ method,
573
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
574
+ ...(body ? { body: JSON.stringify(body) } : {}),
575
+ });
576
+ } catch (error) {
577
+ noteFetchFailure(path, error);
578
+ throw Object.assign(new Error(`The relay could not be reached (${readableServerError(toErrorMessage(error))}).`), { status: 0, code: null });
579
+ }
580
+ noteFetchSuccess();
581
+ const result = await response.json().catch(() => null);
582
+ if (!response.ok) {
583
+ throw Object.assign(
584
+ new Error(readableServerError(result?.error || '') || `The relay answered HTTP ${response.status}.`),
585
+ { status: response.status, code: result?.code || null },
586
+ );
587
+ }
588
+ return result;
589
+ }
590
+
591
+ /** `{ presets }` (built-ins and own), or null when the relay has none to give. */
592
+ export async function loadOpenAIImagePresets() {
593
+ return quietGet(OPENAI_IMAGE_PRESETS_PATH);
594
+ }
595
+
596
+ export async function saveOpenAIImagePreset(preset, { replace = false } = {}) {
597
+ return imagePresetsRequest('PUT', OPENAI_IMAGE_PRESETS_PATH, { preset, ...(replace ? { replace: true } : {}) });
598
+ }
599
+
600
+ export async function deleteOpenAIImagePreset(name) {
601
+ return imagePresetsRequest('DELETE', `${OPENAI_IMAGE_PRESETS_PATH}/${encodeURIComponent(String(name || ''))}`);
602
+ }
603
+
511
604
  export async function loadCursorSettings() {
512
605
  return apiFetch('/api/settings/cursor');
513
606
  }
@@ -517,6 +610,8 @@ export async function updateCursorSettings({
517
610
  model = undefined,
518
611
  enabled = undefined,
519
612
  enabledModels = undefined,
613
+ shownModels = undefined,
614
+ autoEnableNew = undefined,
520
615
  remove = false,
521
616
  } = {}) {
522
617
  const payload = {
@@ -528,6 +623,8 @@ export async function updateCursorSettings({
528
623
  if (typeof model === 'string' && model.trim()) payload.model = model.trim();
529
624
  if (typeof enabled === 'boolean') payload.enabled = enabled;
530
625
  if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
626
+ if (Array.isArray(shownModels)) payload.shownModels = shownModels;
627
+ if (typeof autoEnableNew === 'boolean') payload.autoEnableNew = autoEnableNew;
531
628
  return settingsRequest('/api/settings/cursor', payload, 'Failed to update Cursor settings');
532
629
  }
533
630
 
@@ -546,6 +643,8 @@ export async function updateDeepSeekSettings({
546
643
  model = undefined,
547
644
  enabled = undefined,
548
645
  enabledModels = undefined,
646
+ shownModels = undefined,
647
+ autoEnableNew = undefined,
549
648
  baseUrl = undefined,
550
649
  attribution = undefined,
551
650
  monthlyBudget = undefined,
@@ -563,6 +662,8 @@ export async function updateDeepSeekSettings({
563
662
  if (typeof model === 'string' && model.trim()) payload.model = model.trim();
564
663
  if (typeof enabled === 'boolean') payload.enabled = enabled;
565
664
  if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
665
+ if (Array.isArray(shownModels)) payload.shownModels = shownModels;
666
+ if (typeof autoEnableNew === 'boolean') payload.autoEnableNew = autoEnableNew;
566
667
  if (baseUrl === null || typeof baseUrl === 'string') payload.baseUrl = baseUrl === null ? null : String(baseUrl).trim();
567
668
  if (typeof attribution === 'string' && attribution.trim()) payload.attribution = attribution.trim();
568
669
  if (monthlyBudget === null || (typeof monthlyBudget === 'number' && Number.isFinite(monthlyBudget))) payload.monthlyBudget = monthlyBudget;
@@ -590,6 +691,8 @@ export async function updateClaudeApiSettings({
590
691
  model = undefined,
591
692
  enabled = undefined,
592
693
  enabledModels = undefined,
694
+ shownModels = undefined,
695
+ autoEnableNew = undefined,
593
696
  monthlyCapUsd = undefined,
594
697
  remove = false,
595
698
  } = {}) {
@@ -602,6 +705,8 @@ export async function updateClaudeApiSettings({
602
705
  if (typeof model === 'string' && model.trim()) payload.model = model.trim();
603
706
  if (typeof enabled === 'boolean') payload.enabled = enabled;
604
707
  if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
708
+ if (Array.isArray(shownModels)) payload.shownModels = shownModels;
709
+ if (typeof autoEnableNew === 'boolean') payload.autoEnableNew = autoEnableNew;
605
710
  if (monthlyCapUsd === null || (typeof monthlyCapUsd === 'number' && Number.isFinite(monthlyCapUsd))) payload.monthlyCapUsd = monthlyCapUsd;
606
711
  }
607
712
  return settingsRequest('/api/settings/claude-api', payload, 'Failed to update Claude API settings');
@@ -940,14 +1045,50 @@ export async function refreshModelVariantCatalog() {
940
1045
  return apiFetch('/api/model-variants/refresh', { method: 'POST' });
941
1046
  }
942
1047
 
943
- export async function saveEnabledModelVariants(enabledVariantIds = []) {
1048
+ /**
1049
+ * Copilot's selection and its "Turn new models on automatically". A null
1050
+ * list leaves the selection alone (only the switch changes): an empty one is
1051
+ * refused, at least one variant stays on. `shownVariantIds` names the rows
1052
+ * the save could see: the relay turns only those on or off.
1053
+ */
1054
+ export async function saveEnabledModelVariants(enabledVariantIds = [], { autoEnableNew = undefined, shownVariantIds = undefined } = {}) {
1055
+ const payload = {};
1056
+ if (enabledVariantIds !== null) {
1057
+ payload.enabledVariantIds = Array.isArray(enabledVariantIds)
1058
+ ? enabledVariantIds.map((value) => String(value || '').trim()).filter(Boolean)
1059
+ : [];
1060
+ }
1061
+ if (Array.isArray(shownVariantIds)) {
1062
+ payload.shownVariantIds = shownVariantIds.map((value) => String(value || '').trim()).filter(Boolean);
1063
+ }
1064
+ if (typeof autoEnableNew === 'boolean') payload.autoEnableNew = autoEnableNew;
944
1065
  return apiFetch('/api/model-variants', {
945
1066
  method: 'PATCH',
946
- body: JSON.stringify({
947
- enabledVariantIds: Array.isArray(enabledVariantIds)
948
- ? enabledVariantIds.map((value) => String(value || '').trim()).filter(Boolean)
949
- : [],
950
- }),
1067
+ body: JSON.stringify(payload),
1068
+ });
1069
+ }
1070
+
1071
+ // Opening Select Models clears every provider's "New" tags and the menu dot;
1072
+ // answers `{ ok, unseenNewModelCount }`.
1073
+ export async function markModelsSeen() {
1074
+ return apiFetch('/api/model-variants/seen', { method: 'POST' });
1075
+ }
1076
+
1077
+ // Settings › General › Models: the background check for new models. A failed
1078
+ // read throws with the relay's reason, which the section shows.
1079
+ export async function loadModelRefreshSettings() {
1080
+ return apiFetchOrThrow('/api/model-refresh-settings');
1081
+ }
1082
+
1083
+ /** Any of `{ auto, intervalHours, excluded }`; a refused value throws with the relay's reason. */
1084
+ export async function updateModelRefreshSettings(patch = {}) {
1085
+ const payload = {};
1086
+ if (typeof patch.auto === 'boolean') payload.auto = patch.auto;
1087
+ if (patch.intervalHours !== undefined) payload.intervalHours = Number(patch.intervalHours);
1088
+ if (Array.isArray(patch.excluded)) payload.excluded = patch.excluded.map((value) => String(value || '').trim()).filter(Boolean);
1089
+ return apiFetchOrThrow('/api/model-refresh-settings', {
1090
+ method: 'POST',
1091
+ body: JSON.stringify(payload),
951
1092
  });
952
1093
  }
953
1094
 
@@ -131,7 +131,8 @@ export async function loadRelayQuestions(conversationId) {
131
131
  // after a link the relay did not know: its notice keeps the screen empty
132
132
  // until the reader opens something.
133
133
  const readingAView = document.body?.classList?.contains('inbox-view-active')
134
- || document.body?.classList?.contains('status-view-active');
134
+ || document.body?.classList?.contains('status-view-active')
135
+ || document.body?.classList?.contains('media-view-active');
135
136
  if (!conversationId && pending.length && !readingAView && !isConversationAutoOpenHeld()) {
136
137
  const latest = pending
137
138
  .slice()