xmux-bridge 1.0.39
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 +219 -0
- package/bin/xmux +9 -0
- package/bridge-mcp-server.js +407 -0
- package/dist/bin/xmux-mailbox.js +6 -0
- package/dist/mailbox/cli.js +3 -0
- package/dist/mailbox/core.js +3 -0
- package/package.json +38 -0
- package/scripts/setup_claude_mcp.js +149 -0
- package/scripts/setup_copilot_mcp.js +87 -0
- package/scripts/setup_gemini_mcp.js +89 -0
- package/scripts/setup_xmux_codex_mcp.js +799 -0
- package/scripts/trust_codex_project.js +44 -0
- package/scripts/trust_copilot_project.js +49 -0
- package/scripts/trust_gemini_project.js +47 -0
- package/src/mailbox/cli.js +251 -0
- package/src/mailbox/core.js +887 -0
- package/src/runtime/errors.js +12 -0
- package/src/runtime/json.js +75 -0
- package/src/runtime/lock.js +56 -0
- package/src/runtime/time.js +27 -0
- package/xmux-bridge.zsh +481 -0
- package/xmux-lead-mcp-server.js +486 -0
- package/xmux.zsh +5146 -0
package/README.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# XMux
|
|
2
|
+
|
|
3
|
+
XMux is a Codex-led tmux teammate runtime. The single user-facing command is
|
|
4
|
+
`xmux`; Codex is always the lead, and supported teammates are Claude, Gemini,
|
|
5
|
+
and Copilot.
|
|
6
|
+
|
|
7
|
+
<table>
|
|
8
|
+
<tr>
|
|
9
|
+
<th>Create</th>
|
|
10
|
+
<th>Shutdown</th>
|
|
11
|
+
</tr>
|
|
12
|
+
<tr>
|
|
13
|
+
<td><img src="docs/screenshots/team-create.png" alt="XMux team creation" width="100%"></td>
|
|
14
|
+
<td><img src="docs/screenshots/team-shutdown.png" alt="XMux team shutdown" width="100%"></td>
|
|
15
|
+
</tr>
|
|
16
|
+
</table>
|
|
17
|
+
|
|
18
|
+
## How to Use
|
|
19
|
+
|
|
20
|
+
Install XMux with Homebrew:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
brew tap DwvN-Lee/xmux
|
|
24
|
+
brew install xmux
|
|
25
|
+
```
|
|
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
|
+
Configure Codex integration explicitly:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
xmux setup-codex
|
|
36
|
+
xmux doctor-codex
|
|
37
|
+
```
|
|
38
|
+
|
|
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.
|
|
54
|
+
|
|
55
|
+
Start the Codex lead from the target project directory:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
xmux -n refactor
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
XMux displays short names with the project prefix, such as `XMux/refactor`.
|
|
62
|
+
Raw `tmux ls` may show a slash-free internal session key; use `xmux sessions`
|
|
63
|
+
and `xmux attach XMux/refactor` for user-facing runtime operations.
|
|
64
|
+
|
|
65
|
+
This is the only command users normally need to run directly. After the lead is
|
|
66
|
+
open, ask Codex for teammate work in natural language. For example:
|
|
67
|
+
|
|
68
|
+
- "Use Gemini and Copilot to review this change."
|
|
69
|
+
- "Ask Claude to look for edge cases before implementation."
|
|
70
|
+
- "Ask Copilot for a repository-aware implementation check."
|
|
71
|
+
|
|
72
|
+
Codex then manages the XMux lifecycle through hidden agent-facing commands such as:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
xmux teamStatus
|
|
76
|
+
xmux teammateAdd -t refactor claude gemini copilot
|
|
77
|
+
xmux teammateShutdown -t refactor gemini-worker
|
|
78
|
+
xmux teamShutdown -t refactor --reason manual-shutdown
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Those commands are hidden from the default `xmux --help` output. Use
|
|
82
|
+
`xmux help agent` when agent-facing lifecycle syntax is needed for automation
|
|
83
|
+
or troubleshooting.
|
|
84
|
+
|
|
85
|
+
To start a detached or scripted team outside an interactive lead session, Codex
|
|
86
|
+
automation can use:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
xmux teamCreate -t refactor-team -n refactor claude gemini copilot
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
XMux is agent friendly: when the user explicitly asks to use teammates, the
|
|
93
|
+
Codex lead may create the scoped team, attach requested teammates, send mailbox
|
|
94
|
+
requests, wait for responses, and perform bounded retries without asking the
|
|
95
|
+
user to approve each XMux step. Runtime permission prompts are only for the
|
|
96
|
+
tooling boundary, such as tmux access from a sandboxed process.
|
|
97
|
+
|
|
98
|
+
Inspect and operate a team when debugging:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
xmux teamStatus -t refactor
|
|
102
|
+
xmux doctor -t refactor --log-lines 0
|
|
103
|
+
xmux teammateStatus -t refactor
|
|
104
|
+
xmux paneInfo gemini-worker -t refactor
|
|
105
|
+
xmux teammateShutdown -t refactor gemini-worker
|
|
106
|
+
xmux teamShutdown -t refactor --reason manual-shutdown
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Lower-level diagnostics are also hidden from the default help. Use
|
|
110
|
+
`xmux help debug` for the full troubleshooting surface.
|
|
111
|
+
|
|
112
|
+
`xmux teammateShutdown` keeps the team live. `xmux teamShutdown` is team-wide
|
|
113
|
+
and archives the team state while preserving inboxes, requests, request ids,
|
|
114
|
+
and events. Lead `/exit` triggers shutdown/archive by default; start with
|
|
115
|
+
`--keep-team-on-lead-exit` to leave teammates running for debugging.
|
|
116
|
+
|
|
117
|
+
Unsupported legacy paths fail explicitly because Codex is the XMux lead, not a
|
|
118
|
+
teammate:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
xmux codex
|
|
122
|
+
xmux start --codex
|
|
123
|
+
xmux start -c
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Agent-Managed Internals
|
|
127
|
+
|
|
128
|
+
This section describes the runtime work handled by Codex and XMux automation.
|
|
129
|
+
Users normally do not run these steps directly.
|
|
130
|
+
|
|
131
|
+
Runtime state is project-local:
|
|
132
|
+
|
|
133
|
+
```text
|
|
134
|
+
<project>/.codex/xmux/
|
|
135
|
+
teams/<team>/
|
|
136
|
+
team.json
|
|
137
|
+
inboxes/
|
|
138
|
+
requests/
|
|
139
|
+
events.jsonl
|
|
140
|
+
archive/<timestamp>-<team>/
|
|
141
|
+
archive.json
|
|
142
|
+
team.json
|
|
143
|
+
inboxes/
|
|
144
|
+
requests/
|
|
145
|
+
events.jsonl
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Runtime path environment names are now split by responsibility:
|
|
149
|
+
|
|
150
|
+
```text
|
|
151
|
+
XMUX_INSTALL_DIR # XMux source/install directory
|
|
152
|
+
XMUX_PROJECT_DIR # project root where Codex is working
|
|
153
|
+
XMUX_STATE_DIR # project-local runtime state, usually $XMUX_PROJECT_DIR/.codex/xmux
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Codex uses the normal user runtime under `~/.codex`. XMux does not create an
|
|
157
|
+
isolated Codex home for a team, and Codex teammate mode is unsupported.
|
|
158
|
+
|
|
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.
|
|
164
|
+
|
|
165
|
+
The Codex lead MCP server is `xmux_lead`. `xmux setup-codex` configures it so
|
|
166
|
+
Codex can route requests, wait for teammate responses, read events, and inspect
|
|
167
|
+
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
|
|
176
|
+
implementation details behind Codex-led teammate orchestration.
|
|
177
|
+
|
|
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:
|
|
188
|
+
|
|
189
|
+
```text
|
|
190
|
+
$xmux-teams
|
|
191
|
+
$xmux-claude
|
|
192
|
+
$xmux-gemini
|
|
193
|
+
$xmux-copilot
|
|
194
|
+
$xmux-diagnosis
|
|
195
|
+
$xmux-send-pane
|
|
196
|
+
```
|
|
197
|
+
|
|
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).
|
|
210
|
+
|
|
211
|
+
## Docs
|
|
212
|
+
|
|
213
|
+
- [Documentation index](docs/README.md)
|
|
214
|
+
- [Codex lead runtime](docs/runtime/codex-lead.md)
|
|
215
|
+
- [Homebrew distribution](docs/operations/homebrew.md)
|
|
216
|
+
- [Wrapper-first debugging](docs/operations/debugging.md)
|
|
217
|
+
- [Claude teammate](docs/teammates/claude.md)
|
|
218
|
+
- [Gemini teammate](docs/teammates/gemini.md)
|
|
219
|
+
- [Copilot teammate](docs/teammates/copilot.md)
|
package/bin/xmux
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bridge-mcp-server.js
|
|
4
|
+
* Minimal MCP server for xmux.
|
|
5
|
+
* Exposes write_to_lead(text, summary?) so Claude, Gemini, and Copilot
|
|
6
|
+
* teammates can write directly to the XMux lead inbox.
|
|
7
|
+
*
|
|
8
|
+
* Modes:
|
|
9
|
+
* stdio (default): JSON-RPC over stdin/stdout
|
|
10
|
+
* HTTP/SSE: --http <port> → GET /sse + POST /messages
|
|
11
|
+
*
|
|
12
|
+
* Config resolution order (first wins):
|
|
13
|
+
* 1. CLI args: --outbox <path> --agent <name> --team <name>
|
|
14
|
+
* 2. Env vars: XMUX_OUTBOX, XMUX_AGENT, XMUX_TEAM
|
|
15
|
+
*
|
|
16
|
+
* If neither is provided the server exits immediately. A previous
|
|
17
|
+
* `.bridge-<agent>.env` mtime-scan fallback was removed: it caused
|
|
18
|
+
* standalone CLI sessions (notably Gemini launched outside xmux-gemini)
|
|
19
|
+
* to silently adopt an unrelated active team's outbox + agent identity
|
|
20
|
+
* and forge write_to_lead entries into the wrong outbox. See
|
|
21
|
+
* docs/investigations/orphan-env-fallback-2026-04-20.md.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
const { spawnSync } = require('child_process');
|
|
30
|
+
|
|
31
|
+
// ── Parse ALL CLI args first (determines mode) ───────────────────────────────
|
|
32
|
+
|
|
33
|
+
let AGENT_NAME = '';
|
|
34
|
+
let OUTBOX = '';
|
|
35
|
+
let HTTP_PORT = 0; // 0 = stdio mode
|
|
36
|
+
let XMUX_TEAM = '';
|
|
37
|
+
let XMUX_INSTALL_DIR = '';
|
|
38
|
+
|
|
39
|
+
const cliArgs = process.argv.slice(2);
|
|
40
|
+
for (let i = 0; i < cliArgs.length; i++) {
|
|
41
|
+
if (cliArgs[i] === '--outbox' && cliArgs[i + 1]) OUTBOX = cliArgs[++i];
|
|
42
|
+
if (cliArgs[i] === '--agent' && cliArgs[i + 1]) AGENT_NAME = cliArgs[++i];
|
|
43
|
+
if (cliArgs[i] === '--team' && cliArgs[i + 1]) XMUX_TEAM = cliArgs[++i];
|
|
44
|
+
if (cliArgs[i] === '--http' && cliArgs[i + 1]) HTTP_PORT = parseInt(cliArgs[++i], 10);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Env vars ─────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
if (!OUTBOX) OUTBOX = process.env.XMUX_OUTBOX || '';
|
|
50
|
+
if (!AGENT_NAME) AGENT_NAME = process.env.XMUX_AGENT || '';
|
|
51
|
+
if (!XMUX_TEAM) XMUX_TEAM = process.env.XMUX_TEAM || '';
|
|
52
|
+
XMUX_INSTALL_DIR = process.env.XMUX_INSTALL_DIR
|
|
53
|
+
? path.resolve(process.env.XMUX_INSTALL_DIR)
|
|
54
|
+
: '';
|
|
55
|
+
|
|
56
|
+
// ── Fail fast: reject spawns that never got a team identity ─────────────────
|
|
57
|
+
// A standalone CLI (e.g. user runs `gemini` in ~/Desktop) can spawn this
|
|
58
|
+
// MCP server via a generic `xmux-bridge` entry in its settings. Without
|
|
59
|
+
// explicit OUTBOX/AGENT we refuse to serve — previously a mtime-based
|
|
60
|
+
// .bridge-<agent>.env fallback would pick the most-recently-active team
|
|
61
|
+
// and the standalone CLI's write_to_lead calls ended up in that team's
|
|
62
|
+
// outbox under the wrong `from` (the orphan-env bug, 2026-04-20).
|
|
63
|
+
|
|
64
|
+
if (!OUTBOX || !AGENT_NAME) {
|
|
65
|
+
process.stderr.write(
|
|
66
|
+
'[xmux-bridge] fatal: no team identity configured.\n' +
|
|
67
|
+
' Provide --outbox <path> --agent <name> OR env XMUX_OUTBOX/XMUX_AGENT.\n' +
|
|
68
|
+
' Standalone CLI sessions not launched via xmux claude, xmux gemini, or xmux copilot must not\n' +
|
|
69
|
+
' register xmux-bridge as an MCP server — remove the entry from that tool\'s\n' +
|
|
70
|
+
' settings if you see this message.\n'
|
|
71
|
+
);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Path validation ──────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
function validateOutboxPath(p) {
|
|
78
|
+
const resolved = path.resolve(p);
|
|
79
|
+
const home = process.env.HOME || '';
|
|
80
|
+
const stateDir = process.env.XMUX_STATE_DIR || path.resolve(home, '.codex', 'xmux');
|
|
81
|
+
const allowedBases = [
|
|
82
|
+
path.resolve(stateDir),
|
|
83
|
+
];
|
|
84
|
+
return allowedBases.some(base => resolved.startsWith(base + path.sep)) &&
|
|
85
|
+
resolved.endsWith('.json') && !p.includes('..');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Outbox write ─────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function nowTs() {
|
|
91
|
+
return new Date().toISOString().replace(/(\.\d{3})\d*Z/, '$1Z');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function atomicWrite(filePath, data) {
|
|
95
|
+
const dir = path.dirname(path.resolve(filePath));
|
|
96
|
+
const tmp = path.join(dir, `.tmp-${crypto.randomBytes(6).toString('hex')}.json`);
|
|
97
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
|
98
|
+
fs.renameSync(tmp, filePath);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// mkdir-based cross-process file lock. Coordinates with the Node mailbox
|
|
102
|
+
// runtime that uses the same `<path>.lock.d` mutex.
|
|
103
|
+
// Prevents lost updates when mailbox writers concurrently read-modify-write
|
|
104
|
+
// the same lead inbox JSON file.
|
|
105
|
+
function withLock(targetPath, fn) {
|
|
106
|
+
const lockPath = targetPath + '.lock.d';
|
|
107
|
+
let acquired = false;
|
|
108
|
+
for (let i = 0; i < 200; i++) {
|
|
109
|
+
try {
|
|
110
|
+
fs.mkdirSync(lockPath);
|
|
111
|
+
acquired = true;
|
|
112
|
+
break;
|
|
113
|
+
} catch (e) {
|
|
114
|
+
if (e.code !== 'EEXIST') throw e;
|
|
115
|
+
const start = Date.now();
|
|
116
|
+
while (Date.now() - start < 25) {} // busy-wait 25ms
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!acquired) throw new Error(`could not acquire lock on ${targetPath}`);
|
|
120
|
+
try {
|
|
121
|
+
return fn();
|
|
122
|
+
} finally {
|
|
123
|
+
try { fs.rmdirSync(lockPath); } catch (_) {}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Cap with read-message preference: when over cap, drop oldest READ
|
|
128
|
+
// message first; only drop oldest unread as a last resort. Prevents
|
|
129
|
+
// the double-push (response + idle_notification) from silently losing
|
|
130
|
+
// unread messages at the 50-cap boundary.
|
|
131
|
+
function trimToCap(msgs, cap) {
|
|
132
|
+
while (msgs.length > cap) {
|
|
133
|
+
const idx = msgs.findIndex(m => m.read);
|
|
134
|
+
if (idx >= 0) msgs.splice(idx, 1);
|
|
135
|
+
else msgs.shift();
|
|
136
|
+
}
|
|
137
|
+
return msgs;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function mailboxInstallBases() {
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
const bases = [];
|
|
143
|
+
for (const candidate of [XMUX_INSTALL_DIR, __dirname]) {
|
|
144
|
+
if (!candidate) continue;
|
|
145
|
+
const resolved = path.resolve(candidate);
|
|
146
|
+
if (seen.has(resolved)) continue;
|
|
147
|
+
seen.add(resolved);
|
|
148
|
+
bases.push(resolved);
|
|
149
|
+
}
|
|
150
|
+
return bases;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveMailboxBackend() {
|
|
154
|
+
for (const base of mailboxInstallBases()) {
|
|
155
|
+
const nodeCli = path.join(base, 'dist', 'bin', 'xmux-mailbox.js');
|
|
156
|
+
if (fs.existsSync(nodeCli)) {
|
|
157
|
+
return {
|
|
158
|
+
kind: 'node',
|
|
159
|
+
command: process.execPath || 'node',
|
|
160
|
+
prefixArgs: [nodeCli],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function writeToXMuxMailbox(text, summary, requestId, status) {
|
|
168
|
+
const backend = resolveMailboxBackend();
|
|
169
|
+
if (!backend) return null;
|
|
170
|
+
if (!XMUX_TEAM) return null;
|
|
171
|
+
|
|
172
|
+
const args = [
|
|
173
|
+
...backend.prefixArgs,
|
|
174
|
+
'write-response',
|
|
175
|
+
XMUX_TEAM,
|
|
176
|
+
'--from',
|
|
177
|
+
AGENT_NAME,
|
|
178
|
+
'--text',
|
|
179
|
+
text,
|
|
180
|
+
'--status',
|
|
181
|
+
status || 'done',
|
|
182
|
+
];
|
|
183
|
+
if (summary) args.push('--summary', summary);
|
|
184
|
+
if (requestId) args.push('--request-id', requestId);
|
|
185
|
+
|
|
186
|
+
const result = spawnSync(backend.command, args, {
|
|
187
|
+
env: { ...process.env },
|
|
188
|
+
encoding: 'utf8',
|
|
189
|
+
maxBuffer: 1024 * 1024,
|
|
190
|
+
});
|
|
191
|
+
if (result.error) return `error: ${result.error.message || result.error}`;
|
|
192
|
+
if (result.status !== 0) {
|
|
193
|
+
const detail = String(result.stderr || result.stdout || '').trim();
|
|
194
|
+
return `error: xmux mailbox write failed${detail ? `: ${detail}` : ''}`;
|
|
195
|
+
}
|
|
196
|
+
return 'ok: response delivered to lead';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function writeToLeadImpl(text, summary, requestId, status) {
|
|
200
|
+
if (!OUTBOX) return 'error: XMUX_OUTBOX not set';
|
|
201
|
+
if (!validateOutboxPath(OUTBOX)) return 'error: XMUX_OUTBOX path is invalid or outside allowed directory';
|
|
202
|
+
if (!AGENT_NAME) return 'error: AGENT_NAME not set (pass --agent or set XMUX_AGENT)';
|
|
203
|
+
|
|
204
|
+
const xmuxResult = writeToXMuxMailbox(text, summary, requestId, status);
|
|
205
|
+
if (xmuxResult !== null) return xmuxResult;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
return withLock(OUTBOX, () => {
|
|
209
|
+
let msgs = [];
|
|
210
|
+
try { msgs = JSON.parse(fs.readFileSync(OUTBOX, 'utf-8')); } catch (_) { msgs = []; }
|
|
211
|
+
const ts1 = nowTs();
|
|
212
|
+
const entry = { from: AGENT_NAME, text, timestamp: ts1, read: false };
|
|
213
|
+
if (summary) entry.summary = summary;
|
|
214
|
+
if (requestId) entry.request_id = requestId;
|
|
215
|
+
if (status) entry.status = status;
|
|
216
|
+
msgs.push(entry);
|
|
217
|
+
trimToCap(msgs, 50);
|
|
218
|
+
const ts2 = nowTs();
|
|
219
|
+
const idlePayload = JSON.stringify({
|
|
220
|
+
type: 'idle_notification', from: AGENT_NAME, idleReason: 'available', timestamp: ts2,
|
|
221
|
+
});
|
|
222
|
+
msgs.push({ from: AGENT_NAME, text: idlePayload, timestamp: ts2, read: false });
|
|
223
|
+
trimToCap(msgs, 50);
|
|
224
|
+
atomicWrite(OUTBOX, msgs);
|
|
225
|
+
return 'ok: response delivered to lead';
|
|
226
|
+
});
|
|
227
|
+
} catch (exc) {
|
|
228
|
+
return `error: ${exc}`;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Tool schema ───────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
const TOOL_SCHEMA = {
|
|
235
|
+
name: 'write_to_lead',
|
|
236
|
+
description:
|
|
237
|
+
'Send your completed response to the Codex lead through the XMux mailbox.',
|
|
238
|
+
inputSchema: {
|
|
239
|
+
type: 'object',
|
|
240
|
+
properties: {
|
|
241
|
+
text: { type: 'string', description: 'Your full response text.' },
|
|
242
|
+
summary: { type: 'string', description: 'Optional short summary (first sentence, < 60 chars).' },
|
|
243
|
+
request_id: { type: 'string', description: 'Optional XMux request id this response completes.' },
|
|
244
|
+
status: { type: 'string', description: 'Optional response status, defaults to done.' },
|
|
245
|
+
},
|
|
246
|
+
required: ['text'],
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// ── MCP request handler (returns response object or null) ─────────────────────
|
|
251
|
+
|
|
252
|
+
function buildResponse(msg) {
|
|
253
|
+
const method = msg.method || '';
|
|
254
|
+
const id = msg.id !== undefined ? msg.id : null;
|
|
255
|
+
|
|
256
|
+
if (method === 'initialize') {
|
|
257
|
+
const params = msg.params || {};
|
|
258
|
+
return {
|
|
259
|
+
jsonrpc: '2.0', id,
|
|
260
|
+
result: {
|
|
261
|
+
protocolVersion: params.protocolVersion || '2024-11-05',
|
|
262
|
+
capabilities: { tools: {} },
|
|
263
|
+
serverInfo: { name: 'xmux-bridge', version: '0.3.0' },
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (method === 'notifications/initialized' || method === 'initialized') {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (method === 'tools/list') {
|
|
273
|
+
return { jsonrpc: '2.0', id, result: { tools: [TOOL_SCHEMA] } };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (method === 'resources/list') {
|
|
277
|
+
return { jsonrpc: '2.0', id, result: { resources: [] } };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (method === 'resources/templates/list') {
|
|
281
|
+
return { jsonrpc: '2.0', id, result: { resourceTemplates: [] } };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (method === 'tools/call') {
|
|
285
|
+
const params = msg.params || {};
|
|
286
|
+
if (params.name === 'write_to_lead') {
|
|
287
|
+
const args = params.arguments || {};
|
|
288
|
+
const result = writeToLeadImpl(
|
|
289
|
+
args.text || '',
|
|
290
|
+
args.summary || '',
|
|
291
|
+
args.request_id || args.requestId || '',
|
|
292
|
+
args.status || 'done',
|
|
293
|
+
);
|
|
294
|
+
return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: result }] } };
|
|
295
|
+
}
|
|
296
|
+
return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Unknown tool' } };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (id !== null) {
|
|
300
|
+
return { jsonrpc: '2.0', id, error: { code: -32601, message: `Unknown method: ${method}` } };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── STDIO mode ────────────────────────────────────────────────────────────────
|
|
307
|
+
// CRITICAL: register stdin listener FIRST to avoid race condition.
|
|
308
|
+
// Provider CLIs send `initialize` immediately after spawn; if readline isn't
|
|
309
|
+
// listening yet the message is lost and the CLI times out (Tools: none).
|
|
310
|
+
|
|
311
|
+
function startStdio() {
|
|
312
|
+
const readline = require('readline');
|
|
313
|
+
const messageQueue = [];
|
|
314
|
+
let ready = false;
|
|
315
|
+
|
|
316
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
317
|
+
rl.on('line', (line) => {
|
|
318
|
+
if (!line.trim()) return;
|
|
319
|
+
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);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
// Parent CLI closed stdin → exit so MCP subprocess does not linger and
|
|
329
|
+
// 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;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── HTTP/SSE mode ─────────────────────────────────────────────────────────────
|
|
341
|
+
// Implements the MCP HTTP+SSE transport:
|
|
342
|
+
// GET /sse → SSE stream; first event is `endpoint` with POST URL
|
|
343
|
+
// POST /messages?sessionId=<id> → JSON-RPC request; response via SSE `message` event
|
|
344
|
+
|
|
345
|
+
function startHttpServer(port) {
|
|
346
|
+
const http = require('http');
|
|
347
|
+
const sessions = new Map(); // sessionId → SSE response
|
|
348
|
+
|
|
349
|
+
const server = http.createServer((req, res) => {
|
|
350
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
351
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
352
|
+
|
|
353
|
+
if (req.method === 'OPTIONS') {
|
|
354
|
+
res.writeHead(204);
|
|
355
|
+
res.end();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const reqUrl = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
360
|
+
|
|
361
|
+
if (req.method === 'GET' && reqUrl.pathname === '/sse') {
|
|
362
|
+
const sessionId = crypto.randomBytes(8).toString('hex');
|
|
363
|
+
res.writeHead(200, {
|
|
364
|
+
'Content-Type': 'text/event-stream',
|
|
365
|
+
'Cache-Control': 'no-cache',
|
|
366
|
+
'Connection': 'keep-alive',
|
|
367
|
+
});
|
|
368
|
+
sessions.set(sessionId, res);
|
|
369
|
+
res.write(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`);
|
|
370
|
+
req.on('close', () => sessions.delete(sessionId));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (req.method === 'POST' && reqUrl.pathname === '/messages') {
|
|
375
|
+
const sessionId = reqUrl.searchParams.get('sessionId');
|
|
376
|
+
const sseRes = sessions.get(sessionId);
|
|
377
|
+
let body = '';
|
|
378
|
+
req.on('data', chunk => body += chunk);
|
|
379
|
+
req.on('end', () => {
|
|
380
|
+
res.writeHead(202);
|
|
381
|
+
res.end();
|
|
382
|
+
let msg;
|
|
383
|
+
try { msg = JSON.parse(body); } catch (_) { return; }
|
|
384
|
+
const resp = buildResponse(msg);
|
|
385
|
+
if (resp && sseRes) {
|
|
386
|
+
sseRes.write(`event: message\ndata: ${JSON.stringify(resp)}\n\n`);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
res.writeHead(404);
|
|
393
|
+
res.end();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
server.listen(port, '127.0.0.1', () => {
|
|
397
|
+
process.stderr.write(`[xmux-bridge] HTTP MCP server on http://127.0.0.1:${port}/sse\n`);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
if (HTTP_PORT > 0) {
|
|
404
|
+
startHttpServer(HTTP_PORT);
|
|
405
|
+
} else {
|
|
406
|
+
startStdio();
|
|
407
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "xmux-bridge",
|
|
3
|
+
"version": "1.0.39",
|
|
4
|
+
"description": "MCP stdio server for XMux teammate responses",
|
|
5
|
+
"bin": {
|
|
6
|
+
"xmux-bridge": "./bridge-mcp-server.js",
|
|
7
|
+
"xmux-lead-mcp": "./xmux-lead-mcp-server.js",
|
|
8
|
+
"xmux-mailbox": "./dist/bin/xmux-mailbox.js",
|
|
9
|
+
"xmux": "./bin/xmux",
|
|
10
|
+
"xmux-bridge-relay": "./xmux-bridge.zsh"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bridge-mcp-server.js",
|
|
14
|
+
"xmux-lead-mcp-server.js",
|
|
15
|
+
"xmux-bridge.zsh",
|
|
16
|
+
"bin/xmux",
|
|
17
|
+
"xmux.zsh",
|
|
18
|
+
"scripts/*.js",
|
|
19
|
+
"dist/bin/xmux-mailbox.js",
|
|
20
|
+
"dist/mailbox",
|
|
21
|
+
"src/runtime",
|
|
22
|
+
"src/mailbox"
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mcp",
|
|
26
|
+
"claude",
|
|
27
|
+
"gemini",
|
|
28
|
+
"copilot",
|
|
29
|
+
"tmux",
|
|
30
|
+
"teammate"
|
|
31
|
+
],
|
|
32
|
+
"author": "DwvN-Lee",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/DwvN-Lee/XMux.git"
|
|
37
|
+
}
|
|
38
|
+
}
|