@oar-sh/oar 0.9.23 → 0.9.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +54 -4
  4. package/package.json +9 -7
  5. package/server/agent-sdk-loader.mjs +116 -0
  6. package/server/agent-sdks/claude/entry.mjs +4 -0
  7. package/server/agent-sdks/claude/package-lock.json +1438 -0
  8. package/server/agent-sdks/claude/package.json +13 -0
  9. package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
  10. package/server/agent-sdks/cursor/entry.mjs +3 -0
  11. package/server/agent-sdks/cursor/package-lock.json +171 -0
  12. package/server/agent-sdks/cursor/package.json +11 -0
  13. package/server/agent-sdks/koffi/entry.cjs +2 -0
  14. package/server/agent-sdks/koffi/package-lock.json +368 -0
  15. package/server/agent-sdks/koffi/package.json +10 -0
  16. package/server/claude-worker/claude-preview-tool.mjs +5 -2
  17. package/server/claude-worker/claude-remote-relay-tool.mjs +5 -2
  18. package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +7 -4
  20. package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
  21. package/server/public/app/agent-sdk-ui.js +349 -0
  22. package/server/public/app/api-client.js +91 -8
  23. package/server/public/app/bootstrap.js +204 -69
  24. package/server/public/app/model-refresh-ui.mjs +413 -0
  25. package/server/public/app/no-conversation-state.mjs +5 -2
  26. package/server/public/app/provider-readiness.mjs +67 -4
  27. package/server/public/app/settings-modal.js +167 -0
  28. package/server/public/app/socket-handlers.js +21 -0
  29. package/server/public/index.html +95 -2
  30. package/server/routes/agent-sdk-routes.mjs +73 -0
  31. package/server/routes/messages-routes.mjs +91 -17
  32. package/server/routes/sessions-routes.mjs +235 -109
  33. package/server/server-runtime.mjs +395 -81
  34. package/server/services/about-service.mjs +26 -6
  35. package/server/services/agent-sdk-launch-gate.mjs +124 -0
  36. package/server/services/agent-sdk-paths.mjs +307 -0
  37. package/server/services/agent-sdk-store.mjs +1211 -0
  38. package/server/services/claude-api-settings-service.mjs +54 -17
  39. package/server/services/cli-install-service.mjs +3 -2
  40. package/server/services/copilot-model-discovery-service.mjs +7 -1
  41. package/server/services/database-file.mjs +17 -1
  42. package/server/services/deepseek-settings-service.mjs +58 -20
  43. package/server/services/earlier-database-service.mjs +4 -2
  44. package/server/services/model-refresh-scheduler.mjs +153 -0
  45. package/server/services/model-variant-catalog-service.mjs +141 -9
  46. package/server/services/oar-cli-helpers.mjs +72 -9
  47. package/server/services/oar-state-migration-service.mjs +2 -12
  48. package/server/services/oar-state-root.mjs +17 -0
  49. package/server/services/provider-model-refresh.mjs +326 -0
  50. package/server/services/provider-model-state.mjs +395 -0
  51. package/server/services/relay-singleton-guard.mjs +72 -10
  52. package/server/services/session-worker-launch-service.mjs +13 -0
  53. package/server/services/session-worker-supervisor-service.mjs +19 -1
  54. package/server/services/session-worker-websocket-service.mjs +15 -0
  55. package/server/services/update-install-service.mjs +2 -2
  56. package/server/services/worker-launch-note-service.mjs +14 -2
  57. package/server/services/worker-recycle-service.mjs +145 -11
  58. package/shared/model-refresh-schedule.mjs +120 -0
  59. package/shared/worker-runtime/direct-openai-image.mjs +8 -4
