@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
@@ -24,9 +24,10 @@ export { readGitCommit, resolveBuildCommit };
24
24
  export const ABOUT_TOTALS_CACHE_MS = 60_000;
25
25
  export const ABOUT_MESSAGE_CHUNK_ROWS = 10_000;
26
26
 
27
- // The packages each provider's workers run on, read from node_modules once.
27
+ // The agent SDKs each provider's workers run on, by provider and store id
28
+ // (agent-sdk-store.mjs). Copilot has none of its own: its workers load the
29
+ // SDK the Copilot CLI ships, so its row names the CLI version only.
28
30
  export const ABOUT_PROVIDER_SDK_PACKAGES = Object.freeze({
29
- github: '@github/copilot-sdk',
30
31
  claude: '@anthropic-ai/claude-agent-sdk',
31
32
  cursor: '@cursor/sdk',
32
33
  });
@@ -302,6 +303,7 @@ async function fileSize(fsImpl, filePath) {
302
303
  * @param {() => boolean} [deps.isRelayCliOnline] the Copilot relay CLI is connected
303
304
  * @param {() => object[]} [deps.listWorkers] the session worker registry's entries
304
305
  * @param {() => object|null} [deps.getUpdateState] buildUpdateStatePayload()
306
+ * @param {() => object|null} [deps.getAgentSdkStatus] the agent SDK store's snapshot(); its `installed` versions come first
305
307
  * @param {object} [deps.sdkVersions] package version by provider id; read from node_modules when absent
306
308
  * @param {() => object|null} [deps.describeBackups] the database backups (db-backup-service.mjs describeBackups)
307
309
  */
@@ -320,6 +322,7 @@ export function createAboutService({
320
322
  isRelayCliOnline = () => false,
321
323
  listWorkers = () => [],
322
324
  getUpdateState = () => null,
325
+ getAgentSdkStatus = null,
323
326
  sdkVersions = null,
324
327
  isPidAlive = defaultIsPidAlive,
325
328
  fsImpl = fs,
@@ -332,7 +335,23 @@ export function createAboutService({
332
335
  let resolvedSdkVersions = sdkVersions;
333
336
  const providerForConversation = db.prepare(`SELECT provider_type FROM runtime_sessions WHERE conversation_id = ?`);
334
337
 
335
- function sdkVersionFor(providerId) {
338
+ // The store's installed versions, read per answer (an install or a
339
+ // reinstall changes them under a running relay); null without a store.
340
+ function readAgentSdkSnapshot() {
341
+ if (typeof getAgentSdkStatus !== 'function') return null;
342
+ try {
343
+ return getAgentSdkStatus()?.sdks || null;
344
+ } catch {
345
+ return null;
346
+ }
347
+ }
348
+
349
+ // The store's folder first, then the package's own node_modules: the order
350
+ // agent-sdk-loader.mjs loads them in (a git checkout runs its
351
+ // devDependency until the store has the pinned folder).
352
+ function sdkVersionFor(providerId, agentSdks) {
353
+ const installed = String(agentSdks?.[providerId]?.installed || '').trim();
354
+ if (installed) return installed;
336
355
  if (!resolvedSdkVersions) {
337
356
  resolvedSdkVersions = {};
338
357
  for (const [id, packageName] of Object.entries(ABOUT_PROVIDER_SDK_PACKAGES)) {
@@ -385,6 +404,7 @@ export function createAboutService({
385
404
  const settings = getProviderSettings() || {};
386
405
  const cli = getCachedCliProviders() || {};
387
406
  const relayCliOnline = isRelayCliOnline() === true;
407
+ const agentSdks = readAgentSdkSnapshot();
388
408
  const rows = [];
389
409
  for (const id of Object.keys(ABOUT_PROVIDER_LABELS)) {
390
410
  const entry = settings[id];
@@ -395,15 +415,15 @@ export function createAboutService({
395
415
  const viaRelayCli = id === 'openai' || (id === 'github' && engine === 'extension');
396
416
  const runningWorkers = workerCounts.get(id) || 0;
397
417
  const cliRow = PROVIDER_CLI_IDS[id] ? cli[PROVIDER_CLI_IDS[id]] : null;
398
- const showSdk = id !== 'github' || engine === 'sdk';
418
+ const sdkPackage = ABOUT_PROVIDER_SDK_PACKAGES[id] || null;
399
419
  rows.push({
400
420
  id,
401
421
  label: ABOUT_PROVIDER_LABELS[id],
402
422
  engine,
403
423
  cliVersion: cliRow?.installed ? (cliRow.version || null) : null,
404
424
  cliCheckedAt: cliRow?.checkedAt || null,
405
- sdkPackage: showSdk ? (ABOUT_PROVIDER_SDK_PACKAGES[id] || null) : null,
406
- sdkVersion: showSdk ? sdkVersionFor(id) : null,
425
+ sdkPackage,
426
+ sdkVersion: sdkPackage ? sdkVersionFor(id, agentSdks) : null,
407
427
  state: viaRelayCli
408
428
  ? (relayCliOnline ? 'online' : 'offline')
409
429
  : (runningWorkers > 0 ? 'online' : 'idle'),
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The launch gate of the agent SDKs (agent-sdk-store.mjs): which SDK folder
5
+ * a worker is launched with, and what happens while there is none.
6
+ *
7
+ * A worker kind on an SDK (the Claude workers on Claude's, Cursor on its
8
+ * own) gets the store's ready folder, which it keeps for its whole life. A
9
+ * checkout's devDependency needs no folder. A missing SDK is never waited
10
+ * for: the gate starts (or joins) its install, puts an "Installing the …
11
+ * SDK" note in the conversation, and answers `deferred` at once. The
12
+ * supervisor counts that as no launch and no failure, so the request that
13
+ * asked for the worker (a new chat, a send, the queue's prime) answers right
14
+ * away and the turn stays queued. When the install ends, and whenever the
15
+ * SDK turns ready later (the Retry button, the automatic retry), the
16
+ * sessions that met the gate get their launch back (`onSessionsReleased`):
17
+ * their restart schedule is cleared and the queue is woken.
18
+ *
19
+ * An SDK that failed, or has no build for this platform, stops the launch
20
+ * with one line naming the Settings tab; the worker launch note carries it
21
+ * and the message stays queued for the Retry.
22
+ */
23
+
24
+ export const AGENT_SDK_INSTALLING = 'agent-sdk-installing';
25
+
26
+ /**
27
+ * Whether a stale worker of `kind` may lose its SDK's files to an update,
28
+ * so the recycle replaces it before its next message: a kind on an agent
29
+ * SDK the store installs. One whose SDK comes from a git checkout's
30
+ * node_modules keeps its files across a restart on new code, and is
31
+ * recycled like any worker (between turns, once its CLI child is idle).
32
+ */
33
+ export function staleWorkerLosesAgentSdk(kind, { sdkForWorkerKind, fromCheckout }) {
34
+ const sdk = sdkForWorkerKind(kind);
35
+ return sdk !== null && !fromCheckout(sdk);
36
+ }
37
+
38
+ export function createAgentSdkLaunchGate({
39
+ store,
40
+ sdkForWorkerKind,
41
+ resolveSource,
42
+ noteInstalling = () => {},
43
+ onSessionsReleased = () => {},
44
+ logger = console,
45
+ } = {}) {
46
+ if (!store) throw new Error('createAgentSdkLaunchGate requires a store');
47
+ // Per SDK, the sessions whose launch met the gate and waits for it.
48
+ const waiting = new Map();
49
+ // Per SDK, an install this gate already follows to its end.
50
+ const following = new Set();
51
+
52
+ const log = (message) => {
53
+ try { logger?.log?.(`[agent-sdk] ${message}`); } catch {}
54
+ };
55
+
56
+ function remember(sdk, sdkSessionId) {
57
+ if (!waiting.has(sdk)) waiting.set(sdk, new Set());
58
+ waiting.get(sdk).add(sdkSessionId);
59
+ }
60
+
61
+ /** Hands every session that waited on `sdk` its launch back. */
62
+ function release(sdk, reason) {
63
+ const sessions = [...(waiting.get(sdk) || [])];
64
+ waiting.delete(sdk);
65
+ if (!sessions.length) return [];
66
+ log(`${sdk}: ${reason}; ${sessions.length} waiting session(s) may launch`);
67
+ try { onSessionsReleased({ sdk, sessions, reason }); } catch (error) {
68
+ log(`${sdk}: releasing the waiting sessions failed: ${error?.message || error}`);
69
+ }
70
+ return sessions;
71
+ }
72
+
73
+ function follow(sdk) {
74
+ if (following.has(sdk)) return;
75
+ following.add(sdk);
76
+ Promise.resolve(store.ensure(sdk, { reason: 'first chat' }))
77
+ .catch((error) => log(`${sdk}: install failed: ${error?.message || error}`))
78
+ .finally(() => {
79
+ following.delete(sdk);
80
+ // Ready or failed: the next launch says which.
81
+ release(sdk, 'install ended');
82
+ });
83
+ }
84
+
85
+ /**
86
+ * What the launch of `sdkSessionId` (worker kind `workerKind`) does:
87
+ * `{ sdk: null }` (no SDK, or a checkout's devDependency),
88
+ * `{ sdk, dir }` (launch with this folder), or `{ sdk, deferred }` (not
89
+ * launched now). Throws for an SDK that failed or has no build here.
90
+ */
91
+ function check(sdkSessionId, workerKind) {
92
+ const sdk = sdkForWorkerKind(workerKind);
93
+ if (!sdk) return { sdk: null };
94
+ const found = resolveSource(sdk);
95
+ if (found?.source === 'checkout') return { sdk: null };
96
+ if (found?.dir) return { sdk, dir: found.dir };
97
+ const status = store.status(sdk);
98
+ const label = store.getDescriptor?.(sdk)?.label || status?.label || sdk;
99
+ if (status && ['error', 'unsupported'].includes(status.state) && !status.active) {
100
+ // Remembered too: a Retry that brings the SDK back launches it.
101
+ remember(sdk, sdkSessionId);
102
+ const tab = sdk === 'cursor' ? 'Cursor' : 'Claude';
103
+ const why = status.state === 'unsupported'
104
+ ? `${label} is not available on this platform`
105
+ : `${label} could not be installed: ${status.error || 'unknown error'}`;
106
+ throw new Error(`${why} (Settings → Providers → ${tab})`);
107
+ }
108
+ remember(sdk, sdkSessionId);
109
+ try { noteInstalling(sdkSessionId, { sdk, label }); } catch {}
110
+ follow(sdk);
111
+ return { sdk, deferred: AGENT_SDK_INSTALLING };
112
+ }
113
+
114
+ /** The store's ready hook: an SDK that came back launches its waiters. */
115
+ function onReady(sdk) {
116
+ return release(sdk, 'ready');
117
+ }
118
+
119
+ return {
120
+ check,
121
+ onReady,
122
+ waitingSessions: (sdk) => [...(waiting.get(sdk) || [])],
123
+ };
124
+ }
@@ -0,0 +1,307 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { resolveOarRoot } from './oar-state-root.mjs';
7
+
8
+ /**
9
+ * Where the agent SDKs the relay installs on demand live, and which folder
10
+ * holds a usable copy. Synchronous, without a database and without imports
11
+ * of the relay's own services: the relay's lock check (koffi on Windows) and
12
+ * a worker's loader read this before anything else runs. The installing half
13
+ * is agent-sdk-store.mjs.
14
+ *
15
+ * <root>/<sdk>/<version>-<hash8>/ one folder per SDK, version and platform
16
+ * oar-sdk.json the marker, written last
17
+ * <root>/<sdk>/.leases/<folder>/<pid>-*.json who uses a folder (agent-sdk-store.mjs)
18
+ * <root>/<sdk>/.store.lock held while one process installs or deletes
19
+ *
20
+ * <root> is <state root>/sdks, or OAR_SDK_DIR. <hash8> covers the pins
21
+ * folder's files (package.json, lockfile, entries) and the platform, so a
22
+ * changed entry file or a root shared by Windows and WSL never reuses a
23
+ * folder made for something else.
24
+ */
25
+
26
+ export const AGENT_SDK_PINS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'agent-sdks');
27
+ export const AGENT_SDK_MARKER = 'oar-sdk.json';
28
+ export const AGENT_SDK_LEASES_DIR = '.leases';
29
+ // Bumped when the folder layout or the marker changes meaning.
30
+ export const AGENT_SDK_STORE_FORMAT = 1;
31
+
32
+ /** What each pins folder installs, and which file a loader imports. */
33
+ export const AGENT_SDK_DESCRIPTORS = Object.freeze({
34
+ claude: Object.freeze({
35
+ id: 'claude',
36
+ label: 'Claude Agent SDK',
37
+ packageName: '@anthropic-ai/claude-agent-sdk',
38
+ entries: Object.freeze({ main: 'entry.mjs' }),
39
+ platformScope: '@anthropic-ai/claude-agent-sdk-',
40
+ // Platforms without a vendor build where the SDK runs the host's own
41
+ // Claude Code CLI instead (CLAUDE_CODE_EXECUTABLE). None is verified
42
+ // yet; native Termux (android) would be the candidate.
43
+ hostCliPlatforms: Object.freeze([]),
44
+ platforms: null,
45
+ }),
46
+ cursor: Object.freeze({
47
+ id: 'cursor',
48
+ label: 'Cursor SDK',
49
+ packageName: '@cursor/sdk',
50
+ entries: Object.freeze({ main: 'entry.mjs', sqlite: 'entry-sqlite.mjs' }),
51
+ platformScope: '@cursor/sdk-',
52
+ platforms: null,
53
+ }),
54
+ koffi: Object.freeze({
55
+ id: 'koffi',
56
+ label: 'Windows helper (koffi)',
57
+ packageName: 'koffi',
58
+ entries: Object.freeze({ main: 'entry.cjs' }),
59
+ platformScope: '@koromix/koffi-',
60
+ // The relay needs it only for its lock check on Windows.
61
+ platforms: Object.freeze(['win32']),
62
+ }),
63
+ });
64
+
65
+ export const AGENT_SDK_IDS = Object.freeze(Object.keys(AGENT_SDK_DESCRIPTORS));
66
+
67
+ export function getAgentSdkDescriptor(id) {
68
+ const key = String(id || '').trim().toLowerCase();
69
+ return Object.prototype.hasOwnProperty.call(AGENT_SDK_DESCRIPTORS, key) ? AGENT_SDK_DESCRIPTORS[key] : null;
70
+ }
71
+
72
+ /** `<state root>/sdks`, or OAR_SDK_DIR; always absolute. */
73
+ export function resolveAgentSdkRoot({ env = process.env, platform = process.platform } = {}) {
74
+ const override = String(env?.OAR_SDK_DIR || '').trim();
75
+ if (override) return path.resolve(override);
76
+ return path.join(resolveOarRoot(env, platform), 'sdks');
77
+ }
78
+
79
+ /** Linux's C library as npm tells it, 'glibc' or 'musl'; null elsewhere. */
80
+ export function detectLibc({ platform = process.platform, report = () => process.report?.getReport?.() } = {}) {
81
+ if (platform !== 'linux') return null;
82
+ try {
83
+ return report()?.header?.glibcVersionRuntime ? 'glibc' : 'musl';
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export function hostPlatform({ platform = process.platform, arch = process.arch, libc } = {}) {
90
+ return { platform, arch, libc: libc === undefined ? detectLibc({ platform }) : libc };
91
+ }
92
+
93
+ const pinCache = new Map();
94
+
95
+ /**
96
+ * The pin of an SDK for this host: the pins folder's files as read once
97
+ * (an `oar update` replaces them under a running relay, which goes on
98
+ * installing what it was started with), the pinned version and the folder
99
+ * name, `<version>-<first 8 hex of the content hash>`.
100
+ */
101
+ export function readAgentSdkPin(id, { pinsDir = AGENT_SDK_PINS_DIR, fsImpl = fs, host = hostPlatform() } = {}) {
102
+ const descriptor = getAgentSdkDescriptor(id);
103
+ if (!descriptor) return null;
104
+ const folder = path.join(pinsDir, descriptor.id);
105
+ const cacheKey = JSON.stringify([folder, host.platform, host.arch, host.libc]);
106
+ if (fsImpl === fs && pinCache.has(cacheKey)) return pinCache.get(cacheKey);
107
+ const names = ['package.json', 'package-lock.json', ...Object.values(descriptor.entries)].sort();
108
+ const files = {};
109
+ const hash = crypto.createHash('sha256').update(`oar-sdk-store:${AGENT_SDK_STORE_FORMAT}\0${host.platform}\0${host.arch}\0${host.libc || ''}\0`);
110
+ for (const name of names) {
111
+ files[name] = fsImpl.readFileSync(path.join(folder, name));
112
+ hash.update(`${name}\0`).update(files[name]).update('\0');
113
+ }
114
+ const packageJson = JSON.parse(files['package.json'].toString('utf8'));
115
+ const version = String(packageJson.dependencies?.[descriptor.packageName] || '');
116
+ const fullHash = hash.digest('hex');
117
+ const pin = Object.freeze({
118
+ id: descriptor.id,
119
+ version,
120
+ hash: fullHash,
121
+ dirName: `${version}-${fullHash.slice(0, 8)}`,
122
+ host: Object.freeze({ ...host }),
123
+ files: Object.freeze(files),
124
+ lock: JSON.parse(files['package-lock.json'].toString('utf8')),
125
+ });
126
+ if (fsImpl === fs) pinCache.set(cacheKey, pin);
127
+ return pin;
128
+ }
129
+
130
+ function matchesList(list, value) {
131
+ if (!Array.isArray(list) || !list.length) return true;
132
+ if (list.includes(`!${value}`)) return false;
133
+ const allowed = list.filter((item) => !String(item).startsWith('!'));
134
+ return !allowed.length || allowed.includes(value);
135
+ }
136
+
137
+ /**
138
+ * The vendor's platform package this host gets from the pinned lockfile, or
139
+ * null when the vendor lists none for it. Matches os, cpu and libc as npm
140
+ * does.
141
+ */
142
+ export function platformPackageFor(pin) {
143
+ const descriptor = getAgentSdkDescriptor(pin?.id);
144
+ if (!descriptor || !pin?.lock?.packages) return null;
145
+ const { platform, arch, libc } = pin.host;
146
+ for (const [key, entry] of Object.entries(pin.lock.packages)) {
147
+ const name = key.slice(key.lastIndexOf('node_modules/') + 'node_modules/'.length);
148
+ if (!name.startsWith(descriptor.platformScope) || !entry?.os) continue;
149
+ if (!matchesList(entry.os, platform) || !matchesList(entry.cpu, arch)) continue;
150
+ if (entry.libc && libc && !matchesList(entry.libc, libc)) continue;
151
+ return name;
152
+ }
153
+ return null;
154
+ }
155
+
156
+ /**
157
+ * Whether this host can run the SDK at all, from the descriptor and the
158
+ * lockfile alone: 'yes' (a vendor build for it), 'cli' (none, but the
159
+ * descriptor lets the host's own CLI do the work there) or 'no' ("not
160
+ * available on this platform", never retried).
161
+ */
162
+ export function agentSdkSupport(pin, { hostCliPlatforms } = {}) {
163
+ const descriptor = getAgentSdkDescriptor(pin?.id);
164
+ if (!descriptor) return 'no';
165
+ if (descriptor.platforms && !descriptor.platforms.includes(pin.host.platform)) return 'no';
166
+ if (platformPackageFor(pin)) return 'yes';
167
+ return (hostCliPlatforms ?? descriptor.hostCliPlatforms ?? []).includes(pin.host.platform) ? 'cli' : 'no';
168
+ }
169
+
170
+ /**
171
+ * The files an install is checked by, relative to its folder: the entries,
172
+ * each pinned package's package.json and the platform package's, the SDK's
173
+ * own main module. A file an antivirus took, or a half-deleted tree, changes
174
+ * one of them.
175
+ */
176
+ export function agentSdkCheckedFiles(pin) {
177
+ const descriptor = getAgentSdkDescriptor(pin?.id);
178
+ const files = [...Object.values(descriptor.entries)];
179
+ const packageJson = JSON.parse(pin.files['package.json'].toString('utf8'));
180
+ for (const name of Object.keys(packageJson.dependencies || {})) files.push(`node_modules/${name}/package.json`);
181
+ const platformPackage = platformPackageFor(pin);
182
+ if (platformPackage) files.push(`node_modules/${platformPackage}/package.json`);
183
+ return files;
184
+ }
185
+
186
+ /** The marker of an installed folder, or null. */
187
+ export function readAgentSdkMarker(dir, { fsImpl = fs } = {}) {
188
+ try {
189
+ const marker = JSON.parse(fsImpl.readFileSync(path.join(dir, AGENT_SDK_MARKER), 'utf8'));
190
+ return marker && typeof marker === 'object' && !Array.isArray(marker) ? marker : null;
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ function sizesMatch(dir, files, fsImpl) {
197
+ if (!files || typeof files !== 'object' || !Object.keys(files).length) return false;
198
+ try {
199
+ for (const [rel, size] of Object.entries(files)) {
200
+ const stat = fsImpl.statSync(path.join(dir, ...rel.split('/')));
201
+ if (!stat.isFile() || stat.size !== size) return false;
202
+ }
203
+ return true;
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Whether `dir` holds a finished install made for this host: its marker
211
+ * names the SDK, the store format and the platform, and every file it
212
+ * recorded is there at its size. With `pin`, also the pinned version and
213
+ * content hash.
214
+ */
215
+ export function isValidAgentSdkInstall(dir, { id, pin = null, host = pin?.host || hostPlatform(), fsImpl = fs } = {}) {
216
+ const marker = readAgentSdkMarker(dir, { fsImpl });
217
+ if (!marker || marker.format !== AGENT_SDK_STORE_FORMAT || marker.id !== (pin?.id || id)) return false;
218
+ if (marker.platform !== host.platform || marker.arch !== host.arch || (marker.libc || null) !== (host.libc || null)) return false;
219
+ if (pin && (marker.version !== pin.version || marker.hash !== pin.hash || path.basename(dir) !== pin.dirName)) return false;
220
+ return sizesMatch(dir, marker.files, fsImpl);
221
+ }
222
+
223
+ const FOLDER_NAME = /^\d[\w.+-]*-[0-9a-f]{8}$/;
224
+
225
+ /**
226
+ * The pinned folder of an SDK when it holds a finished install, else null.
227
+ * With `anyVersion`, otherwise the newest valid folder of an earlier pin
228
+ * (koffi: any version reads a process's start time).
229
+ */
230
+ export function findReadyAgentSdkDir(id, {
231
+ env = process.env,
232
+ platform = process.platform,
233
+ root = resolveAgentSdkRoot({ env, platform }),
234
+ pinsDir = AGENT_SDK_PINS_DIR,
235
+ host = hostPlatform({ platform }),
236
+ anyVersion = false,
237
+ fsImpl = fs,
238
+ } = {}) {
239
+ try {
240
+ const pin = readAgentSdkPin(id, { pinsDir, fsImpl, host });
241
+ if (!pin) return null;
242
+ const pinned = path.join(root, pin.id, pin.dirName);
243
+ if (isValidAgentSdkInstall(pinned, { pin, fsImpl })) return pinned;
244
+ if (!anyVersion) return null;
245
+ let best = null;
246
+ for (const name of fsImpl.readdirSync(path.join(root, pin.id))) {
247
+ if (!FOLDER_NAME.test(name)) continue;
248
+ const dir = path.join(root, pin.id, name);
249
+ if (!isValidAgentSdkInstall(dir, { id: pin.id, host, fsImpl })) continue;
250
+ const at = Date.parse(readAgentSdkMarker(dir, { fsImpl })?.installedAt || '') || 0;
251
+ if (!best || at > best.at) best = { dir, at };
252
+ }
253
+ return best?.dir || null;
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ export const AGENT_SDK_PACKAGE_ROOT = path.resolve(AGENT_SDK_PINS_DIR, '..', '..');
260
+
261
+ /**
262
+ * Whether this process takes the SDKs from the package's own node_modules:
263
+ * a git checkout has them as devDependencies at the pinned versions.
264
+ * OAR_AGENT_SDK_SOURCE=store makes a checkout use the store like an install
265
+ * does.
266
+ */
267
+ export function agentSdksFromCheckout({ env = process.env, packageRoot = AGENT_SDK_PACKAGE_ROOT, fsImpl = fs } = {}) {
268
+ if (String(env?.OAR_AGENT_SDK_SOURCE || '').trim().toLowerCase() === 'store') return false;
269
+ try { return fsImpl.existsSync(path.join(packageRoot, '.git')); } catch { return false; }
270
+ }
271
+
272
+ /**
273
+ * Whether this process takes one SDK from the checkout: a checkout (above)
274
+ * whose node_modules really holds the package. One installed without its
275
+ * devDependencies (`npm install --omit=dev`) takes it from the store.
276
+ */
277
+ export function agentSdkFromCheckout(id, { env = process.env, packageRoot = AGENT_SDK_PACKAGE_ROOT, fsImpl = fs } = {}) {
278
+ const descriptor = getAgentSdkDescriptor(id);
279
+ if (!descriptor || !agentSdksFromCheckout({ env, packageRoot, fsImpl })) return false;
280
+ try {
281
+ return fsImpl.existsSync(path.join(packageRoot, 'node_modules', ...descriptor.packageName.split('/'), 'package.json'));
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+
287
+ export function isAgentSdkFolderName(name) {
288
+ return FOLDER_NAME.test(String(name || ''));
289
+ }
290
+
291
+ /**
292
+ * Records that process `pid` uses the folder `dir`, so no store's clean-up
293
+ * deletes it while the process runs (agent-sdk-store.mjs judges the
294
+ * leases). `holder` is the process's identity as the relay lock records it.
295
+ * Returns the lease file, or null when it could not be written.
296
+ */
297
+ export function writeAgentSdkLease(dir, { pid = process.pid, holder = null, bootId = '', now = () => new Date(), fsImpl = fs } = {}) {
298
+ try {
299
+ const leasesDir = path.join(path.dirname(dir), AGENT_SDK_LEASES_DIR, path.basename(dir));
300
+ fsImpl.mkdirSync(leasesDir, { recursive: true });
301
+ const file = path.join(leasesDir, `${pid}-${crypto.randomBytes(3).toString('hex')}.json`);
302
+ fsImpl.writeFileSync(file, `${JSON.stringify({ format: 2, pid, holder, ...(bootId ? { bootId } : {}), startedAt: now().toISOString() })}\n`, { flag: 'wx' });
303
+ return file;
304
+ } catch {
305
+ return null;
306
+ }
307
+ }