0xray 4.0.27 → 4.0.28

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "0xray",
3
- "version": "4.0.27",
3
+ "version": "4.0.28",
4
4
  "description": "0xRay suit for Grok, Hermes, OpenClaw, OpenCode: mill+inspect, Codex, 7 MCP servers. Costume 45/42 is opt-in foundry, not the default wear.",
5
5
  "author": {
6
6
  "name": "0xRay AI",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "./features.schema.json",
3
- "version": "4.0.27",
3
+ "version": "4.0.28",
4
4
  "description": "0xRay Framework - Unified Feature Configuration",
5
5
  "suit_temperament": {
6
6
  "profile": "auto",
package/README.md CHANGED
@@ -52,7 +52,8 @@ Docs: [guides/autonomy-command](docs-site/docs/guides/autonomy-command.md) · Sk
52
52
  | Version | Highlights |
53
53
  |---------|------------|
54
54
  | **Unreleased** | Fasten is **foundry-plant/0**: builtin plant is mill+inspect only. `foundry.json` `"plant": "@0xray/blip"` (aliases `"plant": "blip"` / `"plant": "sound"`) fastens whatever that mill declares (looker / vibe / mixer live in the mill). Nested mill `@0xray/foundry@0.1.12`. CLI shims `foundry blip\|sound` remain (0.1.11+). Shop plant first-class (`shop-extract`, `shop-witness`, `shop-pin` + `foundry.json` `shopPlant`). |
55
- | **4.0.27** | Host compact proof needs a generation id and a numeric usage percent, and the same preCompact receipt is mirrored to nested consumer wear roots. Memory scoring: [docs/memory-scoring.md](docs/memory-scoring.md) (orchestrator links it). Companion `@0xray/grok-bot` 0.1.6: general page vs `house/` overlay, `templates/house/`, `grok-bot house init`, doctor `GROK_BOT_HOUSE` or walk-up. Not published. |
55
+ | **4.0.28** | MCP servers start through `scripts/node/mcp-launch.cjs` and only see PATH, HOME, XRAY_ROOT, plus names on their keep list. Tokens such as NPM_TOKEN are not inherited. User env values stay out of argv. Postinstall keeps user edits to AGENTS.md, .mcp.json, and opencode.json. `house init` does not copy EXAMPLE.md; doctor FAILs while `house/EXAMPLE.md` exists. Inspect adds a machinePlugin check. The Cursor hook defaults XRAY_ROOT to the suit. Companion `@0xray/grok-bot` 0.1.7. Released 2026-09-27. |
56
+ | **4.0.27** | Host compact proof needs a generation id and a numeric usage percent, and the same preCompact receipt is mirrored to nested consumer wear roots. Memory scoring: [docs/memory-scoring.md](docs/memory-scoring.md) (orchestrator links it). Companion `@0xray/grok-bot` 0.1.6: general page vs `house/` overlay, `templates/house/`, `grok-bot house init`, doctor `GROK_BOT_HOUSE` or walk-up. Released 2026-09-26. |
56
57
  | **4.0.9** | Mill target: postinstall does not mill npm global prefix or `_npx` as a consumer. Isolated HOME skips machine `~/.grok`. |
57
58
  | **4.0.0** | Exo + temperament. Constitution always on; ceremony by host. Repertoire organ on (vendored 0.2). Station card survives compact/host-swap. **On npm.** [vision](docs-site/docs/architecture/v4-vision.md) · [now](docs-site/docs/architecture/v4-now.md) |
58
59
  | **3.4.1** | Unified `install-bridges.cjs` on postinstall — OpenCode, Grok, Hermes, OpenClaw in one pass. All 7 MCPs servers via `npx -y 0xray mcp <cmd>` (no `dist/` paths). Canonical `release.mjs` pipeline. |
package/dist/CHANGELOG.md CHANGED
@@ -41,6 +41,14 @@ The format is based on [Conventional Commits](https://www.conventionalcommits.or
41
41
  - Archive root `integrations/` costume SKILL dump → `docs/archive/integrations-costume/`. Live wear is `src/integrations`.
42
42
  - Archive root `commands/` stale copy → `docs/archive/commands-copy/`. Live wear is `src/opencode/commands`.
43
43
 
44
+ ## [4.0.28] - 2026-09-26
45
+
46
+ Next patch after npm `4.0.27`. Released 2026-09-27.
47
+
48
+ MCP servers start through `scripts/node/mcp-launch.cjs` and only see PATH, HOME, and XRAY_ROOT, plus the names on their keep list. Tokens such as NPM_TOKEN are no longer inherited. User env values stay out of argv. Postinstall keeps user edits to AGENTS.md, .mcp.json, and opencode.json. `house init` no longer copies EXAMPLE.md, and doctor FAILs while `house/EXAMPLE.md` exists. Inspect has a new machinePlugin check. The Cursor hook defaults XRAY_ROOT to the suit directory. Companion package `@0xray/grok-bot@0.1.7`.
49
+
50
+ ---
51
+
44
52
  ## [4.0.27] - 2026-09-25
45
53
 
46
54
  Next patch after npm `4.0.26`. Released 2026-09-26.
package/dist/README.md CHANGED
@@ -52,7 +52,8 @@ Docs: [guides/autonomy-command](docs-site/docs/guides/autonomy-command.md) · Sk
52
52
  | Version | Highlights |
53
53
  |---------|------------|
54
54
  | **Unreleased** | Fasten is **foundry-plant/0**: builtin plant is mill+inspect only. `foundry.json` `"plant": "@0xray/blip"` (aliases `"plant": "blip"` / `"plant": "sound"`) fastens whatever that mill declares (looker / vibe / mixer live in the mill). Nested mill `@0xray/foundry@0.1.12`. CLI shims `foundry blip\|sound` remain (0.1.11+). Shop plant first-class (`shop-extract`, `shop-witness`, `shop-pin` + `foundry.json` `shopPlant`). |
55
- | **4.0.27** | Host compact proof needs a generation id and a numeric usage percent, and the same preCompact receipt is mirrored to nested consumer wear roots. Memory scoring: [docs/memory-scoring.md](docs/memory-scoring.md) (orchestrator links it). Companion `@0xray/grok-bot` 0.1.6: general page vs `house/` overlay, `templates/house/`, `grok-bot house init`, doctor `GROK_BOT_HOUSE` or walk-up. Not published. |
55
+ | **4.0.28** | MCP servers start through `scripts/node/mcp-launch.cjs` and only see PATH, HOME, XRAY_ROOT, plus names on their keep list. Tokens such as NPM_TOKEN are not inherited. User env values stay out of argv. Postinstall keeps user edits to AGENTS.md, .mcp.json, and opencode.json. `house init` does not copy EXAMPLE.md; doctor FAILs while `house/EXAMPLE.md` exists. Inspect adds a machinePlugin check. The Cursor hook defaults XRAY_ROOT to the suit. Companion `@0xray/grok-bot` 0.1.7. Released 2026-09-27. |
56
+ | **4.0.27** | Host compact proof needs a generation id and a numeric usage percent, and the same preCompact receipt is mirrored to nested consumer wear roots. Memory scoring: [docs/memory-scoring.md](docs/memory-scoring.md) (orchestrator links it). Companion `@0xray/grok-bot` 0.1.6: general page vs `house/` overlay, `templates/house/`, `grok-bot house init`, doctor `GROK_BOT_HOUSE` or walk-up. Released 2026-09-26. |
56
57
  | **4.0.9** | Mill target: postinstall does not mill npm global prefix or `_npx` as a consumer. Isolated HOME skips machine `~/.grok`. |
57
58
  | **4.0.0** | Exo + temperament. Constitution always on; ceremony by host. Repertoire organ on (vendored 0.2). Station card survives compact/host-swap. **On npm.** [vision](docs-site/docs/architecture/v4-vision.md) · [now](docs-site/docs/architecture/v4-now.md) |
58
59
  | **3.4.1** | Unified `install-bridges.cjs` on postinstall — OpenCode, Grok, Hermes, OpenClaw in one pass. All 7 MCPs servers via `npx -y 0xray mcp <cmd>` (no `dist/` paths). Canonical `release.mjs` pipeline. |
@@ -59,5 +59,5 @@ fi
59
59
 
60
60
  export XRAY_HOOK_EVENT="$EVENT"
61
61
  export XRAY_AI_PATH="$MILL"
62
- export XRAY_ROOT="$MILL"
62
+ export XRAY_ROOT="${XRAY_ROOT:-$(pwd)}"
63
63
  exec "$NODE_BIN" "${MILL}/${REL}"
@@ -14,6 +14,7 @@ const PLATE_IDS = Object.freeze([
14
14
  "processor",
15
15
  "reporting",
16
16
  "memory-recall",
17
+ "house",
17
18
  ]);
18
19
 
19
20
  /** Id token, then extra phrases. Equal top scores recall nothing. */
@@ -61,6 +62,12 @@ const CUES = {
61
62
  [/recall(?:s|ed|ing)?\s+(?:a\s+|one\s+|the\s+)?plate/i, 5],
62
63
  [/stamp(?:ed|ing)?\s+plates?/i, 4],
63
64
  ],
65
+ house: [
66
+ [/house init/i, 4],
67
+ [/HOUSE\.md/i, 4],
68
+ [/GROK_BOT_HOUSE/i, 4],
69
+ [/setup-house/i, 4],
70
+ ],
64
71
  };
65
72
 
66
73
  function platesDir() {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xray-pre-tool",
3
- "version": "4.0.27",
3
+ "version": "4.0.28",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "0xRay constitution + temperament PreToolUse for OpenClaw",
@@ -1,4 +1,4 @@
1
- export declare const PLATE_IDS: readonly ["routing", "governance", "boot", "orchestration", "processor", "reporting", "memory-recall"];
1
+ export declare const PLATE_IDS: readonly ["routing", "governance", "boot", "orchestration", "processor", "reporting", "memory-recall", "house"];
2
2
  export type PlateId = (typeof PLATE_IDS)[number];
3
3
  export interface PlateStamp {
4
4
  id: PlateId;
@@ -13,6 +13,7 @@ export const PLATE_IDS = [
13
13
  'processor',
14
14
  'reporting',
15
15
  'memory-recall',
16
+ 'house',
16
17
  ];
17
18
  function isPlateId(value) {
18
19
  return PLATE_IDS.includes(value);
@@ -0,0 +1,50 @@
1
+ ---
2
+ title: House
3
+ sidebar_label: House
4
+ ---
5
+
6
+ # House
7
+
8
+ One team, one folder. `grok-bot house init` copies `HOUSE.md`, `WAVEBOARD.md`, and `ATTENTION_STATE.md` into `./house`. It does not copy `EXAMPLE.md`. It refuses when a target file is already there. Doctor reads `HOUSE.md` through `GROK_BOT_HOUSE`, or by walking up when that variable is unset, and FAILs with `house/EXAMPLE.md exists — delete it` when that file is present. A wake reads the station, then the board files in `house/`.
9
+
10
+ ```
11
+ ┌──────────────────────────────────────────────────────────────┐
12
+ │ INPUT LAYER │
13
+ │ │
14
+ │ npx @0xray/grok-bot house init │
15
+ │ copies HOUSE.md, WAVEBOARD.md, ATTENTION_STATE.md only │
16
+ │ refuses if a target file is already there │
17
+ └──────────────────────────────┬───────────────────────────────┘
18
+ v
19
+ ┌──────────────────────────────┼───────────────────────────────┐
20
+ │ PROCESSING LAYER v │
21
+ │ │
22
+ │ ┌──────────────────────────────────────────────────────┐ │
23
+ │ │ HOUSE.md — six headings │ │
24
+ │ │ owner · seats · public voice · allowed · ask first │ │
25
+ │ │ board: WAVEBOARD.md + ATTENTION_STATE.md │ │
26
+ │ │ the owner approves Allowed before it counts │ │
27
+ │ └───────────────────────────┬──────────────────────────┘ │
28
+ │ v │
29
+ │ ┌──────────────────────────────────────────────────────┐ │
30
+ │ │ grok-bot doctor │ │
31
+ │ │ GROK_BOT_HOUSE, or walk up for house/HOUSE.md │ │
32
+ │ │ a set path that is missing warns and does not walk │ │
33
+ │ │ a line that starts with (example) fails │ │
34
+ │ │ house/EXAMPLE.md exists — delete it │ │
35
+ │ └───────────────────────────┬──────────────────────────┘ │
36
+ └──────────────────────────────┼───────────────────────────────┘
37
+ v
38
+ ┌──────────────────────────────┼───────────────────────────────┐
39
+ │ OUTPUT LAYER v │
40
+ │ │
41
+ │ read the station, then house/WAVEBOARD.md │
42
+ │ and house/ATTENTION_STATE.md │
43
+ │ a missing board file uses templates/house/ │
44
+ │ one card, one owner │
45
+ │ change the house when the owner says a rule twice │
46
+ └──────────────────────────────────────────────────────────────┘
47
+
48
+ ```
49
+
50
+ stamped · 0xray · @0xray/grok-bot 0.1.7
@@ -13,4 +13,4 @@ The station card stays the short subsystem table. That table is the index still
13
13
 
14
14
  The markdown file is the source of truth for that stamp. When the pipeline changes, edit the plate. A worn project may hold a copy at `.xray/state/plates/<id>.md`. The copy is written only when that file is missing, so a local edit stays.
15
15
 
16
- [Routing](./routing.md) · [Governance](./governance.md) · [Boot](./boot.md) · [Orchestration](./orchestration.md) · [Processor](./processor.md) · [Reporting](./reporting.md) · [Memory recall](./memory-recall.md)
16
+ [Routing](./routing.md) · [Governance](./governance.md) · [Boot](./boot.md) · [Orchestration](./orchestration.md) · [Processor](./processor.md) · [Reporting](./reporting.md) · [Memory recall](./memory-recall.md) · [House](./house.md)
package/llms.txt CHANGED
@@ -12,6 +12,7 @@ Default consumer wear is **mill plant** (`mill` + `inspect`), not a 45-skill / 4
12
12
  - Memory scoring rules: [docs/memory-scoring.md](docs/memory-scoring.md). The orchestrator links that file. It does not copy the rules.
13
13
  - Host `preCompact` writes the hook line and per-session receipt on the suit mill and on nested consumer wear roots (a bench with local `node_modules/0xray`). A counted cut needs a non-empty generation id and a numeric usage percent.
14
14
  - `@0xray/grok-bot`: the general page is `grok-bot/OP-PROC.md`. The team overlay is `house/` and is not in that npm pack. `templates/house/` ships. `grok-bot house init` copies the template and refuses to overwrite. Doctor uses `GROK_BOT_HOUSE` when set, otherwise walks up from the working directory for `house/HOUSE.md`.
15
+ - MCP servers start through `scripts/node/mcp-launch.cjs`. They see PATH, HOME, and XRAY_ROOT, plus the names on their keep list. Tokens such as NPM_TOKEN are not inherited. User env values stay out of argv. Postinstall keeps user edits to AGENTS.md, `.mcp.json`, and `opencode.json`. `house init` does not copy EXAMPLE.md. Doctor FAILs while `house/EXAMPLE.md` exists. Inspect has a machinePlugin check. The Cursor hook defaults XRAY_ROOT to the suit directory.
15
16
 
16
17
  - `npx @0xray/foundry mint --skip-live` — re-fasten mill plant, then inspect.
17
18
  - `npx @0xray/foundry inspect --skip-live` — plant-vs-worn, receipt, CI. Chat is not a receipt.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "0xray",
3
- "version": "4.0.27",
3
+ "version": "4.0.28",
4
4
  "description": "Multi-agent orchestration and Codex governance for OpenCode, Hermes, Grok Build, and OpenClaw",
5
5
  "readme": "README.md",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Mill inspect organ. Runs the six plant checks plus observational harness.
3
+ * Mill inspect organ. Runs the plant checks, a machinePlugin check, and observational harness.
4
4
  * Not an 8th MCP. Not PPE. Does not fasten hooks or Repertoire.
5
5
  *
6
6
  * npx @0xray/foundry inspect [--skip-live] [--go] [--go-out PATH]
@@ -515,7 +515,7 @@ export function packagesToProbe(root) {
515
515
  });
516
516
  }
517
517
 
518
- function checkIsolatedHome(root, env = process.env, machine = mint.machineHome()) {
518
+ export function checkIsolatedHome(root, env = process.env, machine = mint.machineHome()) {
519
519
  const isolated = isIsolatedHome(env, machine);
520
520
  const machinePlugin = machineGrokPluginDir(machine);
521
521
  const dests = mint.resolveGrokPluginDests(root, env, machine);
@@ -542,6 +542,50 @@ function checkIsolatedHome(root, env = process.env, machine = mint.machineHome()
542
542
  };
543
543
  }
544
544
 
545
+ function isXrayPluginName(name) {
546
+ return name === "0xray" || name.startsWith("0xray");
547
+ }
548
+
549
+ /** 0xray plugin dirs under the machine HOME's grok plugin folder. */
550
+ export function machineXrayPluginPaths(machine = mint.machineHome()) {
551
+ const pluginsDir = path.join(machine, ".grok", "plugins");
552
+ if (!fs.existsSync(pluginsDir)) return [];
553
+ let names;
554
+ try {
555
+ names = fs.readdirSync(pluginsDir);
556
+ } catch {
557
+ return [];
558
+ }
559
+ return names
560
+ .filter((name) => isXrayPluginName(name))
561
+ .sort()
562
+ .map((name) => path.join(pluginsDir, name));
563
+ }
564
+
565
+ /**
566
+ * `isolated` stays HOME != machine home. This check is whether a machine-level
567
+ * 0xray Grok plugin exists. Overall inspect ok is false when this check fails.
568
+ */
569
+ export function checkMachinePlugin(machine = mint.machineHome()) {
570
+ const machinePlugin = machineGrokPluginDir(machine);
571
+ const hits = machineXrayPluginPaths(machine);
572
+ if (hits.length > 0) {
573
+ return {
574
+ id: "machinePlugin",
575
+ ok: false,
576
+ machinePlugin,
577
+ hits,
578
+ detail: `machine-level ${hits.join(", ")} exists — delete it`,
579
+ };
580
+ }
581
+ return {
582
+ id: "machinePlugin",
583
+ ok: true,
584
+ machinePlugin,
585
+ hits: [],
586
+ };
587
+ }
588
+
545
589
  export async function inspectSuit(root, opts = {}) {
546
590
  const millRoot = opts.millRoot || millPackageDir();
547
591
  const fetchFn = opts.fetch || fetch;
@@ -584,6 +628,7 @@ export async function inspectSuit(root, opts = {}) {
584
628
  }
585
629
 
586
630
  checks.push(checkIsolatedHome(root, env, machineHome));
631
+ checks.push(checkMachinePlugin(machineHome));
587
632
  checks.push(checkHarness(root, requireHarness));
588
633
 
589
634
  const failed = checks.filter((c) => c.ok === false);
@@ -144,20 +144,25 @@ function detectConsumerExtraMcpServers(targetDir) {
144
144
  try {
145
145
  const repertoireMcp = resolveRepertoireMcp(targetDir);
146
146
  if (!repertoireMcp) return extras;
147
+ const launch = wrapScopedLaunch(
148
+ { command: "node", args: [repertoireMcp] },
149
+ baseScopedEnv(targetDir),
150
+ );
147
151
  extras.hermes.repertoire = {
148
- command: "node",
149
- args: [repertoireMcp],
150
- env: { XRAY_ROOT: targetDir },
152
+ command: launch.command,
153
+ args: launch.args,
154
+ env: launch.env,
151
155
  };
152
156
  extras.opencode.repertoire = {
153
157
  type: "local",
154
- command: ["node", repertoireMcp],
158
+ command: launch.commandList,
155
159
  enabled: true,
160
+ environment: launch.env,
156
161
  };
157
162
  extras.openclaw.repertoire = {
158
- command: "node",
159
- args: [repertoireMcp],
160
- env: { XRAY_ROOT: targetDir },
163
+ command: launch.command,
164
+ args: launch.args,
165
+ env: launch.env,
161
166
  };
162
167
  } catch {
163
168
  // best-effort
@@ -165,11 +170,284 @@ function detectConsumerExtraMcpServers(targetDir) {
165
170
  return extras;
166
171
  }
167
172
 
168
- function buildStdioMcpServer(mcpCmd, env, targetDir) {
173
+ function readInstalledXrayVersion() {
174
+ const pkgPath = path.join(__dirname, "..", "..", "package.json");
175
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
176
+ if (typeof pkg.version !== "string" || !pkg.version) {
177
+ throw new Error("0xray package.json version missing; refusing unpinned MCP launch");
178
+ }
179
+ return pkg.version;
180
+ }
181
+
182
+ function localXrayCli(targetDir) {
183
+ if (!targetDir) return null;
184
+ const cli = path.join(targetDir, "node_modules", "0xray", "dist", "cli", "index.js");
185
+ return fs.existsSync(cli) ? cli : null;
186
+ }
187
+
188
+ /** Prefer the installed CLI. Else pin npx to this package version. Never bare `0xray`. */
189
+ function pinnedMcpLaunch(targetDir, mcpCmd) {
190
+ const cli = localXrayCli(targetDir);
191
+ if (cli) {
192
+ return {
193
+ command: "node",
194
+ args: [cli, "mcp", mcpCmd],
195
+ commandList: ["node", cli, "mcp", mcpCmd],
196
+ };
197
+ }
198
+ const spec = `0xray@${readInstalledXrayVersion()}`;
169
199
  return {
170
200
  command: "npx",
171
- args: ["-y", "0xray", "mcp", mcpCmd],
172
- env: { ...env, XRAY_ROOT: targetDir },
201
+ args: ["-y", spec, "mcp", mcpCmd],
202
+ commandList: ["npx", "-y", spec, "mcp", mcpCmd],
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Stdio MCP hosts merge an `env` object onto the parent process. They cannot
208
+ * drop inherited secrets. argv is world-readable, so values never go on the
209
+ * command line. Every launch is:
210
+ * node <mcp-launch.cjs> --keep PATH,HOME,XRAY_ROOT[,flag][,user names] -- <pinned command>
211
+ * `scripts/node/mcp-launch.cjs` copies those names from its own environ (the
212
+ * live host, after the host merged this entry's `env` block) and spawns the
213
+ * server. PATH and HOME are names only; their values are whatever the host
214
+ * has at launch. XRAY_ROOT and non-secret server flags stay in the `env`
215
+ * block. User keys stay in that block too, and their names are appended to
216
+ * --keep. NODE_OPTIONS is omitted: none of the seven stdio servers read it.
217
+ */
218
+ const LIVE_ENV_NAMES = ["PATH", "HOME"];
219
+
220
+ function mcpLauncherPath() {
221
+ return path.join(__dirname, "mcp-launch.cjs");
222
+ }
223
+
224
+ function baseScopedEnv(targetDir) {
225
+ const env = {};
226
+ if (targetDir) env.XRAY_ROOT = targetDir;
227
+ return env;
228
+ }
229
+
230
+ function orderedEnv(envMap) {
231
+ const preferred = ["PATH", "HOME", "XRAY_ROOT"];
232
+ const ordered = {};
233
+ for (const key of preferred) {
234
+ if (Object.prototype.hasOwnProperty.call(envMap, key)) ordered[key] = String(envMap[key]);
235
+ }
236
+ for (const key of Object.keys(envMap)) {
237
+ if (!Object.prototype.hasOwnProperty.call(ordered, key)) ordered[key] = String(envMap[key]);
238
+ }
239
+ return ordered;
240
+ }
241
+
242
+ function mergeScopedEnv(layers) {
243
+ const merged = {};
244
+ for (const layer of layers) {
245
+ if (!layer || typeof layer !== "object" || Array.isArray(layer)) continue;
246
+ for (const key of Object.keys(layer)) {
247
+ if (layer[key] == null) continue;
248
+ merged[key] = String(layer[key]);
249
+ }
250
+ }
251
+ return orderedEnv(merged);
252
+ }
253
+
254
+ function configEnvFromMap(envMap) {
255
+ const ordered = orderedEnv(envMap || {});
256
+ const config = {};
257
+ for (const key of Object.keys(ordered)) {
258
+ if (LIVE_ENV_NAMES.includes(key)) continue;
259
+ config[key] = ordered[key];
260
+ }
261
+ return config;
262
+ }
263
+
264
+ function keepList(config) {
265
+ return [...LIVE_ENV_NAMES, ...Object.keys(config)];
266
+ }
267
+
268
+ function wrapScopedLaunch(inner, envMap) {
269
+ const env = configEnvFromMap(envMap);
270
+ const tail = [mcpLauncherPath(), "--keep", keepList(env).join(","), "--", inner.command, ...inner.args];
271
+ return {
272
+ command: "node",
273
+ args: tail,
274
+ commandList: ["node", ...tail],
275
+ env,
276
+ };
277
+ }
278
+
279
+ function scopedMcpLaunch(targetDir, mcpCmd, serverEnv) {
280
+ return wrapScopedLaunch(pinnedMcpLaunch(targetDir, mcpCmd), mergeScopedEnv([baseScopedEnv(targetDir), serverEnv]));
281
+ }
282
+
283
+ function commandTokens(server) {
284
+ if (!server || typeof server !== "object") return [];
285
+ if (Array.isArray(server.command)) return server.command.map(String);
286
+ const tokens = [];
287
+ if (typeof server.command === "string") tokens.push(server.command);
288
+ if (Array.isArray(server.args)) tokens.push(...server.args.map(String));
289
+ return tokens;
290
+ }
291
+
292
+ function isMcpLauncherToken(token) {
293
+ return token === "mcp-launch.cjs" || token.endsWith(`${path.sep}mcp-launch.cjs`) || token.endsWith("/mcp-launch.cjs");
294
+ }
295
+
296
+ function peelScopedLaunch(tokens) {
297
+ if (tokens[0] === "env") {
298
+ let i = 1;
299
+ if (tokens[i] === "-i" || tokens[i] === "--ignore-environment") i += 1;
300
+ else return tokens.slice();
301
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i += 1;
302
+ return tokens.slice(i);
303
+ }
304
+ if (tokens[0] === "node" && isMcpLauncherToken(tokens[1] || "")) {
305
+ const dash = tokens.indexOf("--");
306
+ if (dash >= 0) return tokens.slice(dash + 1);
307
+ }
308
+ return tokens.slice();
309
+ }
310
+
311
+ function launchTokens(server) {
312
+ return peelScopedLaunch(commandTokens(server));
313
+ }
314
+
315
+ function innerFromTokens(tokens) {
316
+ if (!tokens.length) return null;
317
+ return { command: tokens[0], args: tokens.slice(1) };
318
+ }
319
+
320
+ function usesCommandArray(server) {
321
+ return Array.isArray(server && server.command);
322
+ }
323
+
324
+ function hasVersionPin(server) {
325
+ return launchTokens(server).some((token) => /^0xray@.+/.test(token));
326
+ }
327
+
328
+ function isUnpinnedXrayLaunch(server) {
329
+ return launchTokens(server).some((token) => token === "0xray");
330
+ }
331
+
332
+ function jsonDeepEqual(a, b) {
333
+ if (a === b) return true;
334
+ if (typeof a !== typeof b) return false;
335
+ if (a === null || b === null || typeof a !== "object") return a === b;
336
+ if (Array.isArray(a) || Array.isArray(b)) {
337
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
338
+ return a.every((item, i) => jsonDeepEqual(item, b[i]));
339
+ }
340
+ const aKeys = Object.keys(a);
341
+ const bKeys = Object.keys(b);
342
+ if (aKeys.length !== bKeys.length) return false;
343
+ return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && jsonDeepEqual(a[key], b[key]));
344
+ }
345
+
346
+ function mcpCmdFromInner(inner) {
347
+ const idx = inner.args.indexOf("mcp");
348
+ if (idx >= 0 && inner.args[idx + 1]) return inner.args[idx + 1];
349
+ return inner.args.length > 0 ? inner.args[inner.args.length - 1] : null;
350
+ }
351
+
352
+ /** Framework fills gaps. User env, enabled/disabled, and version pins win. Unpinned `0xray` is repinned. Launch is `node mcp-launch.cjs --keep names -- cmd`. */
353
+ function mergeUserWinsServer(framework, user, targetDir) {
354
+ if (!user || typeof user !== "object") return framework;
355
+ if (!framework || typeof framework !== "object") return user;
356
+ const next = { ...user };
357
+ const userInner = innerFromTokens(launchTokens(user));
358
+ const frameworkInner = innerFromTokens(launchTokens(framework));
359
+ let inner = userInner || frameworkInner;
360
+ if (userInner && !hasVersionPin(user) && isUnpinnedXrayLaunch(user) && frameworkInner) {
361
+ inner = frameworkInner;
362
+ }
363
+ if (inner && isUnpinnedXrayLaunch({ command: inner.command, args: inner.args })) {
364
+ const mcpCmd = mcpCmdFromInner(inner);
365
+ if (mcpCmd) inner = pinnedMcpLaunch(targetDir, mcpCmd);
366
+ }
367
+ if (!inner) return framework;
368
+ const mergedEnv = mergeScopedEnv([
369
+ baseScopedEnv(targetDir),
370
+ framework.env,
371
+ framework.environment,
372
+ user.env,
373
+ user.environment,
374
+ ]);
375
+ const wrapped = wrapScopedLaunch(inner, mergedEnv);
376
+ if (usesCommandArray(user) || usesCommandArray(framework)) {
377
+ next.command = wrapped.commandList;
378
+ delete next.args;
379
+ } else {
380
+ next.command = wrapped.command;
381
+ next.args = wrapped.args;
382
+ }
383
+ const wantEnvironment =
384
+ Object.prototype.hasOwnProperty.call(user, "environment") ||
385
+ Object.prototype.hasOwnProperty.call(framework, "environment");
386
+ const wantEnv =
387
+ Object.prototype.hasOwnProperty.call(user, "env") || Object.prototype.hasOwnProperty.call(framework, "env");
388
+ if (wantEnvironment) next.environment = mergedEnv;
389
+ if (wantEnv) next.env = mergedEnv;
390
+ else if (!wantEnvironment) next.env = mergedEnv;
391
+ if (!Object.prototype.hasOwnProperty.call(user, "enabled") && Object.prototype.hasOwnProperty.call(framework, "enabled")) {
392
+ next.enabled = framework.enabled;
393
+ }
394
+ if (!Object.prototype.hasOwnProperty.call(user, "disabled") && Object.prototype.hasOwnProperty.call(framework, "disabled")) {
395
+ next.disabled = framework.disabled;
396
+ }
397
+ return next;
398
+ }
399
+
400
+ function mergeMcpMap(frameworkServers, userServers, targetDir) {
401
+ const framework = frameworkServers && typeof frameworkServers === "object" ? frameworkServers : {};
402
+ const user = userServers && typeof userServers === "object" ? userServers : {};
403
+ const merged = {};
404
+ const seen = new Set();
405
+ for (const name of Object.keys(user)) {
406
+ seen.add(name);
407
+ merged[name] = framework[name] ? mergeUserWinsServer(framework[name], user[name], targetDir) : user[name];
408
+ }
409
+ for (const name of Object.keys(framework)) {
410
+ if (seen.has(name)) continue;
411
+ merged[name] = framework[name];
412
+ }
413
+ return merged;
414
+ }
415
+
416
+ function mergeNamedRecords(src, dest) {
417
+ const source = src && typeof src === "object" ? src : {};
418
+ const user = dest && typeof dest === "object" ? dest : {};
419
+ const merged = {};
420
+ const seen = new Set();
421
+ for (const name of Object.keys(user)) {
422
+ seen.add(name);
423
+ const fromSrc = source[name];
424
+ const fromUser = user[name];
425
+ if (
426
+ fromSrc &&
427
+ fromUser &&
428
+ typeof fromSrc === "object" &&
429
+ typeof fromUser === "object" &&
430
+ !Array.isArray(fromSrc) &&
431
+ !Array.isArray(fromUser)
432
+ ) {
433
+ merged[name] = { ...fromSrc, ...fromUser };
434
+ } else {
435
+ merged[name] = fromUser;
436
+ }
437
+ }
438
+ for (const name of Object.keys(source)) {
439
+ if (seen.has(name)) continue;
440
+ merged[name] = source[name];
441
+ }
442
+ return merged;
443
+ }
444
+
445
+ function buildStdioMcpServer(mcpCmd, env, targetDir) {
446
+ const launch = scopedMcpLaunch(targetDir, mcpCmd, env);
447
+ return {
448
+ command: launch.command,
449
+ args: launch.args,
450
+ env: launch.env,
173
451
  };
174
452
  }
175
453
 
@@ -194,12 +472,12 @@ function buildOpenClawMcpServers(targetDir) {
194
472
  function buildOpencodeMcpEntries(targetDir) {
195
473
  const entries = {};
196
474
  for (const s of XRAY_MCP_SERVERS) {
197
- const environment = { ...s.env };
475
+ const launch = scopedMcpLaunch(targetDir, s.mcpCmd, s.env);
198
476
  entries[s.name] = {
199
477
  type: "local",
200
- command: ["npx", "-y", "0xray", "mcp", s.mcpCmd],
478
+ command: launch.commandList,
201
479
  enabled: true,
202
- ...(Object.keys(environment).length > 0 ? { environment } : {}),
480
+ environment: launch.env,
203
481
  };
204
482
  }
205
483
  const extras = detectConsumerExtraMcpServers(targetDir);
@@ -209,23 +487,25 @@ function buildOpencodeMcpEntries(targetDir) {
209
487
  function buildPluginMcpJson(targetDir) {
210
488
  const mcpServers = {};
211
489
  for (const s of XRAY_MCP_SERVERS) {
490
+ const launch = scopedMcpLaunch(targetDir, s.mcpCmd, s.env);
212
491
  mcpServers[s.name] = {
213
- command: "npx",
214
- args: ["-y", "0xray", "mcp", s.mcpCmd],
215
- env: { ...s.env, XRAY_ROOT: targetDir },
492
+ command: launch.command,
493
+ args: launch.args,
494
+ env: launch.env,
216
495
  };
217
496
  }
218
497
  return { mcpServers };
219
498
  }
220
499
 
221
- /** Portable .mcp.json for Grok/Cursor — no absolute XRAY_ROOT */
222
- function buildPortableProjectMcpJson() {
500
+ /** Project .mcp.json for Grok/Cursor. Launcher allowlist drops inherited secrets; XRAY_ROOT is set when targetDir is known. */
501
+ function buildPortableProjectMcpJson(targetDir) {
223
502
  const mcpServers = {};
224
503
  for (const s of XRAY_MCP_SERVERS) {
504
+ const launch = scopedMcpLaunch(targetDir, s.mcpCmd, s.env);
225
505
  mcpServers[s.name] = {
226
- command: "npx",
227
- args: ["-y", "0xray", "mcp", s.mcpCmd],
228
- ...(Object.keys(s.env).length > 0 ? { env: { ...s.env } } : {}),
506
+ command: launch.command,
507
+ args: launch.args,
508
+ env: launch.env,
229
509
  };
230
510
  }
231
511
  return { mcpServers };
@@ -312,16 +592,21 @@ function mergeOpencodeMcpRegistry(targetDir) {
312
592
  config = { $schema: "https://opencode.ai/config.json", mcp: {} };
313
593
  }
314
594
  }
315
- config.mcp = { ...(config.mcp || {}), ...entries };
316
- fs.writeFileSync(opencodePath, `${JSON.stringify(config, null, 2)}\n`);
595
+ const existed = fs.existsSync(opencodePath);
596
+ const nextMcp = mergeMcpMap(entries, config.mcp || {}, targetDir);
597
+ const next = { ...config, mcp: nextMcp };
598
+ if (existed && jsonDeepEqual(next, config)) return Object.keys(entries).length;
599
+ fs.writeFileSync(opencodePath, `${JSON.stringify(next, null, 2)}\n`);
317
600
  return Object.keys(entries).length;
318
601
  }
319
602
 
320
603
  function deployPortableProjectMcpJson(targetDir) {
321
604
  const destPath = path.join(targetDir, ".mcp.json");
322
- const portable = buildPortableProjectMcpJson();
605
+ const portable = buildPortableProjectMcpJson(targetDir);
323
606
  let existing = { mcpServers: {} };
607
+ let hadFile = false;
324
608
  if (fs.existsSync(destPath)) {
609
+ hadFile = true;
325
610
  try {
326
611
  existing = JSON.parse(fs.readFileSync(destPath, "utf8"));
327
612
  } catch {
@@ -329,14 +614,12 @@ function deployPortableProjectMcpJson(targetDir) {
329
614
  }
330
615
  }
331
616
  const extras = detectConsumerExtraMcpServers(targetDir);
617
+ const framework = { ...portable.mcpServers, ...(extras.openclaw || {}) };
332
618
  const merged = {
333
619
  ...existing,
334
- mcpServers: {
335
- ...(existing.mcpServers || {}),
336
- ...portable.mcpServers,
337
- ...(extras.openclaw || {}),
338
- },
620
+ mcpServers: mergeMcpMap(framework, existing.mcpServers || {}, targetDir),
339
621
  };
622
+ if (hadFile && jsonDeepEqual(merged, existing)) return;
340
623
  fs.writeFileSync(destPath, `${JSON.stringify(merged, null, 2)}\n`);
341
624
  }
342
625
 
@@ -573,6 +856,14 @@ module.exports = {
573
856
  buildStdioMcpServer,
574
857
  buildPluginMcpJson,
575
858
  buildPortableProjectMcpJson,
859
+ pinnedMcpLaunch,
860
+ scopedMcpLaunch,
861
+ baseScopedEnv,
862
+ wrapScopedLaunch,
863
+ mergeMcpMap,
864
+ mergeNamedRecords,
865
+ mergeUserWinsServer,
866
+ jsonDeepEqual,
576
867
  copyHermesFindProjectRootHelper,
577
868
  writeHermesPluginArtifacts,
578
869
  syncHermesMcpRegistry,
@@ -353,11 +353,45 @@ function main() {
353
353
  throw new Error(`Missing MCP server in .mcp.json: ${name}`);
354
354
  }
355
355
  const entry = mcp.mcpServers[name];
356
- if (entry.command !== "npx") {
357
- throw new Error(`${name} should use npx, got ${entry.command}`);
356
+ const cli = path.join(tmpRoot, "node_modules", "0xray", "dist", "cli", "index.js");
357
+ const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
358
+ const launcher = path.join(nmRoot, "scripts", "node", "mcp-launch.cjs");
359
+ if (entry.command !== "node" || args[0] !== launcher || args[1] !== "--keep") {
360
+ throw new Error(`${name} must launch via node mcp-launch.cjs --keep, got ${entry.command} ${args.join(" ")}`);
361
+ }
362
+ const dash = args.indexOf("--");
363
+ if (dash < 0) throw new Error(`${name} launcher args missing --`);
364
+ const kept = String(args[2] || "").split(",").filter(Boolean);
365
+ const inner = args.slice(dash + 1);
366
+ const pinnedNode = inner[0] === "node" && inner[1] === cli && inner[2] === "mcp";
367
+ const pinnedNpx =
368
+ inner[0] === "npx" && inner[1] === "-y" && /^0xray@\d/.test(inner[2] || "") && inner[3] === "mcp";
369
+ if (!pinnedNode && !pinnedNpx) {
370
+ throw new Error(`${name} must pin node CLI or npx 0xray@version after --, got ${inner.join(" ")}`);
371
+ }
372
+ if (inner.includes("0xray")) {
373
+ throw new Error(`${name} launched unpinned 0xray`);
374
+ }
375
+ for (const required of ["PATH", "HOME", "XRAY_ROOT"]) {
376
+ if (!kept.includes(required)) {
377
+ throw new Error(`${name} keep list missing ${required}`);
378
+ }
379
+ }
380
+ if (entry.env && (entry.env.PATH || entry.env.HOME)) {
381
+ throw new Error(`${name} baked PATH or HOME into the env block`);
382
+ }
383
+ for (const arg of args) {
384
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
385
+ throw new Error(`${name} put an env assignment in argv: ${arg.split("=")[0]}`);
386
+ }
387
+ }
388
+ for (const banned of ["NPM_TOKEN", "CURSOR_AUTH_TOKEN", "GITHUB_TOKEN", "GH_TOKEN", "RAILWAY_TOKEN"]) {
389
+ if (kept.includes(banned) || (entry.env && Object.prototype.hasOwnProperty.call(entry.env, banned))) {
390
+ throw new Error(`${name} inherited ${banned}`);
391
+ }
358
392
  }
359
393
  }
360
- console.log(` ✅ .mcp.json has ${XRAY_MCP_NAMES.length} npx MCP servers`);
394
+ console.log(` ✅ .mcp.json has ${XRAY_MCP_NAMES.length} version-pinned MCP servers`);
361
395
 
362
396
  assertFreshInstallDefaults(tmpRoot, version);
363
397
  assertOrganRequire(tmpRoot);
@@ -22,6 +22,10 @@ const {
22
22
  wireOpencodeBridge,
23
23
  wireOpenClawBridge,
24
24
  deployPortableProjectMcpJson,
25
+ scopedMcpLaunch,
26
+ mergeMcpMap,
27
+ mergeNamedRecords,
28
+ jsonDeepEqual,
25
29
  copyHermesFindProjectRootHelper,
26
30
  copyHermesHookRuntimes,
27
31
  installOpenClawHostWear,
@@ -199,10 +203,21 @@ function mergeOpencodeJson(targetDir, packageRoot, log) {
199
203
  if (fs.existsSync(userOpencode)) {
200
204
  const destData = JSON.parse(fs.readFileSync(userOpencode, "utf8"));
201
205
  const merged = { ...destData };
202
- if (srcData.agent) merged.agent = srcData.agent;
203
- if (srcData.mcp) merged.mcp = { ...destData.mcp, ...srcData.mcp };
204
- if (srcData.compaction) merged.compaction = srcData.compaction;
205
- fs.writeFileSync(userOpencode, JSON.stringify(merged, null, 2) + "\n");
206
+ if (srcData.agent || destData.agent) {
207
+ merged.agent = mergeNamedRecords(srcData.agent, destData.agent);
208
+ }
209
+ if (srcData.mcp || destData.mcp) {
210
+ merged.mcp = mergeMcpMap(srcData.mcp, destData.mcp, targetDir);
211
+ }
212
+ if (srcData.compaction || destData.compaction) {
213
+ merged.compaction =
214
+ srcData.compaction && destData.compaction
215
+ ? { ...srcData.compaction, ...destData.compaction }
216
+ : destData.compaction || srcData.compaction;
217
+ }
218
+ if (!jsonDeepEqual(merged, destData)) {
219
+ fs.writeFileSync(userOpencode, JSON.stringify(merged, null, 2) + "\n");
220
+ }
206
221
  } else {
207
222
  fs.copyFileSync(rootOpencode, userOpencode);
208
223
  }
@@ -308,13 +323,13 @@ function copyOpencodePlugin(packageRoot, opencodeDest, log) {
308
323
  if (!fs.existsSync(pluginSource)) return;
309
324
  const pluginDestDir = path.dirname(pluginDest);
310
325
  if (!fs.existsSync(pluginDestDir)) fs.mkdirSync(pluginDestDir, { recursive: true });
311
- const shouldCopy =
312
- !fs.existsSync(pluginDest) ||
313
- fs.statSync(pluginSource).mtime > fs.statSync(pluginDest).mtime;
314
- if (shouldCopy) {
315
- fs.copyFileSync(pluginSource, pluginDest);
316
- log("opencode-bridge", "plugin updated", "info");
326
+ const scopePkg = path.join(opencodeDest, "package.json");
327
+ if (!fs.existsSync(scopePkg)) {
328
+ fs.writeFileSync(scopePkg, `${JSON.stringify({ type: "module" })}\n`);
317
329
  }
330
+ const shim = `export { default } from ${JSON.stringify(pluginSource)};\n`;
331
+ fs.writeFileSync(pluginDest, shim);
332
+ log("opencode-bridge", "plugin shim written", "info");
318
333
  }
319
334
 
320
335
  function installOpencodeBridge(targetDir, packageRoot, log) {
@@ -368,15 +383,13 @@ function registerGrokMcpServers(targetDir, log, pluginDirs) {
368
383
 
369
384
  for (const s of XRAY_MCP_SERVERS) {
370
385
  try {
371
- const envEntries = { ...s.env, XRAY_ROOT: targetDir };
372
- const envFlags = Object.entries(envEntries)
373
- .map(([k, v]) => `--env "${k}=${v}"`)
374
- .join(" ");
375
- execSync(
376
- `grok mcp add ${s.name} --command npx --args "-y" "0xray" "mcp" "${s.mcpCmd}" ${envFlags}`,
377
- { stdio: "pipe" }
378
- );
379
- log("grok-bridge", `registered ${s.name} (npx)`, "info");
386
+ const launch = scopedMcpLaunch(targetDir, s.mcpCmd, s.env);
387
+ const argv = ["mcp", "add", s.name, "--command", launch.command, "--args", launch.args[0], ...launch.args.slice(1)];
388
+ for (const [key, value] of Object.entries(launch.env)) {
389
+ argv.push("--env", `${key}=${value}`);
390
+ }
391
+ execFileSync("grok", argv, { stdio: "pipe" });
392
+ log("grok-bridge", `registered ${s.name} (mcp-launch)`, "info");
380
393
  } catch {
381
394
  // already registered or grok config conflict — non-blocking
382
395
  }
@@ -1068,6 +1081,8 @@ module.exports = {
1068
1081
  installFrameworkDogfoodWear,
1069
1082
  wearVendoredRepertoire,
1070
1083
  installCursorBridge,
1084
+ mergeOpencodeJson,
1085
+ copyOpencodePlugin,
1071
1086
  parseExecDaemonCmdline,
1072
1087
  reloadCursorHostHooks,
1073
1088
  resolveCursorHooksTemplate,
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * Start an MCP server with an allowlisted environment.
5
+ *
6
+ * node mcp-launch.cjs --keep PATH,HOME,XRAY_ROOT[,NAME...] -- <cmd> [args...]
7
+ *
8
+ * argv carries names only. The host merges the config `env` block onto this
9
+ * process, so values come from the live environment. The child receives
10
+ * process.env filtered to those names. stdio is inherited. Signals and the
11
+ * exit code are forwarded.
12
+ */
13
+ const { spawn } = require("child_process");
14
+
15
+ function fail(message) {
16
+ process.stderr.write(`mcp-launch: ${message}\n`);
17
+ process.exit(1);
18
+ }
19
+
20
+ function parseArgs(argv) {
21
+ const keepIdx = argv.indexOf("--keep");
22
+ const dash = argv.indexOf("--");
23
+ if (keepIdx < 0 || dash < 0 || dash !== keepIdx + 2 || dash >= argv.length - 1) {
24
+ fail("usage: node mcp-launch.cjs --keep NAME[,NAME...] -- <cmd> [args...]");
25
+ }
26
+ const keep = argv[keepIdx + 1]
27
+ .split(",")
28
+ .map((name) => name.trim())
29
+ .filter(Boolean);
30
+ if (keep.length === 0) fail("--keep needs at least one name");
31
+ for (const name of keep) {
32
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) fail(`refusing keep name ${name}`);
33
+ }
34
+ return { keep, command: argv[dash + 1], args: argv.slice(dash + 2) };
35
+ }
36
+
37
+ function childEnv(keep) {
38
+ const env = {};
39
+ for (const name of keep) {
40
+ if (process.env[name] != null) env[name] = process.env[name];
41
+ }
42
+ return env;
43
+ }
44
+
45
+ function main() {
46
+ const { keep, command, args } = parseArgs(process.argv.slice(2));
47
+ const child = spawn(command, args, { env: childEnv(keep), stdio: "inherit" });
48
+ const forward = (signal) => {
49
+ if (!child.pid) return;
50
+ try {
51
+ child.kill(signal);
52
+ } catch {
53
+ /* child already exited */
54
+ }
55
+ };
56
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
57
+ process.on(signal, () => forward(signal));
58
+ }
59
+ child.on("error", (err) => fail(err.message));
60
+ child.on("exit", (code, signal) => {
61
+ if (signal) {
62
+ process.removeAllListeners(signal);
63
+ process.kill(process.pid, signal);
64
+ return;
65
+ }
66
+ process.exit(code == null ? 1 : code);
67
+ });
68
+ }
69
+
70
+ main();
@@ -25,6 +25,8 @@ function structuredLog(component, action, status, details) {
25
25
  }
26
26
 
27
27
  const XRAY_MANAGED_AGENTS_MARKER = "<!-- 0xray-managed -->";
28
+ const XRAY_MANAGED_AGENTS_BEGIN = "<!-- 0xray-managed:begin -->";
29
+ const XRAY_MANAGED_AGENTS_END = "<!-- 0xray-managed:end -->";
28
30
 
29
31
  function fillConsumerPlaceholders(content, consumer) {
30
32
  const name = consumer.name || "this project";
@@ -36,24 +38,51 @@ function fillConsumerPlaceholders(content, consumer) {
36
38
  .join(versionParen);
37
39
  }
38
40
 
41
+ function renderManagedAgents(packageRoot, targetDir) {
42
+ let content = fs.readFileSync(path.join(packageRoot, "AGENTS-consumer.md"), "utf8");
43
+ const consumer = readPackageIdentity(path.join(targetDir, "package.json"));
44
+ content = fillConsumerPlaceholders(content, consumer).trim();
45
+ if (!content.includes(XRAY_MANAGED_AGENTS_MARKER)) {
46
+ content = `${content}\n\n${XRAY_MANAGED_AGENTS_MARKER}`;
47
+ }
48
+ return `${XRAY_MANAGED_AGENTS_BEGIN}\n${content}\n${XRAY_MANAGED_AGENTS_END}\n`;
49
+ }
50
+
51
+ function managedRegion(text) {
52
+ const beginAt = text.indexOf(XRAY_MANAGED_AGENTS_BEGIN);
53
+ const endAt = text.indexOf(XRAY_MANAGED_AGENTS_END);
54
+ if (beginAt === -1 || endAt === -1 || endAt < beginAt) return null;
55
+ return {
56
+ before: text.slice(0, beginAt),
57
+ after: text.slice(endAt + XRAY_MANAGED_AGENTS_END.length),
58
+ };
59
+ }
60
+
61
+ function outsideRegionEdited(parts) {
62
+ return parts.before.trim() !== "" || parts.after.trim() !== "";
63
+ }
64
+
39
65
  function deployManagedAgents(packageRoot, targetDir, log) {
40
66
  const agentsConsumer = path.join(packageRoot, "AGENTS-consumer.md");
41
67
  const agentsDest = path.join(targetDir, "AGENTS.md");
42
68
  if (!fs.existsSync(agentsConsumer)) return;
43
- const shouldDeployAgents =
44
- !fs.existsSync(agentsDest) ||
45
- fs.readFileSync(agentsDest, "utf8").includes(XRAY_MANAGED_AGENTS_MARKER);
46
- if (shouldDeployAgents) {
47
- let content = fs.readFileSync(agentsConsumer, "utf8");
48
- const consumer = readPackageIdentity(path.join(targetDir, "package.json"));
49
- content = fillConsumerPlaceholders(content, consumer);
50
- if (!content.includes(XRAY_MANAGED_AGENTS_MARKER)) {
51
- content = `${content.trimEnd()}\n\n${XRAY_MANAGED_AGENTS_MARKER}\n`;
52
- }
53
- fs.writeFileSync(agentsDest, content);
54
- } else {
55
- log("postinstall", "Skipped AGENTS.md (consumer-customized)", "info");
69
+ const next = renderManagedAgents(packageRoot, targetDir);
70
+ if (!fs.existsSync(agentsDest)) {
71
+ fs.writeFileSync(agentsDest, next);
72
+ return;
73
+ }
74
+ const current = fs.readFileSync(agentsDest, "utf8");
75
+ const parts = managedRegion(current);
76
+ if (!parts || outsideRegionEdited(parts)) {
77
+ fs.writeFileSync(path.join(targetDir, "AGENTS.md.0xray-new"), next);
78
+ log("postinstall", "AGENTS.md left in place; wrote AGENTS.md.0xray-new", "info");
79
+ return;
56
80
  }
81
+ const beginAt = next.indexOf(XRAY_MANAGED_AGENTS_BEGIN);
82
+ const endAt = next.indexOf(XRAY_MANAGED_AGENTS_END);
83
+ const interior = next.slice(beginAt, endAt + XRAY_MANAGED_AGENTS_END.length);
84
+ const updated = `${parts.before}${interior}${parts.after}`;
85
+ if (updated !== current) fs.writeFileSync(agentsDest, updated);
57
86
  }
58
87
 
59
88
  function deployConsumerGitignore(packageRoot, targetDir, log) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xray-pre-tool",
3
- "version": "4.0.27",
3
+ "version": "4.0.28",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "0xRay constitution + temperament PreToolUse for OpenClaw",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "./features.schema.json",
3
- "version": "4.0.27",
3
+ "version": "4.0.28",
4
4
  "description": "0xRay Framework - Unified Feature Configuration",
5
5
  "suit_temperament": {
6
6
  "profile": "auto",