libtmux-mcp 0.1.0.alpha.1
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +139 -0
- data/exe/libtmux-mcp +6 -0
- data/lib/libtmux/mcp/application.rb +519 -0
- data/lib/libtmux/mcp/catalog.rb +258 -0
- data/lib/libtmux/mcp/catalog_tool.rb +33 -0
- data/lib/libtmux/mcp/cli.rb +239 -0
- data/lib/libtmux/mcp/enrollment.rb +670 -0
- data/lib/libtmux/mcp/mutations.rb +120 -0
- data/lib/libtmux/mcp/observation.rb +474 -0
- data/lib/libtmux/mcp/process_identity.rb +271 -0
- data/lib/libtmux/mcp/resources.rb +101 -0
- data/lib/libtmux/mcp/shell/integration.zsh +50 -0
- data/lib/libtmux/mcp/shell/prepare.rb +137 -0
- data/lib/libtmux/mcp/stdio_transport.rb +504 -0
- data/lib/libtmux/mcp/version.rb +7 -0
- data/lib/libtmux/mcp.rb +7 -0
- data/sig/libtmux-mcp.rbs +26 -0
- data/sig/transport.rbs +16 -0
- metadata +136 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LibTmux
|
|
4
|
+
module MCP
|
|
5
|
+
# One request-local budget covers target acquisition and the typed mutation.
|
|
6
|
+
class Mutation
|
|
7
|
+
def initialize(server:, arguments:, timeout:, cancel:, max_snapshot_bytes:)
|
|
8
|
+
@server, @arguments, @cancel = server, arguments, cancel
|
|
9
|
+
@deadline = clock + timeout
|
|
10
|
+
@max_snapshot_bytes = max_snapshot_bytes
|
|
11
|
+
bytes = 0
|
|
12
|
+
visit = lambda do |value|
|
|
13
|
+
case value
|
|
14
|
+
when String then bytes += value.bytesize
|
|
15
|
+
when Array then value.each { |item| visit.call(item) }
|
|
16
|
+
when Hash then value.each { |key, item| visit.call(key); visit.call(item) }
|
|
17
|
+
end
|
|
18
|
+
raise CapacityError.new("MCP mutation input exceeds its byte limit", phase: :admission) if bytes > Catalog::MUTATION_BYTES
|
|
19
|
+
end
|
|
20
|
+
visit.call(arguments)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(name)
|
|
24
|
+
case name
|
|
25
|
+
when "tmux_create" then create
|
|
26
|
+
when "tmux_send"
|
|
27
|
+
target = resolve(@arguments.fetch("target"))
|
|
28
|
+
input = @arguments.fetch("input")
|
|
29
|
+
result = if input.fetch("type") == "text"
|
|
30
|
+
dispatch { |budget| target.send_text(input.fetch("text"), **budget) }
|
|
31
|
+
else
|
|
32
|
+
dispatch { |budget| target.send_keys(*input.fetch("keys"), **budget) }
|
|
33
|
+
end
|
|
34
|
+
outcome(target, result).merge("completion" => "dispatch_only")
|
|
35
|
+
when "tmux_close"
|
|
36
|
+
target = resolve(@arguments.fetch("target"))
|
|
37
|
+
outcome(target, dispatch { |budget| target.kill(**budget) })
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def delivery(error)
|
|
42
|
+
@dispatched ? error.delivery : :not_sent
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def clock
|
|
48
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def options
|
|
52
|
+
raise Cancelled.new("MCP mutation was cancelled", phase: :admission) if @cancel.cancelled?
|
|
53
|
+
|
|
54
|
+
remaining = @deadline - clock
|
|
55
|
+
raise DeadlineExceeded.new("MCP mutation deadline elapsed", phase: :admission) unless remaining.positive?
|
|
56
|
+
|
|
57
|
+
{timeout: remaining, cancel: @cancel}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def dispatch
|
|
61
|
+
budget = options
|
|
62
|
+
@dispatched = true
|
|
63
|
+
yield budget
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def resolve(reference)
|
|
67
|
+
snapshot = @server.snapshot(**options, max_bytes: @max_snapshot_bytes, max_rows: 4096)
|
|
68
|
+
unless reference.fetch("generation") == snapshot.binding_key
|
|
69
|
+
raise TargetNotFoundError.new("target belongs to another server binding", phase: :admission)
|
|
70
|
+
end
|
|
71
|
+
kind = reference.fetch("kind")
|
|
72
|
+
records = snapshot.public_send({"session" => :sessions, "window" => :windows, "pane" => :panes}.fetch(kind))
|
|
73
|
+
record = records.find { |item| item.id == reference.fetch("id") }
|
|
74
|
+
raise TargetNotFoundError.new("target is not present in this server binding", phase: :admission) unless record
|
|
75
|
+
|
|
76
|
+
@server.public_send(kind, record.ref)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def create
|
|
80
|
+
values = @arguments
|
|
81
|
+
common = {command: values.fetch("argv"), cwd: values["cwd"], environment: values.fetch("environment", {})}
|
|
82
|
+
created = case values.fetch("kind")
|
|
83
|
+
when "session"
|
|
84
|
+
dispatch do |budget|
|
|
85
|
+
@server.new_session(name: values.fetch("name"), window_name: values["window_name"],
|
|
86
|
+
width: values["width"], height: values["height"], receipt: true, **common, **budget)
|
|
87
|
+
end
|
|
88
|
+
when "window"
|
|
89
|
+
parent = resolve(values.fetch("parent"))
|
|
90
|
+
dispatch do |budget|
|
|
91
|
+
parent.new_window(name: values.fetch("name"), index: values["index"],
|
|
92
|
+
focus: values.fetch("focus", false), receipt: true, **common, **budget)
|
|
93
|
+
end
|
|
94
|
+
when "pane"
|
|
95
|
+
parent = resolve(values.fetch("parent"))
|
|
96
|
+
dispatch do |budget|
|
|
97
|
+
parent.split(direction: values.fetch("direction").to_sym,
|
|
98
|
+
size: values["size"], focus: values.fetch("focus", false), **common, **budget)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
entities = if created.is_a?(CreationReceipt)
|
|
102
|
+
[created.entity, created.window, created.pane].uniq
|
|
103
|
+
else
|
|
104
|
+
[created]
|
|
105
|
+
end
|
|
106
|
+
{"entity" => reference(entities.first), "created" => entities.map { |entity| reference(entity) },
|
|
107
|
+
"delivery" => "observed", "program_completion" => "unobserved"}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def reference(entity)
|
|
111
|
+
{"generation" => entity.ref.binding_key, "kind" => entity.ref.kind.to_s, "id" => entity.id}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def outcome(target, result)
|
|
115
|
+
{"target" => reference(target), "delivery" => result.delivery.to_s, "client_exit_status" => result.status.exitstatus}
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
private_constant :Mutation
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "libtmux/mcp/process_identity"
|
|
4
|
+
|
|
5
|
+
module LibTmux
|
|
6
|
+
module MCP
|
|
7
|
+
class Observation
|
|
8
|
+
StaleCursor = Class.new(LibTmux::Error)
|
|
9
|
+
LostObservation = Class.new(LibTmux::Error)
|
|
10
|
+
module CleanupDetails
|
|
11
|
+
attr_reader :mcp_cleanup_errors
|
|
12
|
+
end
|
|
13
|
+
Capture = Data.define(:rows, :capture_id, :reference, :options, :process, :expires, :bytes, :encoding) do
|
|
14
|
+
def close
|
|
15
|
+
process.close
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
Retirement = Data.define(:owner, :tasks, :subscription, :control) do
|
|
20
|
+
def complete?
|
|
21
|
+
tasks.all?(&:finished?) && (!subscription || subscription.closed?) && (!control || control.closed?)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def close(deadline:)
|
|
25
|
+
errors = owner.__send__(:retire_attempt, tasks, subscription: subscription, control: control, deadline: deadline)
|
|
26
|
+
errors << "observation consumers remain pending" unless complete?
|
|
27
|
+
raise TransportError.new("observation consumers are still retiring", phase: :retire, cleanup_errors: errors) unless errors.empty?
|
|
28
|
+
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.capabilities(version)
|
|
34
|
+
parsed = /\A(\d+)\.(\d+)/.match(version)
|
|
35
|
+
native = case RUBY_PLATFORM
|
|
36
|
+
when /\A(?:x86_64|aarch64)-linux/
|
|
37
|
+
["64-bit Linux with peer pidfds (kernel >= 6.6)", "same PID namespace and matching procfs"]
|
|
38
|
+
when /\A(?:x86_64|arm64)-darwin/
|
|
39
|
+
["64-bit Darwin with kqueue NOTE_EXIT/NOTE_REAP", "fresh pinned-route daemon identity"]
|
|
40
|
+
end
|
|
41
|
+
conditional = native && Fiddle::SIZEOF_LONG == 8 && parsed && ([parsed[1].to_i, parsed[2].to_i] <=> [3, 3]) >= 0
|
|
42
|
+
{"screen" => "bounded_rows", "history_continuity" => "unknown",
|
|
43
|
+
"process_cursor" => conditional ? "conditional" : "unsupported",
|
|
44
|
+
"requirements" => ["tmux >= 3.3", *(native || ["supported 64-bit Linux or Darwin process identity backend"]),
|
|
45
|
+
"live pane process", "empty effective capture hook", "stable capture-hook configuration on tmux < 3.5"],
|
|
46
|
+
"wait_conditions" => %w[screen_contains process_exit]}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def initialize(server:, arguments:, timeout:, cancel:, max_snapshot_bytes:)
|
|
50
|
+
@server, @arguments, @cancel = server, arguments, cancel
|
|
51
|
+
@started = clock
|
|
52
|
+
@budget = server.__send__(:operation_budget, [timeout, arguments.fetch("timeout", timeout)].min, cancel)
|
|
53
|
+
@max_snapshot_bytes = max_snapshot_bytes
|
|
54
|
+
@owned = []
|
|
55
|
+
@retirements = []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def close(timeout: 0.4)
|
|
59
|
+
errors = []
|
|
60
|
+
deadline = clock + timeout
|
|
61
|
+
@retirements.dup.each do |group|
|
|
62
|
+
begin
|
|
63
|
+
group.close(deadline: deadline)
|
|
64
|
+
rescue TransportError => error
|
|
65
|
+
errors.concat(error.cleanup_errors)
|
|
66
|
+
ensure
|
|
67
|
+
@retirements.delete(group) if group.complete?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
(@retirements.empty? ? @owned.dup : []).each do |resource|
|
|
71
|
+
begin
|
|
72
|
+
resource.close
|
|
73
|
+
@owned.delete(resource)
|
|
74
|
+
rescue Exception => error
|
|
75
|
+
errors << "observer resource cleanup failed (#{error.class})"
|
|
76
|
+
errors.concat(error.cleanup_errors) if error.is_a?(LibTmux::Error)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
raise TransportError.new("observer resources remain pending", phase: :retire, cleanup_errors: errors) unless errors.empty?
|
|
80
|
+
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def cleanup_remaining
|
|
85
|
+
@cleanup_deadline ||= clock + 0.4
|
|
86
|
+
[@cleanup_deadline - clock, 0].max
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def capture(previous: nil, expires:)
|
|
90
|
+
identity = entry = nil
|
|
91
|
+
response = with_cancellation do
|
|
92
|
+
reference = @arguments.fetch("target").transform_values { |value| value.is_a?(String) ? value.dup : value }
|
|
93
|
+
if previous
|
|
94
|
+
raise StaleCursor, "cursor target differs" unless reference == previous.reference
|
|
95
|
+
|
|
96
|
+
identity = previous.process.retain
|
|
97
|
+
@owned << identity
|
|
98
|
+
end
|
|
99
|
+
snapshot, pane = resolve(reference)
|
|
100
|
+
tracked = previous || @arguments.fetch("track", false)
|
|
101
|
+
if tracked
|
|
102
|
+
require_tracking(snapshot)
|
|
103
|
+
identity ||= acquire_identity(snapshot, pane)
|
|
104
|
+
identity.ensure_live!
|
|
105
|
+
end
|
|
106
|
+
limits = previous ? previous.options : defaults
|
|
107
|
+
rows, encoding, truncated = read_rows(snapshot, pane, limits, identity)
|
|
108
|
+
id = SecureRandom.hex(16)
|
|
109
|
+
result = state(reference, rows, encoding, truncated, limits, identity, id)
|
|
110
|
+
if previous
|
|
111
|
+
prefix = 0
|
|
112
|
+
suffix = 0
|
|
113
|
+
if previous.encoding == encoding
|
|
114
|
+
prefix += 1 while prefix < [rows.length, previous.rows.length].min && rows[prefix] == previous.rows[prefix]
|
|
115
|
+
suffix += 1 while suffix < [rows.length, previous.rows.length].min - prefix && rows[-suffix - 1] == previous.rows[-suffix - 1]
|
|
116
|
+
end
|
|
117
|
+
result.delete("rows")
|
|
118
|
+
result.merge!("mode" => "delta", "base_capture_id" => previous.capture_id,
|
|
119
|
+
"reset" => prefix.zero? && suffix.zero?, "splice" => {"start" => prefix,
|
|
120
|
+
"delete" => previous.rows.length - prefix - suffix, "rows" => rows.slice(prefix, rows.length - prefix - suffix)})
|
|
121
|
+
end
|
|
122
|
+
if tracked
|
|
123
|
+
result["next_cursor"] = id
|
|
124
|
+
entry = Capture.new(rows: rows, capture_id: id.freeze, reference: freeze_tree(reference),
|
|
125
|
+
options: freeze_tree(limits), process: identity, expires: expires,
|
|
126
|
+
bytes: rows.sum(&:bytesize) + JSON.generate(reference).bytesize + 256, encoding: encoding)
|
|
127
|
+
@owned.delete(identity)
|
|
128
|
+
@owned << entry
|
|
129
|
+
identity = nil
|
|
130
|
+
end
|
|
131
|
+
[result, entry]
|
|
132
|
+
end
|
|
133
|
+
@owned.delete(entry)
|
|
134
|
+
response
|
|
135
|
+
rescue TargetNotFoundError, CommandError => error
|
|
136
|
+
raise StaleCursor.new("cursor process changed", delivery: error.delivery), cause: nil if previous
|
|
137
|
+
|
|
138
|
+
raise
|
|
139
|
+
ensure
|
|
140
|
+
# The application retains this observer until its resources retire.
|
|
141
|
+
# Failed cleanup must not mask the first operation error here.
|
|
142
|
+
primary = $!
|
|
143
|
+
begin
|
|
144
|
+
close(timeout: cleanup_remaining)
|
|
145
|
+
rescue TransportError => cleanup
|
|
146
|
+
raise cleanup unless primary
|
|
147
|
+
|
|
148
|
+
attach_cleanup(primary, cleanup.cleanup_errors)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def wait
|
|
153
|
+
identity = control = subscription = nil
|
|
154
|
+
tasks = []
|
|
155
|
+
failure = response = nil
|
|
156
|
+
begin
|
|
157
|
+
response = with_cancellation do
|
|
158
|
+
reference = @arguments.fetch("target")
|
|
159
|
+
condition = @arguments.fetch("condition")
|
|
160
|
+
snapshot, pane = resolve(reference)
|
|
161
|
+
require_tracking(snapshot)
|
|
162
|
+
identity = acquire_identity(snapshot, pane)
|
|
163
|
+
if condition.fetch("type") == "screen_contains"
|
|
164
|
+
link = snapshot.window_links.find { |item| item.window_id == pane.window_id }
|
|
165
|
+
raise TargetNotFoundError.new("pane has no observable session", phase: :admission) unless link
|
|
166
|
+
|
|
167
|
+
session = snapshot.sessions.find { |item| item.id == link.session_id }
|
|
168
|
+
control = @server.open_control(session: session.ref)
|
|
169
|
+
subscription = control.subscribe(pane_id: pane.id, max_bytes: 1 << 18, max_events: 256)
|
|
170
|
+
# A completed private boundary proves attach input is being read.
|
|
171
|
+
line = @server.__send__(:tmux_command, [spellings.fetch("if-shell"), "-F", "0", ""])
|
|
172
|
+
control.exchange(line, **@budget.options)
|
|
173
|
+
end
|
|
174
|
+
limits = defaults
|
|
175
|
+
rows, encoding, truncated = read_rows(snapshot, pane, limits, identity)
|
|
176
|
+
if condition.fetch("type") == "screen_contains" && contains?(rows, encoding, condition.fetch("text"))
|
|
177
|
+
next {"target" => reference, "condition" => "screen_contains",
|
|
178
|
+
"capture" => state(reference, rows, encoding, truncated, limits, identity, SecureRandom.hex(16))}
|
|
179
|
+
end
|
|
180
|
+
changed = ::Async::Notification.new
|
|
181
|
+
pending = nil
|
|
182
|
+
wake = lambda do |value|
|
|
183
|
+
pending = value if pending.nil? || (!pending.is_a?(Exception) && (value.is_a?(Exception) || value == :exited))
|
|
184
|
+
changed.signal
|
|
185
|
+
end
|
|
186
|
+
[[identity.io, :exited], [identity.peer, LostObservation.new("tmux process ended", phase: :observation)]].each do |io, event|
|
|
187
|
+
task = ::Async::Task.new(::Async::Task.current) do
|
|
188
|
+
Fiber.scheduler.io_wait(io, IO::READABLE)
|
|
189
|
+
wake.call(event)
|
|
190
|
+
rescue ::Async::Cancel
|
|
191
|
+
nil
|
|
192
|
+
rescue StandardError => error
|
|
193
|
+
wake.call(error)
|
|
194
|
+
end
|
|
195
|
+
tasks << task
|
|
196
|
+
task.run
|
|
197
|
+
end
|
|
198
|
+
if subscription
|
|
199
|
+
task = ::Async::Task.new(::Async::Task.current) do
|
|
200
|
+
loop do
|
|
201
|
+
event = subscription.next(timeout: @budget.options.fetch(:timeout))
|
|
202
|
+
if event.kind == :gap
|
|
203
|
+
wake.call(LostObservation.new("screen observation has a gap", phase: :observation, delivery: :observed))
|
|
204
|
+
break
|
|
205
|
+
end
|
|
206
|
+
wake.call(:read) if event.kind == :output
|
|
207
|
+
end
|
|
208
|
+
rescue ::Async::Cancel
|
|
209
|
+
nil
|
|
210
|
+
rescue StopIteration
|
|
211
|
+
wake.call(LostObservation.new("screen observation ended", phase: :observation, delivery: :observed))
|
|
212
|
+
rescue StandardError => error
|
|
213
|
+
wake.call(error)
|
|
214
|
+
end
|
|
215
|
+
tasks << task
|
|
216
|
+
task.run
|
|
217
|
+
end
|
|
218
|
+
loop do
|
|
219
|
+
remaining = @budget.options.fetch(:timeout)
|
|
220
|
+
::Async::Task.current.with_timeout(remaining) { changed.wait } unless pending
|
|
221
|
+
event, pending = pending, nil
|
|
222
|
+
raise event if event.is_a?(Exception)
|
|
223
|
+
|
|
224
|
+
if event == :exited
|
|
225
|
+
identity.peer_alive!
|
|
226
|
+
unless condition.fetch("type") == "process_exit"
|
|
227
|
+
raise TargetNotFoundError.new("observed pane process exited", phase: :observation, delivery: :observed)
|
|
228
|
+
end
|
|
229
|
+
break {"target" => reference, "condition" => "process_exit", "process_generation" => identity.generation,
|
|
230
|
+
"observed_at" => clock, "exit_status" => "unobserved"}
|
|
231
|
+
end
|
|
232
|
+
rows, encoding, truncated = read_rows(snapshot, pane, limits, identity)
|
|
233
|
+
if contains?(rows, encoding, condition.fetch("text"))
|
|
234
|
+
break {"target" => reference, "condition" => "screen_contains",
|
|
235
|
+
"capture" => state(reference, rows, encoding, truncated, limits, identity, SecureRandom.hex(16))}
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
rescue ::Async::TimeoutError
|
|
240
|
+
failure = DeadlineExceeded.new("observation deadline elapsed", phase: :observation, delivery: :observed)
|
|
241
|
+
rescue Exception => error
|
|
242
|
+
failure = error
|
|
243
|
+
ensure
|
|
244
|
+
errors = retire(tasks, subscription: subscription, control: control)
|
|
245
|
+
begin
|
|
246
|
+
close(timeout: cleanup_remaining)
|
|
247
|
+
rescue TransportError => cleanup
|
|
248
|
+
errors.concat(cleanup.cleanup_errors)
|
|
249
|
+
end
|
|
250
|
+
unless errors.empty?
|
|
251
|
+
attach_cleanup(failure, errors) if failure
|
|
252
|
+
failure ||= TransportError.new("observation cleanup failed", phase: :retire, cleanup_errors: errors)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
raise failure if failure
|
|
256
|
+
|
|
257
|
+
response
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private
|
|
261
|
+
|
|
262
|
+
def acquire_identity(snapshot, pane)
|
|
263
|
+
identity = ProcessIdentity.acquire(@server, server_pid: snapshot.server_info.fetch(:pid), pane_pid: pane.pid,
|
|
264
|
+
budget: @budget, on_retire: ->(resource) { @owned << resource })
|
|
265
|
+
@owned << identity
|
|
266
|
+
identity
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def clock
|
|
270
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def defaults
|
|
274
|
+
{"max_lines" => @arguments.fetch("max_lines", 200), "max_bytes" => @arguments.fetch("max_bytes", 65536),
|
|
275
|
+
"history_lines" => @arguments.fetch("history_lines", 0)}
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def resolve(reference)
|
|
279
|
+
snapshot = @server.snapshot(**@budget.options, max_bytes: @max_snapshot_bytes, max_rows: 4096)
|
|
280
|
+
pane = snapshot.panes.find { |record| record.id == reference.fetch("id") }
|
|
281
|
+
unless reference.fetch("generation") == snapshot.binding_key && pane
|
|
282
|
+
raise TargetNotFoundError.new("pane reference is outside this live binding", phase: :admission)
|
|
283
|
+
end
|
|
284
|
+
[snapshot, pane]
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def require_tracking(snapshot)
|
|
288
|
+
version = /\A(\d+)\.(\d+)/.match(snapshot.server_info.fetch(:version))
|
|
289
|
+
unless version && ([version[1].to_i, version[2].to_i] <=> [3, 3]) >= 0
|
|
290
|
+
raise UnsupportedFeatureError.new("process cursors require tmux 3.3 death-status formats", phase: :admission)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def read_rows(snapshot, pane, limits, identity)
|
|
295
|
+
identity&.ensure_live!
|
|
296
|
+
names = spellings
|
|
297
|
+
link = snapshot.window_links.find { |item| item.window_id == pane.window_id }
|
|
298
|
+
raise TargetNotFoundError.new("pane has no observable session", phase: :admission) unless link
|
|
299
|
+
|
|
300
|
+
target = "#{link.session_id}:.#{pane.id}"
|
|
301
|
+
guard = "\#{&&:\#{==:\#{session_id},#{link.session_id}},\#{==:\#{pane_id},#{pane.id}}}"
|
|
302
|
+
guard = "\#{&&:#{guard},\#{&&:\#{==:\#{pane_pid},#{identity.pid}},\#{&&:\#{==:\#{pane_dead_status},},\#{==:\#{pane_dead_signal},}}}}" if identity
|
|
303
|
+
body = @server.__send__(:tmux_command, [names.fetch("capture-pane"), "-p", "-t", target,
|
|
304
|
+
"-S", (-limits.fetch("history_lines")).to_s, "-E", "-"])
|
|
305
|
+
# The selected false branch fails parsing, independently of screen bytes.
|
|
306
|
+
failure = @server.__send__(:tmux_command, [names.fetch("capture-pane"), "-t"])
|
|
307
|
+
body = @server.__send__(:tmux_command, [names.fetch("if-shell"), "-F", "-t", target, guard, body, failure])
|
|
308
|
+
hook_error = "libtmux-hook-refused-#{SecureRandom.hex(16)}"
|
|
309
|
+
hook_failure = @server.__send__(:tmux_command, [hook_error])
|
|
310
|
+
begin
|
|
311
|
+
# Before tmux 3.5, whole-array formats are empty even for sparse hooks.
|
|
312
|
+
# Configuration must stay stable between this preflight and capture.
|
|
313
|
+
version = /\A(\d+)\.(\d+)/.match(snapshot.server_info.fetch(:version))
|
|
314
|
+
unless version && ([version[1].to_i, version[2].to_i] <=> [3, 5]) >= 0
|
|
315
|
+
hooks = @server.__send__(:execute_typed,
|
|
316
|
+
[names.fetch("show-options"), "-A", "-v", "-t", target, "after-capture-pane"], **@budget.options)
|
|
317
|
+
unless hooks.stdout.empty?
|
|
318
|
+
raise UnsupportedFeatureError.new("capture hooks prevent isolated screen output", phase: :read)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
result = @server.__send__(:execute_typed, [names.fetch("if-shell"), "-F", "-t", target,
|
|
322
|
+
'#{==:#{after-capture-pane},}', body, hook_failure], **@budget.options)
|
|
323
|
+
rescue CommandError => error
|
|
324
|
+
if error.result&.stderr&.include?(hook_error)
|
|
325
|
+
raise UnsupportedFeatureError.new("capture hooks prevent isolated screen output", phase: :read, delivery: error.delivery), cause: nil
|
|
326
|
+
end
|
|
327
|
+
raise TargetNotFoundError.new("screen target or process changed", phase: :read, delivery: error.delivery), cause: nil
|
|
328
|
+
end
|
|
329
|
+
# capture-pane -p prints a newline even for a blank screen; an empty
|
|
330
|
+
# successful if-shell response does not prove that capture ran.
|
|
331
|
+
unless result.stdout.end_with?("\n")
|
|
332
|
+
raise TargetNotFoundError.new("screen capture did not return complete rows", phase: :read, delivery: :observed)
|
|
333
|
+
end
|
|
334
|
+
identity&.ensure_live!
|
|
335
|
+
@budget.options
|
|
336
|
+
original = result.stdout.lines
|
|
337
|
+
rows = original.last(limits.fetch("max_lines"))
|
|
338
|
+
bytes = rows.join.b
|
|
339
|
+
truncated = rows.length != original.length || bytes.bytesize > limits.fetch("max_bytes")
|
|
340
|
+
bytes = bytes.byteslice(-limits.fetch("max_bytes"), limits.fetch("max_bytes")) if bytes.bytesize > limits.fetch("max_bytes")
|
|
341
|
+
utf8 = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
342
|
+
encoding = utf8.valid_encoding? ? "utf-8" : "base64"
|
|
343
|
+
rows = (encoding == "utf-8" ? utf8 : bytes).lines.map do |row|
|
|
344
|
+
(encoding == "utf-8" ? row : [row].pack("m0")).freeze
|
|
345
|
+
end.freeze
|
|
346
|
+
[rows, encoding, truncated]
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def spellings
|
|
350
|
+
@spellings ||= @server.__send__(:builtin_spellings, "if-shell", "capture-pane", "show-options", budget: @budget)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def contains?(rows, encoding, text)
|
|
354
|
+
rows.map { |row| encoding == "utf-8" ? row.b : row.unpack1("m0") }.join.b.include?(text.b)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def state(reference, rows, encoding, truncated, limits, identity, id)
|
|
358
|
+
bytes = rows.sum { |row| encoding == "utf-8" ? row.bytesize : row.unpack1("m0").bytesize }
|
|
359
|
+
{"mode" => "snapshot", "target" => reference, "capture_id" => id,
|
|
360
|
+
"process_generation" => identity&.generation, "rows" => rows, "encoding" => encoding,
|
|
361
|
+
"row_count" => rows.length, "bytes" => bytes, "truncated" => truncated,
|
|
362
|
+
"history_continuity" => "unknown", "scope" => limits,
|
|
363
|
+
"interval" => {"clock" => "monotonic_seconds", "started" => @started, "finished" => clock}}
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def with_cancellation
|
|
367
|
+
owner = ::Async::Task.current
|
|
368
|
+
armed = true
|
|
369
|
+
watcher_error = nil
|
|
370
|
+
watcher = ::Async::Task.new(owner) do
|
|
371
|
+
Fiber.scheduler.io_wait(@cancel.reader, IO::READABLE) unless @cancel.cancelled?
|
|
372
|
+
owner.cancel if armed && @cancel.cancelled?
|
|
373
|
+
rescue IOError, SystemCallError
|
|
374
|
+
if armed
|
|
375
|
+
watcher_error = TransportError.new("cancellation observation failed", phase: :observation)
|
|
376
|
+
owner.cancel
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
failure = result = nil
|
|
380
|
+
begin
|
|
381
|
+
watcher.run
|
|
382
|
+
result = yield
|
|
383
|
+
rescue ::Async::Cancel => error
|
|
384
|
+
failure = watcher_error || (@cancel.cancelled? ? Cancelled.new("observation cancelled", phase: :read, delivery: :possibly_sent) : error)
|
|
385
|
+
rescue Exception => error
|
|
386
|
+
failure = error
|
|
387
|
+
ensure
|
|
388
|
+
armed = false
|
|
389
|
+
errors = retire([watcher])
|
|
390
|
+
unless errors.empty?
|
|
391
|
+
attach_cleanup(failure, errors) if failure
|
|
392
|
+
failure ||= TransportError.new("cancellation observer cleanup failed", phase: :retire, cleanup_errors: errors)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
raise failure if failure
|
|
396
|
+
|
|
397
|
+
result
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def retire(tasks, subscription: nil, control: nil)
|
|
401
|
+
group = Retirement.new(owner: self, tasks: tasks.dup, subscription: subscription, control: control)
|
|
402
|
+
@retirements << group
|
|
403
|
+
group.close(deadline: clock + cleanup_remaining)
|
|
404
|
+
[]
|
|
405
|
+
rescue TransportError => error
|
|
406
|
+
error.cleanup_errors.dup
|
|
407
|
+
ensure
|
|
408
|
+
@retirements.delete(group) if group&.complete?
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def retire_attempt(tasks, subscription:, control:, deadline:)
|
|
412
|
+
errors = []
|
|
413
|
+
tasks.each do |task|
|
|
414
|
+
begin
|
|
415
|
+
task.cancel unless task.finished?
|
|
416
|
+
rescue ::Async::Cancel
|
|
417
|
+
retry if clock < deadline
|
|
418
|
+
errors << "observer cancellation remained interrupted"
|
|
419
|
+
rescue StandardError => error
|
|
420
|
+
errors << "observer cancellation failed (#{error.class})"
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
begin
|
|
424
|
+
subscription&.close
|
|
425
|
+
rescue ::Async::Cancel
|
|
426
|
+
retry if clock < deadline
|
|
427
|
+
errors << "subscription retirement remained interrupted"
|
|
428
|
+
rescue StandardError => error
|
|
429
|
+
errors << "subscription retirement failed (#{error.class})"
|
|
430
|
+
end
|
|
431
|
+
if control
|
|
432
|
+
begin
|
|
433
|
+
control.close(timeout: [[deadline - clock, 0].max, 0.4].min)
|
|
434
|
+
rescue ::Async::Cancel
|
|
435
|
+
retry if clock < deadline
|
|
436
|
+
errors << "control retirement remained interrupted"
|
|
437
|
+
rescue StandardError => error
|
|
438
|
+
errors << "control retirement failed (#{error.class})"
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
tasks.each do |task|
|
|
442
|
+
begin
|
|
443
|
+
task.wait(timeout: [deadline - clock, 0].max) unless task.finished?
|
|
444
|
+
rescue ::Async::Cancel
|
|
445
|
+
retry if clock < deadline
|
|
446
|
+
errors << "observer retirement remained interrupted"
|
|
447
|
+
rescue StandardError => error
|
|
448
|
+
errors << "observer retirement failed (#{error.class})"
|
|
449
|
+
end
|
|
450
|
+
errors << "observer task remains pending" unless task.finished?
|
|
451
|
+
end
|
|
452
|
+
errors
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def attach_cleanup(failure, errors)
|
|
456
|
+
if failure.is_a?(LibTmux::Error)
|
|
457
|
+
failure.__send__(:attach_cleanup_errors, errors)
|
|
458
|
+
else
|
|
459
|
+
failure.extend(CleanupDetails)
|
|
460
|
+
failure.instance_variable_set(:@mcp_cleanup_errors, ((failure.mcp_cleanup_errors || []) + errors).freeze)
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def freeze_tree(value)
|
|
465
|
+
case value
|
|
466
|
+
when Hash then value.each { |key, item| key.freeze; freeze_tree(item) }
|
|
467
|
+
when Array then value.each { |item| freeze_tree(item) }
|
|
468
|
+
end
|
|
469
|
+
value.freeze
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
private_constant :Observation
|
|
473
|
+
end
|
|
474
|
+
end
|