xmux-bridge 1.0.39 → 1.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,11 +24,6 @@ brew tap DwvN-Lee/xmux
24
24
  brew install xmux
25
25
  ```
26
26
 
27
- Homebrew owns the stable runtime under `$(brew --prefix)/opt/xmux/libexec`.
28
- The installed `xmux` command exports that path as `XMUX_INSTALL_DIR` and then
29
- execs the runtime wrapper in `libexec/bin/xmux`. Ad hoc local directories, npx
30
- caches, and zsh plugin directories are not part of the normal runtime path.
31
-
32
27
  Configure Codex integration explicitly:
33
28
 
34
29
  ```bash
@@ -36,21 +31,8 @@ xmux setup-codex
36
31
  xmux doctor-codex
37
32
  ```
38
33
 
39
- Homebrew installs the XMux CLI/runtime only. `xmux setup-codex` is the command
40
- that mutates `~/.codex`: it registers the `xmux_lead` MCP server through the
41
- versioned npm package, points that MCP runtime back at Homebrew with
42
- `XMUX_INSTALL_DIR`, adds the installed `xmux` path to Codex shell policy,
43
- installs the scoped XMux command rule, and refreshes available XMux skills
44
- under `~/.codex/skills`. Runtime-only installs do not include skill source
45
- files, so pass an external skill source when refreshing skills:
46
-
47
- ```bash
48
- xmux setup-codex --skills-dir /path/to/xmux-skills
49
- ```
50
-
51
- `XMUX_CODEX_SKILLS_DIR` provides the same source path for automation. Without
52
- `--skills-dir` or `XMUX_CODEX_SKILLS_DIR`, `setup-codex` skips skill refresh
53
- and leaves existing user-owned skills untouched.
34
+ `xmux setup-codex` registers XMux with Codex, and `xmux doctor-codex` checks
35
+ that the integration is ready.
54
36
 
55
37
  Start the Codex lead from the target project directory:
56
38
 
@@ -148,7 +130,7 @@ Runtime state is project-local:
148
130
  Runtime path environment names are now split by responsibility:
149
131
 
150
132
  ```text
151
- XMUX_INSTALL_DIR # XMux source/install directory
133
+ XMUX_INSTALL_DIR # XMux install root
152
134
  XMUX_PROJECT_DIR # project root where Codex is working
153
135
  XMUX_STATE_DIR # project-local runtime state, usually $XMUX_PROJECT_DIR/.codex/xmux
154
136
  ```
@@ -156,35 +138,25 @@ XMUX_STATE_DIR # project-local runtime state, usually $XMUX_PROJECT_DIR/.code
156
138
  Codex uses the normal user runtime under `~/.codex`. XMux does not create an
157
139
  isolated Codex home for a team, and Codex teammate mode is unsupported.
158
140
 
159
- Agent automation uses `xmux` from the Codex shell policy PATH that
160
- `xmux setup-codex` writes to `~/.codex/config.toml`. If that wrapper is
161
- unavailable, it falls back to the explicit XMux executable. The user-facing
162
- bootstrap command remains `xmux -n <session>` after setup; ad hoc local paths
163
- and shell-loading details are not part of the agent contract.
141
+ Agent automation uses the installed `xmux` command that `xmux setup-codex`
142
+ makes available to Codex. The user-facing bootstrap command remains
143
+ `xmux -n <session>` after setup.
164
144
 
165
145
  The Codex lead MCP server is `xmux_lead`. `xmux setup-codex` configures it so
166
146
  Codex can route requests, wait for teammate responses, read events, and inspect
167
147
  team status.
168
- The global MCP config is install-scoped: the MCP command is a versioned npm
169
- entrypoint, while `XMUX_INSTALL_DIR` points at the Homebrew runtime that owns
170
- wrapper scripts, state discovery, and lifecycle. It does not pin
171
- `XMUX_PROJECT_DIR`/`XMUX_STATE_DIR`; those values come from the active
172
- `xmux -n <session>` lead runtime.
173
-
174
- Provider teammates write responses through `bridge-mcp-server.js`, using the
175
- team runtime environment prepared by XMux. The bridge and mailbox paths are
148
+ The installed `xmux` command owns the tmux runtime. The `xmux_lead` MCP server
149
+ is delivered as a versioned npm entrypoint, and Codex skills are optional
150
+ shortcuts for orchestrating that runtime. The MCP command is install-scoped and
151
+ does not pin `XMUX_PROJECT_DIR`/`XMUX_STATE_DIR`; those values come from the
152
+ active `xmux -n <session>` lead runtime.
153
+
154
+ Provider teammates write responses through `mcp/servers/bridge.js`, using the
155
+ team runtime environment prepared by XMux. The MCP and mailbox paths are
176
156
  implementation details behind Codex-led teammate orchestration.
177
157
 
178
- The explicit Codex setup installs available XMux skills under
179
- `~/.codex/skills` only from `--skills-dir` or `XMUX_CODEX_SKILLS_DIR`.
180
- Homebrew does not install Codex skills or repo-local plugin files; normal
181
- runtime operation depends on the installed `xmux` command and
182
- `XMUX_INSTALL_DIR`, not a checkout path.
183
-
184
- The plugin skill source of truth is `plugins/xmux/skills`; the top-level
185
- `skills/` directory is a mirrored distribution copy for explicit skill refresh
186
- workflows. Users explicitly invoke Codex skills with `$`, for example
187
- `$xmux-teams`. The official XMux skills cover agent-facing orchestration flows:
158
+ Users can ask for teammate work in natural language. When XMux skills are
159
+ available in Codex, the official skill shortcuts are:
188
160
 
189
161
  ```text
