@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
@@ -28,7 +28,13 @@
28
28
  // * curated ids only seed an EMPTY catalog; once an authoritative snapshot
29
29
  // (a running engine or the server-side discovery) has listed the models,
30
30
  // anything absent from it is marked `unavailable` (enabled rows stay
31
- // listed so a picker can show them greyed; disabled ones are pruned).
31
+ // listed so a picker can show them greyed; disabled ones are pruned);
32
+ // * with a model map (`copilot_model_state`, provider-model-state.mjs) a row
33
+ // seen for the first time starts as the user would expect: off when the
34
+ // user turned that row off before (a pruned row is remembered), like the
35
+ // model's other rows for a new effort level, and as the "Turn new models
36
+ // on automatically" switch says for a whole new model, which is also
37
+ // flagged new. Without one (tests, older wiring) new rows start off.
32
38
  import { humanizeModelLabel } from '../public/app/model-selector-options.mjs';
33
39
  import { latestModelCatalogRefresh } from '../../shared/model-catalog-freshness.mjs';
34
40
  import {
@@ -39,6 +45,7 @@ import {
39
45
  import { KNOWN_REASONING_EFFORTS, MODEL_PICKER_CATEGORIES } from '../../shared/model-descriptors.mjs';
40
46
  import { copilotCatalogIndexByModel, inferCopilotVendor } from '../../shared/copilot-model-order.mjs';
41
47
  import { selectModelIdsForVariantRefresh } from '../../shared/model-refresh.mjs';
48
+ import { decideNewCopilotVariant, migrateCopilotModelState } from './provider-model-state.mjs';
42
49
 
43
50
  export const SUPPORTED_REASONING_EFFORTS = [...KNOWN_REASONING_EFFORTS];
44
51
  /**
@@ -471,6 +478,14 @@ export function createModelVariantCatalogService({
471
478
  autoModelSentinel = 'auto',
472
479
  // (args: string[]) => Promise<string>; the `copilot help` text fallback.
473
480
  runCopilotCliCommand = async () => '',
481
+ // { readCopilot(), writeCopilot(state) } (provider-model-state.mjs); null
482
+ // keeps the old rule: every new row starts off.
483
+ modelStateStore = null,
484
+ // (baseModelIds, on) — whole new models were added (the toast).
485
+ onNewModels = () => {},
486
+ // ({ source }) — a snapshot that read the runtime's real list arrived
487
+ // (counts as a Copilot model refresh).
488
+ onAuthoritativeSnapshot = () => {},
474
489
  } = {}) {
475
490
  const DEFAULT_MODEL = String(defaultModel || 'gpt-5.4-mini').trim() || 'gpt-5.4-mini';
476
491
  const AUTO_MODEL_SENTINEL = String(autoModelSentinel || 'auto');
@@ -511,6 +526,56 @@ export function createModelVariantCatalogService({
511
526
  return modelSelectorSql.listEnabledVariants.all().map((row) => normalizeModelVariantRow(row)).sort(compareVariantRows);
512
527
  }
513
528
 
529
+ /**
530
+ * The Copilot model map, built from the table the first time (every model
531
+ * there known, every disabled row remembered as off). Null without a store.
532
+ */
533
+ function loadCopilotModelState(rows = null) {
534
+ if (!modelStateStore) return null;
535
+ try {
536
+ const stored = modelStateStore.readCopilot();
537
+ if (stored) return stored;
538
+ const migrated = migrateCopilotModelState(rows || listModelVariantRows());
539
+ modelStateStore.writeCopilot(migrated);
540
+ return migrated;
541
+ } catch {
542
+ return null;
543
+ }
544
+ }
545
+
546
+ function saveCopilotModelState(state) {
547
+ try {
548
+ modelStateStore?.writeCopilot?.(state);
549
+ } catch {
550
+ /* the rows are written; the map catches up on the next change */
551
+ }
552
+ }
553
+
554
+ /** `{ autoEnableNew, unseen }` for Select Models and the menu dot. */
555
+ function getCopilotModelState() {
556
+ const state = loadCopilotModelState();
557
+ const bases = new Set(listModelVariantRows().map((row) => row.baseModelId));
558
+ return {
559
+ autoEnableNew: state ? state.autoEnableNew : false,
560
+ unseen: state ? state.unseen.filter((baseModelId) => bases.has(baseModelId)) : [],
561
+ };
562
+ }
563
+
564
+ function setCopilotAutoEnableNew(value) {
565
+ const state = loadCopilotModelState();
566
+ if (!state || typeof value !== 'boolean') return getCopilotModelState();
567
+ saveCopilotModelState({ ...state, autoEnableNew: value });
568
+ return getCopilotModelState();
569
+ }
570
+
571
+ /** Clears the "New" flags; true when one was set. */
572
+ function markCopilotModelsSeen() {
573
+ const state = loadCopilotModelState();
574
+ if (!state || !state.unseen.length) return false;
575
+ saveCopilotModelState({ ...state, unseen: [] });
576
+ return true;
577
+ }
578
+
514
579
  /** Stored metadata per base model (first row wins; all rows carry the same). */
515
580
  function storedMetadataByModel(rows = listModelVariantRows()) {
516
581
  const byModel = {};
@@ -772,12 +837,21 @@ export function createModelVariantCatalogService({
772
837
  const existingEnabled = new Map(existingRows.map((row) => [row.variantId, row.enabled ? 1 : 0]));
773
838
  const incomingIds = new Set(normalizedEntries.map((entry) => entry.variantId));
774
839
  const incomingBaseIds = new Set(normalizedEntries.map((entry) => entry.baseModelId));
840
+ const enabledBaseIds = new Set(existingRows.filter((row) => row.enabled).map((row) => row.baseModelId));
841
+ const modelState = loadCopilotModelState(existingRows);
842
+ const newBaseIds = new Set();
843
+ const prunedDisabledIds = new Set();
775
844
  const nowIso = new Date().toISOString();
776
845
  const tx = db.transaction(() => {
777
846
  for (const entry of normalizedEntries) {
778
- const enabled = preserveEnabled && existingEnabled.has(entry.variantId)
779
- ? existingEnabled.get(entry.variantId)
780
- : (entry.enabled ? 1 : 0);
847
+ let enabled = entry.enabled ? 1 : 0;
848
+ if (preserveEnabled && existingEnabled.has(entry.variantId)) {
849
+ enabled = existingEnabled.get(entry.variantId);
850
+ } else if (modelState && !existingEnabled.has(entry.variantId)) {
851
+ const decision = decideNewCopilotVariant(modelState, entry, { siblingEnabled: enabledBaseIds.has(entry.baseModelId) });
852
+ enabled = decision.enabled ? 1 : 0;
853
+ if (decision.newModel) newBaseIds.add(entry.baseModelId);
854
+ }
781
855
  runVariantUpsert(entry, enabled, nowIso);
782
856
  }
783
857
  const orphanedEnabledBases = new Set();
@@ -788,6 +862,7 @@ export function createModelVariantCatalogService({
788
862
  // The runtime told us this model's exact levels; a row for a
789
863
  // level it does not offer is stale, not "unavailable".
790
864
  if (row.enabled) orphanedEnabledBases.add(row.baseModelId);
865
+ else prunedDisabledIds.add(row.variantId);
791
866
  modelSelectorSql.deleteVariant.run(row.variantId);
792
867
  } else {
793
868
  runVariantUpsert({ ...row, releaseStatus: 'unavailable' }, row.enabled, nowIso);
@@ -801,6 +876,8 @@ export function createModelVariantCatalogService({
801
876
  }
802
877
  // Variants that are both disabled and fully absent from the incoming
803
878
  // base set are no longer relevant; prune them so they do not linger.
879
+ // The model map remembers them as off for when they come back.
880
+ prunedDisabledIds.add(row.variantId);
804
881
  modelSelectorSql.deleteVariant.run(row.variantId);
805
882
  }
806
883
  // A selection must not silently vanish because its effort level did:
@@ -822,6 +899,21 @@ export function createModelVariantCatalogService({
822
899
  }
823
900
  });
824
901
  tx();
902
+ if (modelState) {
903
+ saveCopilotModelState({
904
+ ...modelState,
905
+ knownModels: [...new Set([...modelState.knownModels, ...incomingBaseIds])],
906
+ disabledVariants: [...new Set([...modelState.disabledVariants, ...prunedDisabledIds])],
907
+ unseen: [...new Set([...modelState.unseen, ...newBaseIds])],
908
+ });
909
+ if (newBaseIds.size) {
910
+ try {
911
+ onNewModels([...newBaseIds], modelState.autoEnableNew);
912
+ } catch {
913
+ /* the toast is best-effort */
914
+ }
915
+ }
916
+ }
825
917
  recomputeSortOrder();
826
918
  return getModelVariantSelectorState();
827
919
  }
@@ -839,23 +931,49 @@ export function createModelVariantCatalogService({
839
931
  return reconcileModelVariantRows(entries, { source, error, preserveEnabled, absentBasePolicy: 'reconcile', knownEffortBases });
840
932
  }
841
933
 
842
- function setEnabledModelVariants(variantIds = []) {
934
+ /**
935
+ * The Select Models save. With `shownVariantIds` (the rows the browser
936
+ * drew) only those rows are set, on when listed in `variantIds`; every
937
+ * other row keeps its state, so a row a refresh added after the modal was
938
+ * drawn is not turned off. Without it (an older page) the list is the whole
939
+ * enabled set, as before.
940
+ */
941
+ function setEnabledModelVariants(variantIds = [], { shownVariantIds = null } = {}) {
843
942
  if (!modelSelectorSql?.disableAllVariants?.run || !modelSelectorSql?.enableVariant?.run) {
844
943
  return getModelVariantSelectorState();
845
944
  }
846
945
  const nextEnabled = new Set(
847
946
  Array.isArray(variantIds) ? variantIds.map((value) => String(value || '').trim()).filter(Boolean) : [],
848
947
  );
948
+ // Turning every row off would leave the pickers with the fallback model
949
+ // only; the route refuses it, and so does this.
950
+ if (!nextEnabled.size) return getModelVariantSelectorState();
849
951
  const existingRows = listModelVariantRows();
952
+ const shown = Array.isArray(shownVariantIds)
953
+ ? new Set(shownVariantIds.map((value) => String(value || '').trim()).filter(Boolean))
954
+ : null;
955
+ const touchedRows = shown ? existingRows.filter((row) => shown.has(row.variantId)) : existingRows;
956
+ const modelState = loadCopilotModelState(existingRows);
850
957
  const nowIso = new Date().toISOString();
851
958
  const tx = db.transaction(() => {
852
- modelSelectorSql.disableAllVariants.run(nowIso);
853
- for (const row of existingRows) {
854
- if (!nextEnabled.has(row.variantId)) continue;
855
- modelSelectorSql.enableVariant.run(nowIso, row.variantId);
959
+ if (!shown) modelSelectorSql.disableAllVariants.run(nowIso);
960
+ for (const row of touchedRows) {
961
+ if (nextEnabled.has(row.variantId)) modelSelectorSql.enableVariant.run(nowIso, row.variantId);
962
+ else if (shown) modelSelectorSql.disableVariant.run(nowIso, row.variantId);
856
963
  }
857
964
  });
858
965
  tx();
966
+ if (modelState) {
967
+ // A row turned on is forgotten as off; a row turned off is remembered.
968
+ const touchedIds = new Set(touchedRows.map((row) => row.variantId));
969
+ saveCopilotModelState({
970
+ ...modelState,
971
+ disabledVariants: [...new Set([
972
+ ...modelState.disabledVariants.filter((variantId) => !(touchedIds.has(variantId) && nextEnabled.has(variantId))),
973
+ ...touchedRows.filter((row) => !nextEnabled.has(row.variantId)).map((row) => row.variantId),
974
+ ])],
975
+ });
976
+ }
859
977
  return getModelVariantSelectorState();
860
978
  }
861
979
 
@@ -897,6 +1015,13 @@ export function createModelVariantCatalogService({
897
1015
  refreshedAt: receivedMetadata ? new Date().toISOString() : modelCatalog.refreshedAt,
898
1016
  error: snapshot.error ? String(snapshot.error).trim().slice(0, 300) : null,
899
1017
  };
1018
+ if (authoritative) {
1019
+ try {
1020
+ onAuthoritativeSnapshot({ source });
1021
+ } catch {
1022
+ /* the refresh clock is best-effort */
1023
+ }
1024
+ }
900
1025
  if (modelSelectorSql?.upsertVariant?.run) {
901
1026
  const existingRows = listModelVariantRows();
902
1027
  const stored = storedMetadataByModel(existingRows);
@@ -1074,6 +1199,7 @@ export function createModelVariantCatalogService({
1074
1199
  updateSortOrderForBase: db.prepare(`UPDATE model_variants SET sort_order = ? WHERE base_model_id = ?`),
1075
1200
  disableAllVariants: db.prepare(`UPDATE model_variants SET enabled = 0, updated_at = ?`),
1076
1201
  enableVariant: db.prepare(`UPDATE model_variants SET enabled = 1, updated_at = ? WHERE variant_id = ?`),
1202
+ disableVariant: db.prepare(`UPDATE model_variants SET enabled = 0, updated_at = ? WHERE variant_id = ?`),
1077
1203
  deleteVariant: db.prepare(`DELETE FROM model_variants WHERE variant_id = ?`),
1078
1204
  getSelectorState: db.prepare(`SELECT source, refreshed_at, error, updated_at FROM model_selector_state WHERE id = 1 LIMIT 1`),
1079
1205
  upsertSelectorState: db.prepare(`
@@ -1153,6 +1279,9 @@ export function createModelVariantCatalogService({
1153
1279
  }
1154
1280
  normalizeLegacyVariantIdsTx();
1155
1281
  recomputeSortOrder();
1282
+ // The model map of an install from before it is built from the table as
1283
+ // it stands now, before any snapshot adds a row.
1284
+ loadCopilotModelState();
1156
1285
  }
1157
1286
 
1158
1287
  return {
@@ -1175,6 +1304,9 @@ export function createModelVariantCatalogService({
1175
1304
  getModelVariantSelectorState,
1176
1305
  upsertModelVariantCatalogEntries,
1177
1306
  setEnabledModelVariants,
1307
+ getCopilotModelState,
1308
+ setCopilotAutoEnableNew,
1309
+ markCopilotModelsSeen,
1178
1310
  refreshModelVariantCatalogFromCli,
1179
1311
  parseModelsFromHelpConfigOutput,
1180
1312
  parseReasoningEffortsFromHelpOutput,
@@ -7,6 +7,16 @@ import { randomBytes } from 'node:crypto';
7
7
  import fs from 'node:fs';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
+ import {
11
+ AGENT_SDK_IDS,
12
+ agentSdkSupport,
13
+ agentSdkFromCheckout,
14
+ findReadyAgentSdkDir,
15
+ getAgentSdkDescriptor,
16
+ readAgentSdkMarker,
17
+ readAgentSdkPin,
18
+ resolveAgentSdkRoot,
19
+ } from './agent-sdk-paths.mjs';
10
20
  import { formatMegabytes } from './db-backup-service.mjs';
11
21
 
12
22
  export function generateAuthToken() {
@@ -87,6 +97,7 @@ const CLI_COMMANDS = {
87
97
  uninstall: { flags: ['--purge'] },
88
98
  update: { flags: ['--beta'], values: ['--to'] },
89
99
  'build-native': { flags: ['--force'] },
100
+ sdk: { words: ['install', 'status'], words2: [...AGENT_SDK_IDS], flags: ['--reinstall'] },
90
101
  copilot: { flags: ['--install-extension', '--no-install-extension'], values: ['--port', '--migrate-from'], forwards: true },
91
102
  };
92
103
 
@@ -117,6 +128,9 @@ export function usageText() {
117
128
  ' oar backup [--reason <text>]',
118
129
  ' A copy of the database into the backup folder (safe while the relay runs)',
119
130
  ' oar update [--beta] [--to <version>]',
131
+ ' oar sdk [status] The agent SDKs the relay installs on demand: folder, versions, state',
132
+ ' oar sdk install claude | cursor | koffi [--reinstall]',
133
+ ' Install one now, without the relay (koffi: Windows only)',
120
134
  ' oar build-native [--force]',
121
135
  ' Build the SQLite binding where no prebuilt one ships (Termux, 32-bit ARM);',
122
136
  ' --force builds where one ships too, which only tests the build tools',
@@ -180,6 +194,8 @@ export function parseCliArgs(argv = []) {
180
194
  if (arg === name) index += 1;
181
195
  } else if (spec.words?.includes(arg) && words === 0) {
182
196
  words += 1;
197
+ } else if (spec.words2?.includes(arg) && words === 1) {
198
+ words += 1;
183
199
  } else if (!spec.flags?.includes(arg)) {
184
200
  return { error: arg.startsWith('-') ? `Unknown option for oar ${command}: ${arg}` : `Unexpected argument: ${arg}` };
185
201
  }
@@ -402,14 +418,14 @@ export function buildServicePath({ nodeBin, envPath = '', homeDir = '' } = {}) {
402
418
 
403
419
  /**
404
420
  * The dependencies whose install scripts OAR needs (they fetch or build a
405
- * native binary). npm 12 runs no install script it was not told to allow, and
406
- * a global install has no package.json of its own to say so; npm 10 and 11
407
- * accept the flag and run the scripts as before. `npm-install-scripts.test.mjs`
408
- * holds this list to the lockfile. better-sqlite3 13 has no install script:
409
- * where it ships no binary, OAR builds it (native-addon-build-service.mjs).
421
+ * native binary): none. npm 12 runs no install script it was not told to
422
+ * allow, so a package that gains one must be added here and passed to npm
423
+ * again; `npm-install-scripts.test.mjs` holds this list to the lockfile.
424
+ * better-sqlite3 13 has no install script (where it ships no binary, OAR
425
+ * builds it: native-addon-build-service.mjs), and koffi, whose script the
426
+ * list held, is installed on demand on Windows (agent-sdk-store.mjs).
410
427
  */
411
- export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['koffi']);
412
- export const NPM_ALLOW_SCRIPTS_ARG = `--allow-scripts=${NPM_INSTALL_SCRIPT_PACKAGES.join(',')}`;
428
+ export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze([]);
413
429
 
414
430
  /**
415
431
  * How to run `npm install -g` so the update lands in the install that is
@@ -442,8 +458,9 @@ export function resolveNpmInvocation({
442
458
 
443
459
  /**
444
460
  * Provider CLIs `oar doctor` probes, with the args that answer fast. Cursor is
445
- * deliberately absent: the relay drives it through the bundled @cursor/sdk npm
446
- * package and never invokes a cursor-agent binary.
461
+ * deliberately absent: the relay drives it through the Cursor SDK it installs
462
+ * on demand (the agent SDK block of the report) and never invokes a
463
+ * cursor-agent binary.
447
464
  */
448
465
  export const DOCTOR_PROBES = Object.freeze([
449
466
  { id: 'gh (Copilot)', binary: 'gh', args: ['--version'] },
@@ -487,6 +504,50 @@ function renderBackupLines(backups, now) {
487
504
  return lines;
488
505
  }
489
506
 
507
+ /**
508
+ * The agent SDKs as `oar doctor` and `oar sdk` show them, read from the disk
509
+ * alone (no lock, no spawn): the folder, and per SDK the pinned version, an
510
+ * installed one and its state. A checkout runs its devDependencies instead.
511
+ */
512
+ export function describeAgentSdks({ env = process.env, platform = process.platform } = {}) {
513
+ // A checkout takes each SDK from its node_modules while it is installed there.
514
+ const fromCheckout = AGENT_SDK_IDS.every((id) => agentSdkFromCheckout(id, { env }) || getAgentSdkDescriptor(id).platforms?.includes(platform) === false);
515
+ const root = resolveAgentSdkRoot({ env, platform });
516
+ const rows = [];
517
+ for (const id of AGENT_SDK_IDS) {
518
+ const descriptor = getAgentSdkDescriptor(id);
519
+ let pin = null;
520
+ try { pin = readAgentSdkPin(id); } catch {}
521
+ if (!pin) {
522
+ rows.push({ id, label: descriptor.label, pinned: null, installed: null, state: 'pins missing from the package' });
523
+ continue;
524
+ }
525
+ const support = agentSdkSupport(pin);
526
+ if (descriptor.platforms && !descriptor.platforms.includes(platform)) continue;
527
+ if (agentSdkFromCheckout(id, { env })) {
528
+ rows.push({ id, label: descriptor.label, pinned: pin.version, installed: null, state: 'from the checkout (devDependency)' });
529
+ continue;
530
+ }
531
+ const ready = findReadyAgentSdkDir(id, { env, platform, root });
532
+ const older = ready ? null : findReadyAgentSdkDir(id, { env, platform, root, anyVersion: true });
533
+ const olderVersion = older ? readAgentSdkMarker(older)?.version || null : null;
534
+ const state = support === 'no'
535
+ ? 'not available on this platform'
536
+ : ready ? `ready${support === 'cli' ? " (runs the host's Claude Code CLI)" : ''}`
537
+ : olderVersion ? `${olderVersion} installed; the relay installs ${pin.version} at its next start or first chat`
538
+ : 'not installed (installs on first setup or first chat)';
539
+ rows.push({ id, label: descriptor.label, pinned: pin.version, installed: ready ? pin.version : olderVersion, state, dir: ready || older || null });
540
+ }
541
+ return { fromCheckout, root, rows };
542
+ }
543
+
544
+ export function renderAgentSdkLines(agentSdks) {
545
+ if (!agentSdks) return [];
546
+ const lines = [` agent SDKs : ${agentSdks.fromCheckout ? "from the checkout's node_modules (OAR_AGENT_SDK_SOURCE=store uses the store)" : agentSdks.root}`];
547
+ for (const row of agentSdks.rows) lines.push(` ${row.id.padEnd(14)}: ${row.pinned ? `${row.pinned} ` : ''}${row.state}`);
548
+ return lines;
549
+ }
550
+
490
551
  export function renderDoctorReport({
491
552
  version,
492
553
  nodeVersion,
@@ -502,6 +563,7 @@ export function renderDoctorReport({
502
563
  probes = [],
503
564
  warnings = [],
504
565
  tunnel = '',
566
+ agentSdks = null,
505
567
  }) {
506
568
  const yesNo = (v) => (v ? 'yes' : 'no');
507
569
  const lines = [
@@ -527,6 +589,7 @@ export function renderDoctorReport({
527
589
  for (const probe of probes) {
528
590
  lines.push(` ${probe.id.padEnd(14)}: ${probe.ok ? probe.version || 'installed' : 'not found'}`);
529
591
  }
592
+ lines.push(...renderAgentSdkLines(agentSdks));
530
593
  for (const warning of warnings) lines.push(` warning : ${warning}`);
531
594
  return lines.join('\n');
532
595
  }
@@ -21,25 +21,15 @@
21
21
 
22
22
  import fs from 'node:fs';
23
23
  import path from 'node:path';
24
- import os from 'node:os';
25
24
 
26
25
  import { DB_FILE_NAMES, LEGACY_DB_FILE_NAME, resolveDatabaseFile, sideFiles } from './database-file.mjs';
26
+ import { resolveOarRoot } from './oar-state-root.mjs';
27
27
  import { createRelaySingletonGuard } from './relay-singleton-guard.mjs';
28
28
 
29
29
  export const MIGRATION_MARKER = '.migrated';
30
30
  const MAX_LOGS_BYTES = 50 * 1024 * 1024;
31
31
 
32
- export function resolveOarRoot(env = process.env, platform = process.platform) {
33
- const override = String(env.OAR_STATE_ROOT || '').trim();
34
- if (override) return path.resolve(override);
35
- if (platform === 'win32') {
36
- const appData = String(env.APPDATA || '').trim();
37
- if (appData) return path.join(appData, 'oar');
38
- const profile = String(env.USERPROFILE || '').trim();
39
- return path.join(profile || os.homedir(), 'AppData', 'Roaming', 'oar');
40
- }
41
- return path.join(os.homedir(), '.oar');
42
- }
32
+ export { resolveOarRoot };
43
33
 
44
34
  function defaultIsProcessAlive(pid) {
45
35
  try {
@@ -0,0 +1,17 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ // The OAR state root: ~/.oar, %APPDATA%\oar on Windows, OAR_STATE_ROOT when
5
+ // set. A module of its own, without imports of OAR's, so the relay's lock
6
+ // check can find the agent SDK folder beside it (agent-sdk-paths.mjs).
7
+ export function resolveOarRoot(env = process.env, platform = process.platform) {
8
+ const override = String(env.OAR_STATE_ROOT || '').trim();
9
+ if (override) return path.resolve(override);
10
+ if (platform === 'win32') {
11
+ const appData = String(env.APPDATA || '').trim();
12
+ if (appData) return path.join(appData, 'oar');
13
+ const profile = String(env.USERPROFILE || '').trim();
14
+ return path.join(profile || os.homedir(), 'AppData', 'Roaming', 'oar');
15
+ }
16
+ return path.join(os.homedir(), '.oar');
17
+ }