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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +225 -1
- data/README.md +763 -387
- data/bin/muxr +68 -33
- data/bin/muxr-mcp +172 -16
- data/lib/muxr/application.rb +516 -26
- 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 +112 -14
- data/lib/muxr/layout_manager.rb +83 -57
- data/lib/muxr/mouse_report.rb +24 -0
- data/lib/muxr/pane.rb +122 -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 +34 -7
- data/lib/muxr/remote_pane.rb +263 -0
- data/lib/muxr/renderer.rb +259 -76
- data/lib/muxr/session.rb +19 -2
- data/lib/muxr/session_directory.rb +102 -0
- data/lib/muxr/terminal.rb +759 -82
- 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/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
|
|
@@ -116,6 +127,22 @@ module Muxr
|
|
|
116
127
|
nil
|
|
117
128
|
end
|
|
118
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
|
+
|
|
119
146
|
def close
|
|
120
147
|
reap
|
|
121
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
|