muxr 0.1.11 → 0.2.0

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.
data/bin/muxr CHANGED
@@ -7,6 +7,10 @@ require "fileutils"
7
7
  require "rbconfig"
8
8
  require "socket"
9
9
 
10
+ def configured_prefix_letter
11
+ Muxr::Renderer.prefix_letter(Muxr::Config.load.prefix)
12
+ end
13
+
10
14
  if ARGV.include?("-v") || ARGV.include?("--version")
11
15
  puts "muxr #{Muxr::VERSION}"
12
16
  exit 0
@@ -18,27 +22,31 @@ if ARGV.include?("-l") || ARGV.include?("--list")
18
22
  exit 0
19
23
  end
20
24
 
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.
25
+ install_skill_arg = ARGV.find { |a| a == "--install-skill" || a.start_with?("--install-skill=") }
26
+
27
+ if install_skill_arg
28
+ requested_strategy = install_skill_arg.split("=", 2)[1]
29
+ unless [nil, "copy", "link"].include?(requested_strategy)
30
+ $stderr.puts "muxr: --install-skill takes `copy` or `link` (got #{requested_strategy.inspect})"
31
+ exit 1
32
+ end
28
33
  src = File.expand_path("../skills/muxr-control", __dir__)
29
34
  unless File.directory?(src)
30
35
  $stderr.puts "muxr: skill source not found at #{src} (was the gem packaged without skills/?)"
31
36
  exit 1
32
37
  end
38
+ running_from_source_checkout = File.directory?(File.expand_path("../.git", __dir__))
39
+ strategy = requested_strategy || (running_from_source_checkout ? "link" : "copy")
33
40
  claude_skills = File.expand_path("~/.claude/skills")
34
41
  dst = File.join(claude_skills, "muxr-control")
35
42
  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)
43
+ FileUtils.rm_rf(dst) if File.symlink?(dst) || File.exist?(dst)
44
+ if strategy == "link"
45
+ FileUtils.ln_s(src, dst)
46
+ else
47
+ FileUtils.mkdir_p(dst)
48
+ FileUtils.cp_r(File.join(src, "."), dst)
39
49
  end
40
- FileUtils.mkdir_p(dst)
41
- FileUtils.cp_r(File.join(src, "."), dst)
42
50
 
43
51
  # Pick the most stable absolute path we can point claude's mcp config at:
44
52
  #
@@ -49,13 +57,31 @@ if ARGV.include?("--install-skill")
49
57
  # sits next to the running `muxr` binary.
50
58
  bin_stub = File.join(Gem.bindir, "muxr-mcp") rescue nil
51
59
  bridge = (bin_stub && File.exist?(bin_stub)) ? bin_stub : File.expand_path("muxr-mcp", __dir__)
