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.
@@ -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