@oar-sh/oar 0.9.22 → 0.9.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +258 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +82 -4
  4. package/package.json +16 -15
  5. package/server/agent-sdk-loader.mjs +116 -0
  6. package/server/agent-sdks/claude/entry.mjs +4 -0
  7. package/server/agent-sdks/claude/package-lock.json +1438 -0
  8. package/server/agent-sdks/claude/package.json +13 -0
  9. package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
  10. package/server/agent-sdks/cursor/entry.mjs +3 -0
  11. package/server/agent-sdks/cursor/package-lock.json +171 -0
  12. package/server/agent-sdks/cursor/package.json +11 -0
  13. package/server/agent-sdks/koffi/entry.cjs +2 -0
  14. package/server/agent-sdks/koffi/package-lock.json +368 -0
  15. package/server/agent-sdks/koffi/package.json +10 -0
  16. package/server/claude-worker/claude-cli-pinned-env.mjs +4 -1
  17. package/server/claude-worker/claude-preview-tool.mjs +5 -2
  18. package/server/claude-worker/claude-remote-relay-tool.mjs +5 -2
  19. package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +7 -4
  21. package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
  22. package/server/public/app/agent-sdk-ui.js +349 -0
  23. package/server/public/app/api-client.js +91 -8
  24. package/server/public/app/bootstrap.js +204 -69
  25. package/server/public/app/model-refresh-ui.mjs +413 -0
  26. package/server/public/app/no-conversation-state.mjs +5 -2
  27. package/server/public/app/provider-readiness.mjs +67 -4
  28. package/server/public/app/settings-modal.js +167 -0
  29. package/server/public/app/socket-handlers.js +21 -0
  30. package/server/public/index.html +95 -2
  31. package/server/routes/agent-sdk-routes.mjs +73 -0
  32. package/server/routes/messages-routes.mjs +102 -37
  33. package/server/routes/sessions-routes.mjs +236 -110
  34. package/server/server-runtime.mjs +416 -121
  35. package/server/services/about-service.mjs +26 -6
  36. package/server/services/agent-sdk-launch-gate.mjs +124 -0
  37. package/server/services/agent-sdk-paths.mjs +307 -0
  38. package/server/services/agent-sdk-store.mjs +1211 -0
  39. package/server/services/claude-api-settings-service.mjs +54 -17
  40. package/server/services/cli-install-service.mjs +3 -2
  41. package/server/services/copilot-model-discovery-service.mjs +7 -1
  42. package/server/services/database-file.mjs +17 -1
  43. package/server/services/deepseek-settings-service.mjs +58 -20
  44. package/server/services/earlier-database-service.mjs +5 -3
  45. package/server/services/file-serving.mjs +13 -0
  46. package/server/services/model-refresh-scheduler.mjs +153 -0
  47. package/server/services/model-variant-catalog-service.mjs +141 -9
  48. package/server/services/native-addon-build-service.mjs +224 -0
  49. package/server/services/oar-cli-helpers.mjs +76 -10
  50. package/server/services/oar-state-migration-service.mjs +2 -12
  51. package/server/services/oar-state-root.mjs +17 -0
  52. package/server/services/provider-model-refresh.mjs +326 -0
  53. package/server/services/provider-model-state.mjs +395 -0
  54. package/server/services/relay-console-log.mjs +2 -3
  55. package/server/services/relay-singleton-guard.mjs +72 -10
  56. package/server/services/remote-relay-loopback.mjs +3 -3
  57. package/server/services/sdk-session-sync-service.mjs +1 -1
  58. package/server/services/session-worker-launch-service.mjs +13 -0
  59. package/server/services/session-worker-supervisor-service.mjs +19 -1
  60. package/server/services/session-worker-websocket-service.mjs +15 -0
  61. package/server/services/update-install-service.mjs +34 -3
  62. package/server/services/worker-launch-note-service.mjs +14 -2
  63. package/server/services/worker-recycle-service.mjs +145 -11
  64. package/shared/model-refresh-schedule.mjs +120 -0
  65. package/shared/worker-runtime/direct-openai-image.mjs +8 -4
  66. package/server/services/package-data-rescue-preinstall.mjs +0 -22
  67. package/server/services/package-data-rescue.mjs +0 -139
  68. package/server/tty-console-bootstrap.mjs +0 -75
@@ -11,7 +11,8 @@ import {
11
11
  stripTerminalEscapes,
12
12
  tailOf,
13
13
  } from './cli-process-runner.mjs';