190
162
  $xmux-teams
@@ -195,24 +167,14 @@ $xmux-diagnosis
195
167
  $xmux-send-pane
196
168
  ```
197
169
 
198
- Development verification for agent/runtime changes:
199
-
200
- ```bash
201
- pytest tests -q
202
- zsh -n xmux.zsh
203
- zsh -n xmux-bridge.zsh
204
- node --check scripts/setup_xmux_codex_mcp.js
205
- git diff --check
206
- ```
207
-
208
- Formula draft and distribution notes live in
209
- [Homebrew distribution](docs/operations/homebrew.md).
170
+ Homebrew installation details live in [Homebrew installation](docs/operations/homebrew.md).
210
171
 
211
172
  ## Docs
212
173
 
213
174
  - [Documentation index](docs/README.md)
175
+ - [Repository layout](docs/runtime/repository-layout.md)
214
176
  - [Codex lead runtime](docs/runtime/codex-lead.md)
215
- - [Homebrew distribution](docs/operations/homebrew.md)
177
+ - [Homebrew installation](docs/operations/homebrew.md)
216
178
  - [Wrapper-first debugging](docs/operations/debugging.md)
217
179
  - [Claude teammate](docs/teammates/claude.md)
218
180
  - [Gemini teammate](docs/teammates/gemini.md)
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * bridge-mcp-server.js
3
+ * mcp/servers/bridge.js
4
4
  * Minimal MCP server for xmux.
5
5
  * Exposes write_to_lead(text, summary?) so Claude, Gemini, and Copilot
6
6
  * teammates can write directly to the XMux lead inbox.
@@ -140,7 +140,11 @@ function trimToCap(msgs, cap) {
140
140
  function mailboxInstallBases() {
141
141
  const seen = new Set();
142
142
  const bases = [];
143
- for (const candidate of [XMUX_INSTALL_DIR, __dirname]) {
143
+ const packageRoot = path.basename(__dirname) === 'servers'
144
+ && path.basename(path.dirname(__dirname)) === 'mcp'
145
+ ? path.dirname(path.dirname(__dirname))
146
+ : __dirname;
147
+ for (const candidate of [XMUX_INSTALL_DIR, packageRoot, __dirname]) {
144
148
  if (!candidate) continue;
145
149
  const resolved = path.resolve(candidate);
146
150
  if (seen.has(resolved)) continue;
@@ -305,36 +309,99 @@ function buildResponse(msg) {
305
309
 
306
310
  // ── STDIO mode ────────────────────────────────────────────────────────────────
307
311
  // CRITICAL: register stdin listener FIRST to avoid race condition.
308
- // Provider CLIs send `initialize` immediately after spawn; if readline isn't
312
+ // Provider CLIs send `initialize` immediately after spawn; if stdin isn't
309
313
  // listening yet the message is lost and the CLI times out (Tools: none).
310
314
 
315
+ function writeMcpResponse(resp, framed) {
316
+ const body = JSON.stringify(resp);
317
+ if (framed) {
318
+ process.stdout.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`);
319
+ } else {
320
+ process.stdout.write(body + '\n');
321
+ }
322
+ }
323
+
324
+ function handleStdioMessage(msg, framed) {
325
+ const resp = buildResponse(msg);
326
+ if (resp) writeMcpResponse(resp, framed);
327
+ }
328
+
311
329
  function startStdio() {
312
- const readline = require('readline');
313
- const messageQueue = [];
314
- let ready = false;
330
+ let buffer = Buffer.alloc(0);
331
+
332
+ function parseContentLengthHeader(header) {
333
+ for (const line of header.split(/\r?\n/)) {
334
+ const match = line.match(/^Content-Length:\s*(\d+)\s*$/i);
335
+ if (match) return Number.parseInt(match[1], 10);
336
+ }
337
+ return null;
338
+ }
315
339
 
316
- const rl = readline.createInterface({ input: process.stdin, terminal: false });
317
- rl.on('line', (line) => {
318
- if (!line.trim()) return;
340
+ function handleJsonPayload(payload, framed) {
341
+ if (!payload.trim()) return;
319
342
  let msg;
320
- try { msg = JSON.parse(line); } catch (_) { return; }
321
- if (ready) {
322
- const resp = buildResponse(msg);
323
- if (resp) process.stdout.write(JSON.stringify(resp) + '\n');
324
- } else {
325
- messageQueue.push(msg);
343
+ try {
344
+ msg = JSON.parse(payload);
345
+ } catch (_) {
346
+ return;
326
347
  }
348
+ handleStdioMessage(msg, framed);
349
+ }
350
+
351
+ function drain(flush = false) {
352
+ while (buffer.length) {
353
+ while (buffer[0] === 0x0a || buffer[0] === 0x0d) buffer = buffer.subarray(1);
354
+ if (!buffer.length) return;
355
+
356
+ const text = buffer.toString('utf8');
357
+ if (text.toLowerCase().startsWith('content-length:')) {
358
+ let headerEnd = text.indexOf('\r\n\r\n');
359
+ let separatorLength = 4;
360
+ if (headerEnd === -1) {
361
+ headerEnd = text.indexOf('\n\n');
362
+ separatorLength = 2;
363
+ }
364
+ if (headerEnd === -1) return;
365
+
366
+ const header = text.slice(0, headerEnd);
367
+ const contentLength = parseContentLengthHeader(header);
368
+ if (!Number.isFinite(contentLength) || contentLength < 0) {
369
+ buffer = Buffer.alloc(0);
370
+ return;
371
+ }
372
+
373
+ const bodyStart = Buffer.byteLength(text.slice(0, headerEnd + separatorLength), 'utf8');
374
+ if (buffer.length < bodyStart + contentLength) return;
375
+ const body = buffer.subarray(bodyStart, bodyStart + contentLength).toString('utf8');
376
+ buffer = buffer.subarray(bodyStart + contentLength);
377
+ handleJsonPayload(body, true);
378
+ continue;
379
+ }
380
+
381
+ const newline = buffer.indexOf(0x0a);
382
+ if (newline === -1) {
383
+ if (!flush) return;
384
+ const line = buffer.toString('utf8');
385
+ buffer = Buffer.alloc(0);
386
+ handleJsonPayload(line, false);
387
+ return;
388
+ }
389
+ const line = buffer.subarray(0, newline).toString('utf8');
390
+ buffer = buffer.subarray(newline + 1);
391
+ handleJsonPayload(line, false);
392
+ }
393
+ }
394
+
395
+ process.stdin.on('data', (chunk) => {
396
+ buffer = Buffer.concat([buffer, chunk]);
397
+ drain(false);
327
398
  });
328
399
  // Parent CLI closed stdin → exit so MCP subprocess does not linger and
329
400
  // continue accepting writes after its intended session is over.
330
- rl.on('close', () => process.exit(0));
331
-
332
- ready = true;
333
- for (const msg of messageQueue) {
334
- const resp = buildResponse(msg);
335
- if (resp) process.stdout.write(JSON.stringify(resp) + '\n');
336
- }
337
- messageQueue.length = 0;
401
+ process.stdin.on('end', () => {
402
+ drain(true);
403
+ process.exit(0);
404
+ });
338
405
  }
339
406
 
340
407
  // ── HTTP/SSE mode ─────────────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * xmux-lead-mcp-server.js
3
+ * mcp/servers/lead.js
4
4
  * Stdio-only MCP server exposing XMux lead/team mailbox tools.
5
5
  *
6
6
  * Mailbox persistence is delegated to the Node mailbox CLI.
@@ -138,7 +138,11 @@ function parseJsonOutput(stdout) {
138
138
  function mailboxInstallBases() {
139
139
  const seen = new Set();
140
140
  const bases = [];
141
- for (const candidate of [XMUX_INSTALL_DIR, __dirname]) {
141
+ const packageRoot = path.basename(__dirname) === 'servers'
142
+ && path.basename(path.dirname(__dirname)) === 'mcp'
143
+ ? path.dirname(path.dirname(__dirname))
144
+ : __dirname;
145
+ for (const candidate of [XMUX_INSTALL_DIR, packageRoot, __dirname]) {
142
146
  if (!candidate) continue;
143
147
  const resolved = path.resolve(candidate);
144
148
  if (seen.has(resolved)) continue;
@@ -451,36 +455,94 @@ function buildResponse(msg) {
451
455
  return null;
452
456
  }
453
457
 
458
+ function writeMcpResponse(resp, framed) {
459
+ const body = JSON.stringify(resp);
460
+ if (framed) {
461
+ process.stdout.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`);
462
+ } else {
463
+ process.stdout.write(body + '\n');
464
+ }
465
+ }
466
+
467
+ function handleStdioMessage(msg, framed) {
468
+ const resp = buildResponse(msg);
469
+ if (resp) writeMcpResponse(resp, framed);
470
+ }
471
+
454
472
  function startStdio() {
455
- const readline = require('readline');
456
- const messageQueue = [];
457
- let ready = false;
473
+ let buffer = Buffer.alloc(0);
458
474
 
459
- const rl = readline.createInterface({ input: process.stdin, terminal: false });
460
- rl.on('line', (line) => {
461
- if (!line.trim()) return;
475
+ function parseContentLengthHeader(header) {
476
+ for (const line of header.split(/\r?\n/)) {
477
+ const match = line.match(/^Content-Length:\s*(\d+)\s*$/i);
478
+ if (match) return Number.parseInt(match[1], 10);
479
+ }
480
+ return null;
481
+ }
482
+
483
+ function handleJsonPayload(payload, framed) {
484
+ if (!payload.trim()) return;
462
485
  let msg;
463
486
  try {
464
- msg = JSON.parse(line);
487
+ msg = JSON.parse(payload);
465
488
  } catch (_) {
466
489
  return;
467
490
  }
491
+ handleStdioMessage(msg, framed);
492
+ }
468
493
 
469
- if (ready) {
470
- const resp = buildResponse(msg);
471
- if (resp) process.stdout.write(JSON.stringify(resp) + '\n');
472
- } else {
473
- messageQueue.push(msg);
494
+ function drain(flush = false) {
495
+ while (buffer.length) {
496
+ while (buffer[0] === 0x0a || buffer[0] === 0x0d) buffer = buffer.subarray(1);
497
+ if (!buffer.length) return;
498
+
499
+ const text = buffer.toString('utf8');
500
+ if (text.toLowerCase().startsWith('content-length:')) {
501
+ let headerEnd = text.indexOf('\r\n\r\n');
502
+ let separatorLength = 4;
503
+ if (headerEnd === -1) {
504
+ headerEnd = text.indexOf('\n\n');
505
+ separatorLength = 2;
506
+ }
507
+ if (headerEnd === -1) return;
508
+
509
+ const header = text.slice(0, headerEnd);
510
+ const contentLength = parseContentLengthHeader(header);
511
+ if (!Number.isFinite(contentLength) || contentLength < 0) {
512
+ buffer = Buffer.alloc(0);
513
+ return;
514
+ }
515
+
516
+ const bodyStart = Buffer.byteLength(text.slice(0, headerEnd + separatorLength), 'utf8');
517
+ if (buffer.length < bodyStart + contentLength) return;
518
+ const body = buffer.subarray(bodyStart, bodyStart + contentLength).toString('utf8');
519
+ buffer = buffer.subarray(bodyStart + contentLength);
520
+ handleJsonPayload(body, true);
521
+ continue;
522
+ }
523
+
524
+ const newline = buffer.indexOf(0x0a);
525
+ if (newline === -1) {
526
+ if (!flush) return;
527
+ const line = buffer.toString('utf8');
528
+ buffer = Buffer.alloc(0);
529
+ handleJsonPayload(line, false);
530
+ return;
531
+ }
532
+ const line = buffer.subarray(0, newline).toString('utf8');
533
+ buffer = buffer.subarray(newline + 1);
534
+ handleJsonPayload(line, false);
474
535
  }
475
- });
476
- rl.on('close', () => process.exit(0));
477
-
478
- ready = true;
479
- for (const msg of messageQueue) {
480
- const resp = buildResponse(msg);
481
- if (resp) process.stdout.write(JSON.stringify(resp) + '\n');
482
536
  }
483
- messageQueue.length = 0;
537
+
538
+ process.stdin.on('data', (chunk) => {
539
+ buffer = Buffer.concat([buffer, chunk]);
540
+ drain(false);
541
+ });
542
+ process.stdin.on('end', () => {
543
+ drain(true);
544
+ process.exit(0);
545
+ });
484
546
  }
485
547
 
486
548
  startStdio();
@@ -28,7 +28,7 @@ function stableHomebrewXmuxInstallDir(installDir) {
28
28
 
29
29
  const prefix = resolved.split(marker, 1)[0];
30
30
  const candidate = path.join(prefix, "opt", "xmux", "libexec");
31
- if (fs.existsSync(path.join(candidate, "xmux.zsh"))) {
31
+ if (fs.existsSync(path.join(candidate, "runtime", "shell", "xmux.zsh")) || fs.existsSync(path.join(candidate, "xmux.zsh"))) {
32
32
  return candidate;
33
33
  }
34
34
  return resolved;
@@ -36,13 +36,14 @@ function stableHomebrewXmuxInstallDir(installDir) {
36
36
 
37
37
  function stableHomebrewXmuxFilePath(filePath) {
38
38
  const resolved = absolute(filePath);
39
- const installDir = path.dirname(resolved);
40
- const stableInstallDir = stableHomebrewXmuxInstallDir(installDir);
41
- if (stableInstallDir === installDir) {
42
- return resolved;
43
- }
44
-
45
- const candidate = path.join(stableInstallDir, path.basename(resolved));
39
+ const marker = `${path.sep}Cellar${path.sep}xmux${path.sep}`;
40
+ const libexecSegment = `${path.sep}libexec${path.sep}`;
41
+ const libexecIndex = resolved.indexOf(libexecSegment);
42
+ if (!resolved.includes(marker) || libexecIndex < 0) return resolved;
43
+ const prefix = resolved.split(marker, 1)[0];
44
+ const optDir = path.join(prefix, "opt", "xmux", "libexec");
45
+ const relativePath = resolved.slice(libexecIndex + libexecSegment.length);
46
+ const candidate = path.join(optDir, relativePath);
46
47
  if (fs.existsSync(candidate)) {
47
48
  return candidate;
48
49
  }
@@ -72,7 +73,7 @@ function atomicWriteJson(filePath, data) {
72
73
 
73
74
  function usage() {
74
75
  process.stderr.write(
75
- "usage: setup_claude_mcp.js <bridge_js> <project_dir> <outbox> <agent> <team> <state_dir> <install_dir>\n",
76
+ "usage: claude.js <bridge_js> <project_dir> <outbox> <agent> <team> <state_dir> <install_dir>\n",
76
77
  );
77
78
  }
78
79
 
@@ -19,6 +19,7 @@ const LOCAL_PLUGIN_CACHE_VERSION = "local";
19
19
  const SKILL_MARKER = ".xmux-managed-skill";
20
20
  const DEFAULT_MCP_PACKAGE = "xmux-bridge";
21
21
  const DEFAULT_MCP_BIN = "xmux-lead-mcp";
22
+ const DEFAULT_MCP_NPX_PREFIX = path.join(".cache", "xmux", "npm-prefix");
22
23
 
23
24
  function expandUser(value) {
24
25
  const text = String(value || "");
@@ -31,26 +32,39 @@ function abs(value) {
31
32
  return path.resolve(expandUser(value));
32
33
  }
33
34
 
35
+ function xmux_runtime_shell_path(installDir) {
36
+ return path.join(abs(installDir), "runtime", "shell", "xmux.zsh");
37
+ }
38
+
39
+ function has_xmux_runtime(installDir) {
40
+ const root = abs(installDir);
41
+ return fs.existsSync(xmux_runtime_shell_path(root)) || fs.existsSync(path.join(root, "xmux.zsh"));
42
+ }
43
+
34
44
  function stable_homebrew_xmux_install_dir(xmuxInstallDir) {
35
45
  const installDir = abs(xmuxInstallDir);
36
46
  const marker = `${path.sep}Cellar${path.sep}xmux${path.sep}`;
37
47
  if (!installDir.includes(marker) || !installDir.endsWith(`${path.sep}libexec`)) {
38
48
  return installDir;
39
49
  }
40
- if (!fs.existsSync(path.join(installDir, "xmux.zsh"))) {
50
+ if (!has_xmux_runtime(installDir)) {
41
51
  return installDir;
42
52
  }
43
53
  const prefix = installDir.split(marker, 1)[0];
44
54
  const candidate = path.join(prefix, "opt", "xmux", "libexec");
45
- return fs.existsSync(path.join(candidate, "xmux.zsh")) ? candidate : installDir;
55
+ return has_xmux_runtime(candidate) ? candidate : installDir;
46
56
  }
47
57
 
48
58
  function stable_homebrew_xmux_file_path(inputPath) {
49
59
  const resolved = abs(inputPath);
50
- const installDir = path.dirname(resolved);
51
- const stableInstallDir = stable_homebrew_xmux_install_dir(installDir);
52
- if (stableInstallDir === installDir) return resolved;
53
- const candidate = path.join(stableInstallDir, path.basename(resolved));
60
+ const marker = `${path.sep}Cellar${path.sep}xmux${path.sep}`;
61
+ const libexecSegment = `${path.sep}libexec${path.sep}`;
62
+ const libexecIndex = resolved.indexOf(libexecSegment);
63
+ if (!resolved.includes(marker) || libexecIndex < 0) return resolved;
64
+ const prefix = resolved.split(marker, 1)[0];
65
+ const optDir = path.join(prefix, "opt", "xmux", "libexec");
66
+ const relativePath = resolved.slice(libexecIndex + libexecSegment.length);
67
+ const candidate = path.join(optDir, relativePath);
54
68
  return fs.existsSync(candidate) ? candidate : resolved;
55
69
  }
56
70
 
@@ -138,14 +152,15 @@ function package_spec_has_version(packageSpec) {
138
152
  }
139
153
 
140
154
  function xmux_version_from_install_dir(xmuxInstallDir) {
141
- const content = read_text(path.join(abs(xmuxInstallDir), "xmux.zsh"));
155
+ const root = abs(xmuxInstallDir);
156
+ const content = read_text(xmux_runtime_shell_path(root)) || read_text(path.join(root, "xmux.zsh"));
142
157
  const match = content.match(/^XMUX_VERSION=["']([^"']+)["']/m);
143
158
  return match ? match[1] : "";
144
159
  }
145
160
 
146
161
  function default_mcp_package_spec(xmuxInstallDir, packageName = "", packageVersion = "") {
147
162
  const installPackage = read_json(path.join(abs(xmuxInstallDir), "package.json")) || {};
148
- const scriptPackage = read_json(path.join(path.dirname(path.dirname(abs(__filename))), "package.json")) || {};
163
+ const scriptPackage = read_json(path.join(path.dirname(path.dirname(path.dirname(abs(__filename)))), "package.json")) || {};
149
164
  const name = packageName
150
165
  || process.env.XMUX_MCP_NPM_PACKAGE
151
166
  || installPackage.name
@@ -172,21 +187,33 @@ function node_mcp_config(serverPath) {
172
187
  };
173
188
  }
174
189
 
175
- function npx_mcp_config(packageSpec, binName = DEFAULT_MCP_BIN) {
190
+ function default_mcp_npx_prefix(configPath = "") {
191
+ const fallback = path.join(os.homedir(), DEFAULT_MCP_NPX_PREFIX);
192
+ if (!configPath) return fallback;
193
+ const configDir = path.dirname(abs(configPath));
194
+ if (path.basename(configDir) === ".codex") {
195
+ return path.join(path.dirname(configDir), DEFAULT_MCP_NPX_PREFIX);
196
+ }
197
+ return path.join(configDir, DEFAULT_MCP_NPX_PREFIX);
198
+ }
199
+
200
+ function npx_mcp_config(packageSpec, binName = DEFAULT_MCP_BIN, npxPrefix = "") {
201
+ const prefix = abs(npxPrefix || process.env.XMUX_MCP_NPX_PREFIX || default_mcp_npx_prefix());
176
202
  return {
177
203
  mode: "npx",
178
204
  command: "npx",
179
- args: ["-y", "-p", packageSpec, binName],
205
+ args: ["--prefix", prefix, "-y", "-p", packageSpec, binName],
180
206
  package_spec: packageSpec,
181
207
  bin: binName,
182
- label: `npx -y -p ${packageSpec} ${binName}`,
208
+ npx_prefix: prefix,
209
+ label: `npx --prefix ${prefix} -y -p ${packageSpec} ${binName}`,
183
210
  };
184
211
  }
185
212
 
186
213
  function resolve_mcp_config(xmuxInstallDir, opts = {}) {
187
214
  if (opts.server_path) return node_mcp_config(opts.server_path);
188
215
  const packageSpec = default_mcp_package_spec(xmuxInstallDir, opts.mcp_package, opts.mcp_version);
189
- return npx_mcp_config(packageSpec, opts.mcp_bin || DEFAULT_MCP_BIN);
216
+ return npx_mcp_config(packageSpec, opts.mcp_bin || DEFAULT_MCP_BIN, opts.mcp_npx_prefix || "");
190
217
  }
191
218
 
192
219
  function normalize_mcp_config(mcpConfigOrServerPath, xmuxInstallDir = "", opts = {}) {
@@ -198,6 +225,7 @@ function normalize_mcp_config(mcpConfigOrServerPath, xmuxInstallDir = "", opts =
198
225
  server_path: mcpConfigOrServerPath.server_path || "",
199
226
  package_spec: mcpConfigOrServerPath.package_spec || "",
200
227
  bin: mcpConfigOrServerPath.bin || "",
228
+ npx_prefix: mcpConfigOrServerPath.npx_prefix || "",
201
229
  label: mcpConfigOrServerPath.label || [
202
230
  mcpConfigOrServerPath.command,
203
231
  ...(mcpConfigOrServerPath.args || []),
@@ -212,6 +240,12 @@ function mcp_args_toml(mcpConfig) {
212
240
  return `args = [${mcpConfig.args.map(toml_quote).join(", ")}]`;
213
241
  }
214
242
 
243
+ function ensure_mcp_runtime_dirs(mcpConfig) {
244
+ if (mcpConfig.mode === "npx" && mcpConfig.npx_prefix) {
245
+ fs.mkdirSync(mcpConfig.npx_prefix, { recursive: true });
246
+ }
247
+ }
248
+
215
249
  function build_block(mcpConfigOrServerPath, xmuxInstallDir) {
216
250
  const mcpConfig = normalize_mcp_config(mcpConfigOrServerPath, xmuxInstallDir);
217
251
  const pathEnv = resolve_path_with_node();
@@ -252,7 +286,7 @@ function is_xmux_runtime_bin_path(candidatePath, currentXmuxBin) {
252
286
  if (expanded === abs(currentXmuxBin)) return true;
253
287
  if (path.basename(expanded) !== "bin") return false;
254
288
  const installDir = path.dirname(expanded);
255
- if (fs.existsSync(path.join(installDir, "xmux.zsh")) && fs.existsSync(path.join(expanded, "xmux"))) {
289
+ if (has_xmux_runtime(installDir) && fs.existsSync(path.join(expanded, "xmux"))) {
256
290
  return true;
257
291
  }
258
292
  if (path.basename(installDir) !== "libexec") return false;
@@ -399,7 +433,7 @@ function install_xmux_command_rule(configPath) {
399
433
  let content = remove_marker_block(read_text(filePath), RULE_BEGIN, RULE_END);
400
434
  const block = [
401
435
  RULE_BEGIN,
402
- "# Allow the scoped XMux wrapper command; XMux skills still control operation scope.",
436
+ "# Allow the scoped XMux wrapper command; user intent and XMux wrappers control operation scope.",
403
437
  'prefix_rule(pattern=["xmux"], decision="allow")',
404
438
  RULE_END,
405
439
  ].join("\n");
@@ -548,12 +582,12 @@ function _xmux_lead_mcp_processes_from_ps(psOutput) {
548
582
  const processes = [];
549
583
  for (const rawLine of String(psOutput || "").split(/\r?\n/)) {
550
584
  const stripped = rawLine.trim();
551
- if (!stripped.includes("xmux-lead-mcp-server.js")) continue;
585
+ if (!stripped.includes("mcp/servers/lead.js") && !stripped.includes("xmux-lead-mcp-server.js")) continue;
552
586
  const match = stripped.match(/^(\d+)\s+(.*)$/);
553
587
  if (!match) continue;
554
588
  const [, pid, command] = match;
555
589
  const tokens = splitShellWords(command.trim());
556
- const serverPath = tokens.find((token) => token.endsWith("xmux-lead-mcp-server.js")) || "";
590
+ const serverPath = tokens.find((token) => token.endsWith("mcp/servers/lead.js") || token.endsWith("xmux-lead-mcp-server.js")) || "";
557
591
  if (!serverPath) continue;
558
592
  processes.push({ pid, command: command.trim(), server_path: abs(serverPath) });
559
593
  }
@@ -571,7 +605,7 @@ function running_xmux_lead_mcp_processes() {
571
605
 
572
606
  function _is_homebrew_xmux_mcp_server(serverPath) {
573
607
  const normalized = abs(serverPath);
574
- return normalized.endsWith("xmux-lead-mcp-server.js")
608
+ return (normalized.endsWith(`${path.sep}mcp${path.sep}servers${path.sep}lead.js`) || normalized.endsWith("xmux-lead-mcp-server.js"))
575
609
  && normalized.includes(`${path.sep}Cellar${path.sep}xmux${path.sep}`)
576
610
  && normalized.includes(`${path.sep}libexec${path.sep}`);
577
611
  }
@@ -616,7 +650,7 @@ function doctor_codex(configPath, xmuxInstallDir, mcpConfigOrServerPath, skillsD
616
650
  } else if (installedNames.size) {
617
651
  notes.push(["OK", `XMux Codex skills installed under ${skills_root(configPath)}`]);
618
652
  } else {
619
- notes.push(["WARN", "no XMux skill source directory found; pass --skills-dir or set XMUX_CODEX_SKILLS_DIR"]);
653
+ notes.push(["OK", "optional XMux skills are not configured"]);
620
654
  }
621
655
 
622
656
  if (fs.existsSync(plugin_cache_path(configPath))) {
@@ -665,6 +699,7 @@ function parse_args(argv) {
665
699
  mcp_package: "",
666
700
  mcp_version: "",
667
701
  mcp_bin: DEFAULT_MCP_BIN,
702
+ mcp_npx_prefix: "",
668
703
  };
669
704
  for (let i = 0; i < argv.length;) {
670
705
  const arg = argv[i];
@@ -687,6 +722,7 @@ function parse_args(argv) {
687
722
  "--mcp-package",
688
723
  "--mcp-version",
689
724
  "--mcp-bin",
725
+ "--mcp-npx-prefix",
690
726
  ].includes(arg) && i + 1 < argv.length) {
691
727
  const key = arg.slice(2).replace(/-/g, "_");
692
728
  opts[key] = expandUser(argv[i + 1]);
@@ -725,7 +761,8 @@ function resolve_config_path(opts) {
725
761
  function main(argv = process.argv.slice(2)) {
726
762
  const opts = parse_args(argv);
727
763
  const configPath = resolve_config_path(opts);
728
- const scriptInstallDir = path.dirname(path.dirname(abs(__filename)));
764
+ opts.mcp_npx_prefix = abs(opts.mcp_npx_prefix || process.env.XMUX_MCP_NPX_PREFIX || default_mcp_npx_prefix(configPath));
765
+ const scriptInstallDir = path.dirname(path.dirname(path.dirname(abs(__filename))));
729
766
  const rawInstallDir = abs(opts.xmux_install_dir || scriptInstallDir);
730
767
  const xmuxInstallDir = stable_homebrew_xmux_install_dir(rawInstallDir);
731
768
  const xmuxProjectDir = abs(opts.xmux_project_dir || default_xmux_project_dir());
@@ -736,6 +773,8 @@ function main(argv = process.argv.slice(2)) {
736
773
  return doctor_codex(configPath, xmuxInstallDir, mcpConfig, opts.skills_dir, opts.quiet);
737
774
  }
738
775
 
776
+ ensure_mcp_runtime_dirs(mcpConfig);
777
+
739
778
  let content = remove_xmux_blocks(read_text(configPath));
740
779
  if (opts.remove) {
741
780
  content = remove_codex_shell_environment(content, xmuxInstallDir);
@@ -770,7 +809,9 @@ function main(argv = process.argv.slice(2)) {
770
809
  console.log(" xmux_project_dir: inherited from xmux-launched Codex runtime");
771
810
  console.log(" xmux_state_dir: inherited from xmux-launched Codex runtime");
772
811
  if (installedSkills.length) console.log(` skills: ${installedSkills.join(", ")}`);
773
- else if (opts.install_skills) console.log(" skills: skipped; pass --skills-dir or set XMUX_CODEX_SKILLS_DIR");
812
+ else if (opts.install_skills && (opts.skills_dir || process.env.XMUX_CODEX_SKILLS_DIR)) {
813
+ console.log(" skills: no importable XMux skills found");
814
+ }
774
815
  console.log(" plugin_cache: disabled; stale XMux plugin cache removed if present");
775
816
  return 0;
776
817
  }
@@ -788,6 +829,7 @@ module.exports = {
788
829
  remove_xmux_blocks,
789
830
  build_block,
790
831
  default_mcp_package_spec,
832
+ default_mcp_npx_prefix,
791
833
  resolve_mcp_config,
792
834
  path_with_xmux_bin,
793
835
  ensure_codex_shell_environment,