muxr 0.1.10 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +275 -1
- data/README.md +763 -387
- data/bin/muxr +68 -33
- data/bin/muxr-mcp +172 -16
- data/lib/muxr/application.rb +639 -35
- data/lib/muxr/client.rb +26 -4
- data/lib/muxr/command_dispatcher.rb +69 -0
- data/lib/muxr/config.rb +156 -0
- data/lib/muxr/control_server.rb +301 -3
- data/lib/muxr/history_row.rb +264 -0
- data/lib/muxr/image_store.rb +61 -0
- data/lib/muxr/input_handler.rb +169 -17
- data/lib/muxr/layout_manager.rb +83 -57
- data/lib/muxr/mouse_report.rb +24 -0
- data/lib/muxr/pane.rb +128 -2
- data/lib/muxr/pane_picker.rb +49 -0
- data/lib/muxr/pane_transfer.rb +172 -0
- data/lib/muxr/protocol.rb +51 -5
- data/lib/muxr/pty_process.rb +54 -7
- data/lib/muxr/remote_pane.rb +263 -0
- data/lib/muxr/renderer.rb +297 -74
- data/lib/muxr/session.rb +19 -2
- data/lib/muxr/session_directory.rb +102 -0
- data/lib/muxr/terminal.rb +903 -87
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr/width_probe.rb +121 -0
- data/lib/muxr/window.rb +50 -3
- data/lib/muxr.rb +8 -0
- data/muxr.gemspec +10 -0
- data/skills/muxr-control/SKILL.md +158 -8
- metadata +18 -1
data/lib/muxr/protocol.rb
CHANGED
|
@@ -55,21 +55,67 @@ module Muxr
|
|
|
55
55
|
buf
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
# Encodes a "ROWS COLS" string for HELLO / RESIZE payloads.
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
# Encodes a "ROWS COLS" string for HELLO / RESIZE payloads. Optional
|
|
59
|
+
# capabilities (from the width probe) ride along as trailing "key=val"
|
|
60
|
+
# tokens, e.g. "48 200 ambiguous=2". Older servers that split on the first
|
|
61
|
+
# two tokens still read the size correctly, and a server that doesn't know a
|
|
62
|
+
# given cap simply ignores it.
|
|
63
|
+
def self.encode_size(rows, cols, caps = nil)
|
|
64
|
+
base = "#{rows.to_i} #{cols.to_i}"
|
|
65
|
+
return base if caps.nil? || caps.empty?
|
|
66
|
+
base + " " + encode_caps(caps)
|
|
61
67
|
end
|
|
62
68
|
|
|
63
|
-
#
|
|
69
|
+
# Serializes a caps hash to whitespace-free "key=value" tokens. Integer
|
|
70
|
+
# values encode as "ambiguous=2"; a codepoint=>width map (the width probe's
|
|
71
|
+
# per-glyph overrides) encodes as "glyphs=23fa:2,273b:1" with hex codepoints.
|
|
72
|
+
def self.encode_caps(caps)
|
|
73
|
+
caps.filter_map do |k, v|
|
|
74
|
+
if v.is_a?(Hash)
|
|
75
|
+
next if v.empty?
|
|
76
|
+
"#{k}=" + v.map { |cp, w| "#{cp.to_s(16)}:#{w.to_i}" }.join(",")
|
|
77
|
+
else
|
|
78
|
+
"#{k}=#{v.to_i}"
|
|
79
|
+
end
|
|
80
|
+
end.join(" ")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns [rows, cols] from a HELLO/RESIZE payload, or nil if malformed.
|
|
84
|
+
# Tolerates trailing caps tokens by reading only the first two integers.
|
|
64
85
|
def self.decode_size(payload)
|
|
65
86
|
parts = payload.to_s.strip.split(/\s+/)
|
|
66
|
-
return nil unless parts.length
|
|
87
|
+
return nil unless parts.length >= 2
|
|
67
88
|
r = Integer(parts[0]) rescue nil
|
|
68
89
|
c = Integer(parts[1]) rescue nil
|
|
69
90
|
return nil unless r && c
|
|
70
91
|
[r, c]
|
|
71
92
|
end
|
|
72
93
|
|
|
94
|
+
# Pulls the trailing "key=val" capability tokens out of a HELLO payload into
|
|
95
|
+
# a symbol-keyed hash. Plain integer values decode to Integer; a value with
|
|
96
|
+
# "hex:width" pairs decodes to a {codepoint => width} hash (inverse of
|
|
97
|
+
# encode_caps). Unknown / malformed tokens are skipped, never raised on.
|
|
98
|
+
def self.decode_caps(payload)
|
|
99
|
+
caps = {}
|
|
100
|
+
payload.to_s.strip.split(/\s+/).each do |tok|
|
|
101
|
+
next unless tok.include?("=")
|
|
102
|
+
k, v = tok.split("=", 2)
|
|
103
|
+
next unless k =~ /\A[a-z_]+\z/
|
|
104
|
+
if v.include?(":")
|
|
105
|
+
map = {}
|
|
106
|
+
v.split(",").each do |pair|
|
|
107
|
+
cp, w = pair.split(":", 2)
|
|
108
|
+
next unless cp =~ /\A[0-9a-fA-F]+\z/ && w =~ /\A\d+\z/
|
|
109
|
+
map[cp.to_i(16)] = w.to_i
|
|
110
|
+
end
|
|
111
|
+
caps[k.to_sym] = map unless map.empty?
|
|
112
|
+
elsif v =~ /\A-?\d+\z/
|
|
113
|
+
caps[k.to_sym] = v.to_i
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
caps
|
|
117
|
+
end
|
|
118
|
+
|
|
73
119
|
def self.read_exact(io, n)
|
|
74
120
|
buf = +""
|
|
75
121
|
while buf.bytesize < n
|
data/lib/muxr/pty_process.rb
CHANGED
|
@@ -6,20 +6,31 @@ module Muxr
|
|
|
6
6
|
class PTYProcess
|
|
7
7
|
attr_reader :pid, :io, :rows, :cols
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
# +adopt_io+ / +adopt_pid+ take over a master pty handed across a Unix
|
|
10
|
+
# socket by another muxr server instead of spawning anything: the shell on
|
|
11
|
+
# the far end of that fd is already running and keeps running. The child is
|
|
12
|
+
# not ours, so it can be signalled and inspected but never waited on —
|
|
13
|
+
# #reap already tolerates ECHILD, which is exactly that case.
|
|
14
|
+
def initialize(command: nil, rows: 24, cols: 80, cwd: nil, env_overrides: {}, adopt_io: nil, adopt_pid: nil)
|
|
10
15
|
@rows = rows
|
|
11
16
|
@cols = cols
|
|
12
17
|
@exited = false
|
|
13
18
|
@write_buffer = +"".b
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
if adopt_io
|
|
21
|
+
@reader = adopt_io
|
|
22
|
+
@writer = adopt_io.dup
|
|
23
|
+
@pid = adopt_pid
|
|
24
|
+
else
|
|
25
|
+
shell = command || ENV["SHELL"] || "/bin/sh"
|
|
26
|
+
env = ENV.to_h.merge("TERM" => "xterm-256color").merge(env_overrides)
|
|
27
|
+
env["LINES"] = rows.to_s
|
|
28
|
+
env["COLUMNS"] = cols.to_s
|
|
19
29
|
|
|
20
|
-
|
|
30
|
+
chdir = (cwd && File.directory?(cwd)) ? cwd : Dir.pwd
|
|
21
31
|
|
|
22
|
-
|
|
32
|
+
@reader, @writer, @pid = PTY.spawn(env, shell, chdir: chdir)
|
|
33
|
+
end
|
|
23
34
|
@io = @reader
|
|
24
35
|
resize(rows, cols)
|
|
25
36
|
end
|
|
@@ -81,6 +92,26 @@ module Muxr
|
|
|
81
92
|
end
|
|
82
93
|
end
|
|
83
94
|
|
|
95
|
+
# Coax the foreground program into repainting from scratch by briefly
|
|
96
|
+
# toggling the PTY window size, which delivers SIGWINCH to the tty's
|
|
97
|
+
# foreground process group. Full-screen TUIs (vim, htop, less, fzf) redraw
|
|
98
|
+
# on WINCH, which rewrites muxr's Terminal grid and clears any emulation
|
|
99
|
+
# drift (e.g. a wide glyph that desynced the cursor). The size is restored
|
|
100
|
+
# immediately, so the program redraws at the real dimensions: it reads the
|
|
101
|
+
# current (restored) winsize in its handler and never observes the
|
|
102
|
+
# transient size. No-op when the pane is too narrow to wiggle.
|
|
103
|
+
def nudge_redraw
|
|
104
|
+
return if @exited
|
|
105
|
+
smaller = [@cols - 1, 1].max
|
|
106
|
+
return if smaller == @cols
|
|
107
|
+
begin
|
|
108
|
+
@reader.winsize = [@rows, smaller, 0, 0]
|
|
109
|
+
@reader.winsize = [@rows, @cols, 0, 0]
|
|
110
|
+
rescue StandardError
|
|
111
|
+
# Some platforms reject winsize pokes; reset_frame! still re-emits.
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
84
115
|
def alive?
|
|
85
116
|
return false if @exited
|
|
86
117
|
Process.kill(0, @pid)
|
|
@@ -96,6 +127,22 @@ module Muxr
|
|
|
96
127
|
nil
|
|
97
128
|
end
|
|
98
129
|
|
|
130
|
+
# Give the child up to another server without killing it: drop our fds and
|
|
131
|
+
# hand the corpse-reaping duty to a detach thread, since the process stays
|
|
132
|
+
# our child in the kernel's eyes no matter who holds the pty now. After
|
|
133
|
+
# this the process looks exited to us, so #close is a no-op and nothing
|
|
134
|
+
# signals a shell that has a new owner.
|
|
135
|
+
def relinquish!
|
|
136
|
+
return if @exited
|
|
137
|
+
@exited = true
|
|
138
|
+
@write_buffer.clear
|
|
139
|
+
Process.detach(@pid) if @pid
|
|
140
|
+
@reader.close unless @reader.closed?
|
|
141
|
+
@writer.close if @writer != @reader && !@writer.closed?
|
|
142
|
+
rescue Errno::EBADF, IOError
|
|
143
|
+
# already closed
|
|
144
|
+
end
|
|
145
|
+
|
|
99
146
|
def close
|
|
100
147
|
reap
|
|
101
148
|
Process.kill("TERM", @pid) if alive?
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "json"
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Muxr
|
|
6
|
+
# Stands in for a PTYProcess when a pane is borrowed from another muxr
|
|
7
|
+
# server. It speaks the owner's control protocol (NDJSON over
|
|
8
|
+
# ~/.muxr/sockets/<name>.ctrl.sock) but presents the same surface a Pane
|
|
9
|
+
# expects from a local PTY, so the borrowed pane joins the event loop, the
|
|
10
|
+
# layout, and the Renderer with no special-casing anywhere else.
|
|
11
|
+
#
|
|
12
|
+
# The owner keeps the PTY and stays the only reader of it. What crosses the
|
|
13
|
+
# socket is the raw byte stream in one direction and keystrokes in the other,
|
|
14
|
+
# which makes the local Terminal a byte-exact replica rather than a
|
|
15
|
+
# screen-scrape: colors, cursor, alternate screen and bracketed paste all
|
|
16
|
+
# behave. The price is that geometry belongs to the owner — see Pane#resize.
|
|
17
|
+
#
|
|
18
|
+
# Lifecycle at both ends is failure-shaped rather than negotiated. If the
|
|
19
|
+
# owning server stops, the socket EOFs, #alive? goes false and the borrowing
|
|
20
|
+
# session prunes the pane. If the borrowing server stops, #close drops the
|
|
21
|
+
# mirror and the pane carries on at home at full size.
|
|
22
|
+
class RemotePane
|
|
23
|
+
class Error < StandardError; end
|
|
24
|
+
|
|
25
|
+
HANDSHAKE_TIMEOUT = 2.0
|
|
26
|
+
READ_CHUNK = 64 * 1024
|
|
27
|
+
|
|
28
|
+
attr_reader :session, :pane_id, :rows, :cols, :cwd
|
|
29
|
+
|
|
30
|
+
def self.connect(socket_path:, pane_id:, rows:, cols:)
|
|
31
|
+
new(socket_path: socket_path, pane_id: pane_id, rows: rows, cols: cols)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(socket_path:, pane_id:, rows:, cols:, socket: nil)
|
|
35
|
+
@socket_path = socket_path
|
|
36
|
+
@pane_id = pane_id.to_s
|
|
37
|
+
@requested = [rows, cols]
|
|
38
|
+
@in_buffer = +""
|
|
39
|
+
@write_buffer = +"".b
|
|
40
|
+
@queue = []
|
|
41
|
+
@closed = false
|
|
42
|
+
@terminal = nil
|
|
43
|
+
@socket = socket || connect_socket
|
|
44
|
+
handshake(rows, cols)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def mirror?
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The replica emulator this mirror drives. Bound after construction because
|
|
52
|
+
# the Pane builds its Terminal from the geometry we hand back.
|
|
53
|
+
def bind(terminal)
|
|
54
|
+
@terminal = terminal
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def origin
|
|
58
|
+
"#{@session}:#{@pane_id}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def io
|
|
62
|
+
@socket
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def writer_io
|
|
66
|
+
@socket
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def pid
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def pending_write?
|
|
74
|
+
!@write_buffer.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def alive?
|
|
78
|
+
!@closed
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def write(data)
|
|
82
|
+
return if @closed || data.nil? || data.empty?
|
|
83
|
+
request("pane.send_input", "pane" => @pane_id, "data" => Base64.strict_encode64(data), "base64" => true)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def resize(rows, cols)
|
|
87
|
+
return if @closed
|
|
88
|
+
return if [rows, cols] == @requested
|
|
89
|
+
@requested = [rows, cols]
|
|
90
|
+
request("pane.mirror_resize", "pane" => @pane_id, "rows" => rows, "cols" => cols)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def nudge_redraw
|
|
94
|
+
return if @closed
|
|
95
|
+
request("pane.redraw", "pane" => @pane_id)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def read_nonblock(_max = READ_CHUNK)
|
|
99
|
+
loop do
|
|
100
|
+
chunk = take_queued
|
|
101
|
+
return chunk if chunk
|
|
102
|
+
return nil unless fill
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def drain
|
|
107
|
+
return if @closed || @write_buffer.empty?
|
|
108
|
+
loop do
|
|
109
|
+
n = @socket.write_nonblock(@write_buffer)
|
|
110
|
+
@write_buffer = @write_buffer.byteslice(n..-1) || +"".b
|
|
111
|
+
break if @write_buffer.empty?
|
|
112
|
+
end
|
|
113
|
+
rescue IO::WaitWritable
|
|
114
|
+
# Owner's receive buffer is full; the rest stays queued.
|
|
115
|
+
rescue SystemCallError, IOError
|
|
116
|
+
@closed = true
|
|
117
|
+
@write_buffer.clear
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def close
|
|
121
|
+
return if @closed
|
|
122
|
+
@closed = true
|
|
123
|
+
begin
|
|
124
|
+
@socket.write(JSON.generate("method" => "pane.unmirror", "params" => { "pane" => @pane_id }) + "\n")
|
|
125
|
+
rescue SystemCallError, IOError
|
|
126
|
+
# Owner already gone; it drops the mirror when our socket closes.
|
|
127
|
+
end
|
|
128
|
+
@socket.close rescue nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def connect_socket
|
|
134
|
+
UNIXSocket.new(@socket_path)
|
|
135
|
+
rescue SystemCallError => e
|
|
136
|
+
raise Error, "cannot reach session socket: #{e.message}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def handshake(rows, cols)
|
|
140
|
+
send_line(JSON.generate(
|
|
141
|
+
"id" => 1,
|
|
142
|
+
"method" => "pane.mirror",
|
|
143
|
+
"params" => { "pane" => @pane_id, "rows" => rows, "cols" => cols }
|
|
144
|
+
))
|
|
145
|
+
result = await_response(1)
|
|
146
|
+
@session = result["session"].to_s
|
|
147
|
+
@cwd = result["cwd"]
|
|
148
|
+
@rows = result["rows"].to_i
|
|
149
|
+
@cols = result["cols"].to_i
|
|
150
|
+
snapshot = decode(result["snapshot"])
|
|
151
|
+
@queue << [:bytes, snapshot] if snapshot && !snapshot.empty?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def await_response(id)
|
|
155
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + HANDSHAKE_TIMEOUT
|
|
156
|
+
loop do
|
|
157
|
+
line = @in_buffer.slice!(/\A.*\n/)
|
|
158
|
+
if line
|
|
159
|
+
msg = parse(line)
|
|
160
|
+
next unless msg && msg["id"] == id
|
|
161
|
+
if (err = msg["error"])
|
|
162
|
+
raise Error, err["message"].to_s
|
|
163
|
+
end
|
|
164
|
+
return msg["result"] || {}
|
|
165
|
+
end
|
|
166
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
167
|
+
raise Error, "timed out waiting for the owning session" if remaining <= 0
|
|
168
|
+
raise Error, "timed out waiting for the owning session" unless IO.select([@socket], nil, nil, remaining)
|
|
169
|
+
chunk = begin
|
|
170
|
+
@socket.read_nonblock(READ_CHUNK)
|
|
171
|
+
rescue IO::WaitReadable
|
|
172
|
+
next
|
|
173
|
+
rescue EOFError, SystemCallError, IOError
|
|
174
|
+
raise Error, "owning session closed the connection"
|
|
175
|
+
end
|
|
176
|
+
@in_buffer << chunk
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def request(method, params)
|
|
181
|
+
send_line(JSON.generate("method" => method, "params" => params))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def send_line(line)
|
|
185
|
+
@write_buffer << (line + "\n").b
|
|
186
|
+
drain
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Pull the next relayed output chunk off the queue, applying any geometry
|
|
190
|
+
# change that precedes it first: the owner's repaint snapshot is only
|
|
191
|
+
# meaningful against a replica that has already taken the new size.
|
|
192
|
+
def take_queued
|
|
193
|
+
while (entry = @queue.shift)
|
|
194
|
+
kind, *rest = entry
|
|
195
|
+
case kind
|
|
196
|
+
when :bytes
|
|
197
|
+
return rest[0]
|
|
198
|
+
when :geometry
|
|
199
|
+
rows, cols, snapshot = rest
|
|
200
|
+
@rows = rows
|
|
201
|
+
@cols = cols
|
|
202
|
+
@terminal&.resize(rows, cols)
|
|
203
|
+
return snapshot unless snapshot.nil? || snapshot.empty?
|
|
204
|
+
when :gone
|
|
205
|
+
@closed = true
|
|
206
|
+
return nil
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def fill
|
|
213
|
+
return false if @closed
|
|
214
|
+
chunk = begin
|
|
215
|
+
@socket.read_nonblock(READ_CHUNK)
|
|
216
|
+
rescue IO::WaitReadable
|
|
217
|
+
return false
|
|
218
|
+
rescue EOFError, SystemCallError, IOError
|
|
219
|
+
@closed = true
|
|
220
|
+
return false
|
|
221
|
+
end
|
|
222
|
+
@in_buffer << chunk
|
|
223
|
+
queued = false
|
|
224
|
+
while (line = @in_buffer.slice!(/\A.*\n/))
|
|
225
|
+
queued = true if enqueue(line)
|
|
226
|
+
end
|
|
227
|
+
queued
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def enqueue(line)
|
|
231
|
+
msg = parse(line)
|
|
232
|
+
return false unless msg
|
|
233
|
+
params = msg["params"] || {}
|
|
234
|
+
return false unless params["pane"].to_s == @pane_id
|
|
235
|
+
case msg["method"]
|
|
236
|
+
when "event.pane.mirror"
|
|
237
|
+
data = decode(params["data"])
|
|
238
|
+
return false if data.nil? || data.empty?
|
|
239
|
+
@queue << [:bytes, data]
|
|
240
|
+
when "event.pane.geometry"
|
|
241
|
+
@queue << [:geometry, params["rows"].to_i, params["cols"].to_i, decode(params["snapshot"])]
|
|
242
|
+
when "event.pane.gone"
|
|
243
|
+
@queue << [:gone]
|
|
244
|
+
else
|
|
245
|
+
return false
|
|
246
|
+
end
|
|
247
|
+
true
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def parse(line)
|
|
251
|
+
JSON.parse(line)
|
|
252
|
+
rescue JSON::ParserError
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def decode(value)
|
|
257
|
+
return nil unless value.is_a?(String)
|
|
258
|
+
Base64.strict_decode64(value)
|
|
259
|
+
rescue ArgumentError
|
|
260
|
+
nil
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|