60
+ puts(
61
+ if strategy == "link"
62
+ <<~LINKED
63
+ ✓ Linked skill: #{dst}
64
+ -> #{src}
65
+
66
+ Edits to the skill are live in new claude sessions with no re-run.
67
+ Force a copy instead with `muxr --install-skill=copy`.
68
+ LINKED
69
+ else
70
+ <<~COPIED
71
+ ✓ Copied skill to: #{dst}
72
+ (source: #{src})
73
+
74
+ Re-run `muxr --install-skill` after `gem update muxr` to refresh the
75
+ skill contents. An installed gem is copied rather than linked because
76
+ RubyGems prunes the old versioned gem directory on upgrade, which
77
+ would leave a symlink dangling. If you know this path is stable, force
78
+ a symlink with `muxr --install-skill=link`.
79
+ COPIED
80
+ end
81
+ )
82
+ puts
83
+ letter = configured_prefix_letter
52
84
  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
85
  Next, register the muxr MCP bridge with Claude Code at USER scope
60
86
  (so every claude session sees it, not just ones started from your
61
87
  home directory):
@@ -71,35 +97,44 @@ if ARGV.include?("--install-skill")
71
97
  claude mcp add muxr muxr-mcp --scope user
72
98
 
73
99
  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.
100
+ config. Any claude you launch from inside muxr — a tiled pane or the
101
+ Claude drawer (Ctrl-#{letter} C) — auto-detects its session via MUXR_SESSION /
102
+ MUXR_CONTROL_SOCKET, which muxr injects into every PTY it spawns.
103
+ Outside muxr the bridge stays quiet and offers no tools.
104
+
105
+ Panes that were already running before this install won't have those
106
+ vars — open a new pane (Ctrl-#{letter} c) to pick them up.
76
107
  MSG
77
108
  exit 0
78
109
  end
79
110
 
80
111
  if ARGV.include?("-h") || ARGV.include?("--help")
112
+ letter = configured_prefix_letter
113
+ source = letter == "a" ? "" : ", from #{Muxr::Config.path_from_env.sub(Dir.home, "~")}"
81
114
  puts <<~USAGE
82
115
  muxr #{Muxr::VERSION} — a tiling terminal multiplexer
83
116
 
84
117
  Usage:
85
- muxr attach the default session (auto-spawn if needed)
118
+ muxr attach the session for the current directory (auto-spawn if needed)
86
119
  muxr <name> attach (or start) the named session
87
120
  muxr -s <name> same as above
88
121
  muxr --list list running sessions and exit
89
- muxr --install-skill symlink the MCP skill into ~/.claude/skills and exit
122
+ muxr --install-skill install the MCP skill into ~/.claude/skills and exit
123
+ (symlinks from a source checkout, copies from an
124
+ installed gem; force with --install-skill=copy|link)
90
125
  muxr --version print version and exit
91
126
  muxr --help this help
92
127
 
93
- Keybindings (Ctrl-a prefix):
94
- C-a c new pane C-a n / p next / prev pane
95
- C-a a toggle last pane C-a 1..9 jump to pane by number
96
- C-a k close pane C-a Tab cycle layout
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)
99
- C-a : command prompt C-a ? toggle help
100
- C-a d detach (server stays running)
101
- C-a q kill session (with y/n confirmation)
102
- C-a C-a send literal C-a
128
+ Keybindings (Ctrl-#{letter} prefix#{source}):
129
+ C-#{letter} c new pane C-#{letter} n / p next / prev pane
130
+ C-#{letter} a toggle last pane C-#{letter} 1..9 jump to pane by number
131
+ C-#{letter} x close pane C-#{letter} Tab cycle layout
132
+ C-#{letter} ~ toggle drawer C-#{letter} Enter promote to master
133
+ C-#{letter} C Claude drawer (as is claude in any pane; needs `muxr --install-skill`)
134
+ C-#{letter} : command prompt C-#{letter} ? toggle help
135
+ C-#{letter} d detach (server stays running)
136
+ C-#{letter} q kill session (with y/n confirmation)
137
+ C-#{letter} C-#{letter} send literal C-#{letter}
103
138
  USAGE
104
139
  exit 0
105
140
  end
@@ -131,7 +166,7 @@ end
131
166
  def session_name_from(argv)
132
167
  idx = argv.index("-s") || argv.index("--session")
133
168
  return argv[idx + 1] if idx && argv[idx + 1]
134
- argv.find { |a| !a.start_with?("-") } || "default"
169
+ argv.find { |a| !a.start_with?("-") } || Muxr::Application.default_session_name
135
170
  end
136
171
 
137
172
  def probe_socket(path)
data/bin/muxr-mcp CHANGED
@@ -9,10 +9,15 @@
9
9
  #
10
10
  # 1. $MUXR_CONTROL_SOCKET (absolute path) — exact override
11
11
  # 2. $MUXR_SESSION (session name) — resolved to standard path
12
- # 3. otherwise: error
12
+ # 3. whichever live session lists $MUXR_PANE as a pane it owns, when the
13
+ # two above name a session that no longer has us (see below)
14
+ # 4. otherwise: no tools, and an explanatory error if called anyway
13
15
  #
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
+ # muxr injects all three vars into every PTY it spawns, so end users never set
17
+ # them by hand. They are a snapshot of where the pane was when its shell
18
+ # started, which is why (3) exists: a pane can be moved to another session
19
+ # while its shell keeps running, and a running process's environment cannot
20
+ # be rewritten from outside.
16
21
  #
17
22
  # Typical claude-code mcp config:
18
23
  #
@@ -63,7 +68,7 @@ class MuxrMcpBridge
63
68
  # ids from muxr_panes_list.
64
69
  PANE_REF = {
65
70
  "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."
71
+ "description" => "Stable 6-hex pane id from muxr_panes_list (e.g. \"a3f9b2\"), or the name the human gave the pane with :rename (e.g. \"api\"). Ids survive splits, kills, and promote_to_master — always prefer them over the positional slot numbers shown in the status bar."
67
72
  }.freeze
68
73
 
69
74
  TOOL_SCHEMAS = [
@@ -238,15 +243,22 @@ class MuxrMcpBridge
238
243
  }
239
244
  ].freeze
240
245
 
246
+ SELF_FORBIDDEN_PANE_METHODS = ["pane.read", "pane.send_input", "pane.run", "pane.kill"].freeze
247
+
248
+ DEFAULT_SOCKETS_DIR = File.join(Dir.home, ".muxr", "sockets")
249
+ DISCOVERY_TIMEOUT = 0.5
250
+ OWNERSHIP_TTL = 10.0
251
+
241
252
  def initialize
242
253
  @socket = nil
243
254
  @next_request_id = 0
244
255
  @read_buffer = +""
245
256
  @in_drawer = ENV["MUXR_DRAWER_SELF"] == "1"
257
+ @own_pane = ENV["MUXR_PANE"].to_s
258
+ @verified_at = nil
246
259
  end
247
260
 
248
261
  def run
249
- connect_to_muxr
250
262
  loop do
251
263
  line = $stdin.gets
252
264
  break if line.nil?
@@ -280,7 +292,7 @@ class MuxrMcpBridge
280
292
  when "initialized", "notifications/initialized"
281
293
  # notification, no reply
282
294
  when "tools/list"
283
- respond(id, { "tools" => TOOL_SCHEMAS })
295
+ respond(id, { "tools" => session_reachable? ? TOOL_SCHEMAS : [] })
284
296
  when "tools/call"
285
297
  handle_tool_call(id, params)
286
298
  when "ping"
@@ -310,6 +322,17 @@ class MuxrMcpBridge
310
322
  return
311
323
  end
312
324
 
325
+ unless ensure_owning_connection
326
+ respond_tool_error(id, connection_error)
327
+ return
328
+ end
329
+
330
+ if targets_own_pane?(muxr_method, args)
331
+ respond_tool_error(id, "#{muxr_method} is unavailable on pane #{@own_pane} — that is the pane this claude " \
332
+ "session is running in. Use muxr_panes_list to pick a different pane.")
333
+ return
334
+ end
335
+
313
336
  response = send_muxr_request(muxr_method, args)
314
337
  if response.nil?
315
338
  respond_tool_error(id, "muxr server closed the connection")
@@ -329,7 +352,7 @@ class MuxrMcpBridge
329
352
  # Send one NDJSON request to the muxr control socket and block until the
330
353
  # response with the matching id arrives. Server-pushed events (no id, or
331
354
  # id mismatch) are silently dropped — the v1 bridge doesn't forward them.
332
- def send_muxr_request(method, params)
355
+ def send_muxr_request(method, params, retrying: false)
333
356
  @next_request_id += 1
334
357
  rid = @next_request_id
335
358
  payload = JSON.generate({ "id" => rid, "method" => method, "params" => params }) + "\n"
@@ -337,11 +360,22 @@ class MuxrMcpBridge
337
360
 
338
361
  loop do
339
362
  line = read_muxr_line
340
- return nil unless line # EOF
363
+ break unless line
341
364
  msg = JSON.parse(line)
342
365
  next unless msg["id"] == rid
343
366
  return msg
344
367
  end
368
+
369
+ reconnect_and_retry(method, params, retrying: retrying)
370
+ rescue SystemCallError, IOError
371
+ reconnect_and_retry(method, params, retrying: retrying)
372
+ end
373
+
374
+ def reconnect_and_retry(method, params, retrying:)
375
+ drop_connection
376
+ return nil if retrying
377
+ return nil unless ensure_connection
378
+ send_muxr_request(method, params, retrying: true)
345
379
  end
346
380
 
347
381
  # Line-oriented read on the muxr socket. Reads in chunks and emits one
@@ -360,17 +394,139 @@ class MuxrMcpBridge
360
394
  nil
361
395
  end
362
396
 
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).")
397
+ def targets_own_pane?(muxr_method, args)
398
+ return false if @own_pane.empty?
399
+ return false unless SELF_FORBIDDEN_PANE_METHODS.include?(muxr_method)
400
+ ref = args["pane"].to_s
401
+ ref == @own_pane || names_own_pane?(ref)
402
+ end
403
+
404
+ def names_own_pane?(ref)
405
+ panes = send_muxr_request("panes.list", {})&.dig("result", "panes") || []
406
+ return false if panes.any? { |pane| pane["id"].to_s == ref }
407
+ panes.any? { |pane| pane["id"].to_s == @own_pane && pane["name"] == ref }
408
+ end
409
+
410
+ def control_socket_path
411
+ path = ENV["MUXR_CONTROL_SOCKET"]
412
+ path = derive_socket_from_session if path.nil? || path.empty?
413
+ path
414
+ end
415
+
416
+ def env_socket_path
417
+ path = control_socket_path
418
+ return nil if path.nil? || path.empty? || !File.exist?(path)
419
+ path
420
+ end
421
+
422
+ def resolve_control_socket
423
+ env_path = env_socket_path
424
+ return env_path if @own_pane.empty?
425
+ return env_path if env_path && socket_owns_own_pane?(env_path)
426
+ discover_owning_socket(skip: env_path) || env_path
427
+ end
428
+
429
+ def sockets_dir
430
+ path = control_socket_path
431
+ return DEFAULT_SOCKETS_DIR if path.nil? || path.empty?
432
+ File.dirname(path)
433
+ end
434
+
435
+ def discover_owning_socket(skip: nil)
436
+ dir = sockets_dir
437
+ return nil unless File.directory?(dir)
438
+ Dir.glob(File.join(dir, "*.ctrl.sock")).sort.find do |path|
439
+ next false if path == skip
440
+ socket_owns_own_pane?(path)
368
441
  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>`.")
442
+ end
443
+
444
+ def socket_owns_own_pane?(path)
445
+ sock = UNIXSocket.new(path)
446
+ sock.write(JSON.generate("id" => 1, "method" => "panes.list", "params" => {}) + "\n")
447
+ deadline = monotonic + DISCOVERY_TIMEOUT
448
+ buffer = +""
449
+ loop do
450
+ remaining = deadline - monotonic
451
+ return false if remaining <= 0
452
+ return false unless IO.select([sock], nil, nil, remaining)
453
+ buffer << sock.readpartial(64 * 1024)
454
+ while (nl = buffer.index("\n"))
455
+ line = buffer.slice!(0..nl)
456
+ msg = JSON.parse(line) rescue nil
457
+ next unless msg && msg["id"] == 1
458
+ return owns_own_pane?(msg["result"])
459
+ end
460
+ end
461
+ rescue SystemCallError, IOError, EOFError
462
+ false
463
+ ensure
464
+ sock&.close rescue nil
465
+ end
466
+
467
+ def owns_own_pane?(result)
468
+ panes = result.is_a?(Hash) ? result["panes"] : nil
469
+ return false unless panes.is_a?(Array)
470
+ panes.any? { |pane| pane["id"].to_s == @own_pane && !mirror?(pane) }
471
+ end
472
+
473
+ def mirror?(pane)
474
+ !pane["origin"].nil?
475
+ end
476
+
477
+ def monotonic
478
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
479
+ end
480
+
481
+ def session_reachable?
482
+ !resolve_control_socket.nil?
483
+ end
484
+
485
+ def connection_error
486
+ path = control_socket_path
487
+ if path.nil? || path.empty?
488
+ "not running inside muxr — no MUXR_CONTROL_SOCKET or MUXR_SESSION in the environment. " \
489
+ "Start a pane with `muxr <session>` and run claude from inside it."
490
+ elsif @own_pane.empty?
491
+ "muxr control socket not found at #{path} — is the server running? Start it with `muxr <session>`."
492
+ else
493
+ "no live muxr session owns pane #{@own_pane} (#{path} is gone, and no other session claims it) — " \
494
+ "is the server running? Start it with `muxr <session>`."
372
495
  end
496
+ end
497
+
498
+ def ensure_connection
499
+ return true if @socket && !@socket.closed?
500
+ path = resolve_control_socket
501
+ return false if path.nil?
373
502
  @socket = UNIXSocket.new(path)
503
+ @read_buffer.clear
504
+ @verified_at = monotonic
505
+ true
506
+ rescue SystemCallError, IOError
507
+ @socket = nil
508
+ false
509
+ end
510
+
511
+ def ensure_owning_connection
512
+ return false unless ensure_connection
513
+ return true if @own_pane.empty?
514
+ return true if @verified_at && monotonic - @verified_at < OWNERSHIP_TTL
515
+
516
+ if owns_own_pane?(send_muxr_request("panes.list", {})&.fetch("result", nil))
517
+ @verified_at = monotonic
518
+ return true
519
+ end
520
+
521
+ drop_connection
522
+ ensure_connection
523
+ end
524
+
525
+ def drop_connection
526
+ @socket&.close rescue nil
527
+ @socket = nil
528
+ @read_buffer.clear
529
+ @verified_at = nil
374
530
  end
375
531
 
376
532
  def derive_socket_from_session