muxr 0.1.4 → 0.1.5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a22c29848cc6a0454f9be515cbc0f654537e37f9fbde37902f55dd34c103ce21
4
- data.tar.gz: b96e180cc3750f1f0d3f72fc822669acaf68cc33c65b312029664b36981794cb
3
+ metadata.gz: 0f553caf555f455f8c89689ed8ef73de3158467171551f177a27009363055091
4
+ data.tar.gz: b26779047961944af58a74593773b876af42c4e512ed956e0c7cb7a9becead99
5
5
  SHA512:
6
- metadata.gz: 9139072cdc6e55e0ab94c7144d492858cdb3fa75bf59f5009b925d7f4e6659d8228f64987aba7c08fdff119dbcbb480511969cc64c0904e96a3ae95cb8456ddc
7
- data.tar.gz: 77028f21adf1d5a7710f952af3dfc0621223b4aa0cbb70ff15436c54e797d8d3809885b50d31a71c5f417afaae2bb153bbf149379110813beeb23ba716cefe25
6
+ metadata.gz: 4ab01bd3c63531f1e9b64aecb2532e79eecc4e3736d7970cb1040ee5e9db0fd9f316b06358f1bd758bc93d413ed5e70d17d884ce85ebb4f593cc4d556b180ee3
7
+ data.tar.gz: '09abcbbdec950405196dc6015dc00c201c7200c890db0de49ba0c3b703923b78125c0cce458b950c1921970e569e9eb209b1ffd1930ee9cda83ebeb8f88d8afa'
data/CHANGELOG.md CHANGED
@@ -6,6 +6,69 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.5] - 2026-05-13
10
+
11
+ ### Added
12
+ - MCP (Model Context Protocol) integration so Claude Code can drive a
13
+ muxr session as a tool. A second listener at
14
+ `~/.muxr/sockets/<name>.ctrl.sock` accepts multiple concurrent NDJSON
15
+ clients and exposes read-only and mutating methods over a small
16
+ JSON-RPC surface (`session.get`, `panes.list`, `pane.read`,
17
+ `pane.send_input`, `pane.run`, `pane.subscribe`, `layout.set`,
18
+ `drawer.*`, etc.). The control socket does not interfere with TTY
19
+ attach — programmatic clients never count as "attached", so a Claude
20
+ session and a human can use the multiplexer concurrently.
21
+ - `pane.run` waits for the PTY to go idle before responding. Sends the
22
+ input, polls for output, and returns once no bytes have arrived for
23
+ `idle_ms` (default 500). Server-side idle detection avoids the
24
+ send-then-poll race that plagues naive client-side automation.
25
+ - Stable per-pane ids: every pane carries a 6-hex `SecureRandom` id that
26
+ survives splits, kills, promote_to_master, detach/reattach, and
27
+ cold-restart from the session JSON. The status bar now reads
28
+ `#1 a3f9b2` so users see both the slot (positional, what `Ctrl-a 1`
29
+ targets) and the id (stable, what the MCP client should reference).
30
+ - `bin/muxr-mcp` — standalone MCP-over-stdio bridge that translates
31
+ Claude Code tool calls into NDJSON requests on the control socket.
32
+ Auto-detects the target session from `MUXR_CONTROL_SOCKET` or
33
+ `MUXR_SESSION` env vars.
34
+ - `Ctrl-a C` (also `:claude`) opens a drawer whose shell is `claude`,
35
+ with `MUXR_SESSION`, `MUXR_CONTROL_SOCKET`, `MUXR_FOCUSED_PANE`, and
36
+ `MUXR_DRAWER_SELF=1` injected into its environment. The bridge picks
37
+ those up automatically; the human gets a Quake-style Claude Code
38
+ overlay that already knows what session it's in. The
39
+ `MUXR_DRAWER_SELF` guard makes the bridge refuse `drawer.*` methods
40
+ so a claude drawer can't recurse into its own PTY.
41
+ - Private panes (`Ctrl-a P` / `:private`) hide a pane from programmatic
42
+ callers: `panes.list` strips cwd/rows/cols, and `pane.read`,
43
+ `pane.send_input`, `pane.run`, `pane.subscribe`, and `pane.kill`
44
+ refuse with an error message pointing the human at `Ctrl-a P` to
45
+ expose it. The flag is persisted in session JSON, shown as `[P]` in
46
+ the status bar, and intentionally one-way — there is no control
47
+ method to flip it.
48
+ - Named keys on `pane.send_input`, `pane.run`, and `drawer.send_input`.
49
+ A `keys` array accepts vim-style `<name>` tokens (`<esc>`, `<c-c>`,
50
+ `<cr>`, arrows, etc.) interleaved with literal text, so MCP callers
51
+ no longer have to remember that Escape is `"\e"` and Ctrl-C is
52
+ `"\x03"`. Bracketed-paste wrapping still applies to literal segments
53
+ only.
54
+ - Skill bundle at `skills/muxr-control/SKILL.md` teaching Claude how to
55
+ drive muxr via the MCP. Installable via `muxr --install-skill`, which
56
+ copies the skill into `~/.claude/skills/muxr-control` (survives
57
+ `gem update`) and prints the `claude mcp add` registration line.
58
+
59
+ ### Fixed
60
+ - Honor DSR cursor-position queries (`\e[5n`, `\e[6n`). The Terminal
61
+ emulator now buffers a `\e[0n` OK reply or a `\e[<row>;<col>R` CPR
62
+ reply into a pending-replies queue; the Pane drains it back through
63
+ the PTY input side after each feed. AWS CLI and other programs that
64
+ probe geometry this way no longer log "your terminal doesn't support
65
+ cursor position requests (CPR)" and fall back to a degraded mode.
66
+ - Drawer height now has a floor of 16 rows. The old 35%-of-screen rule
67
+ degraded badly on short terminals — a 24-row terminal gave only ~8
68
+ rows of drawer, barely enough for one prompt and a few lines of
69
+ output. The 35% growth still applies above the floor, so tall
70
+ terminals are unaffected.
71
+
9
72
  ## [0.1.4] - 2026-05-13
