@ravefox/space 0.2.0 → 0.3.0

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 (163) hide show
  1. package/LICENSE +21 -203
  2. package/README.md +89 -26
  3. package/bin/space.js +106 -0
  4. package/lib/agents.js +119 -0
  5. package/lib/credentials.js +95 -0
  6. package/lib/daemon.js +294 -0
  7. package/lib/harness.js +166 -0
  8. package/lib/http.js +101 -0
  9. package/lib/login.js +68 -0
  10. package/lib/mcp.js +34 -0
  11. package/lib/runtime.js +241 -0
  12. package/lib/store.js +96 -0
  13. package/lib/tools.js +93 -0
  14. package/package.json +28 -40
  15. package/NOTICE +0 -44
  16. package/assets/webhook-sdk/python/bot.py +0 -63
  17. package/assets/webhook-sdk/python/commonly.py +0 -232
  18. package/node_modules/@commonlyai/seats/README.md +0 -25
  19. package/node_modules/@commonlyai/seats/package.json +0 -22
  20. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
  21. package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
  22. package/node_modules/@commonlyai/seats/src/contract.js +0 -48
  23. package/node_modules/@commonlyai/seats/src/failures.js +0 -44
  24. package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
  25. package/node_modules/@commonlyai/seats/src/index.js +0 -22
  26. package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
  27. package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
  28. package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
  29. package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
  30. package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
  31. package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
  32. package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
  33. package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
  34. package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
  35. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
  36. package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
  37. package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
  38. package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
  39. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
  40. package/skills/commonly/SKILL.md +0 -233
  41. package/src/commands/agent.js +0 -6334
  42. package/src/commands/daemon.js +0 -933
  43. package/src/commands/dev.js +0 -153
  44. package/src/commands/doctor.js +0 -141
  45. package/src/commands/inbox.js +0 -330
  46. package/src/commands/login.js +0 -194
  47. package/src/commands/mcp.js +0 -56
  48. package/src/commands/pod.js +0 -120
  49. package/src/commands/task.js +0 -279
  50. package/src/commands/verify.js +0 -48
  51. package/src/index.js +0 -99
  52. package/src/lib/adapter-login.js +0 -76
  53. package/src/lib/adapters/broker-bridge-entry.js +0 -76
  54. package/src/lib/adapters/claude.js +0 -2207
  55. package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
  56. package/src/lib/adapters/codex.js +0 -1898
  57. package/src/lib/adapters/dsh.js +0 -513
  58. package/src/lib/adapters/goose.js +0 -510
  59. package/src/lib/adapters/hermes.js +0 -18
  60. package/src/lib/adapters/index.js +0 -68
  61. package/src/lib/adapters/opencode.js +0 -1681
  62. package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
  63. package/src/lib/adapters/pi-mcp-client.mjs +0 -617
  64. package/src/lib/adapters/pi-shell-env.mjs +0 -41
  65. package/src/lib/adapters/pi.js +0 -1025
  66. package/src/lib/adapters/qwen.js +0 -523
  67. package/src/lib/adapters/seat-model.js +0 -8
  68. package/src/lib/adapters/stub.js +0 -1
  69. package/src/lib/adapters/tested-version.js +0 -106
  70. package/src/lib/api.js +0 -205
  71. package/src/lib/claim-outcome.js +0 -128
  72. package/src/lib/claimed-work-gate.js +0 -73
  73. package/src/lib/cli-hint.js +0 -33
  74. package/src/lib/commit-attribution.js +0 -391
  75. package/src/lib/config.js +0 -155
  76. package/src/lib/content-refusal.js +0 -94
  77. package/src/lib/credential-file.js +0 -208
  78. package/src/lib/daemon-agent-states.js +0 -95
  79. package/src/lib/daemon-hint.js +0 -18
  80. package/src/lib/daemon-logs.js +0 -43
  81. package/src/lib/daemon-service-windows.js +0 -263
  82. package/src/lib/daemon-service.js +0 -486
  83. package/src/lib/daemon-state.js +0 -84
  84. package/src/lib/daemon-store.js +0 -119
  85. package/src/lib/daemon-supervisor.js +0 -629
  86. package/src/lib/daemon-watch.js +0 -115
  87. package/src/lib/declared-mcp-guard.js +0 -284
  88. package/src/lib/default-environment.js +0 -207
  89. package/src/lib/device-login.js +0 -126
  90. package/src/lib/digest-frame.js +0 -151
  91. package/src/lib/doctor.js +0 -291
  92. package/src/lib/egress-proxy.js +0 -160
  93. package/src/lib/enforcement.js +0 -946
  94. package/src/lib/environment.js +0 -681
  95. package/src/lib/first-output-watchdog.js +0 -191
  96. package/src/lib/git-readonly-server.js +0 -138
  97. package/src/lib/git-readonly.js +0 -463
  98. package/src/lib/git-remote-commonly.mjs +0 -100
  99. package/src/lib/grant-broker-guard.js +0 -345
  100. package/src/lib/hooks-config.js +0 -238
  101. package/src/lib/host-gh-broker.js +0 -152
  102. package/src/lib/host-gh-shim.mjs +0 -98
  103. package/src/lib/inbox.js +0 -483
  104. package/src/lib/instance-proxy.js +0 -76
  105. package/src/lib/machine-host.js +0 -29
  106. package/src/lib/mcp-credential-delivery.js +0 -121
  107. package/src/lib/mcp-first-run.js +0 -50
  108. package/src/lib/mcp-home.js +0 -644
  109. package/src/lib/mcp-ready.js +0 -68
  110. package/src/lib/mcp-server-version.js +0 -123
  111. package/src/lib/mcp-token.js +0 -36
  112. package/src/lib/mcp-warm-child.mjs +0 -412
  113. package/src/lib/memory-bridge.js +0 -191
  114. package/src/lib/memory-import.js +0 -156
  115. package/src/lib/missing-harness.js +0 -31
  116. package/src/lib/model-unavailable.js +0 -154
  117. package/src/lib/native-runtime-inventory.js +0 -40
  118. package/src/lib/outsider-grant-mcp.js +0 -82
  119. package/src/lib/own-key-launch.js +0 -42
  120. package/src/lib/pod-catch-up.js +0 -55
  121. package/src/lib/pod-focus.js +0 -224
  122. package/src/lib/pod-tail.js +0 -68
  123. package/src/lib/poll-liveness.js +0 -103
  124. package/src/lib/poll-retry.js +0 -81
  125. package/src/lib/poller.js +0 -200
  126. package/src/lib/provider-proxy.js +0 -42
  127. package/src/lib/public-seat-mcp-flag.js +0 -36
  128. package/src/lib/public-tool-error.js +0 -60
  129. package/src/lib/public-withheld-tools.js +0 -48
  130. package/src/lib/reply-receipt.js +0 -55
  131. package/src/lib/resolve-on-path.js +0 -22
  132. package/src/lib/runtime-inventory.js +0 -56
  133. package/src/lib/runtime-observation.js +0 -37
  134. package/src/lib/sandbox/bwrap.js +0 -275
  135. package/src/lib/sandbox/codex-perimeter.js +0 -16
  136. package/src/lib/sandbox/host-check.js +0 -268
  137. package/src/lib/sandbox/mode.js +0 -49
  138. package/src/lib/sandbox/opencode-loopback.mjs +0 -68
  139. package/src/lib/sandbox/seatbelt.js +0 -684
  140. package/src/lib/sandbox/turn-dir.js +0 -56
  141. package/src/lib/sandbox/windows-unsandboxed.js +0 -183
  142. package/src/lib/sandbox/workspace-git-config.js +0 -243
  143. package/src/lib/sandbox/workspace-pin.js +0 -158
  144. package/src/lib/seat-runtime-controls.js +0 -61
  145. package/src/lib/seat-stop.js +0 -97
  146. package/src/lib/server-skills.js +0 -32
  147. package/src/lib/session-recycle.js +0 -219
  148. package/src/lib/session-store.js +0 -215
  149. package/src/lib/skill-prompt.js +0 -74
  150. package/src/lib/skills-import.js +0 -113
  151. package/src/lib/spawn-credential.js +0 -402
  152. package/src/lib/spawn-retry.js +0 -311
  153. package/src/lib/stable-install.js +0 -176
  154. package/src/lib/supervised-turn.js +0 -352
  155. package/src/lib/task.js +0 -235
  156. package/src/lib/turn-failure-notice.js +0 -177
  157. package/src/lib/unconfined-seat-guard.js +0 -94
  158. package/src/lib/update-check.js +0 -192
  159. package/src/lib/upstream-refusal.js +0 -1
  160. package/src/lib/webhook-server.js +0 -109
  161. package/src/lib/windows-command.js +0 -393
  162. package/src/lib/workspace-git.js +0 -87
  163. package/src/lib/workspace-trust.js +0 -162