14
- import { NPM_ALLOW_SCRIPTS_ARG, resolveNpmInvocation } from './oar-cli-helpers.mjs';
14
+ import { NATIVE_BUILD_COMMAND, buildBetterSqlite3, needsSourceBuild } from './native-addon-build-service.mjs';
15
+ import { resolveNpmInvocation } from './oar-cli-helpers.mjs';
15
16
  import { compareSemverIsh, parseSemverIsh } from '../../shared/update-semver.mjs';
16
17
 
17
18
  // Applies an OAR self-update: `npm i -g @oar-sh/oar@<version>` with the log
@@ -107,6 +108,8 @@ export function createUpdateInstallService({
107
108
  spawnImpl = defaultSpawn,
108
109
  env = process.env,
109
110
  platform = process.platform,
111
+ arch = process.arch,
112
+ buildNativeAddons = buildBetterSqlite3,
110
113
  setTimeoutImpl = setTimeout,
111
114
  clearTimeoutImpl = clearTimeout,
112
115
  nowImpl = Date.now,
@@ -224,7 +227,7 @@ export function createUpdateInstallService({
224
227
  // (args are fixed strings + a validated semver, so no injection surface).
225
228
  const npm = resolveNpmInvocation({ execPath, packageRoot, platform, env, existsImpl });
226
229
  const command = npm.command;
227
- 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}`];
228
231
  const result = await runToCompletion(
229
232
  () => spawnImpl(command, args, {
230
233
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -242,15 +245,43 @@ export function createUpdateInstallService({
242
245
  );
243
246
  activeChild = null;
244
247
  flushLogNow();
245
- finishedAt = new Date(nowImpl()).toISOString();
246
248
 
247
249
  if (!result.ok) {
250
+ finishedAt = new Date(nowImpl()).toISOString();
248
251
  state = UPDATE_INSTALL_STATES.ERROR;
249
252
  lastError = result.error || `npm exited with code ${result.code}`;
250
253
  broadcast();
251
254
  return { ok: false, statusCode: 500, error: lastError };
252
255
  }
253
256
 
257
+ // No prebuilt SQLite binding here: build it before the restart, which
258
+ // would start on none (native-addon-build-service.mjs). A failed build
259
+ // keeps this relay running on the binding it has loaded.
260
+ if (needsSourceBuild({ platform, arch })) {
261
+ const toLog = (line) => appendLog(`${line}\n`);
262
+ const build = await buildNativeAddons({
263
+ packageRoot,
264
+ platform,
265
+ arch,
266
+ execPath,
267
+ env: npm.env,
268
+ spawnImpl,
269
+ onOutput: (text) => appendLog(text),
270
+ onChild: (child) => { activeChild = child; },
271
+ logger: { log: toLog, error: toLog },
272
+ });
273
+ activeChild = null;
274
+ flushLogNow();
275
+ if (!build.ok) {
276
+ finishedAt = new Date(nowImpl()).toISOString();
277
+ state = UPDATE_INSTALL_STATES.ERROR;
278
+ lastError = `${UPDATE_PACKAGE_NAME}@${target} is installed, but its SQLite binding did not build (${build.error}). Run ${NATIVE_BUILD_COMMAND}, then restart the relay.`;
279
+ broadcast();
280
+ return { ok: false, statusCode: 500, error: lastError };
281
+ }
282
+ }
283
+ finishedAt = new Date(nowImpl()).toISOString();
284
+
254
285
  // Persist the attempt BEFORE the restart request: the next boot compares
255
286
  // the running version against it and turns "nothing changed" into a
256
287
  // visible failure carrying this log tail.
@@ -18,6 +18,7 @@
18
18
  import { randomUUID } from 'node:crypto';
19
19
 
20
20
  export const WORKER_LAUNCH_NOTE_KINDS = Object.freeze({
21
+ installing: 'worker-launch-installing',
21
22
  failed: 'worker-launch-failed',
22
23
  stopped: 'worker-launch-stopped',
23
24
  started: 'worker-launch-started',
@@ -47,6 +48,11 @@ export function buildWorkerLaunchNoteText({ type, lifecycle = {}, retryEveryMs =
47
48
  const tries = Math.max(0, Number(lifecycle?.retryCount || 0));
48
49
  const triesText = `${tries} ${tries === 1 ? 'try' : 'tries'}`;
49
50
  const cause = clipCause(lifecycle?.lastError);
51
+ if (type === 'installing') {
52
+ // The first chat of a provider whose agent SDK is not installed yet
53
+ // (agent-sdk-store.mjs): the relay installs it before the worker starts.
54
+ return `⏳ Installing the ${lifecycle?.sdkLabel || 'agent SDK'} on the relay host, once for this version. Your message is delivered when it is ready.`;
55
+ }
50
56
  if (type === 'launched') {
51
57
  return `✅ The worker for this conversation started at ${formatTime(lifecycle?.launchAt || new Date().toISOString())}. The queued message is being delivered.`;
52
58
  }
@@ -77,6 +83,7 @@ export function createWorkerLaunchNoteService({
77
83
  const episodes = new Map();
78
84
 
79
85
  function kindFor(type) {
86
+ if (type === 'installing') return WORKER_LAUNCH_NOTE_KINDS.installing;
80
87
  if (type === 'launched') return WORKER_LAUNCH_NOTE_KINDS.started;
81
88
  if (type === 'stopped') return WORKER_LAUNCH_NOTE_KINDS.stopped;
82
89
  return WORKER_LAUNCH_NOTE_KINDS.failed;
@@ -105,7 +112,7 @@ export function createWorkerLaunchNoteService({
105
112
  stmts.updateConvTime?.run?.(at, conversationId);
106
113
  });
107
114
  write();
108
- episodes.set(sdkSessionId, { messageId, conversationId, timestamp: at });
115
+ episodes.set(sdkSessionId, { messageId, conversationId, timestamp: at, kind, text });
109
116
  try { emit('assistant_message', { conversationId, messageId, message: messagePayload({ messageId, text, kind, timestamp: at }) }); } catch {}
110
117
  try { logger.log?.(`[worker-launch] conv=${conversationId.slice(0, 8)} note opened (${type}): ${clipCause(lifecycle?.lastError)}`); } catch {}
111
118
  }
@@ -113,6 +120,11 @@ export function createWorkerLaunchNoteService({
113
120
  function update(episode, sdkSessionId, { type, lifecycle }) {
114
121
  const kind = kindFor(type);
115
122
  const text = buildWorkerLaunchNoteText({ type, lifecycle, retryEveryMs, retryWindowMs, formatTime });
123
+ // The same note again (a launch deferred on every prime while an agent
124
+ // SDK installs): no write, no broadcast.
125
+ if (episode.kind === kind && episode.text === text) return;
126
+ episode.kind = kind;
127
+ episode.text = text;
116
128
  updateNote.run(text, kind, episode.messageId);
117
129
  try {
118
130
  emit('message_updated', {
@@ -130,7 +142,7 @@ export function createWorkerLaunchNoteService({
130
142
  /** The supervisor's onLaunchState listener. */
131
143
  function handle({ type, sdkSessionId, lifecycle = null } = {}) {
132
144
  const sid = String(sdkSessionId || '').trim();
133
- if (!sid || !['exhausted', 'retry-failed', 'stopped', 'launched'].includes(type)) return false;
145
+ if (!sid || !['installing', 'exhausted', 'retry-failed', 'stopped', 'launched'].includes(type)) return false;
134
146
  const episode = episodes.get(sid) || null;
135
147
  if (episode) {
136
148
  update(episode, sid, { type, lifecycle });
@@ -27,6 +27,31 @@
27
27
  * `quietMs` without a delivery or a turn end for the session, the runners'
28
28
  * own idle time.
29
29
  *
30
+ * A stale worker on an agent SDK the store installs (`isAgentSdkWorker`: the
31
+ * claude, claude-api, deepseek and cursor kinds, unless a git checkout runs
32
+ * that SDK from its own node_modules) is the exception. An update moves the
33
+ * SDKs out of the package, so the files such a worker would start its next
34
+ * CLI child from may be gone, and a message it gets once its child has idled
35
+ * out fails. So:
36
+ *
37
+ * - it skips the 'cli' and 'quiet' holds and is recycled as soon as its turn,
38
+ * question and compaction holds (and the processing row) allow;
39
+ * - its background hold stays a hold, and is the one hold it is not fenced
40
+ * for: background tasks run in the CLI child, so while they do the child is
41
+ * up and a delivery goes to it, never to a new child from the gone files;
42
+ * - from the first sweep that sees it stale until its recycle, the
43
+ * replacement fence (below) keeps every delivery away from it, so what is
44
+ * queued waits for the new worker's cold start rather than failing on the
45
+ * old one;
46
+ * - a relay that has just started knows its holds only from its first
47
+ * heartbeat's steering snapshot: until that comes it waits ('steering'),
48
+ * fenced, for at most `steeringWaitMs`; one that still has sent none by
49
+ * then is judged as any other worker, unfenced.
50
+ *
51
+ * Besides the periodic sweep, a sweep runs at start and on every worker hello
52
+ * (`sweepNow`), so a stale worker that reconnects to a new relay is fenced
53
+ * before the hello's own request for work is answered.
54
+ *
30
55
  * From the moment a recycle passes its holds until its launch is done, the
31
56
  * replacement fence (worker-replacement.mjs) keeps every delivery away from
32
57
  * the process being replaced, and the stop and the launch run under the
@@ -61,9 +86,10 @@ const LOG_TAG = '[worker-recycle]';
61
86
  * the worker's own release holds (workerReleaseHold; 'turn' also for a
62
87
  * processing row the session owns), 'cli' while its CLI child runs, and
63
88
  * 'quiet' while a worker that does not report its CLI child has had a
64
- * delivery or a turn end within `quietMs`.
89
+ * delivery or a turn end within `quietMs`, and 'steering' while a worker on
90
+ * an agent SDK has not yet sent the snapshot that names its holds.
65
91
  */
66
- export const RECYCLE_HOLDS = Object.freeze(['turn', 'question', 'compaction', 'background', 'cli', 'quiet']);
92
+ export const RECYCLE_HOLDS = Object.freeze(['turn', 'question', 'compaction', 'background', 'cli', 'quiet', 'steering']);
67
93
  const RECYCLABLE_STATUSES = new Set(['ready', 'processing']);
68
94
 
69
95
  function sid8(sid) {
@@ -155,6 +181,15 @@ export function createWorkerRecycleService({
155
181
  // without a delivery or a turn end: the Claude and Copilot runners'
156
182
  // idleShutdownMs, after which they stop the child themselves.
157
183
  quietMs = 10 * 60_000,
184
+ // worker → whether its kind runs on an agent SDK (agentSdkForWorkerKind):
185
+ // such a worker, once stale, skips the cli and quiet holds and is fenced
186
+ // until its recycle (see the header).
187
+ isAgentSdkWorker = () => false,
188
+ // How long a stale worker on an agent SDK may go without a steering
189
+ // snapshot (from when this service first saw its process) before it is
190
+ // judged as any other worker. Its heartbeat sends one within a second of
191
+ // a relay's hello.
192
+ steeringWaitMs = 30_000,
158
193
  logger = console,
159
194
  } = {}) {
160
195
  if (typeof stopWorker !== 'function' || typeof launchWorker !== 'function') {
@@ -214,8 +249,13 @@ export function createWorkerRecycleService({
214
249
  pendingLaunch: null,
215
250
  lastWaitLogAt: -Infinity,
216
251
  // When this service first saw the session's current pid: the quiet
217
- // window's start for a worker with no start time and no queue rows.
252
+ // window's start for a worker with no start time and no queue rows,
253
+ // and the steering wait's start.
218
254
  firstSeen: null,
255
+ // The pid this service fenced as a stale worker on an agent SDK
256
+ // waiting for its recycle (sdkFenceUp), or null. A recycle admitted
257
+ // takes the fence over and clears this.
258
+ sdkFence: null,
219
259
  };
220
260
  bySession.set(sid, state);
221
261
  }
@@ -394,8 +434,18 @@ export function createWorkerRecycleService({
394
434
  });
395
435
  } finally {
396
436
  // A stop that did not go through (or never ran) leaves the old worker
397
- // serving its session: it takes work again at once.
398
- if (!stopped) fence.release(sid, pid);
437
+ // serving its session: it takes work again at once. Not one on an
438
+ // agent SDK the update took away: its fence stays up through the
439
+ // stop's backoff, until the worker goes or the tries reach stuckAfter
440
+ // (the sweep keeps it, sdkFenced), so no queued row goes to it.
441
+ if (!stopped) {
442
+ if (isAgentSdkWorker(worker) === true && state.failures > 0 && state.failures < stuckAfter && stillSameProcess(sid, pid)) {
443
+ state.sdkFence = pid;
444
+ log(`fence kept ${sid8(sid)} pid ${pid}: its stop failed, deliveries wait for the retry`);
445
+ } else {
446
+ fence.release(sid, pid);
447
+ }
448
+ }
399
449
  }
400
450
  }
401
451
 
@@ -454,17 +504,68 @@ export function createWorkerRecycleService({
454
504
  * worker's start, else when this service first saw the process. Throws
455
505
  * when a probe does; the caller counts that as a probe failure.
456
506
  */
457
- function holdFor(sid, worker, state) {
507
+ function holdFor(sid, worker, state, sdkMode = null) {
458
508
  if (hasRunningTurn(sid) === true) return 'turn';
509
+ if (sdkMode === 'steering') return 'steering';
459
510
  const releaseHold = workerReleaseHold(worker);
460
511
  if (releaseHold) return releaseHold;
512
+ // A stale worker on an agent SDK goes as soon as its own holds allow: its
513
+ // next CLI child may have no files left to start from.
514
+ if (sdkMode === 'fast') return null;
461
515
  const cliAlive = worker?.steering?.cliAlive;
462
516
  if (cliAlive === true) return 'cli';
463
517
  if (cliAlive === false) return null;
518
+ const since = toTimeMs(quietSince(sid)) ?? toTimeMs(worker.startedAt) ?? firstSeenAt(state, worker);
519
+ return now() - since < quietMs ? 'quiet' : null;
520
+ }
521
+
522
+ // When this service first saw the worker's current process.
523
+ function firstSeenAt(state, worker) {
464
524
  const pid = toPid(worker.pid);
465
525
  if (state.firstSeen?.pid !== pid) state.firstSeen = { pid, at: now() };
466
- const since = toTimeMs(quietSince(sid)) ?? toTimeMs(worker.startedAt) ?? state.firstSeen.at;
467
- return now() - since < quietMs ? 'quiet' : null;
526
+ return state.firstSeen.at;
527
+ }
528
+
529
+ /**
530
+ * How a stale worker on an agent SDK is judged: 'fast' (its steering
531
+ * snapshot names its holds: no cli or quiet hold, fenced until the
532
+ * recycle), 'steering' (no snapshot yet, within `steeringWaitMs` of when
533
+ * this service first saw the process: wait for it, fenced), or null (not on
534
+ * an agent SDK, or a snapshot never came: as any other worker). Throws
535
+ * when the predicate does; the caller counts that as a probe failure.
536
+ */
537
+ function sdkModeFor(worker, state) {
538
+ if (isAgentSdkWorker(worker) !== true) return null;
539
+ if (worker?.steering && typeof worker.steering === 'object') return 'fast';
540
+ return now() - firstSeenAt(state, worker) < steeringWaitMs ? 'steering' : null;
541
+ }
542
+
543
+ // Raises the fence for a stale worker on an agent SDK until its recycle:
544
+ // whatever is queued for the session waits for the new worker.
545
+ function sdkFenceUp(sid, pid, state, confirmed) {
546
+ confirmed.add(sid);
547
+ if (state.sdkFence === pid && fence.isHeld(sid, pid)) return;
548
+ fence.hold(sid, pid);
549
+ if (state.sdkFence !== pid) log(`fenced ${sid8(sid)} pid ${pid}: stale on an agent SDK, deliveries wait for the recycle`);
550
+ state.sdkFence = pid;
551
+ }
552
+
553
+ // A recycle (or an archived stop) admitted for the session owns the fence
554
+ // from here: it lowers it when it is done.
555
+ function sdkFenceHandOver(state) {
556
+ state.sdkFence = null;
557
+ }
558
+
559
+ // Lowers the fences this service raised for workers no longer judged
560
+ // fenced by this sweep: gone, recycled, in a background hold, a probe that
561
+ // failed, a snapshot that never came.
562
+ function sdkFencesDown(confirmed) {
563
+ for (const [sid, state] of bySession) {
564
+ if (state.sdkFence === null || confirmed.has(sid)) continue;
565
+ const pid = state.sdkFence;
566
+ state.sdkFence = null;
567
+ if (fence.release(sid, pid)) log(`unfenced ${sid8(sid)} pid ${pid}`);
568
+ }
468
569
  }
469
570
 
470
571
  function noteWaiting(sid, state, hold, nextWaiting) {
@@ -509,6 +610,8 @@ export function createWorkerRecycleService({
509
610
  return;
510
611
  }
511
612
  const running = new Set();
613
+ // The sessions whose agent-SDK fence this sweep keeps up (sdkFenceUp).
614
+ const sdkFenced = new Set();
512
615
  for (const worker of workers) {
513
616
  const sid = String(worker?.sdkSessionId || '').trim();
514
617
  if (!sid) continue;
@@ -527,36 +630,51 @@ export function createWorkerRecycleService({
527
630
  continue;
528
631
  }
529
632
  const state = stateFor(sid);
530
- if (state.inFlight || now() < state.nextAttemptAt) continue;
633
+ if (state.inFlight) continue;
634
+ if (now() < state.nextAttemptAt) {
635
+ // A failed stop's agent-SDK fence stays up through the backoff, up to
636
+ // stuckAfter tries; then the old worker serves again.
637
+ if (state.sdkFence !== null && state.sdkFence === pid && state.failures < stuckAfter) sdkFenced.add(sid);
638
+ continue;
639
+ }
531
640
  if (state.recycledPid && state.recycledPid === toPid(worker.pid)) continue;
532
641
  let hold = null;
533
642
  let archived = false;
643
+ let sdkMode = null;
534
644
  try {
535
- hold = holdFor(sid, worker, state);
645
+ sdkMode = sdkModeFor(worker, state);
646
+ hold = holdFor(sid, worker, state, sdkMode);
536
647
  if (!hold) archived = isArchived(sid) === true;
537
648
  } catch (error) {
538
649
  // The relay cannot tell whether the session is busy or archived: the
539
650
  // worker is not stopped on a guess, and the probe is retried later.
651
+ // Nor is it fenced on a guess: an agent-SDK fence comes down.
540
652
  noteFailure(sid, state, 'probe', errorText(error, 'unknown'));
541
653
  continue;
542
654
  }
543
655
  if (hold) {
656
+ // Background tasks keep the CLI child up, so a delivery goes to it:
657
+ // the one hold such a worker waits out unfenced.
658
+ if (sdkMode && hold !== 'background') sdkFenceUp(sid, pid, state, sdkFenced);
544
659
  noteWaiting(sid, state, hold, nextWaiting);
545
660
  continue;
546
661
  }
547
662
  if (inFlight >= maxInFlight) {
548
663
  deferred = true;
664
+ if (sdkMode) sdkFenceUp(sid, pid, state, sdkFenced);
549
665
  continue;
550
666
  }
551
667
  if (archived) {
552
668
  // Fenced from here: the holds just passed, and a delivery to the
553
669
  // process being stopped would start a turn on it.
554
670
  fence.hold(sid, pid);
671
+ sdkFenceHandOver(state);
555
672
  startedSteps.push(begin(state, () => stopArchived(sid, worker, state)));
556
673
  continue;
557
674
  }
558
675
  if (!canLaunchNow()) {
559
676
  deferred = true;
677
+ if (sdkMode) sdkFenceUp(sid, pid, state, sdkFenced);
560
678
  continue;
561
679
  }
562
680
  lastAdmitAt = now();
@@ -564,8 +682,10 @@ export function createWorkerRecycleService({
564
682
  // await: no delivery can reach the old process between this check and
565
683
  // its stop, nor between the stop and the launch.
566
684
  fence.hold(sid, pid);
685
+ sdkFenceHandOver(state);
567
686
  startedSteps.push(begin(state, () => recycle(sid, worker, state)));
568
687
  }
688
+ sdkFencesDown(sdkFenced);
569
689
  // Launches owed after a stop went through: the registry lists no new
570
690
  // process for those sessions.
571
691
  for (const [sid, state] of bySession) {
@@ -639,6 +759,20 @@ export function createWorkerRecycleService({
639
759
  }
640
760
  started = true;
641
761
  scheduleNext();
762
+ // The start's own sweep: workers left from before the restart need not
763
+ // wait a whole interval.
764
+ runSweep();
765
+ }
766
+
767
+ /**
768
+ * A sweep now, outside the interval, while the service runs (a worker's
769
+ * hello). Its judging, the fence included, is done synchronously before
770
+ * this returns; the promise resolves once the recycles it started are done.
771
+ * Never rejects.
772
+ */
773
+ function sweepNow() {
774
+ if (!started) return Promise.resolve();
775
+ return sweep().catch(() => {});
642
776
  }
643
777
 
644
778
  function stop() {
@@ -667,7 +801,7 @@ export function createWorkerRecycleService({
667
801
  };
668
802
  }
669
803
 
670
- return { start, stop, sweep, isStale, status };
804
+ return { start, stop, sweep, sweepNow, isStale, status };
671
805
  }
672
806
 
673
807
  /**
@@ -0,0 +1,120 @@
1
+ // "Check for new models" (Settings → General → Models): the relay asks each
2
+ // enabled provider for its model list on a timer, skipping a provider whose
3
+ // list was refreshed recently by anything (the Refresh button, a settings
4
+ // save, a login, an install, the boot). Relay-wide app_settings keys:
5
+ //
6
+ // model_refresh_auto 'true' / 'false'; absent = on
7
+ // model_refresh_interval_hours 6 / 12 / 24 / 72 / 168; absent = 24
8
+ // model_refresh_excluded JSON list of provider ids left out; absent = none
9
+ // model_refresh_last JSON { <provider>: { at, ok, error, lastOkAt, lastFailureAt } }
10
+
11
+ export const MODEL_REFRESH_AUTO_SETTING_KEY = 'model_refresh_auto';
12
+ export const MODEL_REFRESH_INTERVAL_HOURS_SETTING_KEY = 'model_refresh_interval_hours';
13
+ export const MODEL_REFRESH_EXCLUDED_SETTING_KEY = 'model_refresh_excluded';
14
+ export const MODEL_REFRESH_LAST_SETTING_KEY = 'model_refresh_last';
15
+
16
+ export const DEFAULT_MODEL_REFRESH_AUTO = true;
17
+ export const MODEL_REFRESH_INTERVAL_HOURS_CHOICES = Object.freeze([6, 12, 24, 72, 168]);
18
+ export const DEFAULT_MODEL_REFRESH_INTERVAL_HOURS = 24;
19
+ // A failed check is tried again at most this often, whatever the interval.
20
+ export const MODEL_REFRESH_FAILURE_RETRY_MS = 60 * 60 * 1000;
21
+ // The first look a minute after boot (overdue providers are checked then),
22
+ // then every quarter of an hour.
23
+ export const MODEL_REFRESH_FIRST_TICK_MS = 60 * 1000;
24
+ export const MODEL_REFRESH_TICK_MS = 15 * 60 * 1000;
25
+
26
+ // The providers in the order Settings and Select Models list them; `github`
27
+ // is Copilot. `tab` is the provider's Select Models tab.
28
+ export const MODEL_REFRESH_PROVIDERS = Object.freeze([
29
+ Object.freeze({ id: 'github', label: 'Copilot', tab: 'copilot' }),
30
+ Object.freeze({ id: 'openai', label: 'OpenAI', tab: 'openai' }),
31
+ Object.freeze({ id: 'claude', label: 'Claude', tab: 'anthropic' }),
32
+ Object.freeze({ id: 'claude-api', label: 'Claude API', tab: 'claude-api' }),
33
+ Object.freeze({ id: 'cursor', label: 'Cursor', tab: 'cursor' }),
34
+ Object.freeze({ id: 'grok', label: 'Grok', tab: 'grok' }),
35
+ Object.freeze({ id: 'deepseek', label: 'DeepSeek', tab: 'deepseek' }),
36
+ ]);
37
+ const PROVIDER_IDS = new Set(MODEL_REFRESH_PROVIDERS.map((provider) => provider.id));
38
+
39
+ export function modelRefreshProvider(id) {
40
+ return MODEL_REFRESH_PROVIDERS.find((provider) => provider.id === String(id || '').trim().toLowerCase()) || null;
41
+ }
42
+
43
+ /** An unset or unreadable setting is on. */
44
+ export function readModelRefreshAutoSetting(storedValue) {
45
+ const text = String(storedValue ?? '').trim().toLowerCase();
46
+ if (text === 'false') return false;
47
+ if (text === 'true') return true;
48
+ return DEFAULT_MODEL_REFRESH_AUTO;
49
+ }
50
+
51
+ export function parseModelRefreshAutoUpdate(value) {
52
+ if (typeof value !== 'boolean') return { ok: false, error: 'auto must be true or false' };
53
+ return { ok: true, auto: value };
54
+ }
55
+
56
+ /** One of the choices; an unset or unknown value is the default. */
57
+ export function readModelRefreshIntervalHoursSetting(storedValue) {
58
+ const hours = Number(String(storedValue ?? '').trim());
59
+ return MODEL_REFRESH_INTERVAL_HOURS_CHOICES.includes(hours) ? hours : DEFAULT_MODEL_REFRESH_INTERVAL_HOURS;
60
+ }
61
+
62
+ export function parseModelRefreshIntervalHoursUpdate(value) {
63
+ const hours = typeof value === 'number' ? value : (typeof value === 'string' && value.trim() ? Number(value.trim()) : NaN);
64
+ if (!MODEL_REFRESH_INTERVAL_HOURS_CHOICES.includes(hours)) {
65
+ return { ok: false, error: `intervalHours must be one of ${MODEL_REFRESH_INTERVAL_HOURS_CHOICES.join(', ')}` };
66
+ }
67
+ return { ok: true, intervalHours: hours };
68
+ }
69
+
70
+ function knownProviderIds(values) {
71
+ return [...new Set((Array.isArray(values) ? values : [])
72
+ .map((value) => String(value || '').trim().toLowerCase())
73
+ .filter((value) => PROVIDER_IDS.has(value)))];
74
+ }
75
+
76
+ /** The providers left out of the check; unknown ids are dropped. */
77
+ export function readModelRefreshExcludedSetting(storedValue) {
78
+ try {
79
+ return knownProviderIds(JSON.parse(String(storedValue || '[]')));
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
84
+
85
+ export function parseModelRefreshExcludedUpdate(value) {
86
+ if (!Array.isArray(value)) return { ok: false, error: 'excluded must be a list of provider ids' };
87
+ const unknown = value.map((entry) => String(entry || '').trim().toLowerCase()).filter((entry) => entry && !PROVIDER_IDS.has(entry));
88
+ if (unknown.length) return { ok: false, error: `Unknown provider: ${unknown[0]}` };
89
+ return { ok: true, excluded: knownProviderIds(value) };
90
+ }
91
+
92
+ function isoOrNull(value) {
93
+ const text = String(value ?? '').trim();
94
+ return text && Number.isFinite(Date.parse(text)) ? new Date(text).toISOString() : null;
95
+ }
96
+
97
+ /** The last refresh of each provider; entries that cannot be read are dropped. */
98
+ export function readModelRefreshLastSetting(storedValue) {
99
+ let parsed;
100
+ try {
101
+ parsed = JSON.parse(String(storedValue || '{}'));
102
+ } catch {
103
+ return {};
104
+ }
105
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
106
+ const out = {};
107
+ for (const [id, entry] of Object.entries(parsed)) {
108
+ if (!PROVIDER_IDS.has(id) || !entry || typeof entry !== 'object') continue;
109
+ const at = isoOrNull(entry.at);
110
+ if (!at) continue;
111
+ out[id] = {
112
+ at,
113
+ ok: entry.ok === true,
114
+ error: entry.ok === true ? null : (String(entry.error || '').trim().slice(0, 300) || null),
115
+ lastOkAt: isoOrNull(entry.lastOkAt),
116
+ lastFailureAt: isoOrNull(entry.lastFailureAt),
117
+ };
118
+ }
119
+ return out;
120
+ }
@@ -99,11 +99,15 @@ export async function runDirectOpenAIImageTurn({
99
99
  attachments: Array.isArray(message?.attachments) ? message.attachments : [],
100
100
  }, requestOptions);
101
101
  } catch (error) {
102
- if (isDirectOpenAIImageTerminalStatus(error?.status)) {
103
- const terminalError = new Error(
104
- `OpenAI image request failed: ${error?.detail || `HTTP ${error?.status || 'error'}`}`,
105
- );
102
+ // `relayProcessing`: the provider answered 2xx and the relay could not
103
+ // process the reply; the generation is billed, so no requeue either.
104
+ const relayProcessing = error?.body?.relayProcessing === true;
105
+ if (isDirectOpenAIImageTerminalStatus(error?.status) || relayProcessing) {
106
+ const terminalError = new Error(relayProcessing
107
+ ? `The relay could not process the image OpenAI returned (${error?.detail || 'unknown error'}). The generation was billed; the message was not retried.`
108
+ : `OpenAI image request failed: ${error?.detail || `HTTP ${error?.status || 'error'}`}`);
106
109
  terminalError.code = OPENAI_IMAGE_TERMINAL_CODE;
110
+ terminalError.relayProcessing = relayProcessing;
107
111
  terminalError.status = error?.status;
108
112
  terminalError.detail = error?.detail || null;
109
113
  throw terminalError;
@@ -1,22 +0,0 @@
1
- /**
2
- * The `preinstall` of @oar-sh/oar. Nothing of OAR loads before the `try`:
3
- * a module of this package that cannot load (a broken file, a missing one)
4
- * must not fail the install on any platform. Only a copy of an old
5
- * database that failed (RescueCopyError) exits 1, so npm stops the update
6
- * and puts the old package, data and all, back in place; anything else is
7
- * one line and exit 0, as this script only ever copies. See
8
- * package-data-rescue.mjs.
9
- */
10
- import { fileURLToPath } from 'node:url';
11
-
12
- try {
13
- const { rescuePackageData } = await import('./package-data-rescue.mjs');
14
- rescuePackageData({ packageRoot: fileURLToPath(new URL('../..', import.meta.url)) });
15
- } catch (error) {
16
- if (error?.rescueCopyFailed === true) {
17
- console.error(`[oar] ${error.message}`);
18
- process.exitCode = 1;
19
- } else {
20
- console.error(`[oar] Did not copy the data of the version being replaced: ${error?.message || error}`);
21
- }
22
- }