@commonlyai/cli 0.1.76 → 0.1.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.76",
3
+ "version": "0.1.77",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -42,6 +42,9 @@
42
42
  },
43
43
  "jest": {
44
44
  "testEnvironment": "node",
45
+ "setupFiles": [
46
+ "<rootDir>/jest.setup.cjs"
47
+ ],
45
48
  "transform": {},
46
49
  "testMatch": [
47
50
  "**/__tests__/**/*.[jt]s?(x)",
@@ -74,6 +74,7 @@ import {
74
74
  } from '../sandbox/seatbelt.js';
75
75
  import { CREDENTIAL_FILE_VAR, CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
76
76
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
77
+ import { prepareMcpSpawn } from '../mcp-home.js';
77
78
  import { buildMemoryPreamble } from '../memory-bridge.js';
78
79
  import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
79
80
 
@@ -341,10 +342,19 @@ const buildMcpConfig = (mcpServers, ctx = {}) => {
341
342
  // config, which claude's `--mcp-config` reads directly.
342
343
  const mcpServersMap = {};
343
344
  for (const server of mcpServers) {
345
+ // TASK-174: the command this seat will EXECUTE, not the one it declared —
346
+ // `node <home>/<version>/node_modules/@commonlyai/mcp/<bin>` when the seat's
347
+ // MCP home has a warmed build, and otherwise the unchanged declaration. The
348
+ // credential channel below is decided on that same command, so the two can
349
+ // never disagree about which release is running.
350
+ const spawnCommand = server.command
351
+ ? prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl })
352
+ : null;
353
+ const executed = spawnCommand ? { ...server, command: spawnCommand } : server;
344
354
  const entry = { type: server.transport || 'stdio' };
345
355
  if (server.url) entry.url = server.url;
346
- if (server.command) {
347
- const [command, ...args] = server.command;
356
+ if (spawnCommand) {
357
+ const [command, ...args] = spawnCommand;
348
358
  entry.command = command;
349
359
  if (args.length) entry.args = args;
350
360
  }
@@ -352,7 +362,7 @@ const buildMcpConfig = (mcpServers, ctx = {}) => {
352
362
  // config claude actually reads, so the value never has to exist in the
353
363
  // runtime's environment at all (TASK-083).
354
364
  if (server.env) {
355
- entry.env = deliverSeatCredential(server, {
365
+ entry.env = deliverSeatCredential(executed, {
356
366
  credentialFile: ctx.credentialFile,
357
367
  label: 'claude',
358
368
  }).env;
@@ -58,6 +58,7 @@ import {
58
58
  } from 'path';
59
59
  import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
60
60
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
61
+ import { prepareMcpSpawn } from '../mcp-home.js';
61
62
  import { buildMemoryPreamble } from '../memory-bridge.js';
62
63
  import { isLegacySandboxTrust, normalizeSandboxTrust } from '../environment.js';
63
64
  import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
@@ -138,20 +139,26 @@ const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
138
139
  const flags = [];
139
140
  const forwardedEnv = {};
140
141
  for (const server of mcpServers || []) {
142
+ const transport = typeof server?.transport === 'string' ? server.transport.trim().toLowerCase() : 'stdio';
143
+ if (!server?.name || transport !== 'stdio'
144
+ || !Array.isArray(server.command) || !server.command.length) continue;
145
+ // TASK-174: the command that will EXECUTE — the warmed build from the seat's
146
+ // MCP home when there is one — and the credential channel is decided on that
147
+ // same command, so the two can never disagree about which release runs. The
148
+ // rewrite is before `substitutePlaceholders` because it introduces no
149
+ // placeholder: it is a path this host resolved.
150
+ const spawnCommand = prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl });
141
151
  // A declared credential is rewritten to the file channel before anything is
142
152
  // substituted, so for our server the token is not token-bearing at all: it
143
153
  // lands in `env={...}` as a path and never reaches `env_vars`, which is the
144
154
  // one path by which the value ends up in codex's own environment and from
145
155
  // there in every MCP child it spawns (TASK-083, measured on a live codex
146
156
  // seat before the change).
147
- const declaredEnv = deliverSeatCredential(server, {
157
+ const declaredEnv = deliverSeatCredential({ ...server, command: spawnCommand }, {
148
158
  credentialFile: ctx.credentialFile,
149
159
  label: 'codex',
150
160
  }).env;
151
- const transport = typeof server?.transport === 'string' ? server.transport.trim().toLowerCase() : 'stdio';
152
- if (!server?.name || transport !== 'stdio'
153
- || !Array.isArray(server.command) || !server.command.length) continue;
154
- const [command, ...rest] = server.command.map((a) => substitutePlaceholders(a, ctx));
161
+ const [command, ...rest] = spawnCommand.map((a) => substitutePlaceholders(a, ctx));
155
162
  // The doc block above promises bearer tokens never ride in argv, and this is
156
163
  // the place that had to hold it: an env value that carries the token is
157
164
  // diverted to env_vars, but a COMMAND ARGUMENT has no such route — codex
@@ -46,6 +46,7 @@ import { buildMemoryPreamble } from '../memory-bridge.js';
46
46
  import { effectiveSandboxTrust } from '../environment.js';
47
47
  import { GRANT_BROKER_REFUSAL, isGrantBrokerUrl } from './pi-mcp-client.mjs';
48
48
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
49
+ import { prepareMcpSpawn } from '../mcp-home.js';
49
50
  import { removeCredentialFile, writeCredentialFile } from '../credential-file.js';
50
51
 
51
52
  const DEFAULT_TIMEOUT_MS = (() => {
@@ -207,13 +208,17 @@ export const resolveMcpServers = (mcpServers, ctx = {}) => {
207
208
  }
208
209
  continue;
209
210
  }
211
+ // TASK-174: what will EXECUTE — the warmed build from the seat's MCP home
212
+ // when there is one — and the credential channel is decided on that same
213
+ // command, so the two can never disagree about which release runs.
214
+ const spawnCommand = prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl });
210
215
  carried.push({
211
216
  name: server.name,
212
- command: server.command.map((a) => substitutePlaceholders(a, ctx)),
217
+ command: spawnCommand.map((a) => substitutePlaceholders(a, ctx)),
213
218
  // Rewritten before substitution, so our own server is handed the file
214
219
  // (whose value the bridge pipes) instead of the token itself.
215
220
  env: Object.fromEntries(Object.entries(
216
- deliverSeatCredential(server, {
221
+ deliverSeatCredential({ ...server, command: spawnCommand }, {
217
222
  credentialFile: ctx.credentialFile,
218
223
  label: 'pi',
219
224
  }).env,
@@ -0,0 +1,465 @@
1
+ /**
2
+ * Where a seat's `@commonlyai/mcp` is executed FROM.
3
+ *
4
+ * Why this exists (TASK-174). Every seat's default declaration is
5
+ * `npx -y @commonlyai/mcp@latest` (default-environment.js), and npx names its
6
+ * cache dir by the hash of the SPEC STRING — npm 11.12.1,
7
+ * libnpmexec/lib/index.js:238-250, sha512(spec)[0:16]. So
8
+ * sha512('@commonlyai/mcp@latest')[0:16] = 6d82e98be466b586 is ONE dir shared by
9
+ * every seat, rewritten in place when the first spawn after a publish sees a new
10
+ * version, and every npx run does registry + lock work before the server is even
11
+ * started. Two failures were measured on 2026-09-27: seats lost the server for
12
+ * minutes after the 0.3.13 publish at 10:47:57Z (the in-place rebuild), and Vera
13
+ * got a 30 s CONNECT_TIMEOUT with no publish at all (the shared dir's mtime
14
+ * moves on every npx run, so a slow registry round-trip alone exceeds the
15
+ * client's connect budget).
16
+ *
17
+ * The fix takes npx OFF the spawn path. A spawn executes
18
+ * `node <home>/<version>/node_modules/@commonlyai/mcp/<bin>` for the version a
19
+ * pointer file names; versions are installed by EXACT spec into their own
20
+ * version dir by a background single-flight warm (mcp-warm-child.mjs), and the
21
+ * pointer moves only after the new bin answers MCP `initialize`.
22
+ *
23
+ * The DECLARATION is not touched. `declared-mcp-guard.isShippedCommonlyMcpEntry`
24
+ * still audits `npx -y @commonlyai/mcp@latest` verbatim (TASK-150) — that guard
25
+ * judges what an operator declared, and this module only changes what a spawn
26
+ * executes. A declaration that PINS a version is never rewritten: that is the
27
+ * operator saying which build to run, and the npx cache already gives a pinned
28
+ * spec its own dir.
29
+ *
30
+ * Every filesystem answer here is injected, because the two states that matter
31
+ * (a home that has never been warmed, and a home whose pointed-at version dir was
32
+ * deleted under it) cannot both be produced on one host.
33
+ */
34
+ import { spawn } from 'node:child_process';
35
+ import {
36
+ existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync,
37
+ } from 'node:fs';
38
+ import { homedir } from 'node:os';
39
+ import { dirname, join, sep } from 'node:path';
40
+ import { fileURLToPath } from 'node:url';
41
+ import { isShippedCommonlyMcpCommand } from './declared-mcp-guard.js';
42
+ import { MCP_PACKAGE, parseVersion } from './mcp-server-version.js';
43
+
44
+ /** The env override, so tests (and an operator) can point the home elsewhere. */
45
+ export const MCP_HOME_ENV = 'COMMONLY_MCP_HOME';
46
+
47
+ export const WARM_LOCK_NAME = '.warm.lock';
48
+ export const REGISTRY_CACHE_NAME = '.registry.json';
49
+
50
+ /** How long a cached registry answer is trusted before the warm re-checks. */
51
+ export const REGISTRY_TTL_MS = 15 * 60 * 1000;
52
+
53
+ /** A lock older than this is a warm that died; reclaim rather than wedge. */
54
+ export const STALE_LOCK_MS = 10 * 60 * 1000;
55
+
56
+ /**
57
+ * Version dirs kept after a successful advance.
58
+ *
59
+ * This is a DISK BOUND, not the safety rule. Retention by count cannot express
60
+ * "not while someone is running it": keeping the newest three protects the
61
+ * newest three, and a seat still executing the fourth-newest dir is exactly the
62
+ * case that matters. A DIR IS REMOVABLE only when it is not `current`, carries
63
+ * no LIVE liveness claim, and is past `PRUNE_GRACE_MS` (Vera, 74878/74887).
64
+ */
65
+ export const KEEP_VERSION_DIRS = 3;
66
+
67
+ /**
68
+ * A version dir younger than this is never pruned, even with no live claimant.
69
+ *
70
+ * Time, not count, is what bounds the exposure: a dir inside the window may
71
+ * belong to a spawn that is starting up and has not written its claim yet, or
72
+ * whose claim was written by a pid the kernel has since recycled.
73
+ */
74
+ export const PRUNE_GRACE_MS = 24 * 60 * 60 * 1000;
75
+
76
+ /** Where a spawn records that a process may still be executing from a version. */
77
+ export const IN_USE_DIR_NAME = '.inuse';
78
+
79
+ /** The warm child's own exit codes, read by the spawn path's logs. */
80
+ export const WARM_RESULTS = Object.freeze({
81
+ LOCKED: 'locked',
82
+ CURRENT: 'current',
83
+ ADVANCED: 'advanced',
84
+ REGISTRY_UNAVAILABLE: 'registry-unavailable',
85
+ INSTALL_FAILED: 'install-failed',
86
+ PROBE_FAILED: 'probe-failed',
87
+ });
88
+
89
+ export const mcpHomeDir = (env = process.env) => {
90
+ const configured = env && env[MCP_HOME_ENV];
91
+ // A DEFECT THIS LITERALLY PREVENTED, 2026-09-27: `process.env.X = undefined`
92
+ // does not unset X, it sets the STRING "undefined" (Node coerces env values),
93
+ // so a caller that saved and restored an unset override handed the warm the
94
+ // home `./undefined/` — which it created, populated and pointed at, inside the
95
+ // package directory. This value is a PATH; a value that is not a plausible
96
+ // path is not a home, and the real home is the safe answer.
97
+ if (typeof configured === 'string' && configured && configured !== 'undefined' && configured !== 'null') {
98
+ return configured;
99
+ }
100
+ return join(homedir(), '.commonly', 'mcp');
101
+ };
102
+
103
+ /** True for a home a warm may write into: a real, absolute-looking path. */
104
+ export const isUsableWarmHome = (home) => (
105
+ typeof home === 'string'
106
+ && home.length > 1
107
+ && home !== 'undefined'
108
+ && home !== 'null'
109
+ && (home.startsWith('/') || /^[A-Za-z]:[\\/]/.test(home))
110
+ );
111
+
112
+ /** The home a caller asked for, or the configured one — never a junk string. */
113
+ export const resolveHomeDir = (home) => (isUsableWarmHome(home) ? home : mcpHomeDir());
114
+
115
+ export const currentPointerPath = (home) => join(home, 'current');
116
+ export const versionDirFor = (home, version) => join(home, version);
117
+ export const packageDirFor = (home, version) => join(home, version, 'node_modules', MCP_PACKAGE);
118
+ export const lockPathFor = (home) => join(home, WARM_LOCK_NAME);
119
+
120
+ /** `<home>/<version>/.inuse/` — one empty file per claiming pid. */
121
+ export const inUseDirFor = (home, version) => join(versionDirFor(home, version), IN_USE_DIR_NAME);
122
+ export const inUseMarkerPath = (home, version, pid) => join(inUseDirFor(home, version), String(pid));
123
+
124
+ /** A pid is a positive integer, or it is not a pid. */
125
+ const parsePid = (value) => {
126
+ const pid = Number(value);
127
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
128
+ };
129
+
130
+ /**
131
+ * Is this pid still running? `kill(pid, 0)` asks the kernel and sends nothing.
132
+ *
133
+ * EPERM means the pid exists and belongs to another user — alive. ESRCH means
134
+ * gone — dead. Anything else, including a value that was never a pid, is DEAD,
135
+ * because the two errors are not symmetric: guessing "alive" pins a version dir
136
+ * forever (unbounded disk with extra steps), while guessing "dead" costs one
137
+ * reinstall on a later warm.
138
+ */
139
+ export const isPidAlive = (pid, { kill = process.kill } = {}) => {
140
+ const parsed = parsePid(pid);
141
+ if (parsed === null) return false;
142
+ try {
143
+ kill(parsed, 0);
144
+ return true;
145
+ } catch (error) {
146
+ return Boolean(error) && error.code === 'EPERM';
147
+ }
148
+ };
149
+
150
+ /**
151
+ * The claimants under a version dir that are still running.
152
+ *
153
+ * The marker's PRESENCE is not the signal — a seat that is killed or crashes
154
+ * leaves its file behind, and a pruner that read presence would never remove
155
+ * that version again. The check is whether the claimant still exists; a dead or
156
+ * unparseable marker counts as absent.
157
+ */
158
+ export const liveInUsePids = (home, version, {
159
+ readDir = (dir) => {
160
+ try {
161
+ return readdirSync(dir);
162
+ } catch {
163
+ return [];
164
+ }
165
+ },
166
+ isAlive = isPidAlive,
167
+ } = {}) => readDir(inUseDirFor(home, version))
168
+ .map(parsePid)
169
+ .filter((pid) => pid !== null && isAlive(pid));
170
+
171
+ /**
172
+ * Claim a version dir for a process that may be executing from it.
173
+ *
174
+ * Called by `prepareMcpSpawn`, keyed by THIS process: the MCP server runs as a
175
+ * descendant of the process that materialised the command, so while that process
176
+ * lives its version dir must not be pruned. Best-effort by construction — a home
177
+ * that cannot be written to costs the seat nothing but its claim.
178
+ */
179
+ export const claimInUse = ({
180
+ home,
181
+ version,
182
+ pid = process.pid,
183
+ mkdir = mkdirSync,
184
+ writeFile = writeFileSync,
185
+ } = {}) => {
186
+ try {
187
+ if (!isUsableWarmHome(home) || !parseVersion(version) || parsePid(pid) === null) return false;
188
+ mkdir(inUseDirFor(home, version), { recursive: true });
189
+ writeFile(inUseMarkerPath(home, version, pid), '');
190
+ return true;
191
+ } catch {
192
+ return false;
193
+ }
194
+ };
195
+
196
+ const defaultRead = (path) => {
197
+ try {
198
+ return existsSync(path) ? readFileSync(path, 'utf8') : null;
199
+ } catch {
200
+ return null;
201
+ }
202
+ };
203
+
204
+ const defaultExists = (path) => {
205
+ try {
206
+ return existsSync(path);
207
+ } catch {
208
+ return false;
209
+ }
210
+ };
211
+
212
+ /**
213
+ * The version the pointer file names, or null.
214
+ *
215
+ * A pointer that does not parse is treated as no pointer at all: a spawn must
216
+ * never build a path out of a string it did not validate, and the fallback is
217
+ * the declared command, not a guess.
218
+ */
219
+ export const readCurrentVersion = (home, { readFile = defaultRead } = {}) => {
220
+ const raw = readFile(currentPointerPath(home));
221
+ if (typeof raw !== 'string') return null;
222
+ const version = raw.trim();
223
+ return parseVersion(version) ? version : null;
224
+ };
225
+
226
+ /**
227
+ * The bin a package.json beside a script names, resolved — never guessed.
228
+ *
229
+ * `bin` is `{ 'commonly-mcp': 'src/index.js' }` at 0.3.13 and a bare string in
230
+ * older releases, so both shapes are read. A dir whose bin is missing on disk is
231
+ * not a build this seat can run: returning null there is what sends a spawn back
232
+ * to the declared command instead of exec'ing a path that does not exist.
233
+ */
234
+ const readBinInPackageDir = (pkgDir, {
235
+ readFile = defaultRead, exists = defaultExists,
236
+ } = {}) => {
237
+ const raw = readFile(join(pkgDir, 'package.json'));
238
+ if (typeof raw !== 'string') return null;
239
+ let pkg;
240
+ try {
241
+ pkg = JSON.parse(raw);
242
+ } catch {
243
+ return null;
244
+ }
245
+ if (!pkg || typeof pkg !== 'object') return null;
246
+ // A package.json naming another package settles it: this dir is not ours.
247
+ if (pkg.name !== undefined && pkg.name !== MCP_PACKAGE) return null;
248
+ const bin = pkg.bin;
249
+ let relative = null;
250
+ if (typeof bin === 'string' && bin) {
251
+ relative = bin;
252
+ } else if (bin && typeof bin === 'object') {
253
+ const keys = Object.keys(bin).filter((k) => typeof bin[k] === 'string' && bin[k]);
254
+ if (keys.length === 1) [relative] = keys.map((k) => bin[k]);
255
+ else if (typeof bin['commonly-mcp'] === 'string' && bin['commonly-mcp']) relative = bin['commonly-mcp'];
256
+ }
257
+ if (!relative) return null;
258
+ const binPath = join(pkgDir, relative);
259
+ // `join` resolves `..`, so this is the whole confinement. The package is our
260
+ // own published build and today `bin` cannot escape — but a check that costs
261
+ // one line makes the exec path self-evident instead of trusted (Vera, 74880).
262
+ if (!binPath.startsWith(`${pkgDir}${sep}`)) return null;
263
+ return exists(binPath) ? binPath : null;
264
+ };
265
+
266
+ /** The bin of a version dir inside the seat's home. */
267
+ export const readBinPath = (home, version, options = {}) => {
268
+ if (!parseVersion(version)) return null;
269
+ return readBinInPackageDir(packageDirFor(home, version), options);
270
+ };
271
+
272
+ /**
273
+ * The bin of an npm PREFIX dir, which is the shape the warm installs into:
274
+ * `npm install --prefix <dir>` leaves `<dir>/node_modules/...`, with no version
275
+ * component in the path. Reading the two shapes with one function would mean
276
+ * looking for a version dir inside the temp prefix and concluding the install
277
+ * failed when it did not.
278
+ */
279
+ export const readBinInPrefix = (prefix, options = {}) => (
280
+ readBinInPackageDir(join(prefix, 'node_modules', MCP_PACKAGE), options)
281
+ );
282
+
283
+ /**
284
+ * The newest version dir in the home that actually has a runnable bin.
285
+ *
286
+ * The pointer is only written after a verified warm, so it is normally the whole
287
+ * answer. This is the recovery path for the case the pointer cannot cover: the
288
+ * pointed-at version dir deleted under a live seat (hand-pruned, a wiped home,
289
+ * npm falling back to a reinstall). Falling back to the next-newest local build
290
+ * keeps the spawn off npx even then, which is the whole point of the change.
291
+ */
292
+ export const newestInstalledVersion = (home, {
293
+ readDir = (dir) => {
294
+ try {
295
+ return readdirSync(dir);
296
+ } catch {
297
+ return [];
298
+ }
299
+ },
300
+ readBin = (h, v) => readBinPath(h, v),
301
+ } = {}) => {
302
+ const versions = readDir(home)
303
+ .filter((name) => parseVersion(name))
304
+ .filter((name) => readBin(home, name))
305
+ .sort((a, b) => {
306
+ const [am, an, ap] = parseVersion(a);
307
+ const [bm, bn, bp] = parseVersion(b);
308
+ return (am - bm) || (an - bn) || (ap - bp);
309
+ });
310
+ return versions.length ? versions[versions.length - 1] : null;
311
+ };
312
+
313
+ /** True for the shipped, UNPINNED spec — the only command shape this rewrites. */
314
+ const isUnpinnedShippedSpec = (spec) => (
315
+ spec === MCP_PACKAGE || spec === `${MCP_PACKAGE}@latest`
316
+ );
317
+
318
+ /**
319
+ * What a spawn should execute, and where that decision came from.
320
+ *
321
+ * `source: 'home'` — the command is `node <bin>` from the version dir, no npx
322
+ * and no network. `source: 'declared'` — the declaration runs as written, which
323
+ * is either an operator's pinned spec or an EMPTY HOME (fresh host, or a home
324
+ * that was deleted). The empty-home path is the old failure mode and it is
325
+ * accepted, written down, and once per empty home rather than once per publish.
326
+ * `reason` names which of those it was, for the spawn path's logs.
327
+ */
328
+ export const planMcpSpawn = (command, {
329
+ home,
330
+ readFile = defaultRead,
331
+ exists = defaultExists,
332
+ readBin = (h, v) => readBinPath(h, v, { readFile, exists }),
333
+ newest = (h) => newestInstalledVersion(h, { readBin }),
334
+ } = {}) => {
335
+ const target = resolveHomeDir(home);
336
+ if (!isShippedCommonlyMcpCommand(command) || !isUnpinnedShippedSpec(String(command[2]))) {
337
+ return { command, source: 'declared', version: null, reason: 'not-the-shipped-unpinned-spec' };
338
+ }
339
+ const pointer = readCurrentVersion(target, { readFile });
340
+ if (pointer) {
341
+ const bin = readBin(target, pointer);
342
+ if (bin) return { command: ['node', bin], source: 'home', version: pointer, reason: 'pointer' };
343
+ }
344
+ const fallback = newest(target);
345
+ if (fallback) {
346
+ const bin = readBin(target, fallback);
347
+ if (bin) {
348
+ return {
349
+ command: ['node', bin],
350
+ source: 'home',
351
+ version: fallback,
352
+ reason: pointer ? 'pointer-bin-missing' : 'no-pointer',
353
+ };
354
+ }
355
+ }
356
+ return { command, source: 'declared', version: null, reason: 'home-empty' };
357
+ };
358
+
359
+ /** The warm's cached registry answer, with its age. */
360
+ export const readRegistryCache = (home, { readFile = defaultRead } = {}) => {
361
+ const raw = readFile(join(home, REGISTRY_CACHE_NAME));
362
+ if (typeof raw !== 'string') return null;
363
+ try {
364
+ const parsed = JSON.parse(raw);
365
+ const version = parsed && typeof parsed.version === 'string' ? parsed.version : null;
366
+ const checkedAt = parsed && Number.isFinite(parsed.checkedAt) ? parsed.checkedAt : null;
367
+ if (!parseVersion(version) || checkedAt === null) return null;
368
+ return { version, checkedAt };
369
+ } catch {
370
+ return null;
371
+ }
372
+ };
373
+
374
+ /** True when the cached "latest" is newer than what this spawn is about to run. */
375
+ export const warmLooksWanted = (home, version, { now = Date.now(), readFile = defaultRead } = {}) => {
376
+ const cached = readRegistryCache(home, { readFile });
377
+ if (!cached) return true;
378
+ if (now - cached.checkedAt > REGISTRY_TTL_MS) return true;
379
+ if (!version) return true;
380
+ const target = parseVersion(cached.version);
381
+ const running = parseVersion(version);
382
+ if (!target || !running) return true;
383
+ return (target[0] > running[0])
384
+ || (target[0] === running[0] && target[1] > running[1])
385
+ || (target[0] === running[0] && target[1] === running[1] && target[2] > running[2]);
386
+ };
387
+
388
+ export const warmChildPath = () => join(dirname(fileURLToPath(import.meta.url)), 'mcp-warm-child.mjs');
389
+
390
+ /**
391
+ * Start the warm and walk away. Detached + unref'd, so a seat that spawns and
392
+ * exits does not wait on it, and the child is written to never throw at the
393
+ * parent: a warm that fails costs the NEXT spawn nothing but the old version.
394
+ */
395
+ export const kickWarm = ({
396
+ home,
397
+ apiUrl = null,
398
+ spawnImpl = spawn,
399
+ childPath = warmChildPath(),
400
+ } = {}) => {
401
+ try {
402
+ // argv entries are coerced with String(), so an undefined home reaches the
403
+ // child as the literal 'undefined' and it warms a directory by that name.
404
+ const targetHome = resolveHomeDir(home);
405
+ const child = spawnImpl(process.execPath, [childPath, targetHome], {
406
+ detached: true,
407
+ stdio: 'ignore',
408
+ env: {
409
+ ...process.env,
410
+ COMMONLY_MCP_HOME: targetHome,
411
+ ...(apiUrl ? { COMMONLY_WARM_API_URL: String(apiUrl) } : {}),
412
+ },
413
+ });
414
+ if (child && typeof child.unref === 'function') child.unref();
415
+ return true;
416
+ } catch {
417
+ return false;
418
+ }
419
+ };
420
+
421
+ const lockPresent = (home, { exists = defaultExists } = {}) => exists(lockPathFor(home));
422
+
423
+ /**
424
+ * The one entry the adapters call: what to execute, plus a warm if one is due.
425
+ *
426
+ * Sync and network-free by construction — the registry check, the install and
427
+ * the probe all live in the child. A lock already held means another spawn is
428
+ * warming, so this spawn just runs the current version.
429
+ */
430
+ export const prepareMcpSpawn = (command, {
431
+ home,
432
+ apiUrl = null,
433
+ spawnImpl,
434
+ childPath,
435
+ now = Date.now(),
436
+ readFile = defaultRead,
437
+ exists = defaultExists,
438
+ } = {}) => {
439
+ const resolvedHome = resolveHomeDir(home);
440
+ try {
441
+ const plan = planMcpSpawn(command, { home: resolvedHome, readFile, exists });
442
+ // Claim the version this spawn is about to execute from, BEFORE returning the
443
+ // command, so a prune that runs while the seat is starting cannot take it.
444
+ if (plan.source === 'home') claimInUse({ home: resolvedHome, version: plan.version });
445
+ const due = !lockPresent(resolvedHome, { exists })
446
+ && warmLooksWanted(resolvedHome, plan.version, { now, readFile });
447
+ if (due) kickWarm({ home: resolvedHome, apiUrl, spawnImpl, childPath });
448
+ return plan.command;
449
+ } catch {
450
+ // A rewrite that throws must not cost the seat its tools: the declaration
451
+ // is what the guard approved, and it still runs.
452
+ return command;
453
+ }
454
+ };
455
+
456
+ /** Kept for callers that want the plan and the command in one call. */
457
+ export const resolveMcpSpawnCommand = prepareMcpSpawn;
458
+
459
+ export const statOrNull = (path) => {
460
+ try {
461
+ return statSync(path);
462
+ } catch {
463
+ return null;
464
+ }
465
+ };
@@ -51,40 +51,51 @@ export const versionOlderThan = (version, target) => {
51
51
  * `null` as the return value means "not identifiable as our server", which is a
52
52
  * different answer and takes a different branch: a stranger's server gets its
53
53
  * declaration honoured unchanged.
54
+ *
55
+ * The ENTRY SCRIPT is checked first, and that ordering is load-bearing since
56
+ * TASK-174. A seat home runs `node <home>/<version>/node_modules/@commonlyai/mcp/<bin>`,
57
+ * and that path CONTAINS the package name — so a spec-first order matched the
58
+ * path, took the `@` of `@commonlyai/mcp` for the version separator, and returned
59
+ * `{ version: null }` ("unpinned, so never old") for every home build. The
60
+ * version then decided nothing: measured, a home holding 0.3.7 was handed the
61
+ * credential FILE channel, whose reader does not exist before 0.3.12. Nor is
62
+ * "a spec has no slash" a usable rule — a SCOPED spec is `@commonlyai/mcp`.
54
63
  */
55
64
  export const describeMcpCommand = (command, {
56
65
  readTextFile = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : null),
57
66
  } = {}) => {
58
67
  if (!Array.isArray(command) || command.length === 0) return null;
59
68
  const parts = command.map(String);
69
+ const scriptPath = parts.find((p) => p.endsWith('.js') || p.endsWith('.mjs'));
70
+ if (scriptPath) {
71
+ // `src/index.js` → `../package.json`; also try one level further up, because
72
+ // a bin shim can live in `bin/` beside `src/`.
73
+ for (const candidate of [join(dirname(scriptPath), '..', 'package.json'), join(dirname(scriptPath), 'package.json')]) {
74
+ let raw;
75
+ try {
76
+ raw = readTextFile(candidate);
77
+ } catch {
78
+ raw = null;
79
+ }
80
+ if (!raw) continue;
81
+ try {
82
+ const pkg = JSON.parse(raw);
83
+ if (!pkg || typeof pkg !== 'object') continue;
84
+ if (pkg.name === MCP_PACKAGE) return { isCommonly: true, version: parseVersion(pkg.version) };
85
+ // A package.json that names another package settles it: not ours, so its
86
+ // declaration is none of this function's business.
87
+ return null;
88
+ } catch {
89
+ // A malformed package.json is not an answer; keep looking.
90
+ }
91
+ }
92
+ return null;
93
+ }
60
94
  const pkgArg = parts.find((p) => p.includes(MCP_PACKAGE));
61
95
  if (pkgArg) {
62
96
  const at = pkgArg.lastIndexOf('@');
63
97
  if (at <= pkgArg.indexOf(MCP_PACKAGE)) return { isCommonly: true, version: null };
64
98
  return { isCommonly: true, version: parseVersion(pkgArg.slice(at + 1)) };
65
99
  }
66
- const scriptPath = parts.find((p) => p.endsWith('.js') || p.endsWith('.mjs'));
67
- if (!scriptPath) return null;
68
- // `src/index.js` → `../package.json`; also try one level further up, because a
69
- // bin shim can live in `bin/` beside `src/`.
70
- for (const candidate of [join(dirname(scriptPath), '..', 'package.json'), join(dirname(scriptPath), 'package.json')]) {
71
- let raw;
72
- try {
73
- raw = readTextFile(candidate);
74
- } catch {
75
- raw = null;
76
- }
77
- if (!raw) continue;
78
- try {
79
- const pkg = JSON.parse(raw);
80
- if (!pkg || typeof pkg !== 'object') continue;
81
- if (pkg.name === MCP_PACKAGE) return { isCommonly: true, version: parseVersion(pkg.version) };
82
- // A package.json that names another package settles it: not ours, so its
83
- // declaration is none of this function's business.
84
- return null;
85
- } catch {
86
- // A malformed package.json is not an answer; keep looking.
87
- }
88
- }
89
100
  return null;
90
101
  };
@@ -0,0 +1,388 @@
1
+ /**
2
+ * The background warm for the seat MCP home — see mcp-home.js for why.
3
+ *
4
+ * This runs as a DETACHED child, never on a spawn path, because everything it
5
+ * does is slow or networked: a registry lookup, an `npm install`, and an MCP
6
+ * `initialize` handshake against the freshly installed bin. The spawn path only
7
+ * reads a pointer file and exec's `node <bin>`.
8
+ *
9
+ * Order of operations is the contract: install into `.tmp-<version>-<pid>`,
10
+ * PROBE the bin inside that temp dir, and only then `rename` it into
11
+ * `<home>/<version>` and move the pointer. So a version dir in the home is a
12
+ * version that answered the protocol at least once, and a half-installed or
13
+ * non-starting build can never become what seats execute.
14
+ *
15
+ * Single-flight by lock file (O_EXCL), so concurrent spawns on a fresh host
16
+ * trigger exactly one install. A lock older than STALE_LOCK_MS is a warm that
17
+ * died (a laptop sleeping mid-install is the normal way) and is reclaimed rather
18
+ * than allowed to wedge the home forever.
19
+ *
20
+ * The probe's env is measured, not assumed: at 0.3.13 the bin exits 1 with
21
+ * "[commonly-mcp] fatal: COMMONLY_API_URL is required" and, with that set, exits
22
+ * 1 again on "No runtime token". With both present as PLACEHOLDERS it answers
23
+ * `initialize` with a result — the boot check is presence-only and `initialize`
24
+ * does not touch the network (both measured on this host 2026-09-27), which is
25
+ * what makes a credential-free probe honest rather than a stub.
26
+ */
27
+ import { spawn as spawnChild } from 'node:child_process';
28
+ import {
29
+ closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync,
30
+ } from 'node:fs';
31
+ import { join } from 'node:path';
32
+ import {
33
+ KEEP_VERSION_DIRS, PRUNE_GRACE_MS, REGISTRY_CACHE_NAME, REGISTRY_TTL_MS, STALE_LOCK_MS,
34
+ WARM_RESULTS, currentPointerPath, isUsableWarmHome, liveInUsePids, lockPathFor,
35
+ readBinInPrefix, versionDirFor,
36
+ } from './mcp-home.js';
37
+ import { MCP_PACKAGE, parseVersion } from './mcp-server-version.js';
38
+
39
+ export const PROBE_API_URL_PLACEHOLDER = 'http://127.0.0.1:1';
40
+ export const PROBE_TOKEN_PLACEHOLDER = 'cm_agent_warm_probe';
41
+ export const PROBE_TIMEOUT_MS = 10 * 1000;
42
+ export const REGISTRY_TIMEOUT_MS = 20 * 1000;
43
+
44
+ const INITIALIZE_REQUEST = JSON.stringify({
45
+ jsonrpc: '2.0',
46
+ id: 1,
47
+ method: 'initialize',
48
+ params: {
49
+ protocolVersion: '2024-11-05',
50
+ capabilities: {},
51
+ clientInfo: { name: 'commonly-cli-warm', version: '1' },
52
+ },
53
+ });
54
+
55
+ const exists = (path) => {
56
+ try {
57
+ return existsSync(path);
58
+ } catch {
59
+ return false;
60
+ }
61
+ };
62
+
63
+ const readIfAny = (path) => {
64
+ try {
65
+ return existsSync(path) ? readFileSync(path, 'utf8') : null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ };
70
+
71
+ /**
72
+ * Take the warm lock, reclaiming one that is too old to belong to a live warm.
73
+ *
74
+ * Returns the open fd, or null when another process holds a fresh lock.
75
+ */
76
+ export const acquireWarmLock = (home, {
77
+ now = Date.now(),
78
+ open = (path) => openSync(path, 'wx'),
79
+ stat = (path) => statSync(path),
80
+ remove = (path) => unlinkSync(path),
81
+ staleMs = STALE_LOCK_MS,
82
+ } = {}) => {
83
+ const lockPath = lockPathFor(home);
84
+ try {
85
+ return open(lockPath);
86
+ } catch (error) {
87
+ if (error && error.code !== 'EEXIST') return null;
88
+ }
89
+ let age;
90
+ try {
91
+ age = now - stat(lockPath).mtimeMs;
92
+ } catch {
93
+ return null; // vanished between EEXIST and stat: not ours to break
94
+ }
95
+ if (age <= staleMs) return null;
96
+ try {
97
+ remove(lockPath);
98
+ } catch {
99
+ return null;
100
+ }
101
+ try {
102
+ return open(lockPath);
103
+ } catch {
104
+ // Lost the reclaim race to another spawn's warm. Fine: that one is working.
105
+ return null;
106
+ }
107
+ };
108
+
109
+ /** Default `npm` runner. Resolves `{ ok, stdout }`; never rejects. */
110
+ export const runCommand = (bin, args, { timeout = REGISTRY_TIMEOUT_MS } = {}) => new Promise((resolve) => {
111
+ let settled = false;
112
+ let child;
113
+ const done = (result) => {
114
+ if (settled) return;
115
+ settled = true;
116
+ resolve(result);
117
+ };
118
+ try {
119
+ child = spawnChild(bin, args, { stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
120
+ } catch (error) {
121
+ done({ ok: false, stdout: '', stderr: String(error && error.message) });
122
+ return;
123
+ }
124
+ let stdout = '';
125
+ let stderr = '';
126
+ child.stdout.on('data', (d) => { stdout += String(d); });
127
+ child.stderr.on('data', (d) => { stderr += String(d); });
128
+ const timer = setTimeout(() => {
129
+ try {
130
+ child.kill('SIGKILL');
131
+ } catch { /* already gone */ }
132
+ done({ ok: false, stdout, stderr: `${stderr}\n[commonly] timed out after ${timeout}ms` });
133
+ }, timeout);
134
+ child.on('error', (error) => { clearTimeout(timer); done({ ok: false, stdout, stderr: String(error && error.message) }); });
135
+ child.on('close', (code) => { clearTimeout(timer); done({ ok: code === 0, code, stdout, stderr }); });
136
+ });
137
+
138
+ /** The registry's current version for the package, cached on disk with a TTL. */
139
+ export const resolveRegistryLatest = async (home, {
140
+ now = Date.now(),
141
+ exec = runCommand,
142
+ ttlMs = REGISTRY_TTL_MS,
143
+ writeFile = (path, text) => writeFileSync(path, text),
144
+ } = {}) => {
145
+ const cachePath = join(home, REGISTRY_CACHE_NAME);
146
+ const cached = readIfAny(cachePath);
147
+ if (cached) {
148
+ try {
149
+ const parsed = JSON.parse(cached);
150
+ if (parsed && Number.isFinite(parsed.checkedAt) && now - parsed.checkedAt < ttlMs
151
+ && parseVersion(parsed.version)) {
152
+ return { version: parsed.version, source: 'cache' };
153
+ }
154
+ } catch { /* unreadable cache is just a miss */ }
155
+ }
156
+ const result = await exec('npm', ['view', MCP_PACKAGE, 'version', '--json'], { timeout: REGISTRY_TIMEOUT_MS });
157
+ const raw = result && result.ok ? String(result.stdout || '').trim() : '';
158
+ let version = null;
159
+ if (raw) {
160
+ try {
161
+ const parsed = JSON.parse(raw);
162
+ version = typeof parsed === 'string' ? parsed : null;
163
+ } catch {
164
+ version = raw.replace(/^"|"$/g, '');
165
+ }
166
+ }
167
+ if (version && parseVersion(version)) {
168
+ try {
169
+ writeFile(cachePath, JSON.stringify({ version, checkedAt: now }));
170
+ } catch { /* a cache we cannot write is not a warm we should fail */ }
171
+ return { version, source: 'registry' };
172
+ }
173
+ // Registry down: the last known answer is better than none, and better than
174
+ // moving the pointer backwards.
175
+ if (cached) {
176
+ try {
177
+ const parsed = JSON.parse(cached);
178
+ if (parseVersion(parsed.version)) return { version: parsed.version, source: 'stale-cache' };
179
+ } catch { /* fall through */ }
180
+ }
181
+ return { version: null, source: 'unavailable' };
182
+ };
183
+
184
+ /**
185
+ * Does the installed bin start and speak the protocol?
186
+ *
187
+ * Accepts a JSON-RPC response to id 1 whether it is a `result` or an `error`:
188
+ * what this proves is that the build runs and answers `initialize`, which is
189
+ * exactly what the pointer's contract claims. It is not an authorization check —
190
+ * the seat's own token is what makes calls work.
191
+ */
192
+ export const probeBin = (binPath, {
193
+ spawnImpl = spawnChild,
194
+ timeoutMs = PROBE_TIMEOUT_MS,
195
+ apiUrl = process.env.COMMONLY_WARM_API_URL || PROBE_API_URL_PLACEHOLDER,
196
+ } = {}) => new Promise((resolve) => {
197
+ let settled = false;
198
+ let child;
199
+ const done = (ok) => {
200
+ if (settled) return;
201
+ settled = true;
202
+ try {
203
+ if (child) child.kill('SIGKILL');
204
+ } catch { /* already gone */ }
205
+ resolve(ok);
206
+ };
207
+ try {
208
+ child = spawnImpl(process.execPath, [binPath], {
209
+ stdio: ['pipe', 'pipe', 'pipe'],
210
+ env: {
211
+ PATH: process.env.PATH,
212
+ HOME: process.env.HOME,
213
+ COMMONLY_API_URL: String(apiUrl),
214
+ COMMONLY_AGENT_TOKEN: PROBE_TOKEN_PLACEHOLDER,
215
+ },
216
+ });
217
+ } catch {
218
+ resolve(false);
219
+ return;
220
+ }
221
+ const timer = setTimeout(() => done(false), timeoutMs);
222
+ let seen = '';
223
+ const onData = (chunk) => {
224
+ seen += String(chunk);
225
+ if (/"id"\s*:\s*1/.test(seen)) {
226
+ clearTimeout(timer);
227
+ done(true);
228
+ }
229
+ };
230
+ child.stdout.on('data', onData);
231
+ child.on('error', () => { clearTimeout(timer); done(false); });
232
+ child.on('close', () => { clearTimeout(timer); done(false); });
233
+ try {
234
+ child.stdin.write(`${INITIALIZE_REQUEST}\n`);
235
+ } catch {
236
+ clearTimeout(timer);
237
+ done(false);
238
+ }
239
+ });
240
+
241
+ const writePointerAtomic = (home, version, { writeTemp = writeFileSync } = {}) => {
242
+ const target = currentPointerPath(home);
243
+ const temp = join(home, `.current-${process.pid}`);
244
+ writeTemp(temp, `${version}\n`);
245
+ renameSync(temp, target);
246
+ };
247
+
248
+ /** Keep the newest N version dirs; the current one is never a candidate. */
249
+ export const pruneVersionDirs = (home, current, {
250
+ keep = KEEP_VERSION_DIRS,
251
+ grace = PRUNE_GRACE_MS,
252
+ now = Date.now(),
253
+ readDir = (dir) => {
254
+ try {
255
+ return readdirSync(dir);
256
+ } catch {
257
+ return [];
258
+ }
259
+ },
260
+ remove = (path) => rmSync(path, { recursive: true, force: true }),
261
+ mtime = (path) => {
262
+ try {
263
+ return statSync(path).mtimeMs;
264
+ } catch {
265
+ return null;
266
+ }
267
+ },
268
+ livePids = (version) => liveInUsePids(home, version),
269
+ } = {}) => {
270
+ const versions = readDir(home).filter((name) => parseVersion(name));
271
+ versions.sort((a, b) => {
272
+ const [am, an, ap] = parseVersion(a);
273
+ const [bm, bn, bp] = parseVersion(b);
274
+ return (bm - am) || (bn - an) || (bp - ap);
275
+ });
276
+ const removed = [];
277
+ versions.slice(keep).forEach((name) => {
278
+ if (name === current) return;
279
+ // A live claimant outranks the count: this dir may be executing right now.
280
+ if (livePids(name).length > 0) return;
281
+ // And time outranks the count too — a dir inside the grace window may belong
282
+ // to a spawn that has not written its claim yet. An unreadable/absent mtime
283
+ // is not evidence of age, so it is left alone.
284
+ const installedAt = mtime(versionDirFor(home, name));
285
+ if (typeof installedAt !== 'number' || now - installedAt < grace) return;
286
+ try {
287
+ remove(versionDirFor(home, name));
288
+ removed.push(name);
289
+ } catch { /* a dir we cannot remove is not a failed warm */ }
290
+ });
291
+ return removed;
292
+ };
293
+
294
+ /**
295
+ * The whole warm, in one function so a test can drive every branch with injected
296
+ * I/O. Returns one of WARM_RESULTS — never throws, because it runs unattended.
297
+ */
298
+ export const warmMcpHome = async (home, {
299
+ now = Date.now(),
300
+ exec = runCommand,
301
+ probe = probeBin,
302
+ spawnProbe,
303
+ lock = () => acquireWarmLock(home, { now }),
304
+ closeLock = (fd) => closeSync(fd),
305
+ mkdir = (dir) => mkdirSync(dir, { recursive: true }),
306
+ remove = (path) => rmSync(path, { recursive: true, force: true }),
307
+ rename = (from, to) => renameSync(from, to),
308
+ readBin = (prefix) => readBinInPrefix(prefix),
309
+ readCurrent = (h) => {
310
+ const raw = readIfAny(currentPointerPath(h));
311
+ if (typeof raw !== 'string') return null;
312
+ const trimmed = raw.trim();
313
+ return parseVersion(trimmed) ? trimmed : null;
314
+ },
315
+ keep = KEEP_VERSION_DIRS,
316
+ } = {}) => {
317
+ mkdir(home);
318
+ const fd = lock();
319
+ if (fd === null) return WARM_RESULTS.LOCKED;
320
+ try {
321
+ const latest = await resolveRegistryLatest(home, { now, exec });
322
+ if (!latest.version) return WARM_RESULTS.REGISTRY_UNAVAILABLE;
323
+ const target = latest.version;
324
+ const current = readCurrent(home);
325
+ if (current && current === target) return WARM_RESULTS.CURRENT;
326
+ if (current && parseVersion(current) && parseVersion(target)) {
327
+ const [tm, tn, tp] = parseVersion(target);
328
+ const [cm, cn, cp] = parseVersion(current);
329
+ if ((tm < cm) || (tm === cm && tn < cn) || (tm === cm && tn === cn && tp <= cp)) {
330
+ return WARM_RESULTS.CURRENT;
331
+ }
332
+ }
333
+ const temp = join(home, `.tmp-${target}-${process.pid}`);
334
+ remove(temp);
335
+ mkdir(temp);
336
+ const install = await exec('npm', [
337
+ 'install', '--prefix', temp, `${MCP_PACKAGE}@${target}`, '--no-save', '--no-audit', '--no-fund',
338
+ ], { timeout: 5 * 60 * 1000 });
339
+ if (!install || !install.ok) {
340
+ remove(temp);
341
+ return WARM_RESULTS.INSTALL_FAILED;
342
+ }
343
+ const bin = readBin(temp);
344
+ if (!bin) {
345
+ remove(temp);
346
+ return WARM_RESULTS.INSTALL_FAILED;
347
+ }
348
+ const started = await (spawnProbe || probe)(bin, { apiUrl: process.env.COMMONLY_WARM_API_URL });
349
+ if (!started) {
350
+ remove(temp);
351
+ return WARM_RESULTS.PROBE_FAILED;
352
+ }
353
+ const destination = versionDirFor(home, target);
354
+ if (exists(destination)) remove(temp);
355
+ else rename(temp, destination);
356
+ writePointerAtomic(home, target);
357
+ pruneVersionDirs(home, target, { keep });
358
+ return WARM_RESULTS.ADVANCED;
359
+ } catch {
360
+ return WARM_RESULTS.INSTALL_FAILED;
361
+ } finally {
362
+ try {
363
+ closeLock(fd);
364
+ } catch { /* nothing to close */ }
365
+ try {
366
+ rmSync(lockPathFor(home), { force: true });
367
+ } catch { /* lock is best-effort cleanup */ }
368
+ }
369
+ };
370
+
371
+ const invokedDirectly = process.argv[1] && /mcp-warm-child\.mjs$/.test(process.argv[1]);
372
+ if (invokedDirectly) {
373
+ // Validated, not trusted: argv entries are coerced with String(), so a parent
374
+ // that passed an undefined home sends the literal 'undefined' and this child
375
+ // would otherwise create and populate a directory by that name (which is how
376
+ // one appeared inside the package directory on 2026-09-27).
377
+ const home = process.argv[2];
378
+ if (isUsableWarmHome(home)) {
379
+ warmMcpHome(home, { now: Date.now() })
380
+ .then((result) => {
381
+ // A warm runs with stdio ignored; this is for a human running it by hand
382
+ // and for `--verbose` debugging, and it must never be on a spawn path.
383
+ if (process.env.COMMONLY_WARM_VERBOSE) process.stdout.write(`${result}\n`);
384
+ process.exitCode = 0;
385
+ })
386
+ .catch(() => { process.exitCode = 0; });
387
+ }
388
+ }