10
73
 
11
74
  ### Fixed
@@ -108,7 +171,8 @@ Initial release.
108
171
  boundaries.
109
172
  - Renderer that composes one frame and diff-emits ANSI to STDOUT.
110
173
 
111
- [Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.4...HEAD
174
+ [Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.5...HEAD
175
+ [0.1.5]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.5
112
176
  [0.1.4]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.4
113
177
  [0.1.3]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.3
114
178
  [0.1.2]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.2
data/bin/muxr CHANGED
@@ -18,6 +18,65 @@ if ARGV.include?("-l") || ARGV.include?("--list")
18
18
  exit 0
19
19
  end
20
20
 
21
+ if ARGV.include?("--install-skill")
22
+ # Copies skills/muxr-control into ~/.claude/skills/muxr-control so the
23
+ # claude CLI loads it from anywhere. We *copy* rather than symlink so the
24
+ # install survives a RubyGems upgrade (which deletes the old versioned
25
+ # gem path that a symlink would have pointed at). Idempotent: re-running
26
+ # this after a `gem update muxr` is the supported way to refresh the
27
+ # skill content.
28
+ src = File.expand_path("../skills/muxr-control", __dir__)
29
+ unless File.directory?(src)
30
+ $stderr.puts "muxr: skill source not found at #{src} (was the gem packaged without skills/?)"
31
+ exit 1
32
+ end
33
+ claude_skills = File.expand_path("~/.claude/skills")
34
+ dst = File.join(claude_skills, "muxr-control")
35
+ FileUtils.mkdir_p(claude_skills)
36
+ # Wipe any prior install — symlink (from older dev installs) or directory.
37
+ if File.symlink?(dst) || File.exist?(dst)
38
+ FileUtils.rm_rf(dst)
39
+ end
40
+ FileUtils.mkdir_p(dst)
41
+ FileUtils.cp_r(File.join(src, "."), dst)
42
+
43
+ # Pick the most stable absolute path we can point claude's mcp config at:
44
+ #
45
+ # - Installed gem: $GEM_HOME/bin/muxr-mcp is a wrapper bin stub that
46
+ # survives version upgrades (RubyGems repoints it on `gem update`).
47
+ # Always prefer it when present.
48
+ # - Source checkout: no bin stub exists; fall back to the script that
49
+ # sits next to the running `muxr` binary.
50
+ bin_stub = File.join(Gem.bindir, "muxr-mcp") rescue nil
51
+ bridge = (bin_stub && File.exist?(bin_stub)) ? bin_stub : File.expand_path("muxr-mcp", __dir__)
52
+ puts <<~MSG
53
+ ✓ Copied skill to: #{dst}
54
+ (source: #{src})
55
+
56
+ Re-run `muxr --install-skill` after `gem update muxr` to refresh the
57
+ skill contents.
58
+
59
+ Next, register the muxr MCP bridge with Claude Code at USER scope
60
+ (so every claude session sees it, not just ones started from your
61
+ home directory):
62
+
63
+ claude mcp add muxr #{bridge} --scope user
64
+
65
+ If you omit `--scope user`, `claude mcp add` defaults to local scope
66
+ and the MCP is only loaded when claude starts from your current cwd —
67
+ a common first-run gotcha.
68
+
69
+ If your gem bin directory is on $PATH, `muxr-mcp` works as a bare name too:
70
+
71
+ claude mcp add muxr muxr-mcp --scope user
72
+
73
+ Then restart any running Claude Code sessions so they pick up the new
74
+ config, and launch claude from inside a muxr drawer (Ctrl-a C) — the
75
+ bridge auto-detects the session via MUXR_SESSION / MUXR_CONTROL_SOCKET.
76
+ MSG
77
+ exit 0
78
+ end
79
+
21
80
  if ARGV.include?("-h") || ARGV.include?("--help")
22
81
  puts <<~USAGE
23
82
  muxr #{Muxr::VERSION} — a tiling terminal multiplexer
@@ -27,6 +86,7 @@ if ARGV.include?("-h") || ARGV.include?("--help")
27
86
  muxr <name> attach (or start) the named session
28
87
  muxr -s <name> same as above
29
88
  muxr --list list running sessions and exit
89
+ muxr --install-skill symlink the MCP skill into ~/.claude/skills and exit
30
90
  muxr --version print version and exit
31
91
  muxr --help this help
32
92
 
@@ -35,6 +95,7 @@ if ARGV.include?("-h") || ARGV.include?("--help")
35
95
  C-a a toggle last pane C-a 1..9 jump to pane by number
36
96
  C-a k close pane C-a Tab cycle layout
37
97
  C-a ~ toggle drawer C-a Enter promote to master
98
+ C-a C Claude drawer (MCP-aware; needs `muxr --install-skill` + bridge configured)
38
99
  C-a : command prompt C-a ? toggle help
39
100
  C-a d detach (server stays running)
40
101
  C-a q kill session (with y/n confirmation)
data/bin/muxr-mcp ADDED
@@ -0,0 +1,412 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # muxr-mcp — Model Context Protocol bridge for muxr.
5
+ #
6
+ # Speaks MCP JSON-RPC over stdio (one JSON object per line) and translates
7
+ # tool calls into NDJSON requests on the muxr control socket at
8
+ # ~/.muxr/sockets/<name>.ctrl.sock. Connection target is determined by:
9
+ #
10
+ # 1. $MUXR_CONTROL_SOCKET (absolute path) — exact override
11
+ # 2. $MUXR_SESSION (session name) — resolved to standard path
12
+ # 3. otherwise: error
13
+ #
14
+ # Both env vars are injected automatically when claude is launched from the
15
+ # muxr Claude-drawer (Ctrl-a C), so end users almost never set them by hand.
16
+ #
17
+ # Typical claude-code mcp config:
18
+ #
19
+ # {
20
+ # "mcpServers": {
21
+ # "muxr": { "command": "/abs/path/to/bin/muxr-mcp" }
22
+ # }
23
+ # }
24
+
25
+ require "json"
26
+ require "socket"
27
+
28
+ class MuxrMcpBridge
29
+ PROTOCOL_VERSION = "2024-11-05" # MCP protocol revision the bridge speaks.
30
+ SERVER_NAME = "muxr-mcp"
31
+ SERVER_VERSION = "0.1.0"
32
+
33
+ # Map each exposed MCP tool name to the underlying muxr control method.
34
+ # The tool surface is intentionally a flat one-to-one — every method shipped
35
+ # by the control server gets a tool. Names are snake_case and prefixed with
36
+ # `muxr_` so they sit cleanly in Claude's tool list alongside other servers.
37
+ TOOL_TO_MUXR_METHOD = {
38
+ "muxr_ping" => "ping",
39
+ "muxr_session_get" => "session.get",
40
+ "muxr_session_save" => "session.save",
41
+ "muxr_panes_list" => "panes.list",
42
+ "muxr_pane_read" => "pane.read",
43
+ "muxr_pane_send_input" => "pane.send_input",
44
+ "muxr_pane_focus" => "pane.focus",
45
+ "muxr_pane_new" => "pane.new",
46
+ "muxr_pane_kill" => "pane.kill",
47
+ "muxr_pane_promote" => "pane.promote",
48
+ "muxr_pane_run" => "pane.run",
49
+ "muxr_layout_set" => "layout.set",
50
+ "muxr_layout_cycle" => "layout.cycle",
51
+ "muxr_drawer_toggle" => "drawer.toggle",
52
+ "muxr_drawer_show" => "drawer.show",
53
+ "muxr_drawer_hide" => "drawer.hide",
54
+ "muxr_drawer_reset" => "drawer.reset",
55
+ "muxr_drawer_send_input"=> "drawer.send_input",
56
+ "muxr_drawer_read" => "drawer.read"
57
+ }.freeze
58
+
59
+ # Reusable JSON schema fragment: pane identifier. We expose only the
60
+ # string-id form via MCP (slot-as-integer would have required `oneOf`,
61
+ # which the Anthropic tool-use API doesn't accept). The id-only contract
62
+ # matches the skill's "always use ids" guidance anyway. Callers get the
63
+ # ids from muxr_panes_list.
64
+ PANE_REF = {
65
+ "type" => "string",
66
+ "description" => "Stable 6-hex pane id from muxr_panes_list (e.g. \"a3f9b2\"). Ids survive splits, kills, and promote_to_master — always prefer them over the positional slot numbers shown in the status bar."
67
+ }.freeze
68
+
69
+ TOOL_SCHEMAS = [
70
+ {
71
+ "name" => "muxr_session_get",
72
+ "description" => "Get a summary of the muxr session: name, layout, focused pane, drawer visibility, dimensions, pane count. Call this first to ground yourself in the current state.",
73
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
74
+ },
75
+ {
76
+ "name" => "muxr_panes_list",
77
+ "description" => "List every pane with its stable id, 1-based slot number, focused/master flags, cwd, and grid dimensions. Always reference panes by id in subsequent calls (slots shift on split/kill).",
78
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
79
+ },
80
+ {
81
+ "name" => "muxr_pane_read",
82
+ "description" => "Read the currently-visible terminal contents of a pane as plain text (one row per line, trailing whitespace trimmed). Cheap and idempotent.",
83
+ "inputSchema" => {
84
+ "type" => "object",
85
+ "properties" => { "pane" => PANE_REF },
86
+ "required" => ["pane"],
87
+ "additionalProperties" => false
88
+ }
89
+ },
90
+ {
91
+ "name" => "muxr_pane_send_input",
92
+ "description" => "Send raw bytes to a pane's PTY without waiting. For full commands prefer muxr_pane_run, which sends + waits for output to settle. Set bracketed=true when sending multi-line text into an editor or REPL. When the sequence needs any special key (Escape, Enter-as-terminator, arrows, Ctrl-*), use `keys` instead of `data` — sending \"\\n\" or \"\\e\" through `data` is a footgun that easily leaves vim in insert mode.",
93
+ "inputSchema" => {
94
+ "type" => "object",
95
+ "properties" => {
96
+ "pane" => PANE_REF,
97
+ "data" => { "type" => "string", "description" => "Literal bytes to send, sent exactly as-is (no \\r appended). Lower-level than `keys` — use `keys` whenever any named special key is involved. Mutually exclusive with `keys`." },
98
+ "keys" => {
99
+ "type" => "array",
100
+ "items" => { "type" => "string" },
101
+ "description" => "Mixed array of literal text and vim-style named keys. Each element is either literal text or a single `<name>`. Supported names (case-insensitive): <esc>, <enter>/<cr>, <tab>, <s-tab>, <bs>, <space>, <up>/<down>/<left>/<right>, <home>, <end>, <pageup>, <pagedown>, <c-a>..<c-z>, <f1>..<f12>. Mutually exclusive with `data`. Example for vim open-paste-save: [\"G\", \"o\", \"hello world\", \"<esc>\", \":w\", \"<enter>\"]."
102
+ },
103
+ "bracketed" => { "type" => "boolean", "description" => "Wrap literal text in bracketed-paste markers (\\e[200~ … \\e[201~). Named keys in `keys` are never bracketed." }
104
+ },
105
+ "required" => ["pane"],
106
+ "additionalProperties" => false
107
+ }
108
+ },
109
+ {
110
+ "name" => "muxr_pane_run",
111
+ "description" => "Send input to a pane and wait for the PTY to go idle before returning. The killer tool for driving shells: returns the pane's full visible text after the command settles, plus a timed_out flag. By default appends \\r and waits 500ms of idle. Bump idle_ms to ~800 for bursty commands (test runners) or timeout_ms for long builds. When the sequence needs any special key (Escape, arrows, Ctrl-*), use `keys` instead of `input`.",
112
+ "inputSchema" => {
113
+ "type" => "object",
114
+ "properties" => {
115
+ "pane" => PANE_REF,
116
+ "input" => { "type" => "string", "description" => "Command text. Omit/empty to just wait. Use `keys` instead when any named special key is needed. Mutually exclusive with `keys`." },
117
+ "keys" => {
118
+ "type" => "array",
119
+ "items" => { "type" => "string" },
120
+ "description" => "Mixed array of literal text and vim-style named keys (see muxr_pane_send_input for the full list). Preferred over `input` whenever Escape/arrows/Ctrl-* are involved. Mutually exclusive with `input`."
121
+ },
122
+ "append_enter"=> { "type" => "boolean", "description" => "Append \\r after input/keys (default true)." },
123
+ "bracketed" => { "type" => "boolean", "description" => "Wrap literal text in bracketed-paste markers (default false). Named keys in `keys` are never bracketed." },
124
+ "idle_ms" => { "type" => "integer", "minimum" => 50, "maximum" => 60000, "description" => "Idle window before considering the command settled. Default 500." },
125
+ "timeout_ms" => { "type" => "integer", "minimum" => 100, "maximum" => 300000, "description" => "Absolute timeout for the wait. Default 30000." }
126
+ },
127
+ "required" => ["pane"],
128
+ "additionalProperties" => false
129
+ }
130
+ },
131
+ {
132
+ "name" => "muxr_pane_focus",
133
+ "description" => "Focus a pane (moves the human's cursor to it). Mostly used to set up state before the human takes over.",
134
+ "inputSchema" => {
135
+ "type" => "object",
136
+ "properties" => { "pane" => PANE_REF },
137
+ "required" => ["pane"],
138
+ "additionalProperties" => false
139
+ }
140
+ },
141
+ {
142
+ "name" => "muxr_pane_new",
143
+ "description" => "Create a new pane, optionally in a specific cwd. Returns the new pane's id and slot. Use sparingly — the human usually controls layout.",
144
+ "inputSchema" => {
145
+ "type" => "object",
146
+ "properties" => {
147
+ "cwd" => { "type" => "string", "description" => "Working directory for the new shell. Defaults to the focused pane's cwd." }
148
+ },
149
+ "additionalProperties" => false
150
+ }
151
+ },
152
+ {
153
+ "name" => "muxr_pane_kill",
154
+ "description" => "Close a pane. Destructive — only call when the user has named the specific pane to kill.",
155
+ "inputSchema" => {
156
+ "type" => "object",
157
+ "properties" => { "pane" => PANE_REF },
158
+ "required" => ["pane"],
159
+ "additionalProperties" => false
160
+ }
161
+ },
162
+ {
163
+ "name" => "muxr_pane_promote",
164
+ "description" => "Promote a pane to the master slot (position 0). Useful for setting up a tall-layout focal pane.",
165
+ "inputSchema" => {
166
+ "type" => "object",
167
+ "properties" => { "pane" => PANE_REF },
168
+ "required" => ["pane"],
169
+ "additionalProperties" => false
170
+ }
171
+ },
172
+ {
173
+ "name" => "muxr_layout_set",
174
+ "description" => "Switch to a specific layout: tall, grid, or monocle.",
175
+ "inputSchema" => {
176
+ "type" => "object",
177
+ "properties" => { "layout" => { "type" => "string", "enum" => ["tall", "grid", "monocle"] } },
178
+ "required" => ["layout"],
179
+ "additionalProperties" => false
180
+ }
181
+ },
182
+ {
183
+ "name" => "muxr_layout_cycle",
184
+ "description" => "Cycle to the next layout (tall → grid → monocle → tall).",
185
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
186
+ },
187
+ {
188
+ "name" => "muxr_drawer_toggle",
189
+ "description" => "Toggle the persistent Quake-style drawer overlay.",
190
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
191
+ },
192
+ {
193
+ "name" => "muxr_drawer_show",
194
+ "description" => "Show the drawer if it's hidden.",
195
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
196
+ },
197
+ {
198
+ "name" => "muxr_drawer_hide",
199
+ "description" => "Hide the drawer (its shell keeps running).",
200
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
201
+ },
202
+ {
203
+ "name" => "muxr_drawer_reset",
204
+ "description" => "Tear down the drawer's shell process. Next show recreates it fresh.",
205
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
206
+ },
207
+ {
208
+ "name" => "muxr_drawer_read",
209
+ "description" => "Read the drawer pane's current text (works even when hidden — the PTY survives).",
210
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
211
+ },
212
+ {
213
+ "name" => "muxr_drawer_send_input",
214
+ "description" => "Send bytes to the drawer pane. When the sequence needs any special key (Escape, Enter-as-terminator, arrows, Ctrl-*), use `keys` instead of `data`.",
215
+ "inputSchema" => {
216
+ "type" => "object",
217
+ "properties" => {
218
+ "data" => { "type" => "string", "description" => "Literal bytes. Mutually exclusive with `keys`." },
219
+ "keys" => {
220
+ "type" => "array",
221
+ "items" => { "type" => "string" },
222
+ "description" => "Mixed literal text and vim-style named keys (see muxr_pane_send_input for the full list). Mutually exclusive with `data`."
223
+ },
224
+ "bracketed" => { "type" => "boolean", "description" => "Wrap literal text in bracketed-paste markers. Named keys in `keys` are never bracketed." }
225
+ },
226
+ "additionalProperties" => false
227
+ }
228
+ },
229
+ {
230
+ "name" => "muxr_session_save",
231
+ "description" => "Persist the structural session (layout, indices, cwds, drawer state) to ~/.muxr/sessions/<name>.json. Shell history is not saved.",
232
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
233
+ },
234
+ {
235
+ "name" => "muxr_ping",
236
+ "description" => "Health check — returns {pong: true} if the muxr server is responsive.",
237
+ "inputSchema" => { "type" => "object", "properties" => {}, "additionalProperties" => false }
238
+ }
239
+ ].freeze
240
+
241
+ def initialize
242
+ @socket = nil
243
+ @next_request_id = 0
244
+ @read_buffer = +""
245
+ @in_drawer = ENV["MUXR_DRAWER_SELF"] == "1"
246
+ end
247
+
248
+ def run
249
+ connect_to_muxr
250
+ loop do
251
+ line = $stdin.gets
252
+ break if line.nil?
253
+ handle_message(line.chomp)
254
+ end
255
+ rescue Interrupt
256
+ # graceful exit
257
+ ensure
258
+ @socket&.close
259
+ end
260
+
261
+ private
262
+
263
+ def handle_message(line)
264
+ return if line.empty?
265
+ msg = JSON.parse(line)
266
+ rescue JSON::ParserError
267
+ return # silently drop unparsable input — stdio is best-effort
268
+ else
269
+ id = msg["id"]
270
+ method = msg["method"]
271
+ params = msg["params"] || {}
272
+
273
+ case method
274
+ when "initialize"
275
+ respond(id, {
276
+ "protocolVersion" => PROTOCOL_VERSION,
277
+ "capabilities" => { "tools" => {} },
278
+ "serverInfo" => { "name" => SERVER_NAME, "version" => SERVER_VERSION }
279
+ })
280
+ when "initialized", "notifications/initialized"
281
+ # notification, no reply
282
+ when "tools/list"
283
+ respond(id, { "tools" => TOOL_SCHEMAS })
284
+ when "tools/call"
285
+ handle_tool_call(id, params)
286
+ when "ping"
287
+ respond(id, {})
288
+ when nil
289
+ # bare notification or invalid — ignore
290
+ else
291
+ respond_error(id, -32601, "Method not found: #{method}") if id
292
+ end
293
+ end
294
+
295
+ def handle_tool_call(id, params)
296
+ tool_name = params["name"].to_s
297
+ args = params["arguments"] || {}
298
+
299
+ muxr_method = TOOL_TO_MUXR_METHOD[tool_name]
300
+ if muxr_method.nil?
301
+ respond_tool_error(id, "Unknown tool: #{tool_name}")
302
+ return
303
+ end
304
+
305
+ # When the bridge is running inside the muxr drawer itself, calling any
306
+ # drawer.* method would either recurse into our own pty or yank the
307
+ # drawer out from under us. Refuse those instead.
308
+ if @in_drawer && muxr_method.start_with?("drawer.")
309
+ respond_tool_error(id, "drawer.* methods are unavailable from inside the drawer (MUXR_DRAWER_SELF=1).")
310
+ return
311
+ end
312
+
313
+ response = send_muxr_request(muxr_method, args)
314
+ if response.nil?
315
+ respond_tool_error(id, "muxr server closed the connection")
316
+ elsif response["error"]
317
+ err = response["error"]
318
+ respond_tool_error(id, "muxr error #{err["code"]}: #{err["message"]}")
319
+ else
320
+ result = response["result"]
321
+ respond(id, {
322
+ "content" => [{ "type" => "text", "text" => JSON.pretty_generate(result) }]
323
+ })
324
+ end
325
+ rescue StandardError => e
326
+ respond_tool_error(id, "#{e.class}: #{e.message}")
327
+ end
328
+
329
+ # Send one NDJSON request to the muxr control socket and block until the
330
+ # response with the matching id arrives. Server-pushed events (no id, or
331
+ # id mismatch) are silently dropped — the v1 bridge doesn't forward them.
332
+ def send_muxr_request(method, params)
333
+ @next_request_id += 1
334
+ rid = @next_request_id
335
+ payload = JSON.generate({ "id" => rid, "method" => method, "params" => params }) + "\n"
336
+ @socket.write(payload)
337
+
338
+ loop do
339
+ line = read_muxr_line
340
+ return nil unless line # EOF
341
+ msg = JSON.parse(line)
342
+ next unless msg["id"] == rid
343
+ return msg
344
+ end
345
+ end
346
+
347
+ # Line-oriented read on the muxr socket. Reads in chunks and emits one
348
+ # JSON line at a time as they accumulate in @read_buffer.
349
+ def read_muxr_line
350
+ loop do
351
+ nl = @read_buffer.index("\n")
352
+ if nl
353
+ line = @read_buffer.slice!(0..nl)
354
+ return line.chomp
355
+ end
356
+ chunk = @socket.readpartial(64 * 1024)
357
+ @read_buffer << chunk
358
+ end
359
+ rescue EOFError, IOError
360
+ nil
361
+ end
362
+
363
+ def connect_to_muxr
364
+ path = ENV["MUXR_CONTROL_SOCKET"] || derive_socket_from_session
365
+ if path.nil? || path.empty?
366
+ die("set MUXR_CONTROL_SOCKET to the path of the muxr control socket, " \
367
+ "or MUXR_SESSION to the session name (the drawer-launched claude sets these for you).")
368
+ end
369
+ unless File.exist?(path)
370
+ die("muxr control socket not found at #{path} — is the server running? " \
371
+ "Start it with `muxr <session>`.")
372
+ end
373
+ @socket = UNIXSocket.new(path)
374
+ end
375
+
376
+ def derive_socket_from_session
377
+ name = ENV["MUXR_SESSION"]
378
+ return nil unless name && !name.empty?
379
+ File.join(Dir.home, ".muxr", "sockets", "#{name}.ctrl.sock")
380
+ end
381
+
382
+ # ---- JSON-RPC plumbing ----
383
+
384
+ def respond(id, result)
385
+ return if id.nil?
386
+ write_message({ "jsonrpc" => "2.0", "id" => id, "result" => result })
387
+ end
388
+
389
+ def respond_error(id, code, message)
390
+ return if id.nil?
391
+ write_message({ "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message } })
392
+ end
393
+
394
+ def respond_tool_error(id, text)
395
+ respond(id, {
396
+ "content" => [{ "type" => "text", "text" => text }],
397
+ "isError" => true
398
+ })
399
+ end
400
+
401
+ def write_message(obj)
402
+ $stdout.write(JSON.generate(obj) + "\n")
403
+ $stdout.flush
404
+ end
405
+
406
+ def die(msg)
407
+ $stderr.puts "muxr-mcp: #{msg}"
408
+ exit 1
409
+ end
410
+ end
411
+
412
+ MuxrMcpBridge.new.run if $PROGRAM_NAME == __FILE__