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 +4 -4
- data/CHANGELOG.md +65 -1
- data/bin/muxr +61 -0
- data/bin/muxr-mcp +412 -0
- data/lib/muxr/application.rb +150 -26
- data/lib/muxr/command_dispatcher.rb +2 -0
- data/lib/muxr/control_server.rb +670 -0
- data/lib/muxr/drawer.rb +9 -2
- data/lib/muxr/input_handler.rb +2 -0
- data/lib/muxr/key_parser.rb +89 -0
- data/lib/muxr/pane.rb +50 -3
- data/lib/muxr/renderer.rb +14 -3
- data/lib/muxr/session.rb +13 -2
- data/lib/muxr/terminal.rb +37 -0
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr.rb +1 -0
- data/muxr.gemspec +3 -1
- data/skills/muxr-control/SKILL.md +190 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f553caf555f455f8c89689ed8ef73de3158467171551f177a27009363055091
|
|
4
|
+
data.tar.gz: b26779047961944af58a74593773b876af42c4e512ed956e0c7cb7a9becead99
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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__
|