@@ -1,644 +0,0 @@
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
- * The cold path is pinned too (TASK-174's second half). A spawn with no usable
31
- * home still runs the declaration, but not with the shared `@latest` spec: the
32
- * spec this spawn executes is the version the registry already answered with,
33
- * read from the warm's own cache, so `@latest`'s one shared npx dir is not on
34
- * the spawn path even once. The fallback is the declaration as written, and it
35
- * is used exactly when nothing has ever resolved a version on this host — a
36
- * fresh machine's first spawn, where `@latest` is also the only spec that could
37
- * work — or when the cache is unreadable. Nothing here throws.
38
- *
39
- * Every filesystem answer here is injected, because the two states that matter
40
- * (a home that has never been warmed, and a home whose pointed-at version dir was
41
- * deleted under it) cannot both be produced on one host.
42
- */
43
- import { spawn } from 'node:child_process';
44
- import {
45
- existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync,
46
- } from 'node:fs';
47
- import { homedir } from 'node:os';
48
- import { dirname, join, relative, sep } from 'node:path';
49
- import { fileURLToPath } from 'node:url';
50
- import { isShippedCommonlyMcpCommand } from './declared-mcp-guard.js';
51
- import { MCP_PACKAGE, exactVersion, parseVersion } from './mcp-server-version.js';
52
-
53
- /** The env override, so tests (and an operator) can point the home elsewhere. */
54
- export const MCP_HOME_ENV = 'COMMONLY_MCP_HOME';
55
- /**
56
- * TASK-273: a public seat whose MCP server has no host-prepared build does not
57
- * run the turn. Spawning without its tools left the model nothing to do but
58
- * narrate the failure, and the run loop posted that narration to a stranger
59
- * (Commonly HQ, 2026-10-15: "The commonly MCP server failed to connect… check
60
- * the MCP server config"). Refusing before the model call posts nothing: the
61
- * turn fails, the event stays unacked and is retried once the warm finishes,
62
- * and the seat log names the reason. Every adapter raises this one error.
63
- */
64
- export const PUBLIC_MCP_UNAVAILABLE_CODE = 'public_mcp_unavailable';
65
- export const publicMcpUnavailableError = (names) => {
66
- const error = new Error(
67
- `public seat refused this turn: MCP server ${names.join(', ')} has no host-prepared build yet, `
68
- + 'so the model was not run and nothing was posted',
69
- );
70
- error.code = PUBLIC_MCP_UNAVAILABLE_CODE;
71
- return error;
72
- };
73
-
74
- export const WARM_LOCK_NAME = '.warm.lock';
75
- export const REGISTRY_CACHE_NAME = '.registry.json';
76
-
77
- /** How long a cached registry answer is trusted before the warm re-checks. */
78
- export const REGISTRY_TTL_MS = 15 * 60 * 1000;
79
-
80
- /** A lock older than this is a warm that died; reclaim rather than wedge. */
81
- export const STALE_LOCK_MS = 10 * 60 * 1000;
82
-
83
- /**
84
- * Version dirs kept after a successful advance.
85
- *
86
- * This is a DISK BOUND, not the safety rule. Retention by count cannot express
87
- * "not while someone is running it": keeping the newest three protects the
88
- * newest three, and a seat still executing the fourth-newest dir is exactly the
89
- * case that matters. A DIR IS REMOVABLE only when it is not `current`, carries
90
- * no LIVE liveness claim, and is past `PRUNE_GRACE_MS` (Vera, 74878/74887).
91
- */
92
- export const KEEP_VERSION_DIRS = 3;
93
-
94
- /**
95
- * A version dir younger than this is never pruned, even with no live claimant.
96
- *
97
- * Time, not count, is what bounds the exposure: a dir inside the window may
98
- * belong to a spawn that is starting up and has not written its claim yet, or
99
- * whose claim was written by a pid the kernel has since recycled.
100
- */
101
- export const PRUNE_GRACE_MS = 24 * 60 * 60 * 1000;
102
-
103
- /** Where a spawn records that a process may still be executing from a version. */
104
- export const IN_USE_DIR_NAME = '.inuse';
105
-
106
- /** The warm child's own exit codes, read by the spawn path's logs. */
107
- export const WARM_RESULTS = Object.freeze({
108
- LOCKED: 'locked',
109
- CURRENT: 'current',
110
- ADVANCED: 'advanced',
111
- REGISTRY_UNAVAILABLE: 'registry-unavailable',
112
- INSTALL_FAILED: 'install-failed',
113
- PROBE_FAILED: 'probe-failed',
114
- });
115
-
116
- export const mcpHomeDir = (env = process.env) => {
117
- const configured = env && env[MCP_HOME_ENV];
118
- // A DEFECT THIS LITERALLY PREVENTED, 2026-09-27: `process.env.X = undefined`
119
- // does not unset X, it sets the STRING "undefined" (Node coerces env values),
120
- // so a caller that saved and restored an unset override handed the warm the
121
- // home `./undefined/` — which it created, populated and pointed at, inside the
122
- // package directory. This value is a PATH; a value that is not a plausible
123
- // path is not a home, and the real home is the safe answer.
124
- if (typeof configured === 'string' && configured && configured !== 'undefined' && configured !== 'null') {
125
- return configured;
126
- }
127
- return join(homedir(), '.commonly', 'mcp');
128
- };
129
-
130
- /** True for a home a warm may write into: a real, absolute-looking path. */
131
- export const isUsableWarmHome = (home) => (
132
- typeof home === 'string'
133
- && home.length > 1
134
- && home !== 'undefined'
135
- && home !== 'null'
136
- && (home.startsWith('/') || /^[A-Za-z]:[\\/]/.test(home))
137
- );
138
-
139
- /** The home a caller asked for, or the configured one — never a junk string. */
140
- export const resolveHomeDir = (home) => (isUsableWarmHome(home) ? home : mcpHomeDir());
141
-
142
- export const currentPointerPath = (home) => join(home, 'current');
143
-
144
- /**
145
- * Where a version dir name that is not a triple lands: inside the home, and
146
- * never a version (a version starts with a digit), so every read of it misses
147
- * and the spawn falls back to its declaration. The alternative — building the
148
- * path from whatever string arrived — is how `0.3.13/../../../../tmp/x` resolved
149
- * outside the home, and #1970's `startsWith(pkgDir)` confinement is computed
150
- * FROM that dir, so it would have confined to the wrong place (Vera, 74938).
151
- */
152
- const UNUSABLE_VERSION_DIR = '.unusable-version';
153
-
154
- /**
155
- * A version dir, built from the parsed triple — and only when the string given
156
- * IS that triple.
157
- *
158
- * Refusing beats rebuilding here: this result is what the warm RENAMES into and
159
- * what the pruner REMOVES, so silently rewriting `0.3.14-rc.1` to `0.3.14` would
160
- * aim both at a different build. Normalising belongs at the producer of a
161
- * version value (`resolveRegistryLatest`, `readCurrentVersion`,
162
- * `readRegistryCache`, `pinShippedSpec`), where the choice is deliberate and
163
- * visible; a producer that names something else gets a name no version can have.
164
- */
165
- export const versionDirFor = (home, version) => {
166
- const triple = exactVersion(version);
167
- const name = triple && triple === String(version).trim() ? triple : UNUSABLE_VERSION_DIR;
168
- return join(home, name);
169
- };
170
- export const packageDirFor = (home, version) => join(versionDirFor(home, version), 'node_modules', MCP_PACKAGE);
171
- export const lockPathFor = (home) => join(home, WARM_LOCK_NAME);
172
-
173
- /** `<home>/<version>/.inuse/` — one empty file per claiming pid. */
174
- export const inUseDirFor = (home, version) => join(versionDirFor(home, version), IN_USE_DIR_NAME);
175
- export const inUseMarkerPath = (home, version, pid) => join(inUseDirFor(home, version), String(pid));
176
-
177
- /** A pid is a positive integer, or it is not a pid. */
178
- const parsePid = (value) => {
179
- const pid = Number(value);
180
- return Number.isInteger(pid) && pid > 0 ? pid : null;
181
- };
182
-
183
- /**
184
- * Is this pid still running? `kill(pid, 0)` asks the kernel and sends nothing.
185
- *
186
- * EPERM means the pid exists and belongs to another user — alive. ESRCH means
187
- * gone — dead. Anything else, including a value that was never a pid, is DEAD,
188
- * because the two errors are not symmetric: guessing "alive" pins a version dir
189
- * forever (unbounded disk with extra steps), while guessing "dead" costs one
190
- * reinstall on a later warm.
191
- */
192
- export const isPidAlive = (pid, { kill = process.kill } = {}) => {
193
- const parsed = parsePid(pid);
194
- if (parsed === null) return false;
195
- try {
196
- kill(parsed, 0);
197
- return true;
198
- } catch (error) {
199
- return Boolean(error) && error.code === 'EPERM';
200
- }
201
- };
202
-
203
- /**
204
- * The claimants under a version dir that are still running.
205
- *
206
- * The marker's PRESENCE is not the signal — a seat that is killed or crashes
207
- * leaves its file behind, and a pruner that read presence would never remove
208
- * that version again. The check is whether the claimant still exists; a dead or
209
- * unparseable marker counts as absent.
210
- */
211
- export const liveInUsePids = (home, version, {
212
- readDir = (dir) => {
213
- try {
214
- return readdirSync(dir);
215
- } catch {
216
- return [];
217
- }
218
- },
219
- isAlive = isPidAlive,
220
- } = {}) => readDir(inUseDirFor(home, version))
221
- .map(parsePid)
222
- .filter((pid) => pid !== null && isAlive(pid));
223
-
224
- /**
225
- * Claim a version dir for a process that may be executing from it.
226
- *
227
- * Called by `prepareMcpSpawn`, keyed by THIS process: the MCP server runs as a
228
- * descendant of the process that materialised the command, so while that process
229
- * lives its version dir must not be pruned. A claim that cannot be written is
230
- * reported, never swallowed: `prepareMcpSpawn` refuses to exec from a dir it
231
- * could not claim (Vera 74903), because an unclaimed dir is one the pruner is
232
- * free to `rm -rf` while this seat is running out of it.
233
- */
234
- export const claimInUse = ({
235
- home,
236
- version,
237
- pid = process.pid,
238
- mkdir = mkdirSync,
239
- writeFile = writeFileSync,
240
- } = {}) => {
241
- try {
242
- if (!isUsableWarmHome(home) || !parseVersion(version) || parsePid(pid) === null) {
243
- return { claimed: false, reason: 'not_claimable' };
244
- }
245
- mkdir(inUseDirFor(home, version), { recursive: true });
246
- writeFile(inUseMarkerPath(home, version, pid), '');
247
- return { claimed: true, reason: null };
248
- } catch (error) {
249
- // The errno is the difference between a home nobody can write to and a
250
- // `.inuse` path that is a file, and only one of those is worth a log line
251
- // that says which.
252
- return { claimed: false, reason: (error && error.code) || 'claim_failed' };
253
- }
254
- };
255
-
256
- /**
257
- * Say it out loud. A seat whose claim failed is still a seat, but it must not be
258
- * a silent one: warn() is injectable so a test can hold the sentence.
259
- */
260
- const defaultWarn = (line) => {
261
- try {
262
- process.stderr.write(`${line}\n`);
263
- } catch { /* a seat that cannot warn still runs */ }
264
- };
265
-
266
- const defaultRead = (path) => {
267
- try {
268
- return existsSync(path) ? readFileSync(path, 'utf8') : null;
269
- } catch {
270
- return null;
271
- }
272
- };
273
-
274
- const defaultExists = (path) => {
275
- try {
276
- return existsSync(path);
277
- } catch {
278
- return false;
279
- }
280
- };
281
-
282
- /**
283
- * The version the pointer file names, or null.
284
- *
285
- * A pointer that does not parse is treated as no pointer at all: a spawn must
286
- * never build a path out of a string it did not validate, and the fallback is
287
- * the declared command, not a guess.
288
- */
289
- export const readCurrentVersion = (home, { readFile = defaultRead } = {}) => {
290
- const raw = readFile(currentPointerPath(home));
291
- if (typeof raw !== 'string') return null;
292
- const version = exactVersion(raw.trim());
293
- return version || null;
294
- };
295
-
296
- /**
297
- * The bin a package.json beside a script names, resolved — never guessed.
298
- *
299
- * `bin` is `{ 'commonly-mcp': 'src/index.js' }` at 0.3.13 and a bare string in
300
- * older releases, so both shapes are read. A dir whose bin is missing on disk is
301
- * not a build this seat can run: returning null there is what sends a spawn back
302
- * to the declared command instead of exec'ing a path that does not exist.
303
- */
304
- const readBinInPackageDir = (pkgDir, {
305
- readFile = defaultRead, exists = defaultExists,
306
- } = {}) => {
307
- const raw = readFile(join(pkgDir, 'package.json'));
308
- if (typeof raw !== 'string') return null;
309
- let pkg;
310
- try {
311
- pkg = JSON.parse(raw);
312
- } catch {
313
- return null;
314
- }
315
- if (!pkg || typeof pkg !== 'object') return null;
316
- // A package.json naming another package settles it: this dir is not ours.
317
- if (pkg.name !== undefined && pkg.name !== MCP_PACKAGE) return null;
318
- const bin = pkg.bin;
319
- let relative = null;
320
- if (typeof bin === 'string' && bin) {
321
- relative = bin;
322
- } else if (bin && typeof bin === 'object') {
323
- const keys = Object.keys(bin).filter((k) => typeof bin[k] === 'string' && bin[k]);
324
- if (keys.length === 1) [relative] = keys.map((k) => bin[k]);
325
- else if (typeof bin['commonly-mcp'] === 'string' && bin['commonly-mcp']) relative = bin['commonly-mcp'];
326
- }
327
- if (!relative) return null;
328
- const binPath = join(pkgDir, relative);
329
- // `join` resolves `..`, so this is the whole confinement. The package is our
330
- // own published build and today `bin` cannot escape — but a check that costs
331
- // one line makes the exec path self-evident instead of trusted (Vera, 74880).
332
- if (!binPath.startsWith(`${pkgDir}${sep}`)) return null;
333
- return exists(binPath) ? binPath : null;
334
- };
335
-
336
- /** The bin of a version dir inside the seat's home. */
337
- export const readBinPath = (home, version, options = {}) => {
338
- // Truthiness of `parseVersion` is truthiness of `exactVersion`; the check that
339
- // matters here is `versionDirFor` refusing a name that is not a triple, so a
340
- // reader naming `0.3.14-rc.1` gets nothing rather than 0.3.14's build.
341
- if (!parseVersion(version)) return null;
342
- return readBinInPackageDir(packageDirFor(home, version), options);
343
- };
344
-
345
- /** Read grant for only the verified warm build selected for this spawn. */
346
- export const selectedMcpVersionDir = (command, home = mcpHomeDir()) => {
347
- if (!Array.isArray(command) || command.length !== 2 || command[0] !== 'node') return null;
348
- const version = relative(home, command[1]).split(sep)[0];
349
- if (exactVersion(version) !== version || readBinPath(home, version) !== command[1]) return null;
350
- return versionDirFor(home, version);
351
- };
352
-
353
- /**
354
- * Whether the warm build this spawn runs writes the ready file (F421).
355
- *
356
- * A public OpenCode seat's stream reports nothing about its MCP servers, so the
357
- * adapter names a file the Commonly server creates once its host reaches it
358
- * (commonly-mcp/src/ready-file.js) and withholds a turn that ends without it.
359
- * Only a release that says so in its package.json (`commonly.readyFile`) is
360
- * asked: a build from before the file existed would leave every turn withheld.
361
- * Anything this cannot read or verify answers false, which keeps the turn as it
362
- * was before F421.
363
- */
364
- export const selectedMcpWritesReadyFile = (command, home = mcpHomeDir(), { readFile = defaultRead } = {}) => {
365
- if (!selectedMcpVersionDir(command, home)) return false;
366
- const version = relative(home, command[1]).split(sep)[0];
367
- const raw = readFile(join(packageDirFor(home, version), 'package.json'));
368
- if (typeof raw !== 'string') return false;
369
- try {
370
- const pkg = JSON.parse(raw);
371
- return pkg?.name === MCP_PACKAGE && pkg?.commonly?.readyFile === true;
372
- } catch {
373
- return false;
374
- }
375
- };
376
-
377
- /**
378
- * The bin of an npm PREFIX dir, which is the shape the warm installs into:
379
- * `npm install --prefix <dir>` leaves `<dir>/node_modules/...`, with no version
380
- * component in the path. Reading the two shapes with one function would mean
381
- * looking for a version dir inside the temp prefix and concluding the install
382
- * failed when it did not.
383
- */
384
- export const readBinInPrefix = (prefix, options = {}) => (
385
- readBinInPackageDir(join(prefix, 'node_modules', MCP_PACKAGE), options)
386
- );
387
-
388
- /**
389
- * The newest version dir in the home that actually has a runnable bin.
390
- *
391
- * The pointer is only written after a verified warm, so it is normally the whole
392
- * answer. This is the recovery path for the case the pointer cannot cover: the
393
- * pointed-at version dir deleted under a live seat (hand-pruned, a wiped home,
394
- * npm falling back to a reinstall). Falling back to the next-newest local build
395
- * keeps the spawn off npx even then, which is the whole point of the change.
396
- */
397
- export const newestInstalledVersion = (home, {
398
- readDir = (dir) => {
399
- try {
400
- return readdirSync(dir);
401
- } catch {
402
- return [];
403
- }
404
- },
405
- readBin = (h, v) => readBinPath(h, v),
406
- } = {}) => {
407
- const versions = readDir(home)
408
- // An exact triple, not a prefix match: a dir literally named `0.3.13-rc.1`
409
- // is not `0.3.13`, and normalising the name would point this read at a dir
410
- // the caller never mentioned.
411
- .filter((name) => exactVersion(name) === name)
412
- .filter((name) => readBin(home, name))
413
- .sort((a, b) => {
414
- const [am, an, ap] = parseVersion(a);
415
- const [bm, bn, bp] = parseVersion(b);
416
- return (am - bm) || (an - bn) || (ap - bp);
417
- });
418
- return versions.length ? versions[versions.length - 1] : null;
419
- };
420
-
421
- /** True for the shipped, UNPINNED spec — the only command shape this rewrites. */
422
- const isUnpinnedShippedSpec = (spec) => (
423
- spec === MCP_PACKAGE || spec === `${MCP_PACKAGE}@latest`
424
- );
425
-
426
- /**
427
- * The shipped command with its spec pinned to `version`, or the command
428
- * unchanged when there is no version to pin to.
429
- *
430
- * Only slot 2 is touched, and only after `isShippedCommonlyMcpCommand` has
431
- * proven the slot is the package spec — so `npx`, its `-y` and every other
432
- * entry survive byte-for-byte, and a command that is not the shipped 3-arg form
433
- * cannot be rewritten by construction. npx keys its cache dir by the spec
434
- * string, which is the whole point: `@latest` is one dir every seat shares and a
435
- * publish rewrites in place, while `@0.3.13` is a dir only this version ever
436
- * uses.
437
- */
438
- export const pinShippedSpec = (command, version) => {
439
- // The triple, not the string: this spec is an argv entry we execute, so a
440
- // version carrying a suffix must not reach it.
441
- const safe = exactVersion(version);
442
- if (!Array.isArray(command) || !safe) return command;
443
- let changed = false;
444
- const pinned = command.map((part, index) => {
445
- // Slot 2 only. `isShippedCommonlyMcpCommand` already proves the package sits
446
- // in slot 2 of a 3-arg command, so this bound is not reachable today — it is
447
- // here so the function is safe to read on its own, and it is armed in the
448
- // tests rather than argued from the shape check (Vera, 74940).
449
- if (index !== 2 || !isUnpinnedShippedSpec(String(part))) return part;
450
- changed = true;
451
- return `${MCP_PACKAGE}@${safe}`;
452
- });
453
- // The same reference when nothing changed, so a caller can use identity as the
454
- // "did this spawn's spec move" check — the convention the rest of this module
455
- // already keeps (`ensureCommonlyMcpServer`, `withholdGrantBroker`).
456
- return changed ? pinned : command;
457
- };
458
-
459
- /**
460
- * What a spawn should execute, and where that decision came from.
461
- *
462
- * `source: 'home'` — the command is `node <bin>` from the version dir, no npx
463
- * and no network. `source: 'declared'` — the declaration runs as written, which
464
- * is either an operator's pinned spec or an EMPTY HOME (fresh host, or a home
465
- * that was deleted). The empty-home path is the old failure mode and it is
466
- * accepted, written down, and once per empty home rather than once per publish.
467
- * `reason` names which of those it was, for the spawn path's logs.
468
- */
469
- export const planMcpSpawn = (command, {
470
- home,
471
- readFile = defaultRead,
472
- exists = defaultExists,
473
- readBin = (h, v) => readBinPath(h, v, { readFile, exists }),
474
- newest = (h) => newestInstalledVersion(h, { readBin }),
475
- } = {}) => {
476
- const target = resolveHomeDir(home);
477
- if (!isShippedCommonlyMcpCommand(command) || !isUnpinnedShippedSpec(String(command[2]))) {
478
- return { command, source: 'declared', version: null, reason: 'not-the-shipped-unpinned-spec' };
479
- }
480
- const pointer = readCurrentVersion(target, { readFile });
481
- if (pointer) {
482
- const bin = readBin(target, pointer);
483
- if (bin) return { command: ['node', bin], source: 'home', version: pointer, reason: 'pointer' };
484
- }
485
- const fallback = newest(target);
486
- if (fallback) {
487
- const bin = readBin(target, fallback);
488
- if (bin) {
489
- return {
490
- command: ['node', bin],
491
- source: 'home',
492
- version: fallback,
493
- reason: pointer ? 'pointer-bin-missing' : 'no-pointer',
494
- };
495
- }
496
- }
497
- // No usable home, so the declaration is what runs — and the one thing worth
498
- // changing about it is the spec string. The cached registry answer is used
499
- // rather than a fresh lookup because this function is synchronous and on the
500
- // spawn's critical path, and the warm refreshes that same cache on the same
501
- // TTL (`REGISTRY_TTL_MS`). A stale-but-real version still gives this run its
502
- // own npx dir, which `@latest` does not, and a stale cache means the registry
503
- // has been answering nothing for a while — the case where `@latest` is least
504
- // usable. `pinned` is reported so the caller can log which spec it ran.
505
- const cached = readRegistryCache(target, { readFile });
506
- const pinned = cached ? pinShippedSpec(command, cached.version) : command;
507
- return {
508
- command: pinned,
509
- source: 'declared',
510
- version: null,
511
- reason: 'home-empty',
512
- pinned: cached ? cached.version : null,
513
- };
514
- };
515
-
516
- /** The warm's cached registry answer, with its age. */
517
- export const readRegistryCache = (home, { readFile = defaultRead } = {}) => {
518
- const raw = readFile(join(home, REGISTRY_CACHE_NAME));
519
- if (typeof raw !== 'string') return null;
520
- try {
521
- const parsed = JSON.parse(raw);
522
- const version = exactVersion(parsed && parsed.version);
523
- const checkedAt = parsed && Number.isFinite(parsed.checkedAt) ? parsed.checkedAt : null;
524
- if (!version || checkedAt === null) return null;
525
- return { version, checkedAt };
526
- } catch {
527
- return null;
528
- }
529
- };
530
-
531
- /** True when the cached "latest" is newer than what this spawn is about to run. */
532
- export const warmLooksWanted = (home, version, { now = Date.now(), readFile = defaultRead } = {}) => {
533
- const cached = readRegistryCache(home, { readFile });
534
- if (!cached) return true;
535
- if (now - cached.checkedAt > REGISTRY_TTL_MS) return true;
536
- if (!version) return true;
537
- const target = parseVersion(cached.version);
538
- const running = parseVersion(version);
539
- if (!target || !running) return true;
540
- return (target[0] > running[0])
541
- || (target[0] === running[0] && target[1] > running[1])
542
- || (target[0] === running[0] && target[1] === running[1] && target[2] > running[2]);
543
- };
544
-
545
- export const warmChildPath = () => join(dirname(fileURLToPath(import.meta.url)), 'mcp-warm-child.mjs');
546
-
547
- /**
548
- * Start the warm and walk away. Detached + unref'd, so a seat that spawns and
549
- * exits does not wait on it, and the child is written to never throw at the
550
- * parent: a warm that fails costs the NEXT spawn nothing but the old version.
551
- */
552
- export const kickWarm = ({
553
- home,
554
- apiUrl = null,
555
- spawnImpl = spawn,
556
- childPath = warmChildPath(),
557
- } = {}) => {
558
- try {
559
- // argv entries are coerced with String(), so an undefined home reaches the
560
- // child as the literal 'undefined' and it warms a directory by that name.
561
- const targetHome = resolveHomeDir(home);
562
- const child = spawnImpl(process.execPath, [childPath, targetHome], {
563
- detached: true,
564
- stdio: 'ignore',
565
- env: {
566
- ...process.env,
567
- COMMONLY_MCP_HOME: targetHome,
568
- ...(apiUrl ? { COMMONLY_WARM_API_URL: String(apiUrl) } : {}),
569
- },
570
- });
571
- if (child && typeof child.unref === 'function') child.unref();
572
- return true;
573
- } catch {
574
- return false;
575
- }
576
- };
577
-
578
- const lockPresent = (home, { exists = defaultExists } = {}) => exists(lockPathFor(home));
579
-
580
- /**
581
- * The one entry the adapters call: what to execute, plus a warm if one is due.
582
- *
583
- * Sync and network-free by construction — the registry check, the install and
584
- * the probe all live in the child. A lock already held means another spawn is
585
- * warming, so this spawn just runs the current version. Public seats receive
586
- * null if that version is unavailable and the fallback is a package runner;
587
- * adapters omit that MCP declaration and tell the seat.
588
- */
589
- export const prepareMcpSpawn = (command, {
590
- home,
591
- apiUrl = null,
592
- publicSeat = false,
593
- spawnImpl,
594
- childPath,
595
- now = Date.now(),
596
- readFile = defaultRead,
597
- exists = defaultExists,
598
- warn = defaultWarn,
599
- } = {}) => {
600
- const resolvedHome = resolveHomeDir(home);
601
- try {
602
- const plan = planMcpSpawn(command, { home: resolvedHome, readFile, exists });
603
- // Claim the version this spawn is about to execute from, BEFORE returning the
604
- // command, so a prune that runs while the seat is starting cannot take it.
605
- let resolved = plan.command;
606
- if (plan.source === 'home') {
607
- const claim = claimInUse({ home: resolvedHome, version: plan.version });
608
- // A claim that could not be written means the pruner may take this very
609
- // directory out from under the running seat — the hazard the claim exists
610
- // for. The declared command is what the guard approved and what no pruner
611
- // owns, so the spawn falls back to it rather than executing unprotected,
612
- // and says why. The warm still runs: with the seat off the dir, a prune or
613
- // a repair is safe.
614
- if (!claim.claimed) {
615
- warn(`commonly: cannot claim ${versionDirFor(resolvedHome, plan.version)} for pid ${process.pid} (${claim.reason}) — running the declared command instead, because a prune could delete the directory this seat would run from`);
616
- resolved = command;
617
- }
618
- }
619
- const due = !lockPresent(resolvedHome, { exists })
620
- && warmLooksWanted(resolvedHome, plan.version, { now, readFile });
621
- if (due) kickWarm({ home: resolvedHome, apiUrl, spawnImpl, childPath });
622
- if (publicSeat && resolved[0] === 'npx') {
623
- warn('commonly: an MCP server is unavailable for this public turn; no host-prepared executable was ready');
624
- return null;
625
- }
626
- return resolved;
627
- } catch {
628
- // Trusted seats retain the declared fallback. A public seat must not
629
- // execute a package runner from the workspace even on this failure path.
630
- if (publicSeat && command?.[0] === 'npx') return null;
631
- return command;
632
- }
633
- };
634
-
635
- /** Kept for callers that want the plan and the command in one call. */
636
- export const resolveMcpSpawnCommand = prepareMcpSpawn;
637
-
638
- export const statOrNull = (path) => {
639
- try {
640
- return statSync(path);
641
- } catch {
642
- return null;
643
- }
644
- };