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,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LibTmux
4
+ module MCP
5
+ module Catalog
6
+ READ_ONLY = %w[tmux_capabilities tmux_snapshot].freeze
7
+ MUTATIONS = %w[tmux_send tmux_create tmux_close tmux_run].freeze
8
+ OBSERVATIONS = %w[tmux_capture tmux_wait].freeze
9
+ NAMES = (READ_ONLY + MUTATIONS + OBSERVATIONS).freeze
10
+ MUTATION_BYTES = 1 << 16
11
+ MIN_MUTATION_RESPONSE_BYTES = 4096
12
+
13
+ def self.object(properties, required = properties.keys)
14
+ {"type" => "object", "properties" => properties, "required" => required, "additionalProperties" => false}
15
+ end
16
+
17
+ def self.text
18
+ {"type" => "string"}
19
+ end
20
+
21
+ def self.input(name)
22
+ return object({}) if name == "tmux_capabilities"
23
+ if name == "tmux_run"
24
+ return object({"target" => reference("pane"), "script" => mutation_text,
25
+ "stdout_limit" => {"type" => "integer", "minimum" => 0, "maximum" => 262144},
26
+ "stderr_limit" => {"type" => "integer", "minimum" => 0, "maximum" => 262144}}, %w[target script])
27
+ end
28
+ return mutation_input(name) if MUTATIONS.include?(name)
29
+ return observation_input(name) if OBSERVATIONS.include?(name)
30
+
31
+ criteria = FilterExpr.json_schema
32
+ object({"entity" => {"enum" => Internal::Catalog.kinds.map(&:to_s)},
33
+ "criteria" => {"oneOf" => criteria.fetch("oneOf")},
34
+ "limit" => {"type" => "integer", "minimum" => 1, "maximum" => 200},
35
+ "cursor" => {"type" => "string", "pattern" => "^[a-f0-9]{32}:[0-9]{1,10}$", "maxLength" => 43}}, []).merge(
36
+ "$defs" => criteria.fetch("$defs"), "oneOf" => [
37
+ {"required" => ["entity"], "not" => {"required" => ["cursor"]}},
38
+ {"required" => ["cursor"], "not" => {"anyOf" => %w[entity criteria limit].map { |key| {"required" => [key]} }}}
39
+ ])
40
+ end
41
+
42
+ def self.identity
43
+ object({"generation" => text, "pid" => {"type" => "integer"},
44
+ "start_time" => {"type" => "integer"}, "tmux_version" => text})
45
+ end
46
+
47
+ def self.capture_limits
48
+ {"max_lines" => {"type" => "integer", "minimum" => 1, "maximum" => 1000},
49
+ "max_bytes" => {"type" => "integer", "minimum" => 1, "maximum" => 262144},
50
+ "history_lines" => {"type" => "integer", "minimum" => 0, "maximum" => 1000}}
51
+ end
52
+
53
+ def self.observation_input(name)
54
+ if name == "tmux_capture"
55
+ {"oneOf" => [object(capture_limits.merge("target" => reference("pane"), "track" => {"type" => "boolean"}), ["target"]),
56
+ object({"target" => reference("pane"), "cursor" => text.merge("pattern" => "^[a-f0-9]{32}$", "maxLength" => 32)})]}
57
+ else
58
+ {"oneOf" => [object(capture_limits.merge("target" => reference("pane"),
59
+ "timeout" => {"type" => "number", "exclusiveMinimum" => 0, "maximum" => 60},
60
+ "condition" => {"oneOf" => [object({"type" => {"const" => "screen_contains"},
61
+ "text" => text.merge("minLength" => 1, "maxLength" => 4096)}), object({"type" => {"const" => "process_exit"}})]}), %w[target condition])]}
62
+ end
63
+ end
64
+
65
+ def self.capture_output
66
+ common = {"target" => reference("pane"), "capture_id" => text,
67
+ "process_generation" => {"type" => ["string", "null"]},
68
+ "encoding" => {"enum" => %w[utf-8 base64]}, "row_count" => {"type" => "integer", "minimum" => 0, "maximum" => 1000},
69
+ "bytes" => {"type" => "integer", "minimum" => 0, "maximum" => 262144},
70
+ "truncated" => {"type" => "boolean"}, "history_continuity" => {"const" => "unknown"},
71
+ "interval" => object({"clock" => {"const" => "monotonic_seconds"}, "started" => {"type" => "number"}, "finished" => {"type" => "number"}}),
72
+ "scope" => object(capture_limits), "next_cursor" => text}
73
+ rows = {"type" => "array", "maxItems" => 1000, "items" => text}
74
+ required = common.keys - ["next_cursor"]
75
+ {"oneOf" => [object(common.merge("mode" => {"const" => "snapshot"}, "rows" => rows), required + %w[mode rows]),
76
+ object(common.merge("mode" => {"const" => "delta"}, "base_capture_id" => text, "reset" => {"type" => "boolean"},
77
+ "splice" => object({"start" => {"type" => "integer", "minimum" => 0}, "delete" => {"type" => "integer", "minimum" => 0}, "rows" => rows})),
78
+ required + %w[mode base_capture_id reset splice next_cursor])]}
79
+ end
80
+
81
+ def self.wait_output
82
+ {"oneOf" => [object({"target" => reference("pane"), "condition" => {"const" => "screen_contains"}, "capture" => capture_output}),
83
+ object({"target" => reference("pane"), "condition" => {"const" => "process_exit"}, "process_generation" => text,
84
+ "observed_at" => {"type" => "number"}, "exit_status" => {"const" => "unobserved"}})]}
85
+ end
86
+
87
+ def self.reference(kind = nil)
88
+ kinds = kind ? [kind] : %w[session window pane window_link]
89
+ {"oneOf" => kinds.map do |entity|
90
+ prefix = {"session" => "\\$", "window" => "@", "pane" => "%", "window_link" => "@"}.fetch(entity)
91
+ properties = {"generation" => text.merge("minLength" => 1, "maxLength" => 128), "kind" => {"const" => entity},
92
+ "id" => text.merge("pattern" => "^#{prefix}[0-9]+$", "maxLength" => 32)}
93
+ if entity == "window_link"
94
+ properties.merge!("session_id" => text.merge("pattern" => "^\\$[0-9]+$", "maxLength" => 32),
95
+ "index" => {"type" => "integer", "minimum" => 0})
96
+ end
97
+ object(properties)
98
+ end}
99
+ end
100
+
101
+ def self.mutation_text
102
+ text.merge("maxLength" => MUTATION_BYTES, "pattern" => "^[^\\u0000]*$")
103
+ end
104
+
105
+ def self.mutation_input(name)
106
+ case name
107
+ when "tmux_send"
108
+ {"oneOf" => [object({"target" => reference("pane"), "input" => {"oneOf" => [
109
+ object({"type" => {"const" => "text"}, "text" => mutation_text}),
110
+ object({"type" => {"const" => "keys"}, "keys" => {"type" => "array", "minItems" => 1, "maxItems" => 256,
111
+ "items" => mutation_text.merge("minLength" => 1)}})
112
+ ]}})]}
113
+ when "tmux_close"
114
+ {"oneOf" => [object({"target" => {"oneOf" => %w[session window pane].flat_map { |kind| reference(kind).fetch("oneOf") }}})]}
115
+ when "tmux_create"
116
+ common = {"argv" => {"type" => "array", "minItems" => 1, "maxItems" => 256,
117
+ "prefixItems" => [mutation_text.merge("minLength" => 1)], "items" => mutation_text},
118
+ "cwd" => mutation_text.merge("minLength" => 1),
119
+ "environment" => {"type" => "object", "maxProperties" => 128,
120
+ "propertyNames" => {"pattern" => "^[A-Za-z_][A-Za-z0-9_]*$"}, "additionalProperties" => mutation_text}}
121
+ positive = {"type" => "integer", "minimum" => 1, "maximum" => (1 << 31) - 1}
122
+ {"oneOf" => [
123
+ object(common.merge("kind" => {"const" => "session"}, "name" => mutation_text.merge("minLength" => 1),
124
+ "window_name" => mutation_text.merge("minLength" => 1), "width" => positive, "height" => positive), %w[kind name argv]),
125
+ object(common.merge("kind" => {"const" => "window"}, "parent" => reference("session"),
126
+ "name" => mutation_text.merge("minLength" => 1), "index" => {"type" => "integer", "minimum" => 0, "maximum" => (1 << 31) - 1},
127
+ "focus" => {"type" => "boolean"}), %w[kind parent name argv]),
128
+ object(common.merge("kind" => {"const" => "pane"}, "parent" => reference("pane"),
129
+ "direction" => {"enum" => %w[horizontal vertical]}, "size" => {"oneOf" => [positive,
130
+ {"type" => "string", "pattern" => "^(?:[1-9][0-9]?|100)%$"}]},
131
+ "focus" => {"type" => "boolean"}), %w[kind parent direction argv])
132
+ ]}
133
+ end
134
+ end
135
+
136
+ def self.record
137
+ {"oneOf" => Internal::Catalog.kinds.map do |kind|
138
+ fields = Internal::Catalog.entity(kind).fields.values.to_h do |field|
139
+ type = {text: "string", integer: "integer", boolean: "boolean"}.fetch(field.type)
140
+ schema = {"type" => field.nullable ? [type, "null"] : type}
141
+ schema["minimum"] = field.min if field.min
142
+ schema["maximum"] = field.max if field.max
143
+ [field.wire_name, schema]
144
+ end
145
+ object({"kind" => {"const" => kind.to_s}, "fields" => object(fields),
146
+ "ref" => kind == :client ? {"type" => "null"} : reference(kind.to_s)})
147
+ end}
148
+ end
149
+
150
+ def self.output(name)
151
+ payload = if name == "tmux_run"
152
+ object({"target" => reference("pane"), "authorization" => authorization,
153
+ "completion" => run_completion, "stdout" => run_bytes, "stderr" => run_bytes})
154
+ elsif MUTATIONS.include?(name)
155
+ mutation_output(name)
156
+ elsif name == "tmux_capture"
157
+ capture_output
158
+ elsif name == "tmux_wait"
159
+ wait_output
160
+ elsif name == "tmux_capabilities"
161
+ object({"endpoint" => text, "server_identity" => identity,
162
+ "enabled_tools" => {"type" => "array", "items" => text},
163
+ "criteria_schema" => {"type" => "object"},
164
+ "limits" => {"type" => "object", "additionalProperties" => {"type" => "number"}},
165
+ "owns_daemon" => {"const" => false}, "resource_subscriptions" => {"const" => false},
166
+ "authored_run" => object({"availability" => {"enum" => %w[conditional unsupported]},
167
+ "shell_profile" => {"const" => "zsh-5.9-zle"}, "enrollment" => {"const" => "explicit_source"},
168
+ "authorization" => {"const" => "exact_generation_at_queue_grant"},
169
+ "stdin" => {"const" => "closed"}, "persistent_shell_changes" => {"const" => false},
170
+ "descendant_termination" => {"const" => "unobserved"},
171
+ "requirements" => {"type" => "array", "items" => text}}),
172
+ "observation" => object({"screen" => {"const" => "bounded_rows"}, "history_continuity" => {"const" => "unknown"},
173
+ "process_cursor" => {"enum" => %w[conditional unsupported]}, "requirements" => {"type" => "array", "items" => text},
174
+ "wait_conditions" => {"type" => "array", "items" => {"enum" => %w[screen_contains process_exit]}}})})
175
+ else
176
+ object({"capture_id" => text, "server_identity" => identity,
177
+ "entity" => {"enum" => Internal::Catalog.kinds.map(&:to_s)},
178
+ "items" => {"type" => "array", "items" => record, "maxItems" => 200},
179
+ "coverage" => {"type" => "object", "additionalProperties" => {"enum" => %w[complete unloaded]}},
180
+ "interval" => object({"clock" => {"const" => "monotonic_seconds"},
181
+ "started" => {"type" => "number"}, "finished" => {"type" => "number"},
182
+ "reads" => {"type" => "integer", "minimum" => 0}}),
183
+ "truncated" => {"type" => "boolean"}, "next_cursor" => text},
184
+ %w[capture_id server_identity entity items coverage interval truncated])
185
+ end
186
+ {"oneOf" => [object({"ok" => {"const" => true}, "data" => payload}),
187
+ object({"ok" => {"const" => false}, "error" => object({"code" => text,
188
+ "message" => text, "delivery" => {"enum" => %w[not_sent possibly_sent observed]},
189
+ "effects" => name == "tmux_run" ? run_effects : effects}, %w[code message delivery])})]}
190
+ end
191
+
192
+ def self.authorization
193
+ token = text.merge("pattern" => "^[a-f0-9]{32}$", "maxLength" => 32)
194
+ object({"state" => {"const" => "authorized"}, "run_id" => token,
195
+ "script_digest" => text.merge("pattern" => "^[a-f0-9]{64}$", "maxLength" => 64),
196
+ "server_generation" => text.merge("minLength" => 1, "maxLength" => 128),
197
+ "pane_id" => text.merge("pattern" => "^%[0-9]+$", "maxLength" => 32),
198
+ "enrollment_generation" => token, "process_generation" => token})
199
+ end
200
+
201
+ def self.run_completion
202
+ {"oneOf" => [object({"state" => {"const" => "exited"},
203
+ "exit_status" => {"type" => "integer", "minimum" => 0, "maximum" => 255}, "signal" => {"type" => "null"}}),
204
+ object({"state" => {"const" => "signaled"}, "exit_status" => {"type" => "null"},
205
+ "signal" => {"type" => "integer", "minimum" => 1, "maximum" => 255}})]}
206
+ end
207
+
208
+ def self.run_bytes
209
+ object({"encoding" => {"enum" => %w[utf-8 base64]}, "data" => text.merge("maxLength" => 349528),
210
+ "bytes" => {"type" => "integer", "minimum" => 0, "maximum" => 262144}, "truncated" => {"const" => false}})
211
+ end
212
+
213
+ def self.run_effects
214
+ object({"state" => {"enum" => %w[none known unknown]},
215
+ "authorization" => {"oneOf" => [authorization, {"type" => "null"}]},
216
+ "completion" => {"oneOf" => [object({"state" => {"const" => "unobserved"}}), *run_completion.fetch("oneOf")]}})
217
+ end
218
+
219
+ def self.effects
220
+ object({"state" => {"enum" => %w[none known unknown]},
221
+ "created" => {"type" => "array", "maxItems" => 3, "items" => reference}})
222
+ end
223
+
224
+ def self.mutation_output(name)
225
+ if name == "tmux_create"
226
+ object({"entity" => reference, "created" => {"type" => "array", "minItems" => 1, "maxItems" => 3, "items" => reference},
227
+ "delivery" => {"const" => "observed"}, "program_completion" => {"const" => "unobserved"}})
228
+ else
229
+ properties = {"target" => reference, "delivery" => {"const" => "observed"},
230
+ "client_exit_status" => {"const" => 0}}
231
+ properties["completion"] = {"const" => "dispatch_only"} if name == "tmux_send"
232
+ object(properties)
233
+ end
234
+ end
235
+
236
+ def self.description(name)
237
+ if name == "tmux_run"
238
+ "Run an authored POSIX script in an explicitly enrolled idle, empty zsh 5.9 editor. Opt-in; no terminal injection. Exact queue authorization binds the existing shell generation; execution may follow a later pane replacement without retargeting it. Inherits cwd/exported environment, closed stdin, separate bounded UTF-8/base64 outputs. Defaults: 65536 bytes per output, 65536 script bytes; overflow refuses completion. Reports native exit/signal only from the helper; cancellation never proves descendants stopped."
239
+ elsif name == "tmux_capture"
240
+ "Read exact-pane screen/history rows preserving LF. Defaults: 200 lines, 65536 bytes, zero history lines, track=false. Limits: 1000 lines/history, 262144 bytes. UTF-8 or base64; truncation is separate from unknown history continuity. Tracked process cursors require tmux>=3.3 and a native process identity backend: Linux peer pidfds with matching PID namespaces, or Darwin kqueue process observation. Continuation returns an exact state row splice, not a live-output journal. Refuses capture after-hooks."
241
+ elsif name == "tmux_wait"
242
+ "Wait for observed literal screen text or the initial pane process exit. Deadline in seconds is capped by the application limit. Uses control events/process descriptors; no polling. Requires tmux>=3.3 and Linux peer pidfds with matching PID namespaces or Darwin kqueue process observation. Reports observed evidence, never remote termination caused by cancellation or an inferred process exit status."
243
+ elsif name == "tmux_send"
244
+ "Send literal UTF-8 text or named keys to one exact pane. Opt-in mutation; 64 KiB total text bytes, at most 256 keys. Reports tmux client completion and input dispatch only; never shell completion."
245
+ elsif name == "tmux_create"
246
+ "Create a session, window in an exact session, or split an exact pane using argv. Opt-in mutation; 64 KiB total string bytes and at most 256 argv entries. Window/pane focus defaults false. Cwd/environment are optional; size is cells or percent. Assigned refs prove creation, not program readiness or completion."
247
+ elsif name == "tmux_close"
248
+ "Destroy one exact session, window or pane. Opt-in destructive operation; closing a session or window also removes its contained topology. Never selects by name or closes the daemon directly. Returns the final tmux client outcome."
249
+ elsif name == "tmux_capabilities"
250
+ "Discover the fixed endpoint, server binding, enabled tools, criteria schema and limits. Acquires metadata; starts no daemon."
251
+ else
252
+ "Capture ordered metadata and evaluate Ruby criteria locally. Pages retain one immutable capture; expired cursors require a new query. Limit defaults to 50 records, maximum 200."
253
+ end
254
+ end
255
+ end
256
+ private_constant :Catalog
257
+ end
258
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LibTmux
4
+ module MCP
5
+ # Handshake metadata does not need schemas. Validate each through the SDK
6
+ # before exposing it; Application validates both before dispatching effects.
7
+ class CatalogTool < ::MCP::Tool
8
+ class << self
9
+ def define(input_schema:, output_schema:, **metadata, &block)
10
+ super(**metadata, &block).tap do |tool|
11
+ tool.instance_variable_set(:@input_definition, input_schema)
12
+ tool.instance_variable_set(:@output_definition, output_schema)
13
+ end
14
+ end
15
+
16
+ def input_schema_value
17
+ @input_schema_value ||= ::MCP::Tool::InputSchema.new(@input_definition)
18
+ end
19
+
20
+ def output_schema_value
21
+ @output_schema_value ||= ::MCP::Tool::OutputSchema.new(@output_definition)
22
+ end
23
+
24
+ def to_h
25
+ input_schema_value
26
+ output_schema_value
27
+ super
28
+ end
29
+ end
30
+ end
31
+ private_constant :CatalogTool
32
+ end
33
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "libtmux/mcp"
4
+ require "optparse"
5
+
6
+ module LibTmux
7
+ module MCP
8
+ class CLI
9
+ def self.run(arguments, input: $stdin, out: $stdout, err: $stderr)
10
+ new(arguments, input, out, err).run
11
+ end
12
+
13
+ def initialize(arguments, input, output, error)
14
+ @arguments, @input, @output, @error = arguments.dup, input, output, error
15
+ @options = {endpoint_name: "local", executable: "tmux", tools: Catalog::READ_ONLY.dup,
16
+ timeout: 5.0, concurrency: 4, max_requests: 32, max_frame_bytes: 1 << 20,
17
+ max_output_bytes: 1 << 22, max_response_bytes: 1 << 20,
18
+ enrollments: [], enrollment_timeout: 60.0}
19
+ @setup_files, @enrollment_tasks = [], []
20
+ end
21
+ private_class_method :new
22
+
23
+ def run
24
+ parser.parse!(@arguments)
25
+ if @options[:help] || @options[:version]
26
+ @output.puts(@options[:help] ? parser : VERSION)
27
+ return 0
28
+ end
29
+ validate_arguments
30
+ endpoint = Endpoint.new(socket_path: @options[:socket_path], socket_name: @options[:socket_name],
31
+ executable: @options.fetch(:executable))
32
+ Async do |task|
33
+ begin
34
+ serve(endpoint, task)
35
+ 0
36
+ rescue Interrupt, LibTmux::Cancelled => error
37
+ report("MCP interrupted; dispatched effects may remain.", 130, failure: error)
38
+ rescue StandardError => error
39
+ report("MCP execution failed (#{error.class}); dispatched effects may remain.", 1, failure: error)
40
+ end
41
+ end.wait
42
+ rescue OptionParser::ParseError, ArgumentError => error
43
+ report("Invalid arguments; use --help for supported options.", 2, failure: error)
44
+ rescue Interrupt, LibTmux::Cancelled => error
45
+ report("MCP interrupted; dispatched effects may remain.", 130, failure: error)
46
+ rescue StandardError => error
47
+ report("MCP startup failed (#{error.class}).", 1, failure: error)
48
+ end
49
+
50
+ private
51
+
52
+ def serve(endpoint, task)
53
+ Server.open(endpoint: endpoint) do |server|
54
+ LibTmux::Async.open(server: server, parent: task) do |scope|
55
+ app = Application.new(server: scope.server, endpoint_name: @options.fetch(:endpoint_name),
56
+ enabled_tools: @options.fetch(:tools).uniq, request_timeout: @options.fetch(:timeout),
57
+ max_response_bytes: @options.fetch(:max_response_bytes))
58
+ failure = transport = nil
59
+ begin
60
+ prepare_enrollments(app, scope.server, task)
61
+ transport = StdioTransport.new(server: app.sdk_server, parent: task, input: @input, output: @output,
62
+ concurrency: @options.fetch(:concurrency), max_requests: @options.fetch(:max_requests),
63
+ max_frame_bytes: @options.fetch(:max_frame_bytes), max_output_bytes: @options.fetch(:max_output_bytes),
64
+ request_timeout: @options.fetch(:timeout))
65
+ transport.run
66
+ rescue Exception => error
67
+ failure = error
68
+ ensure
69
+ [-> { transport&.close }, -> { close_enrollments }, -> { app.close }].each do |cleanup|
70
+ begin
71
+ cleanup.call
72
+ rescue Exception => error
73
+ if failure
74
+ ProcessIdentity.attach_cleanup(failure, ["MCP cleanup failed (#{error.class})"])
75
+ diagnostic("MCP cleanup also failed (#{error.class}).", failure: failure)
76
+ end
77
+ failure ||= error
78
+ end
79
+ end
80
+ end
81
+ raise failure if failure
82
+ end
83
+ end
84
+ end
85
+
86
+ def prepare_enrollments(app, server, task)
87
+ return if @options.fetch(:enrollments).empty?
88
+
89
+ panes = server.list_panes(timeout: @options.fetch(:timeout)).to_h { |pane| [pane.id, pane.ref] }
90
+ @options.fetch(:enrollments).each do |id, path|
91
+ reference = panes[id]
92
+ raise TargetNotFoundError.new("enrollment pane does not exist", phase: :admission) unless reference
93
+
94
+ invitation = app.invite_shell(reference, timeout: @options.fetch(:timeout),
95
+ expires_in: @options.fetch(:enrollment_timeout))
96
+ arguments = invitation.shell_arguments.map do |value|
97
+ raise ArgumentError if value.include?("\0") || value.include?("\n") || value.include?("\r")
98
+
99
+ "'#{value.gsub("'") { %q('\'') }}'"
100
+ end
101
+ owned = publish_setup_file(path, "source #{arguments.join(' ')}\n")
102
+ child = ::Async::Task.new(task) do
103
+ begin
104
+ app.accept_shell(invitation)
105
+ rescue LibTmux::Cancelled, ::Async::Cancel => error
106
+ diagnostic("Shell enrollment was cancelled.", failure: error) unless @enrollment_stopping
107
+ rescue StandardError => error
108
+ diagnostic("Shell enrollment failed (#{error.class}).", failure: error) unless @enrollment_stopping
109
+ ensure
110
+ remove_setup_file(owned)
111
+ end
112
+ end
113
+ @enrollment_tasks << child
114
+ child.run
115
+ end
116
+ end
117
+
118
+ def publish_setup_file(path, command)
119
+ temporary = File.join(File.dirname(path), ".libtmux-ruby-shell-#{SecureRandom.hex(16)}")
120
+ owned = staging = nil
121
+ Thread.handle_interrupt(Exception => :never) do
122
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
123
+ staging = [temporary, file.stat]
124
+ @setup_files << staging
125
+ file.chmod(0o600)
126
+ file.write(command)
127
+ end
128
+ # A hard link publishes the complete file and refuses existing names.
129
+ File.link(temporary, path)
130
+ owned = [path, staging.last]
131
+ @setup_files << owned
132
+ end
133
+ remove_setup_file(staging)
134
+ owned
135
+ end
136
+
137
+ def remove_setup_file(owned)
138
+ path, identity = owned
139
+ begin
140
+ current = File.lstat(path)
141
+ File.unlink(path) if [current.dev, current.ino] == [identity.dev, identity.ino]
142
+ rescue Errno::ENOENT
143
+ nil
144
+ end
145
+ @setup_files.delete(owned)
146
+ end
147
+
148
+ def close_enrollments
149
+ @enrollment_stopping = true
150
+ errors = []
151
+ interrupted = nil
152
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.5
153
+ actions = @enrollment_tasks.map do |child|
154
+ lambda do
155
+ child.cancel unless child.finished?
156
+ child.wait(timeout: [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
157
+ end
158
+ end
159
+ actions.concat(@setup_files.dup.map { |owned| -> { remove_setup_file(owned) } })
160
+ actions.each do |action|
161
+ begin
162
+ action.call
163
+ rescue ::Async::Cancel => error
164
+ interrupted ||= error
165
+ retry if Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
166
+ errors << "shell setup retirement was interrupted"
167
+ rescue Exception => error
168
+ errors << "shell setup retirement failed (#{error.class})"
169
+ end
170
+ end
171
+ @enrollment_tasks.reject!(&:finished?)
172
+ errors << "shell enrollment tasks remain active" unless @enrollment_tasks.empty?
173
+ if interrupted
174
+ ProcessIdentity.attach_cleanup(interrupted, errors) unless errors.empty?
175
+ raise interrupted
176
+ end
177
+ raise TransportError.new("shell setup cleanup remains pending", phase: :retire, cleanup_errors: errors) unless errors.empty?
178
+
179
+ nil
180
+ end
181
+
182
+ def parser
183
+ @parser ||= OptionParser.new do |options|
184
+ options.banner = "Usage: libtmux-mcp (--socket PATH | --socket-name NAME) [options]"
185
+ options.on("--socket PATH", "Borrow this existing tmux Unix socket") { |value| @options[:socket_path] = value }
186
+ options.on("--socket-name NAME", "Borrow this explicit tmux socket name") { |value| @options[:socket_name] = value }
187
+ options.on("--endpoint NAME", "Public endpoint alias (default: local)") { |value| @options[:endpoint_name] = value }
188
+ options.on("--tmux EXECUTABLE", "Resolve and pin this tmux executable at startup") { |value| @options[:executable] = value }
189
+ options.on("--enable-tool NAME", "Enable one additional tool; repeat as needed") { |value| @options.fetch(:tools) << value }
190
+ options.on("--enroll-pane %ID=FILE", "Write a private zsh setup file for this exact pane; requires tmux_run") do |value|
191
+ id, path = value.split("=", 2)
192
+ raise ArgumentError unless id && /\A%\d+\z/.match?(id) && path && !path.empty? && path.bytesize <= 4096 && !/[\0\r\n]/.match?(path)
193
+
194
+ @options.fetch(:enrollments) << [id.freeze, File.expand_path(path).freeze].freeze
195
+ end
196
+ options.on("--enrollment-timeout SECONDS", Float, "Setup file lifetime, at most 300 seconds (default: 60)") { |value| @options[:enrollment_timeout] = value }
197
+ options.on("--timeout SECONDS", Float, "Total request deadline (default: 5)") { |value| @options[:timeout] = value }
198
+ options.on("--concurrency COUNT", Integer, "Active requests (default: 4)") { |value| @options[:concurrency] = value }
199
+ options.on("--max-requests COUNT", Integer, "Active plus waiting requests (default: 32)") { |value| @options[:max_requests] = value }
200
+ options.on("--max-frame-bytes BYTES", Integer, "Input frame limit (default: 1048576)") { |value| @options[:max_frame_bytes] = value }
201
+ options.on("--max-output-bytes BYTES", Integer, "Queued protocol output limit (default: 4194304)") { |value| @options[:max_output_bytes] = value }
202
+ options.on("--max-response-bytes BYTES", Integer, "Structured tool response limit (default: 1048576)") { |value| @options[:max_response_bytes] = value }
203
+ options.on("--version", "Print the gem version") { @options[:version] = true }
204
+ options.on("-h", "--help", "Show supported options") { @options[:help] = true }
205
+ end
206
+ end
207
+
208
+ def validate_arguments
209
+ raise ArgumentError unless @arguments.empty?
210
+ raise ArgumentError unless [@options[:socket_path], @options[:socket_name]].compact.length == 1
211
+ raise ArgumentError unless @options.fetch(:tools).all? { |name| Catalog::NAMES.include?(name) }
212
+ raise ArgumentError unless /\A[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\z/.match?(@options.fetch(:endpoint_name))
213
+ raise ArgumentError unless @options.fetch(:timeout).finite? && @options.fetch(:timeout).positive?
214
+ lifetime = @options.fetch(:enrollment_timeout)
215
+ raise ArgumentError unless lifetime.finite? && lifetime.positive? && lifetime <= 300
216
+ enrollments = @options.fetch(:enrollments)
217
+ raise ArgumentError unless enrollments.length <= 8
218
+ raise ArgumentError unless enrollments.map(&:first).uniq.length == enrollments.length && enrollments.map(&:last).uniq.length == enrollments.length
219
+ raise ArgumentError if !enrollments.empty? && !@options.fetch(:tools).include?("tmux_run")
220
+ %i[concurrency max_requests max_frame_bytes max_output_bytes max_response_bytes].each do |key|
221
+ raise ArgumentError unless @options.fetch(key).positive?
222
+ end
223
+ raise ArgumentError unless [@input, @output].all? { |io| io.is_a?(IO) && !io.closed? }
224
+ end
225
+
226
+ def report(message, status, failure: nil)
227
+ diagnostic(message, failure: failure)
228
+ status
229
+ end
230
+
231
+ def diagnostic(message, failure: nil)
232
+ @error.puts(message)
233
+ rescue Exception => error
234
+ ProcessIdentity.attach_cleanup(failure, ["MCP diagnostic failed (#{error.class})"]) if failure
235
+ nil
236
+ end
237
+ end
238
+ end
239
+ end