@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.
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
- package/CHANGELOG.md +307 -0
- package/README.md +12 -11
- package/bin/oar.js +54 -4
- package/package.json +11 -7
- package/server/agent-sdk-loader.mjs +116 -0
- package/server/agent-sdks/claude/entry.mjs +4 -0
- package/server/agent-sdks/claude/package-lock.json +1438 -0
- package/server/agent-sdks/claude/package.json +13 -0
- package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
- package/server/agent-sdks/cursor/entry.mjs +3 -0
- package/server/agent-sdks/cursor/package-lock.json +171 -0
- package/server/agent-sdks/cursor/package.json +11 -0
- package/server/agent-sdks/koffi/entry.cjs +2 -0
- package/server/agent-sdks/koffi/package-lock.json +368 -0
- package/server/agent-sdks/koffi/package.json +10 -0
- package/server/claude-worker/claude-preview-tool.mjs +5 -2
- package/server/claude-worker/claude-remote-relay-tool.mjs +20 -2
- package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +13 -8
- package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
- package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
- package/server/migrations/0039-media-index.mjs +167 -0
- package/server/migrations/0040-image-session-defaults.mjs +93 -0
- package/server/public/app/agent-sdk-ui.js +349 -0
- package/server/public/app/api-client.js +149 -8
- package/server/public/app/ask-user-view.js +2 -1
- package/server/public/app/attachments-view.js +66 -9
- package/server/public/app/bootstrap.js +289 -94
- package/server/public/app/chat-actions-menu-state.mjs +1 -0
- package/server/public/app/context-media-tab.js +143 -0
- package/server/public/app/conversation-view.js +90 -5
- package/server/public/app/image-composer-ui.js +518 -0
- package/server/public/app/image-turn-placeholder.mjs +233 -0
- package/server/public/app/journal-view.js +12 -2
- package/server/public/app/media-gallery-model.mjs +202 -0
- package/server/public/app/media-gallery-ui.js +524 -0
- package/server/public/app/media-view.js +79 -0
- package/server/public/app/model-refresh-ui.mjs +413 -0
- package/server/public/app/no-conversation-state.mjs +5 -2
- package/server/public/app/openai-image-composer.mjs +492 -0
- package/server/public/app/openai-image-cost-ui.js +426 -0
- package/server/public/app/openai-image-spend.mjs +443 -0
- package/server/public/app/plan-usage-view.mjs +38 -6
- package/server/public/app/provider-readiness.mjs +67 -4
- package/server/public/app/relay-error-ctas.mjs +5 -0
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +171 -0
- package/server/public/app/socket-handlers.js +47 -0
- package/server/public/app/tab-strip-fade.mjs +1 -1
- package/server/public/app/viewer-gallery.mjs +23 -1
- package/server/public/index.html +478 -9
- package/server/relay-tools.md +1 -0
- package/server/repositories/image-conversation-repository.mjs +69 -0
- package/server/repositories/media-repository.mjs +305 -0
- package/server/routes/agent-sdk-routes.mjs +73 -0
- package/server/routes/media-routes.mjs +361 -0
- package/server/routes/messages-routes.mjs +1467 -343
- package/server/routes/sessions-routes.mjs +596 -123
- package/server/server-runtime.mjs +414 -81
- package/server/services/about-service.mjs +26 -6
- package/server/services/agent-sdk-launch-gate.mjs +124 -0
- package/server/services/agent-sdk-paths.mjs +307 -0
- package/server/services/agent-sdk-store.mjs +1211 -0
- package/server/services/claude-api-settings-service.mjs +54 -17
- package/server/services/cli-install-service.mjs +3 -2
- package/server/services/copilot-model-discovery-service.mjs +7 -1
- package/server/services/database-file.mjs +23 -5
- package/server/services/deepseek-settings-service.mjs +58 -20
- package/server/services/earlier-database-service.mjs +4 -2
- package/server/services/generated-image-cleanup-service.mjs +2 -1
- package/server/services/generated-image-files.mjs +110 -0
- package/server/services/image-input-paths.mjs +389 -0
- package/server/services/image-operation-service.mjs +6 -2
- package/server/services/media-extract.mjs +653 -0
- package/server/services/media-index-service.mjs +221 -0
- package/server/services/model-refresh-scheduler.mjs +153 -0
- package/server/services/model-variant-catalog-service.mjs +141 -9
- package/server/services/oar-cli-helpers.mjs +72 -9
- package/server/services/oar-state-migration-service.mjs +2 -12
- package/server/services/oar-state-root.mjs +17 -0
- package/server/services/openai-image-preview-turns.mjs +330 -0
- package/server/services/openai-image-spend-service.mjs +516 -0
- package/server/services/openai-image-stream.mjs +170 -0
- package/server/services/plan-usage-openai-image.mjs +148 -0
- package/server/services/provider-model-refresh.mjs +326 -0
- package/server/services/provider-model-state.mjs +395 -0
- package/server/services/relay-singleton-guard.mjs +72 -10
- package/server/services/remote-relay-caller-context.mjs +6 -0
- package/server/services/remote-relay-dispatcher.mjs +386 -22
- package/server/services/remote-relay-registry-service.mjs +3 -1
- package/server/services/session-worker-launch-service.mjs +13 -0
- package/server/services/session-worker-supervisor-service.mjs +19 -1
- package/server/services/session-worker-websocket-service.mjs +15 -0
- package/server/services/token-usage-service.mjs +15 -0
- package/server/services/update-install-service.mjs +2 -2
- package/server/services/worker-launch-note-service.mjs +14 -2
- package/server/services/worker-recycle-service.mjs +145 -11
- package/shared/image-header.mjs +90 -0
- package/shared/model-refresh-schedule.mjs +120 -0
- package/shared/openai-image-options.mjs +299 -0
- package/shared/openai-image-presets.mjs +175 -0
- package/shared/openai-image-previews.mjs +40 -0
- package/shared/openai-image-prices.mjs +291 -0
- package/shared/openai-image-spend.mjs +90 -0
- package/shared/remote-relay-contract.mjs +126 -4
- package/shared/worker-runtime/direct-openai-image.mjs +98 -48
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Which of a provider's models are on, remembered per model and forever.
|
|
4
|
+
//
|
|
5
|
+
// Before 0.9.24 the providers with a model list of their own (Claude, Cursor,
|
|
6
|
+
// Grok, DeepSeek, Claude API) kept an allowlist where an empty list meant
|
|
7
|
+
// "all on": the first save in Select Models froze the list, so a model found
|
|
8
|
+
// later stayed off without a word, and unticking everything, a list gone
|
|
9
|
+
// fully stale or a removed key turned every model back on. Now each provider
|
|
10
|
+
// keeps one map in `app_settings` (`<provider>_model_state`):
|
|
11
|
+
//
|
|
12
|
+
// { v: 1, autoEnableNew, selectedAt, models: { "<id>": { on, firstSeen, lastSeen, unseen } } }
|
|
13
|
+
//
|
|
14
|
+
// A model is added the first time it is seen, on when the provider's
|
|
15
|
+
// "Turn new models on automatically" switch is (autoEnableNew), and flagged
|
|
16
|
+
// unseen for the "New" tag. Nothing is ever deleted, so a model the user
|
|
17
|
+
// turned off stays off when it vanishes from the list and comes back. A
|
|
18
|
+
// Select Models save stamps selectedAt and the lastSeen of every model it
|
|
19
|
+
// showed: only a model that was on and left the list after that save makes
|
|
20
|
+
// the "every model you turned on has left" warning. The
|
|
21
|
+
// default model is always on. Ids match case-insensitively; the casing of the
|
|
22
|
+
// first sighting is kept.
|
|
23
|
+
//
|
|
24
|
+
// Copilot keeps its rows in `model_variants`; its map (`copilot_model_state`)
|
|
25
|
+
// only remembers which base models are known, which variant rows were turned
|
|
26
|
+
// off and which models are unseen (model-variant-catalog-service.mjs).
|
|
27
|
+
|
|
28
|
+
import { buildCanonicalModelIndex, normalizeProviderModelId } from './provider-model-selection.mjs';
|
|
29
|
+
|
|
30
|
+
export const PROVIDER_MODEL_STATE_VERSION = 1;
|
|
31
|
+
|
|
32
|
+
// Setting key prefixes of the providers. Copilot is `github` everywhere else
|
|
33
|
+
// in the relay; its settings were always named after Copilot.
|
|
34
|
+
const SETTING_PREFIXES = Object.freeze({
|
|
35
|
+
github: 'copilot',
|
|
36
|
+
openai: 'openai',
|
|
37
|
+
claude: 'claude',
|
|
38
|
+
cursor: 'cursor',
|
|
39
|
+
grok: 'grok',
|
|
40
|
+
deepseek: 'deepseek',
|
|
41
|
+
'claude-api': 'claude_api',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export function providerModelStateKey(provider) {
|
|
45
|
+
const id = String(provider || '').trim().toLowerCase();
|
|
46
|
+
const prefix = SETTING_PREFIXES[id] || id.replace(/[^a-z0-9]+/g, '_');
|
|
47
|
+
return `${prefix}_model_state`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isoNow(now) {
|
|
51
|
+
const value = typeof now === 'function' ? now() : now;
|
|
52
|
+
const date = value instanceof Date ? value : new Date(value ?? Date.now());
|
|
53
|
+
return Number.isFinite(date.getTime()) ? date.toISOString() : new Date().toISOString();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function uniqueIds(values) {
|
|
57
|
+
return [...buildCanonicalModelIndex(Array.isArray(values) ? values : []).values()];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function textOrNull(value) {
|
|
61
|
+
const text = String(value ?? '').trim();
|
|
62
|
+
return text || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A stored map, or null when there is none or it cannot be read. */
|
|
66
|
+
export function normalizeProviderModelState(raw) {
|
|
67
|
+
const parsed = typeof raw === 'string' ? safeParse(raw) : raw;
|
|
68
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
69
|
+
const models = {};
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
const source = parsed.models && typeof parsed.models === 'object' && !Array.isArray(parsed.models) ? parsed.models : {};
|
|
72
|
+
for (const [rawId, entry] of Object.entries(source)) {
|
|
73
|
+
const id = normalizeProviderModelId(rawId);
|
|
74
|
+
if (!id || seen.has(id.toLowerCase())) continue;
|
|
75
|
+
seen.add(id.toLowerCase());
|
|
76
|
+
const value = entry && typeof entry === 'object' ? entry : {};
|
|
77
|
+
models[id] = {
|
|
78
|
+
on: value.on === true,
|
|
79
|
+
firstSeen: textOrNull(value.firstSeen),
|
|
80
|
+
lastSeen: textOrNull(value.lastSeen),
|
|
81
|
+
unseen: value.unseen === true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
v: PROVIDER_MODEL_STATE_VERSION,
|
|
86
|
+
autoEnableNew: parsed.autoEnableNew !== false,
|
|
87
|
+
// Absent in maps written before it: every vanished model that was on and
|
|
88
|
+
// had been seen counts then.
|
|
89
|
+
selectedAt: textOrNull(parsed.selectedAt),
|
|
90
|
+
models,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function safeParse(text) {
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(String(text || ''));
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cloneState(state) {
|
|
103
|
+
const base = normalizeProviderModelState(state) || { v: PROVIDER_MODEL_STATE_VERSION, autoEnableNew: true, models: {} };
|
|
104
|
+
return { ...base, models: Object.fromEntries(Object.entries(base.models).map(([id, entry]) => [id, { ...entry }])) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** lower-case id → the key the map holds it under. */
|
|
108
|
+
function keyIndex(state) {
|
|
109
|
+
return new Map(Object.keys(state?.models || {}).map((id) => [id.toLowerCase(), id]));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function lookup(state, index, id) {
|
|
113
|
+
const key = index.get(normalizeProviderModelId(id).toLowerCase());
|
|
114
|
+
return key ? state.models[key] : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The map an install had before it, built from the old allowlist so the
|
|
119
|
+
* pickers show exactly what they showed: no allowlist, or one none of whose
|
|
120
|
+
* ids is offered any more, means every offered model on (the old "fall back
|
|
121
|
+
* to all"); otherwise the listed ones on and the rest off, and listed ids
|
|
122
|
+
* that are not offered kept as on, for when they come back. New models start
|
|
123
|
+
* on (autoEnableNew).
|
|
124
|
+
*/
|
|
125
|
+
export function migrateFromAllowlist({ allowlist = [], available = [], now = new Date() } = {}) {
|
|
126
|
+
const stamp = isoNow(now);
|
|
127
|
+
const offered = uniqueIds(available);
|
|
128
|
+
const listed = uniqueIds(allowlist);
|
|
129
|
+
const listedKeys = new Set(listed.map((id) => id.toLowerCase()));
|
|
130
|
+
const offeredKeys = new Set(offered.map((id) => id.toLowerCase()));
|
|
131
|
+
const narrowed = listed.some((id) => offeredKeys.has(id.toLowerCase()));
|
|
132
|
+
const models = {};
|
|
133
|
+
for (const id of offered) {
|
|
134
|
+
models[id] = { on: narrowed ? listedKeys.has(id.toLowerCase()) : true, firstSeen: stamp, lastSeen: stamp, unseen: false };
|
|
135
|
+
}
|
|
136
|
+
// A fully stale list is dropped, as the old code ignored it; keeping its
|
|
137
|
+
// ids on would read as "every model you turned on has left" (fallback).
|
|
138
|
+
for (const id of narrowed ? listed : []) {
|
|
139
|
+
if (offeredKeys.has(id.toLowerCase())) continue;
|
|
140
|
+
models[id] = { on: true, firstSeen: stamp, lastSeen: null, unseen: false };
|
|
141
|
+
}
|
|
142
|
+
return { v: PROVIDER_MODEL_STATE_VERSION, autoEnableNew: true, models };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A discovery's list against the map: an id never seen is added, on when
|
|
147
|
+
* the switch is and flagged unseen; a known id has its lastSeen moved.
|
|
148
|
+
* Nothing is deleted. `added` lists the new ids with their state; `changed`
|
|
149
|
+
* is true when something the user can see changed (a model was added).
|
|
150
|
+
*/
|
|
151
|
+
export function reconcileDiscovered(state, ids = [], { now = new Date() } = {}) {
|
|
152
|
+
const next = cloneState(state);
|
|
153
|
+
const stamp = isoNow(now);
|
|
154
|
+
const index = keyIndex(next);
|
|
155
|
+
const added = [];
|
|
156
|
+
for (const id of uniqueIds(ids)) {
|
|
157
|
+
const known = lookup(next, index, id);
|
|
158
|
+
if (known) {
|
|
159
|
+
known.lastSeen = stamp;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
next.models[id] = { on: next.autoEnableNew, firstSeen: stamp, lastSeen: stamp, unseen: true };
|
|
163
|
+
index.set(id.toLowerCase(), id);
|
|
164
|
+
added.push({ id, on: next.autoEnableNew });
|
|
165
|
+
}
|
|
166
|
+
return { state: next, added, changed: added.length > 0 };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The models a provider offers: the default model, then every available id
|
|
171
|
+
* that is on, in the available order. An available id the map does not hold
|
|
172
|
+
* yet follows the switch. Never "all": with everything off only the default
|
|
173
|
+
* is left. `fallback` is set when every model the user had on has left the
|
|
174
|
+
* list, so the default stands in for them (the Select Models warning).
|
|
175
|
+
* `unseen` is the available ids still flagged new.
|
|
176
|
+
*/
|
|
177
|
+
export function resolveEnabled(state, availableIds = [], defaultModel = '') {
|
|
178
|
+
const current = normalizeProviderModelState(state) || { autoEnableNew: true, models: {} };
|
|
179
|
+
const index = keyIndex(current);
|
|
180
|
+
const fallbackModel = normalizeProviderModelId(defaultModel);
|
|
181
|
+
const fallbackKey = fallbackModel.toLowerCase();
|
|
182
|
+
const available = uniqueIds(availableIds);
|
|
183
|
+
const availableKeys = new Set(available.map((id) => id.toLowerCase()));
|
|
184
|
+
const enabled = available.filter((id) => {
|
|
185
|
+
if (id.toLowerCase() === fallbackKey) return false;
|
|
186
|
+
const entry = lookup(current, index, id);
|
|
187
|
+
return entry ? entry.on : current.autoEnableNew;
|
|
188
|
+
});
|
|
189
|
+
const models = uniqueIds([fallbackModel, ...enabled]);
|
|
190
|
+
// A model that left before the last save (an old allowlist's stale id, one
|
|
191
|
+
// gone when the user unticked everything) is no reason to warn.
|
|
192
|
+
const selectedAt = current.selectedAt || '';
|
|
193
|
+
const vanishedOn = Object.entries(current.models)
|
|
194
|
+
.some(([id, entry]) => entry.on && id.toLowerCase() !== fallbackKey && !availableKeys.has(id.toLowerCase())
|
|
195
|
+
&& !!entry.lastSeen && entry.lastSeen >= selectedAt);
|
|
196
|
+
const unseen = available.filter((id) => lookup(current, index, id)?.unseen === true);
|
|
197
|
+
return {
|
|
198
|
+
models,
|
|
199
|
+
fallback: enabled.length === 0 && vanishedOn,
|
|
200
|
+
unseen,
|
|
201
|
+
autoEnableNew: current.autoEnableNew,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A save from Select Models: each shown id is on when it was ticked, else
|
|
207
|
+
* off; ids that were not shown (a model that left the list, or one a refresh
|
|
208
|
+
* added after the modal was drawn) keep their state. The default model is
|
|
209
|
+
* always on. A save that showed models stamps selectedAt and their lastSeen
|
|
210
|
+
* (they were on the list). `autoEnableNew`, when a boolean, sets the switch.
|
|
211
|
+
*/
|
|
212
|
+
export function applySelection(state, enabledIds = [], shownIds = [], { defaultModel = '', autoEnableNew, now = new Date() } = {}) {
|
|
213
|
+
const next = cloneState(state);
|
|
214
|
+
const stamp = isoNow(now);
|
|
215
|
+
const index = keyIndex(next);
|
|
216
|
+
const enabledKeys = new Set(uniqueIds(enabledIds).map((id) => id.toLowerCase()));
|
|
217
|
+
const fallbackModel = normalizeProviderModelId(defaultModel);
|
|
218
|
+
const shown = uniqueIds([...(fallbackModel ? [fallbackModel] : []), ...(Array.isArray(shownIds) ? shownIds : [])]);
|
|
219
|
+
for (const id of shown) {
|
|
220
|
+
const on = enabledKeys.has(id.toLowerCase()) || id.toLowerCase() === fallbackModel.toLowerCase();
|
|
221
|
+
const known = lookup(next, index, id);
|
|
222
|
+
if (known) {
|
|
223
|
+
known.on = on;
|
|
224
|
+
known.lastSeen = stamp;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
next.models[id] = { on, firstSeen: stamp, lastSeen: stamp, unseen: false };
|
|
228
|
+
index.set(id.toLowerCase(), id);
|
|
229
|
+
}
|
|
230
|
+
if (shown.length) next.selectedAt = stamp;
|
|
231
|
+
if (typeof autoEnableNew === 'boolean') next.autoEnableNew = autoEnableNew;
|
|
232
|
+
return next;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Clears every "New" flag; `changed` tells whether one was set. */
|
|
236
|
+
export function markSeen(state) {
|
|
237
|
+
const next = cloneState(state);
|
|
238
|
+
let changed = false;
|
|
239
|
+
for (const entry of Object.values(next.models)) {
|
|
240
|
+
if (!entry.unseen) continue;
|
|
241
|
+
entry.unseen = false;
|
|
242
|
+
changed = true;
|
|
243
|
+
}
|
|
244
|
+
return { state: next, changed };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── Copilot ────────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/** The stored Copilot map, or null. */
|
|
250
|
+
export function normalizeCopilotModelState(raw) {
|
|
251
|
+
const parsed = typeof raw === 'string' ? safeParse(raw) : raw;
|
|
252
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
253
|
+
const list = (values) => [...new Set((Array.isArray(values) ? values : [])
|
|
254
|
+
.map((value) => String(value || '').trim())
|
|
255
|
+
.filter(Boolean))];
|
|
256
|
+
return {
|
|
257
|
+
v: PROVIDER_MODEL_STATE_VERSION,
|
|
258
|
+
autoEnableNew: parsed.autoEnableNew !== false,
|
|
259
|
+
knownModels: list(parsed.knownModels),
|
|
260
|
+
disabledVariants: list(parsed.disabledVariants),
|
|
261
|
+
unseen: list(parsed.unseen),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The Copilot map an install had before it: every base model in the table is
|
|
267
|
+
* known and every disabled row stays off; models first found later start on.
|
|
268
|
+
*/
|
|
269
|
+
export function migrateCopilotModelState(rows = []) {
|
|
270
|
+
const safeRows = Array.isArray(rows) ? rows : [];
|
|
271
|
+
return normalizeCopilotModelState({
|
|
272
|
+
autoEnableNew: true,
|
|
273
|
+
knownModels: safeRows.map((row) => row?.baseModelId),
|
|
274
|
+
disabledVariants: safeRows.filter((row) => !row?.enabled).map((row) => row?.variantId),
|
|
275
|
+
unseen: [],
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Whether a variant row seen for the first time starts on: a row the user
|
|
281
|
+
* turned off before stays off; a new effort row of a known model follows the
|
|
282
|
+
* model's other rows (on when one of them is on); a whole new model follows
|
|
283
|
+
* the switch and is `newModel` (the caller adds it to knownModels and unseen).
|
|
284
|
+
*/
|
|
285
|
+
export function decideNewCopilotVariant(state, { variantId = '', baseModelId = '' } = {}, { siblingEnabled = false } = {}) {
|
|
286
|
+
const current = normalizeCopilotModelState(state) || migrateCopilotModelState([]);
|
|
287
|
+
if (current.disabledVariants.includes(String(variantId || '').trim())) return { enabled: false, newModel: false };
|
|
288
|
+
if (current.knownModels.includes(String(baseModelId || '').trim())) return { enabled: siblingEnabled === true, newModel: false };
|
|
289
|
+
return { enabled: current.autoEnableNew, newModel: true };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ─── The store ──────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The maps in a settings store (`readSetting(key)` / `writeSetting(key,
|
|
296
|
+
* value, stamp)`). `legacyAllowlist` (an array or a function returning one)
|
|
297
|
+
* is only read when a provider has no map yet, to build it. A store that
|
|
298
|
+
* cannot write still answers from what it computed.
|
|
299
|
+
*/
|
|
300
|
+
export function createProviderModelStateStore({
|
|
301
|
+
readSetting = () => '',
|
|
302
|
+
writeSetting = null,
|
|
303
|
+
now = () => new Date(),
|
|
304
|
+
} = {}) {
|
|
305
|
+
function read(provider) {
|
|
306
|
+
try {
|
|
307
|
+
return normalizeProviderModelState(readSetting(providerModelStateKey(provider)));
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function write(provider, state) {
|
|
314
|
+
if (typeof writeSetting !== 'function') return false;
|
|
315
|
+
try {
|
|
316
|
+
writeSetting(providerModelStateKey(provider), JSON.stringify(state), isoNow(now));
|
|
317
|
+
return true;
|
|
318
|
+
} catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function legacyList(legacyAllowlist) {
|
|
324
|
+
try {
|
|
325
|
+
const value = typeof legacyAllowlist === 'function' ? legacyAllowlist() : legacyAllowlist;
|
|
326
|
+
return Array.isArray(value) ? value : [];
|
|
327
|
+
} catch {
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The provider's map, built from its old allowlist (and saved) the first time. */
|
|
333
|
+
function ensure(provider, { availableModels = [], legacyAllowlist = [] } = {}) {
|
|
334
|
+
const stored = read(provider);
|
|
335
|
+
if (stored) return stored;
|
|
336
|
+
const migrated = migrateFromAllowlist({ allowlist: legacyList(legacyAllowlist), available: availableModels, now: now() });
|
|
337
|
+
write(provider, migrated);
|
|
338
|
+
return migrated;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function resolve(provider, { availableModels = [], defaultModel = '', legacyAllowlist = [] } = {}) {
|
|
342
|
+
return resolveEnabled(ensure(provider, { availableModels, legacyAllowlist }), availableModels, defaultModel);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* A save: `enabledModels` (a list) sets the shown models; anything else
|
|
347
|
+
* (a save of the switch alone) leaves every model as it is. Callers pass
|
|
348
|
+
* the request's own shown list when it has one (the rows the browser drew)
|
|
349
|
+
* and the offered list otherwise (an older page).
|
|
350
|
+
*/
|
|
351
|
+
function select(provider, {
|
|
352
|
+
enabledModels,
|
|
353
|
+
shownModels = [],
|
|
354
|
+
defaultModel = '',
|
|
355
|
+
autoEnableNew,
|
|
356
|
+
availableModels = shownModels,
|
|
357
|
+
legacyAllowlist = [],
|
|
358
|
+
} = {}) {
|
|
359
|
+
const state = ensure(provider, { availableModels, legacyAllowlist });
|
|
360
|
+
const next = Array.isArray(enabledModels)
|
|
361
|
+
? applySelection(state, enabledModels, shownModels, { defaultModel, autoEnableNew, now: now() })
|
|
362
|
+
: applySelection(state, [], [], { autoEnableNew, now: now() });
|
|
363
|
+
write(provider, next);
|
|
364
|
+
return next;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function reconcile(provider, { availableModels = [], legacyAllowlist = [] } = {}) {
|
|
368
|
+
const state = ensure(provider, { availableModels, legacyAllowlist });
|
|
369
|
+
const result = reconcileDiscovered(state, availableModels, { now: now() });
|
|
370
|
+
write(provider, result.state);
|
|
371
|
+
return { added: result.added, changed: result.changed };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function markProviderSeen(provider) {
|
|
375
|
+
const state = read(provider);
|
|
376
|
+
if (!state) return false;
|
|
377
|
+
const result = markSeen(state);
|
|
378
|
+
if (result.changed) write(provider, result.state);
|
|
379
|
+
return result.changed;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function readCopilot() {
|
|
383
|
+
try {
|
|
384
|
+
return normalizeCopilotModelState(readSetting(providerModelStateKey('github')));
|
|
385
|
+
} catch {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function writeCopilot(state) {
|
|
391
|
+
return write('github', normalizeCopilotModelState(state));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return { read, write, ensure, resolve, select, reconcile, markSeen: markProviderSeen, readCopilot, writeCopilot };
|
|
395
|
+
}
|
|
@@ -5,6 +5,8 @@ import crypto from "crypto";
|
|
|
5
5
|
import { spawnSync } from "child_process";
|
|
6
6
|
import { createRequire } from "module";
|
|
7
7
|
|
|
8
|
+
import { agentSdkFromCheckout, findReadyAgentSdkDir, writeAgentSdkLease } from "./agent-sdk-paths.mjs";
|
|
9
|
+
|
|
8
10
|
// An EPERM is a process this user may not signal: alive.
|
|
9
11
|
export function defaultIsProcessAlive(pid) {
|
|
10
12
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
@@ -17,11 +19,26 @@ export function defaultIsProcessAlive(pid) {
|
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
let processTimes;
|
|
22
|
+
// koffi for the kernel call: a checkout's devDependency, else the copy the
|
|
23
|
+
// relay installs on demand (any installed version will do). Throws while
|
|
24
|
+
// there is none. The folder is leased once loaded: its binary stays mapped
|
|
25
|
+
// for the life of the process.
|
|
26
|
+
export function loadRelayKoffi({ env = process.env, fromCheckout = agentSdkFromCheckout("koffi", { env }) } = {}) {
|
|
27
|
+
if (fromCheckout) return createRequire(import.meta.url)("koffi");
|
|
28
|
+
const dir = findReadyAgentSdkDir("koffi", { env, anyVersion: true });
|
|
29
|
+
if (!dir) throw Object.assign(new Error("koffi is not installed yet"), { code: "ERR_MODULE_NOT_FOUND" });
|
|
30
|
+
const entry = path.join(dir, "entry.cjs");
|
|
31
|
+
const koffi = createRequire(entry)(entry);
|
|
32
|
+
writeAgentSdkLease(dir, { bootId: currentBootId() });
|
|
33
|
+
return koffi;
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
// When a process started, in ms, or null when that cannot be told. Windows
|
|
21
|
-
// asks the kernel (GetProcessTimes through koffi
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
37
|
+
// asks the kernel (GetProcessTimes through koffi): PowerShell fails in
|
|
38
|
+
// ConstrainedLanguage mode and with a broken TEMP. Elsewhere `ps`. Without
|
|
39
|
+
// koffi (Windows, until the relay installed it) null, which the lock checks
|
|
40
|
+
// meet with unknownStartMayRun.
|
|
41
|
+
export function processStartedAt(pid, { platform = process.platform, loadKoffi = loadRelayKoffi } = {}) {
|
|
25
42
|
try {
|
|
26
43
|
if (platform !== "win32") {
|
|
27
44
|
// `lstart`, the full date; LC_ALL=C to parse; unparsable: cannot tell.
|
|
@@ -29,10 +46,11 @@ export function processStartedAt(pid, { platform = process.platform } = {}) {
|
|
|
29
46
|
const ms = Date.parse(String(result.stdout || "").trim());
|
|
30
47
|
return Number.isFinite(ms) ? ms : null;
|
|
31
48
|
}
|
|
32
|
-
if (
|
|
33
|
-
const koffi =
|
|
49
|
+
if (processTimes?.loadKoffi !== loadKoffi) {
|
|
50
|
+
const koffi = loadKoffi();
|
|
34
51
|
const kernel32 = koffi.load("kernel32.dll");
|
|
35
52
|
processTimes = {
|
|
53
|
+
loadKoffi,
|
|
36
54
|
open: kernel32.func("void * __stdcall OpenProcess(uint32_t access, bool inherit, uint32_t pid)"),
|
|
37
55
|
times: kernel32.func("bool __stdcall GetProcessTimes(void *h, _Out_ uint64_t *a, _Out_ uint64_t *b, _Out_ uint64_t *c, _Out_ uint64_t *d)"),
|
|
38
56
|
close: kernel32.func("bool __stdcall CloseHandle(void *h)"),
|
|
@@ -89,6 +107,40 @@ function sameStart(a, b) {
|
|
|
89
107
|
return typeof a === "number" && typeof b === "number" ? Math.abs(a - b) <= 1000 : a === b;
|
|
90
108
|
}
|
|
91
109
|
|
|
110
|
+
// A Windows process's image name in lower case ("node.exe"), or null when it
|
|
111
|
+
// cannot be told. tasklist, not PowerShell (see processStartedAt).
|
|
112
|
+
export function processImageName(pid, { spawnSyncImpl = spawnSync } = {}) {
|
|
113
|
+
try {
|
|
114
|
+
const result = spawnSyncImpl("tasklist", ["/FI", `PID eq ${Number(pid)}`, "/FO", "CSV", "/NH"], { encoding: "utf8", timeout: 5000, windowsHide: true });
|
|
115
|
+
const row = String(result?.stdout || "").split(/\r?\n/).find((line) => line.startsWith('"'));
|
|
116
|
+
const fields = (row?.match(/"[^"]*"/g) || []).map((field) => field.slice(1, -1));
|
|
117
|
+
if (fields.length < 2 || Number(fields[1]) !== Number(pid)) return null;
|
|
118
|
+
return fields[0].toLowerCase() || null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The image names a relay runs as: node.exe, or the Node it was started with.
|
|
125
|
+
export function relayImageNames({ execPath = process.execPath, env = process.env } = {}) {
|
|
126
|
+
return new Set(["node.exe", path.win32.basename(String(execPath || "")), path.win32.basename(String(env?.COPILOT_WEB_RELAY_NODE || ""))]
|
|
127
|
+
.map((name) => name.toLowerCase())
|
|
128
|
+
.filter(Boolean));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A live pid whose start cannot be told (Windows while koffi is not
|
|
132
|
+
// installed): on Windows a lock written before the last boot, or whose pid
|
|
133
|
+
// now runs another program than Node, is stale; else the pid holds it.
|
|
134
|
+
// Without these a lock a crash or a reboot left behind, whose pid Windows
|
|
135
|
+
// has handed on, would keep the relay from starting.
|
|
136
|
+
function unknownStartMayRun(lock, pid, { platform, bootTime, imageNameOf, relayImages }) {
|
|
137
|
+
if (platform !== "win32") return true;
|
|
138
|
+
const lockedAt = Date.parse(String(lock?.startedAt || ""));
|
|
139
|
+
if (Number.isFinite(lockedAt) && lockedAt < bootTime) return false;
|
|
140
|
+
const image = imageNameOf(pid);
|
|
141
|
+
return !image || relayImages.has(image);
|
|
142
|
+
}
|
|
143
|
+
|
|
92
144
|
/**
|
|
93
145
|
* Whether the relay a lock names may still run; true when that cannot be
|
|
94
146
|
* told: better a refused start than two relays. A lock this version wrote
|
|
@@ -98,7 +150,8 @@ function sameStart(a, b) {
|
|
|
98
150
|
* recorded (Linux) is not the running one. Only a lock without the format
|
|
99
151
|
* (0.9.13's) goes by the clock, the one case a clock step can mislead:
|
|
100
152
|
* written before the last boot, or older than the pid's process by more
|
|
101
|
-
* than 5 s (a reused pid), it is stale.
|
|
153
|
+
* than 5 s (a reused pid), it is stale. A start that cannot be told goes
|
|
154
|
+
* through unknownStartMayRun.
|
|
102
155
|
*/
|
|
103
156
|
export function lockHolderMayRun({
|
|
104
157
|
lock,
|
|
@@ -107,20 +160,25 @@ export function lockHolderMayRun({
|
|
|
107
160
|
bootIdOf = currentBootId,
|
|
108
161
|
startedAtOf = processStartedAt,
|
|
109
162
|
bootTime = Date.now() - os.uptime() * 1000,
|
|
163
|
+
platform = process.platform,
|
|
164
|
+
imageNameOf = processImageName,
|
|
165
|
+
relayImages = relayImageNames(),
|
|
110
166
|
}) {
|
|
111
167
|
const pid = Number.parseInt(String(lock?.pid ?? ""), 10);
|
|
112
168
|
if (!Number.isInteger(pid) || pid <= 0 || !isProcessAlive(pid)) return false;
|
|
169
|
+
const unknownStart = { platform, bootTime, imageNameOf, relayImages };
|
|
113
170
|
if (lock?.format >= 2 || lock?.holder) {
|
|
114
171
|
const current = lock.holder ? identityOf(pid) : null;
|
|
115
172
|
const recordedBoot = lock.holder?.bootId || lock.bootId;
|
|
116
173
|
const runningBoot = current?.bootId || bootIdOf();
|
|
117
174
|
if (recordedBoot && runningBoot && recordedBoot !== runningBoot) return false;
|
|
118
|
-
return
|
|
175
|
+
return current ? sameStart(lock.holder.start, current.start) : unknownStartMayRun(lock, pid, unknownStart);
|
|
119
176
|
}
|
|
120
177
|
const lockedAt = Date.parse(String(lock?.startedAt || ""));
|
|
121
178
|
if (Number.isFinite(lockedAt) && lockedAt < bootTime) return false;
|
|
122
179
|
const startedAt = startedAtOf(pid);
|
|
123
|
-
if (
|
|
180
|
+
if (startedAt === null) return unknownStartMayRun(lock, pid, unknownStart);
|
|
181
|
+
if (Number.isFinite(lockedAt) && startedAt > lockedAt + 5000) return false;
|
|
124
182
|
return true;
|
|
125
183
|
}
|
|
126
184
|
|
|
@@ -163,6 +221,10 @@ export function createRelaySingletonGuard({
|
|
|
163
221
|
bootIdOf = currentBootId,
|
|
164
222
|
startedAtOf = processStartedAt,
|
|
165
223
|
bootTime = Date.now() - os.uptime() * 1000,
|
|
224
|
+
// What a pid runs, when its start cannot be told (unknownStartMayRun).
|
|
225
|
+
platform = process.platform,
|
|
226
|
+
imageNameOf = processImageName,
|
|
227
|
+
relayImages = relayImageNames(),
|
|
166
228
|
// An unreadable lock is read again for `readRetryMs`; still unreadable,
|
|
167
229
|
// it is stale only when its time is more than `unreadableStaleMs` off.
|
|
168
230
|
readRetryMs = 2000,
|
|
@@ -321,7 +383,7 @@ export function createRelaySingletonGuard({
|
|
|
321
383
|
// A lock from before the last boot, or whose pid now belongs to a
|
|
322
384
|
// process started after it, is stale (a crash, a power loss, a forced
|
|
323
385
|
// reboot): recovered below.
|
|
324
|
-
if (existingPid && lockHolderMayRun({ lock: existingLock, isProcessAlive: processAlive, identityOf, bootIdOf, startedAtOf, bootTime })) {
|
|
386
|
+
if (existingPid && lockHolderMayRun({ lock: existingLock, isProcessAlive: processAlive, identityOf, bootIdOf, startedAtOf, bootTime, platform, imageNameOf, relayImages })) {
|
|
325
387
|
const owner = existingTokenHash && existingTokenHash === currentTokenHash
|
|
326
388
|
? "Relay already running"
|
|
327
389
|
: "Relay appears to be owned by another live process";
|
|
@@ -153,6 +153,12 @@ function describeSettingsChange(args) {
|
|
|
153
153
|
if (toText(args.thinking_display)) parts.push(`Thinking text: ${toText(args.thinking_display) === 'omitted' ? 'hidden' : 'visible'}`);
|
|
154
154
|
if (toText(args.cwd)) parts.push(`Folder: ${excerpt(args.cwd)} (its CLI restarts there)`);
|
|
155
155
|
if (typeof args.planOnly === 'boolean') parts.push(`planOnly: ${args.planOnly}`);
|
|
156
|
+
if (args.image && typeof args.image === 'object' && !Array.isArray(args.image)) {
|
|
157
|
+
const options = Object.entries(args.image)
|
|
158
|
+
.filter(([, value]) => toText(value))
|
|
159
|
+
.map(([name, value]) => `${name.replace(/_/g, ' ')} ${excerpt(value)}`);
|
|
160
|
+
if (options.length) parts.push(`Image defaults: ${options.join(', ')}`);
|
|
161
|
+
}
|
|
156
162
|
return parts.join(' · ');
|
|
157
163
|
}
|
|
158
164
|
|