@@ -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, which the Copilot SDK
22
- // brings): PowerShell fails in ConstrainedLanguage mode and with a broken
23
- // TEMP. Elsewhere `ps`.
24
- export function processStartedAt(pid, { platform = process.platform } = {}) {
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 (!processTimes) {
33
- const koffi = createRequire(import.meta.url)("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 !current || sameStart(lock.holder.start, current.start);
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 (Number.isFinite(lockedAt) && startedAt !== null && startedAt > lockedAt + 5000) return false;
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";
@@ -197,6 +197,16 @@ export function resolveWorkerKind(env = {}) {
197
197
  return Object.hasOwn(NODE_WORKER_DESCRIPTORS, kind) ? kind : 'copilot';
198
198
  }
199
199
 
200
+ // The agent SDK a worker kind runs on (agent-sdk-store.mjs), or null: the
201
+ // Claude workers (subscription, API key, DeepSeek) on the Claude SDK, Cursor
202
+ // on its own; every other kind needs none.
203
+ const WORKER_KIND_AGENT_SDK = Object.freeze({ claude: 'claude', 'claude-api': 'claude', deepseek: 'claude', cursor: 'cursor' });
204
+
205
+ export function agentSdkForWorkerKind(kind) {
206
+ const key = String(kind || '').trim().toLowerCase();
207
+ return Object.hasOwn(WORKER_KIND_AGENT_SDK, key) ? WORKER_KIND_AGENT_SDK[key] : null;
208
+ }
209
+
200
210
  export function applyClaudeProviderEnvironment(env = {}, {
201
211
  enabled = false,
202
212
  model = '',
@@ -933,6 +943,9 @@ export const TMUX_WORKER_EXPORTED_ENV_VARS = Object.freeze([
933
943
  // extension engine's "it is attached" marker.
934
944
  'OAR_MCP_SERVER',
935
945
  'OAR_MCP_SERVER_ATTACHED',
946
+ // The agent SDK folder a worker runs on for its whole life (agent-sdk-loader.mjs).
947
+ 'OAR_AGENT_SDK_DIR_CLAUDE',
948
+ 'OAR_AGENT_SDK_DIR_CURSOR',
936
949
  'SESSION_ID',
937
950
  'COPILOT_WORKSPACE_ROOT',
938
951
  'INIT_CWD',
@@ -556,7 +556,8 @@ export function createSessionWorkerSupervisor({
556
556
  if (!to || from === to) return true;
557
557
  const allowed = {
558
558
  new: new Set(['starting', 'ready', 'error', 'processing']),
559
- starting: new Set(['ready', 'error']),
559
+ // 'new': a start the agent SDK gate deferred, so no worker came up.
560
+ starting: new Set(['ready', 'error', 'new']),
560
561
  ready: new Set(['processing', 'starting', 'error']),
561
562
  processing: new Set(['ready', 'error']),
562
563
  error: new Set(['starting', 'ready']),
@@ -683,6 +684,23 @@ export function createSessionWorkerSupervisor({
683
684
  if (!isStartActive() || isKillBlocked(sessionId)) {
684
685
  return buildStartBlockedResult();
685
686
  }
687
+ // Not launched, and not a failure either (the agent SDK it needs is
688
+ // still installing, agent-sdk-launch-gate.mjs): no worker, no
689
+ // restart backoff, no launch note; the turn stays queued until the
690
+ // gate hands the session its launch back.
691
+ if (spawned?.deferred) {
692
+ const waitingState = setWorkerState(sessionId, {
693
+ sdkSessionId: sessionId,
694
+ // No worker yet ('new'), so nothing waits for its heartbeat.
695
+ status: 'new',
696
+ workerId: null,
697
+ pid: null,
698
+ queueDepth: startingState.queueDepth || 0,
699
+ retryCount: startingState.retryCount || 0,
700
+ lastError: null,
701
+ });
702
+ return { ok: true, deferred: String(spawned.deferred), reused: false, worker: waitingState, lifecycle: toLifecycleSnapshot(sessionId) };
703
+ }
686
704
  const readyState = setWorkerState(sessionId, {
687
705
  ...startingState,
688
706
  workerId: String(spawned?.workerId || startingState.workerId || `worker-${sessionId.slice(0, 8)}`).trim(),
@@ -132,6 +132,11 @@ export function createSessionWorkerWebSocketService({
132
132
  // ready: (sessionId, buildId, pid). The registry keeps it, so the relay can
133
133
  // tell a worker on older code from one that runs what the relay runs.
134
134
  onWorkerBuildId = () => {},
135
+ // A worker said hello: ({ sessionId, pid, kind, buildId }), with what the
136
+ // socket knows of it. Called before the hello's own request for work, so
137
+ // the relay can register the process and fence it first (a stale worker
138
+ // reconnecting to a new relay: the worker recycle).
139
+ onWorkerHello = () => {},
135
140
  // The relay's own build id, sent in every server.hello so a worker can log
136
141
  // that it runs other code; null leaves the key out.
137
142
  buildId = null,
@@ -572,6 +577,16 @@ export function createSessionWorkerWebSocketService({
572
577
  meta.ready = payload.ready !== false;
573
578
  meta.lastHelloAt = nowIso();
574
579
  noteHeartbeat(meta, 'worker-hello');
580
+ if (meta.sessionId) {
581
+ try {
582
+ onWorkerHello({
583
+ sessionId: meta.sessionId,
584
+ pid: meta.pid || null,
585
+ kind: meta.workerKind || null,
586
+ buildId: meta.workerBuildId || null,
587
+ });
588
+ } catch {}
589
+ }
575
590
  emitEvent(ws, serverHello('ack', meta.sessionId));
576
591
  void maybeDeliverToSocket(ws, 'worker-hello');
577
592
  return;
@@ -12,7 +12,7 @@ import {
12
12
  tailOf,
13
13
  } from './cli-process-runner.mjs';
14
14
  import { NATIVE_BUILD_COMMAND, buildBetterSqlite3, needsSourceBuild } from './native-addon-build-service.mjs';
15
- import { NPM_ALLOW_SCRIPTS_ARG, resolveNpmInvocation } from './oar-cli-helpers.mjs';
15
+ import { resolveNpmInvocation } from './oar-cli-helpers.mjs';
16
16
  import { compareSemverIsh, parseSemverIsh } from '../../shared/update-semver.mjs';
17
17
 
18
18
  // Applies an OAR self-update: `npm i -g @oar-sh/oar@<version>` with the log
@@ -227,7 +227,7 @@ export function createUpdateInstallService({
227
227
  // (args are fixed strings + a validated semver, so no injection surface).
228
228
  const npm = resolveNpmInvocation({ execPath, packageRoot, platform, env, existsImpl });
229
229
  const command = npm.command;
230
- const args = ['install', '-g', ...npm.prefixArgs, NPM_ALLOW_SCRIPTS_ARG, `${UPDATE_PACKAGE_NAME}@${target}`];
230
+ const args = ['install', '-g', ...npm.prefixArgs, `${UPDATE_PACKAGE_NAME}@${target}`];
231
231
  const result = await runToCompletion(
232
232
  () => spawnImpl(command, args, {
233
233
  stdio: ['ignore', 'pipe', 'pipe'],