@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,68 +0,0 @@
1
- /**
2
- * Ready files for a public seat's MCP servers (F421, F460).
3
- *
4
- * The Commonly MCP server reads the seat credential once, at start, and
5
- * creates the file named in COMMONLY_MCP_READY_FILE on its host's first
6
- * `tools/list` (commonly-mcp/src/ready-file.js); the grant-broker bridge does
7
- * the same (codex-broker-bridge.mjs). So once every ready file a spawn named
8
- * exists, nothing in that spawn still needs the credential file, and
9
- * `armCredentialRelease` (credential-file.js) deletes it.
10
- *
11
- * A seat names files only when the spawn has a ready folder, which the adapter
12
- * creates for a public seat alone. A public seat whose Commonly server can't
13
- * be shown to write one is refused: a warm build from before ready files, or
14
- * our package run from a path outside the host-prepared MCP home (a checkout).
15
- * Without the file its credential would stay readable to the deadline on every
16
- * turn, or, named by nothing, be gone before that server read it.
17
- */
18
- import { randomUUID } from 'node:crypto';
19
- import { join } from 'node:path';
20
- import { CREDENTIAL_FILE_VAR } from './credential-file.js';
21
- import { isShippedCommonlyMcpCommand } from './declared-mcp-guard.js';
22
- import { selectedMcpWritesReadyFile } from './mcp-home.js';
23
- import { describeMcpCommand } from './mcp-server-version.js';
24
-
25
- export const MCP_READY_FILE_ENV = 'COMMONLY_MCP_READY_FILE';
26
-
27
- const nameReadyFile = (name, ctx) => {
28
- const path = join(ctx.readyFileDir, randomUUID());
29
- ctx.readyFiles.push({ name, path });
30
- return path;
31
- };
32
-
33
- export const publicMcpOutdatedError = (name) => new Error(
34
- `public seats need a Space MCP server that reports when it has started, and the one this seat runs for `
35
- + `${name} does not (a build from before that, or one run from outside the host-prepared MCP home); `
36
- + 'update @commonlyai/mcp',
37
- );
38
-
39
- export const publicMcpUnreportedReaderError = (name) => new Error(
40
- `public seats hand the runtime credential only to the Space MCP server, which reports when it has read it; `
41
- + `${name} is another server declaring ${CREDENTIAL_FILE_VAR}, so it would lose the credential before reading it. `
42
- + 'Remove that declaration, or run this server on a private seat',
43
- );
44
-
45
- /**
46
- * The ready file for `server` (as declared), run as `spawnCommand` (the warm
47
- * build it resolved to), or null when this spawn names none for it.
48
- */
49
- export const commonlyMcpReadyFile = (server, spawnCommand, ctx, {
50
- writesReadyFile = selectedMcpWritesReadyFile,
51
- describe = describeMcpCommand,
52
- env = null,
53
- } = {}) => {
54
- if (!ctx?.readyFileDir) return null;
55
- const ours = isShippedCommonlyMcpCommand(server?.command) || Boolean(describe(spawnCommand)?.isCommonly);
56
- if (!ours) {
57
- // `env` is the entry's environment after credential delivery: a server that
58
- // is not ours but was handed the credential path names no ready file, so
59
- // the release would not wait for it.
60
- if (env && String(env[CREDENTIAL_FILE_VAR] ?? '').trim()) throw publicMcpUnreportedReaderError(server?.name);
61
- return null;
62
- }
63
- if (!writesReadyFile(spawnCommand)) throw publicMcpOutdatedError(server.name);
64
- return nameReadyFile(server.name, ctx);
65
- };
66
-
67
- /** The broker bridge ships with this CLI, so it always writes one. */
68
- export const bridgeReadyFile = (server, ctx) => (ctx?.readyFileDir ? nameReadyFile(server.name, ctx) : null);
@@ -1,123 +0,0 @@
1
- /**
2
- * Which `@commonlyai/mcp` a declared MCP command would run, and which channels
3
- * that release understands.
4
- *
5
- * Shared by the pi bridge (which decides whether to pipe a credential) and by
6
- * the claude and codex adapters (which decide whether to hand over a PATH or the
7
- * value): one predicate, because three copies of "is this old enough" would
8
- * drift, and the drift would be silent in exactly one adapter.
9
- */
10
- import { existsSync, readFileSync } from 'node:fs';
11
- import { dirname, join } from 'node:path';
12
-
13
- export const MCP_PACKAGE = '@commonlyai/mcp';
14
-
15
- /** The release whose reader accepts the credential on an inherited fd. */
16
- export const PIPE_READER_VERSION = [0, 3, 11];
17
-
18
- /** The release whose reader accepts `COMMONLY_TOKEN_FILE` (a PATH, not a secret). */
19
- export const FILE_READER_VERSION = [0, 3, 12];
20
-
21
- export const parseVersion = (spec) => {
22
- const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(spec || '').trim());
23
- return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
24
- };
25
-
26
- /**
27
- * The version as a bare triple — or null when there is none to build.
28
- *
29
- * `parseVersion` is a PREFIX test, which is right for ordering two versions and
30
- * wrong for building anything out of one: it accepts `0.3.13/../../../../tmp/x`,
31
- * so a version read out of a file (`~/.commonly/mcp/.registry.json`, the
32
- * `current` pointer) or out of `npm view` could become a path or an npm SPEC
33
- * ARGUMENT that leaves the seat's home. Everything that turns a version into a
34
- * STRING used outside this module's own comparisons goes through here instead,
35
- * and the string it builds is the parsed triple, so no suffix can travel.
36
- *
37
- * The cost is deliberate and one-directional: a prerelease (`0.3.13-rc.1`)
38
- * becomes `0.3.13` rather than falling back to `@latest` — the stable release of
39
- * the same triple, which is a real published version, and strictly better than
40
- * the shared `@latest` dir this whole change exists to get off. Anchoring the
41
- * regex at the end instead would reject the prerelease and leave it unpinned.
42
- */
43
- export const exactVersion = (spec) => {
44
- const triple = parseVersion(spec);
45
- return triple ? triple.join('.') : null;
46
- };
47
-
48
- /**
49
- * True when `version` is older than `target`; null when there is no version to
50
- * judge (an unpinned `@latest` tracks the published release, so it is never
51
- * treated as old).
52
- */
53
- export const versionOlderThan = (version, target) => {
54
- if (!version) return null;
55
- for (let i = 0; i < 3; i += 1) {
56
- if (version[i] !== target[i]) return version[i] < target[i];
57
- }
58
- return false;
59
- };
60
-
61
- /**
62
- * What an `@commonlyai/mcp` command would run, or null when the command cannot
63
- * be identified as that package at all.
64
- *
65
- * Two shapes matter: `npx [-y] @commonlyai/mcp@<spec>` (a spec is a version, or
66
- * `latest`/absent, which resolves to whatever is published — never treated as
67
- * old), and a local checkout, `node <path>/src/index.js`, which is what the
68
- * staging seats run; for that one the package.json beside it is the only honest
69
- * answer, and a package.json naming something else means this is not our server.
70
- *
71
- * `{ isCommonly: true, version: null }` means "our server, version unknown" —
72
- * an unpinned npx spec, whose whole point is that it tracks the published one.
73
- * `null` as the return value means "not identifiable as our server", which is a
74
- * different answer and takes a different branch: a stranger's server gets its
75
- * declaration honoured unchanged.
76
- *
77
- * The ENTRY SCRIPT is checked first, and that ordering is load-bearing since
78
- * TASK-174. A seat home runs `node <home>/<version>/node_modules/@commonlyai/mcp/<bin>`,
79
- * and that path CONTAINS the package name — so a spec-first order matched the
80
- * path, took the `@` of `@commonlyai/mcp` for the version separator, and returned
81
- * `{ version: null }` ("unpinned, so never old") for every home build. The
82
- * version then decided nothing: measured, a home holding 0.3.7 was handed the
83
- * credential FILE channel, whose reader does not exist before 0.3.12. Nor is
84
- * "a spec has no slash" a usable rule — a SCOPED spec is `@commonlyai/mcp`.
85
- */
86
- export const describeMcpCommand = (command, {
87
- readTextFile = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : null),
88
- } = {}) => {
89
- if (!Array.isArray(command) || command.length === 0) return null;
90
- const parts = command.map(String);
91
- const scriptPath = parts.find((p) => p.endsWith('.js') || p.endsWith('.mjs'));
92
- if (scriptPath) {
93
- // `src/index.js` → `../package.json`; also try one level further up, because
94
- // a bin shim can live in `bin/` beside `src/`.
95
- for (const candidate of [join(dirname(scriptPath), '..', 'package.json'), join(dirname(scriptPath), 'package.json')]) {
96
- let raw;
97
- try {
98
- raw = readTextFile(candidate);
99
- } catch {
100
- raw = null;
101
- }
102
- if (!raw) continue;
103
- try {
104
- const pkg = JSON.parse(raw);
105
- if (!pkg || typeof pkg !== 'object') continue;
106
- if (pkg.name === MCP_PACKAGE) return { isCommonly: true, version: parseVersion(pkg.version) };
107
- // A package.json that names another package settles it: not ours, so its
108
- // declaration is none of this function's business.
109
- return null;
110
- } catch {
111
- // A malformed package.json is not an answer; keep looking.
112
- }
113
- }
114
- return null;
115
- }
116
- const pkgArg = parts.find((p) => p.includes(MCP_PACKAGE));
117
- if (pkgArg) {
118
- const at = pkgArg.lastIndexOf('@');
119
- if (at <= pkgArg.indexOf(MCP_PACKAGE)) return { isCommonly: true, version: null };
120
- return { isCommonly: true, version: parseVersion(pkgArg.slice(at + 1)) };
121
- }
122
- return null;
123
- };
@@ -1,36 +0,0 @@
1
- /**
2
- * The 0600 copy of an agent's runtime token that `commonly mcp config` points
3
- * COMMONLY_TOKEN_FILE at (TASK-666). It is a second live secret, so the agent
4
- * token's lifecycle owns it: save refreshes it, delete removes it.
5
- */
6
- import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
7
- import { homedir } from 'os';
8
- import { join } from 'path';
9
-
10
- export const validMcpTokenName = (name) => typeof name === 'string' && /^[A-Za-z0-9._-]+$/.test(name) && !name.startsWith('.');
11
-
12
- export const mcpTokenPath = (name) => {
13
- if (!validMcpTokenName(name)) throw new Error(`Invalid agent name '${name}'`);
14
- return join(homedir(), '.commonly', 'mcp', `${name}.token`);
15
- };
16
-
17
- // Remove first, then create exclusively at 0600: no write into a looser-mode file or through a symlink.
18
- export const writeMcpTokenCopy = (name, token) => {
19
- const dir = join(homedir(), '.commonly', 'mcp');
20
- mkdirSync(dir, { recursive: true, mode: 0o700 });
21
- chmodSync(dir, 0o700);
22
- const path = mcpTokenPath(name);
23
- rmSync(path, { force: true });
24
- writeFileSync(path, `${token}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
25
- return path;
26
- };
27
-
28
- // A bad name never has a copy, so remove and refresh are no-ops for it and cannot block detach or save.
29
- export const removeMcpTokenCopy = (name) => {
30
- if (validMcpTokenName(name)) rmSync(mcpTokenPath(name), { force: true });
31
- };
32
-
33
- // Reattach mints a new token; a copy that already exists must follow it.
34
- export const refreshMcpTokenCopy = (name, token) => {
35
- if (token && validMcpTokenName(name) && existsSync(mcpTokenPath(name))) writeMcpTokenCopy(name, token);
36
- };
@@ -1,412 +0,0 @@
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, exactVersion, parseVersion } from './mcp-server-version.js';
38
- import { resolveLaunch } from './windows-command.js';
39
-
40
- export const PROBE_API_URL_PLACEHOLDER = 'http://127.0.0.1:1';
41
- export const PROBE_TOKEN_PLACEHOLDER = 'cm_agent_warm_probe';
42
- export const PROBE_TIMEOUT_MS = 10 * 1000;
43
- export const REGISTRY_TIMEOUT_MS = 20 * 1000;
44
-
45
- const INITIALIZE_REQUEST = JSON.stringify({
46
- jsonrpc: '2.0',
47
- id: 1,
48
- method: 'initialize',
49
- params: {
50
- protocolVersion: '2024-11-05',
51
- capabilities: {},
52
- clientInfo: { name: 'commonly-cli-warm', version: '1' },
53
- },
54
- });
55
-
56
- const exists = (path) => {
57
- try {
58
- return existsSync(path);
59
- } catch {
60
- return false;
61
- }
62
- };
63
-
64
- const readIfAny = (path) => {
65
- try {
66
- return existsSync(path) ? readFileSync(path, 'utf8') : null;
67
- } catch {
68
- return null;
69
- }
70
- };
71
-
72
- /**
73
- * Take the warm lock, reclaiming one that is too old to belong to a live warm.
74
- *
75
- * Returns the open fd, or null when another process holds a fresh lock.
76
- */
77
- export const acquireWarmLock = (home, {
78
- now = Date.now(),
79
- open = (path) => openSync(path, 'wx'),
80
- stat = (path) => statSync(path),
81
- remove = (path) => unlinkSync(path),
82
- staleMs = STALE_LOCK_MS,
83
- } = {}) => {
84
- const lockPath = lockPathFor(home);
85
- try {
86
- return open(lockPath);
87
- } catch (error) {
88
- if (error && error.code !== 'EEXIST') return null;
89
- }
90
- let age;
91
- try {
92
- age = now - stat(lockPath).mtimeMs;
93
- } catch {
94
- return null; // vanished between EEXIST and stat: not ours to break
95
- }
96
- if (age <= staleMs) return null;
97
- try {
98
- remove(lockPath);
99
- } catch {
100
- return null;
101
- }
102
- try {
103
- return open(lockPath);
104
- } catch {
105
- // Lost the reclaim race to another spawn's warm. Fine: that one is working.
106
- return null;
107
- }
108
- };
109
-
110
- /**
111
- * Default `npm` runner. Resolves `{ ok, stdout }`; never rejects. On Windows
112
- * `npm` is npm.cmd, which Node cannot spawn without a shell, so the name goes
113
- * through the resolver the adapters use (npm.cmd becomes node.exe npm-cli.js).
114
- * Off Windows it is spawned as given. `resolution` is the resolver's test seam.
115
- */
116
- export const runCommand = (bin, args, {
117
- timeout = REGISTRY_TIMEOUT_MS, cwd, resolution,
118
- } = {}) => new Promise((resolve) => {
119
- let settled = false;
120
- let child;
121
- const done = (result) => {
122
- if (settled) return;
123
- settled = true;
124
- resolve(result);
125
- };
126
- try {
127
- // The child runs in `cwd`, so a relative PATH entry is read there.
128
- const launch = resolveLaunch(bin, args, { cwd, ...resolution });
129
- child = spawnChild(launch.command, launch.args, { stdio: ['ignore', 'pipe', 'pipe'], env: process.env, cwd });
130
- } catch (error) {
131
- done({ ok: false, stdout: '', stderr: String(error && error.message) });
132
- return;
133
- }
134
- let stdout = '';
135
- let stderr = '';
136
- child.stdout.on('data', (d) => { stdout += String(d); });
137
- child.stderr.on('data', (d) => { stderr += String(d); });
138
- const timer = setTimeout(() => {
139
- try {
140
- child.kill('SIGKILL');
141
- } catch { /* already gone */ }
142
- done({ ok: false, stdout, stderr: `${stderr}\n[commonly] timed out after ${timeout}ms` });
143
- }, timeout);
144
- child.on('error', (error) => { clearTimeout(timer); done({ ok: false, stdout, stderr: String(error && error.message) }); });
145
- child.on('close', (code) => { clearTimeout(timer); done({ ok: code === 0, code, stdout, stderr }); });
146
- });
147
-
148
- /** The registry's current version for the package, cached on disk with a TTL. */
149
- export const resolveRegistryLatest = async (home, {
150
- now = Date.now(),
151
- exec = runCommand,
152
- ttlMs = REGISTRY_TTL_MS,
153
- writeFile = (path, text) => writeFileSync(path, text),
154
- } = {}) => {
155
- const cachePath = join(home, REGISTRY_CACHE_NAME);
156
- const cached = readIfAny(cachePath);
157
- if (cached) {
158
- try {
159
- const parsed = JSON.parse(cached);
160
- const cachedVersion = parsed ? exactVersion(parsed.version) : null;
161
- if (cachedVersion && Number.isFinite(parsed.checkedAt) && now - parsed.checkedAt < ttlMs) {
162
- return { version: cachedVersion, source: 'cache' };
163
- }
164
- } catch { /* unreadable cache is just a miss */ }
165
- }
166
- const result = await exec('npm', ['view', MCP_PACKAGE, 'version', '--json'], {
167
- timeout: REGISTRY_TIMEOUT_MS, cwd: home,
168
- });
169
- const raw = result && result.ok ? String(result.stdout || '').trim() : '';
170
- let version = null;
171
- if (raw) {
172
- try {
173
- const parsed = JSON.parse(raw);
174
- version = typeof parsed === 'string' ? parsed : null;
175
- } catch {
176
- version = raw.replace(/^"|"$/g, '');
177
- }
178
- }
179
- // This answer is both persisted and, downstream, an argv entry
180
- // (`npm install <pkg>@<version>`) and a path (`<home>/<version>`, the temp
181
- // prefix). Rebuild it as a triple here, at the one place it enters, so the
182
- // rest of the warm can keep treating a version as a name.
183
- const safe = exactVersion(version);
184
- if (safe) {
185
- try {
186
- writeFile(cachePath, JSON.stringify({ version: safe, checkedAt: now }));
187
- } catch { /* a cache we cannot write is not a warm we should fail */ }
188
- return { version: safe, source: 'registry' };
189
- }
190
- // Registry down: the last known answer is better than none, and better than
191
- // moving the pointer backwards.
192
- if (cached) {
193
- try {
194
- const parsed = JSON.parse(cached);
195
- const stale = parsed ? exactVersion(parsed.version) : null;
196
- if (stale) return { version: stale, source: 'stale-cache' };
197
- } catch { /* fall through */ }
198
- }
199
- return { version: null, source: 'unavailable' };
200
- };
201
-
202
- /**
203
- * Does the installed bin start and speak the protocol?
204
- *
205
- * Accepts a JSON-RPC response to id 1 whether it is a `result` or an `error`:
206
- * what this proves is that the build runs and answers `initialize`, which is
207
- * exactly what the pointer's contract claims. It is not an authorization check —
208
- * the seat's own token is what makes calls work.
209
- */
210
- export const probeBin = (binPath, {
211
- spawnImpl = spawnChild,
212
- timeoutMs = PROBE_TIMEOUT_MS,
213
- apiUrl = process.env.COMMONLY_WARM_API_URL || PROBE_API_URL_PLACEHOLDER,
214
- cwd,
215
- } = {}) => new Promise((resolve) => {
216
- let settled = false;
217
- let child;
218
- const done = (ok) => {
219
- if (settled) return;
220
- settled = true;
221
- try {
222
- if (child) child.kill('SIGKILL');
223
- } catch { /* already gone */ }
224
- resolve(ok);
225
- };
226
- try {
227
- child = spawnImpl(process.execPath, [binPath], {
228
- stdio: ['pipe', 'pipe', 'pipe'],
229
- cwd,
230
- env: {
231
- PATH: process.env.PATH,
232
- HOME: process.env.HOME,
233
- COMMONLY_API_URL: String(apiUrl),
234
- COMMONLY_AGENT_TOKEN: PROBE_TOKEN_PLACEHOLDER,
235
- },
236
- });
237
- } catch {
238
- resolve(false);
239
- return;
240
- }
241
- const timer = setTimeout(() => done(false), timeoutMs);
242
- let seen = '';
243
- const onData = (chunk) => {
244
- seen += String(chunk);
245
- if (/"id"\s*:\s*1/.test(seen)) {
246
- clearTimeout(timer);
247
- done(true);
248
- }
249
- };
250
- child.stdout.on('data', onData);
251
- child.on('error', () => { clearTimeout(timer); done(false); });
252
- child.on('close', () => { clearTimeout(timer); done(false); });
253
- try {
254
- child.stdin.write(`${INITIALIZE_REQUEST}\n`);
255
- } catch {
256
- clearTimeout(timer);
257
- done(false);
258
- }
259
- });
260
-
261
- const writePointerAtomic = (home, version, { writeTemp = writeFileSync } = {}) => {
262
- const target = currentPointerPath(home);
263
- const temp = join(home, `.current-${process.pid}`);
264
- writeTemp(temp, `${version}\n`);
265
- renameSync(temp, target);
266
- };
267
-
268
- /** Keep the newest N version dirs; the current one is never a candidate. */
269
- export const pruneVersionDirs = (home, current, {
270
- keep = KEEP_VERSION_DIRS,
271
- grace = PRUNE_GRACE_MS,
272
- now = Date.now(),
273
- readDir = (dir) => {
274
- try {
275
- return readdirSync(dir);
276
- } catch {
277
- return [];
278
- }
279
- },
280
- remove = (path) => rmSync(path, { recursive: true, force: true }),
281
- mtime = (path) => {
282
- try {
283
- return statSync(path).mtimeMs;
284
- } catch {
285
- return null;
286
- }
287
- },
288
- livePids = (version) => liveInUsePids(home, version),
289
- } = {}) => {
290
- // Exact triples only, for the same reason `newestInstalledVersion` uses them:
291
- // a dir named `0.3.13-rc.1` is not the dir `0.3.13`, and this loop REMOVES what
292
- // it names.
293
- const versions = readDir(home).filter((name) => exactVersion(name) === name);
294
- versions.sort((a, b) => {
295
- const [am, an, ap] = parseVersion(a);
296
- const [bm, bn, bp] = parseVersion(b);
297
- return (bm - am) || (bn - an) || (bp - ap);
298
- });
299
- const removed = [];
300
- versions.slice(keep).forEach((name) => {
301
- if (name === current) return;
302
- // A live claimant outranks the count: this dir may be executing right now.
303
- if (livePids(name).length > 0) return;
304
- // And time outranks the count too — a dir inside the grace window may belong
305
- // to a spawn that has not written its claim yet. An unreadable/absent mtime
306
- // is not evidence of age, so it is left alone.
307
- const installedAt = mtime(versionDirFor(home, name));
308
- if (typeof installedAt !== 'number' || now - installedAt < grace) return;
309
- try {
310
- remove(versionDirFor(home, name));
311
- removed.push(name);
312
- } catch { /* a dir we cannot remove is not a failed warm */ }
313
- });
314
- return removed;
315
- };
316
-
317
- /**
318
- * The whole warm, in one function so a test can drive every branch with injected
319
- * I/O. Returns one of WARM_RESULTS — never throws, because it runs unattended.
320
- */
321
- export const warmMcpHome = async (home, {
322
- now = Date.now(),
323
- exec = runCommand,
324
- probe = probeBin,
325
- spawnProbe,
326
- lock = () => acquireWarmLock(home, { now }),
327
- closeLock = (fd) => closeSync(fd),
328
- mkdir = (dir) => mkdirSync(dir, { recursive: true }),
329
- remove = (path) => rmSync(path, { recursive: true, force: true }),
330
- rename = (from, to) => renameSync(from, to),
331
- readBin = (prefix) => readBinInPrefix(prefix),
332
- readCurrent = (h) => {
333
- const raw = readIfAny(currentPointerPath(h));
334
- if (typeof raw !== 'string') return null;
335
- return exactVersion(raw.trim()) || null;
336
- },
337
- keep = KEEP_VERSION_DIRS,
338
- } = {}) => {
339
- mkdir(home);
340
- const fd = lock();
341
- if (fd === null) return WARM_RESULTS.LOCKED;
342
- try {
343
- const latest = await resolveRegistryLatest(home, { now, exec });
344
- if (!latest.version) return WARM_RESULTS.REGISTRY_UNAVAILABLE;
345
- const target = latest.version;
346
- const current = readCurrent(home);
347
- if (current && current === target) return WARM_RESULTS.CURRENT;
348
- if (current && parseVersion(current) && parseVersion(target)) {
349
- const [tm, tn, tp] = parseVersion(target);
350
- const [cm, cn, cp] = parseVersion(current);
351
- if ((tm < cm) || (tm === cm && tn < cn) || (tm === cm && tn === cn && tp <= cp)) {
352
- return WARM_RESULTS.CURRENT;
353
- }
354
- }
355
- const temp = join(home, `.tmp-${target}-${process.pid}`);
356
- remove(temp);
357
- mkdir(temp);
358
- const install = await exec('npm', [
359
- 'install', '--prefix', temp, `${MCP_PACKAGE}@${target}`, '--no-save', '--no-audit', '--no-fund',
360
- ], { timeout: 5 * 60 * 1000, cwd: home });
361
- if (!install || !install.ok) {
362
- remove(temp);
363
- return WARM_RESULTS.INSTALL_FAILED;
364
- }
365
- const bin = readBin(temp);
366
- if (!bin) {
367
- remove(temp);
368
- return WARM_RESULTS.INSTALL_FAILED;
369
- }
370
- const started = await (spawnProbe || probe)(bin, {
371
- apiUrl: process.env.COMMONLY_WARM_API_URL, cwd: home,
372
- });
373
- if (!started) {
374
- remove(temp);
375
- return WARM_RESULTS.PROBE_FAILED;
376
- }
377
- const destination = versionDirFor(home, target);
378
- if (exists(destination)) remove(temp);
379
- else rename(temp, destination);
380
- writePointerAtomic(home, target);
381
- pruneVersionDirs(home, target, { keep });
382
- return WARM_RESULTS.ADVANCED;
383
- } catch {
384
- return WARM_RESULTS.INSTALL_FAILED;
385
- } finally {
386
- try {
387
- closeLock(fd);
388
- } catch { /* nothing to close */ }
389
- try {
390
- rmSync(lockPathFor(home), { force: true });
391
- } catch { /* lock is best-effort cleanup */ }
392
- }
393
- };
394
-
395
- const invokedDirectly = process.argv[1] && /mcp-warm-child\.mjs$/.test(process.argv[1]);
396
- if (invokedDirectly) {
397
- // Validated, not trusted: argv entries are coerced with String(), so a parent
398
- // that passed an undefined home sends the literal 'undefined' and this child
399
- // would otherwise create and populate a directory by that name (which is how
400
- // one appeared inside the package directory on 2026-09-27).
401
- const home = process.argv[2];
402
- if (isUsableWarmHome(home)) {
403
- warmMcpHome(home, { now: Date.now() })
404
- .then((result) => {
405
- // A warm runs with stdio ignored; this is for a human running it by hand
406
- // and for `--verbose` debugging, and it must never be on a spawn path.
407
- if (process.env.COMMONLY_WARM_VERBOSE) process.stdout.write(`${result}\n`);
408
- process.exitCode = 0;
409
- })
410
- .catch(() => { process.exitCode = 0; });
411
- }
412
- }