libtmux-mcp 0.1.0.alpha.1 → 0.1.0.alpha.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8716cbbd29f60027cd48806d7ec292c1e431cad8e00721a31f36ca1700832480
4
- data.tar.gz: 5a84c18e207a57bfe2bc2c2b29d9a06eec1cc4ca58db90472e9274fe6ef80eda
3
+ metadata.gz: af3b345cc9efd2ed4fcb1c384d8ee9a0e8f14b0da9052eedafb5e88b285a2501
4
+ data.tar.gz: f6c4f2647b58a02730b17ab57190f7cfc5ef2927c923961608d2a192d9f1b077
5
5
  SHA512:
6
- metadata.gz: 1cc0536edcdb10af7fa48c860e283c1bbf9bdd23512a580cce7f0fcff1836317d4966a86f2ab7b54429d313e5f777b9bed8236e8dd6f71ee243fcca8922c69d2
7
- data.tar.gz: 8652d60ba20578e39b3de4d9da8aa17724e95bfbac8f50767099ac88f58c5a2be6bb7b66a7f6999e041afaee6f75bf5d944023938e272e0e5ac7d5b05a1b91f4
6
+ metadata.gz: 62dddd73fc6080af123ef9b4b5aeb5dca20c72d8798232d69df61777f3e1aaa9b44db30d1853949e2e5d266d063b6827ed2b3f4feb4950072418d2283739bba2
7
+ data.tar.gz: 37bffa621af62f041a08dffa029d2f060874686983982e70a1906343fd2755bfc6b81daefeb1e2914f99a53a38ba7d88c1b90edb1982f3f48349396bdb1a91e9
data/README.md CHANGED
@@ -1,19 +1,112 @@
1
+ <!-- libtmux-logo -->
2
+ <div align="center">
3
+ <picture>
4
+ <source srcset="assets/logo.svg" type="image/svg+xml">
5
+ <img src="assets/logo.png" width="128" height="128" alt="libtmux for Ruby MCP">
6
+ </picture>
7
+ </div>
8
+ <!-- /libtmux-logo -->
9
+
10
+ <div align="center">
11
+
1
12
  # libtmux-mcp
2
13
 
3
14
  Expose an existing tmux server over MCP stdio. Read snapshots, capture pane
4
- output, wait for events, or explicitly enable creation, input and shell commands.
5
- The official MCP SDK handles the protocol; bounded Async tasks handle transport.
15
+ output and wait for events, or enable creation, input and shell commands one
16
+ tool at a time.
17
+
18
+ [API reference](https://libtmux.org/en/ruby/latest/mcp/reference/) ·
19
+ [Tools](https://libtmux.org/en/ruby/latest/mcp/tools/) ·
20
+ [Guides](https://libtmux.org/en/ruby/latest/mcp/guides/) ·
21
+ [RubyGems](https://rubygems.org/gems/libtmux-mcp)
22
+
23
+ </div>
24
+
25
+ Part of [libtmux for Ruby](https://github.com/libtmux/libtmux-ruby#readme).
26
+ Built on [libtmux](https://github.com/libtmux/libtmux-ruby/tree/master/gems/libtmux)
27
+ and [libtmux-async](https://github.com/libtmux/libtmux-ruby/tree/master/gems/libtmux-async).
28
+
29
+ An MCP client starts `libtmux-mcp` and talks to it over stdin and stdout. The
30
+ official MCP SDK handles the protocol, and bounded Async tasks handle
31
+ transport.
32
+
33
+ **Contents** — [Quick start](#quick-start) ·
34
+ [Connect a client](#connect-a-client) ·
35
+ [Choose the server](#choose-which-tmux-server-it-serves) ·
36
+ [The tools](#the-tools) · [Read](#read-the-layout-and-the-screen) ·
37
+ [Create](#create-type-and-close) · [Run](#run-a-script-in-a-pane) ·
38
+ [Embed](#embed-it-in-ruby) · [Leaves out](#what-it-leaves-out) ·
39
+ [Support](#support-and-status) · [Documentation](#documentation)
6
40
 
7
- Install the alpha and its `libtmux-mcp` executable:
41
+ ## Quick start
42
+
43
+ Install the alpha and its `libtmux-mcp` executable. It needs Ruby 3.3 or newer
44
+ and tmux 3.3a or newer:
8
45
 
9
46
  ```console
10
47
  $ gem install libtmux-mcp --pre
11
48
  ```
12
49
 
13
- ## Start the server
50
+ The server borrows a tmux server that is already running. Start one on a
51
+ socket of its own:
52
+
53
+ ```console
54
+ $ tmux -L agent new-session -d -s work
55
+ ```
56
+
57
+ Add the server to Claude Code. Until you enable more tools, it serves
58
+ [`tmux_capabilities`][tool-tmux_capabilities] and
59
+ [`tmux_snapshot`][tool-tmux_snapshot]:
60
+
61
+ ```console
62
+ $ claude mcp add tmux -- libtmux-mcp --socket-name agent --endpoint dev
63
+ ```
64
+
65
+ Run by hand, `libtmux-mcp` waits for input until EOF. `libtmux-mcp --help`
66
+ lists every option.
67
+
68
+ ## Connect a client
69
+
70
+ ### `claude mcp add`: Claude Code
71
+
72
+ The command in the quick start registers the server with Claude Code. Start the
73
+ tmux server first, as above.
74
+
75
+ ### `codex mcp add`: Codex CLI
76
+
77
+ Codex CLI takes the same command after `--`:
78
+
79
+ ```console
80
+ $ codex mcp add tmux -- libtmux-mcp --socket-name agent --endpoint dev
81
+ ```
82
+
83
+ ### `mcpServers`: clients that read JSON
84
+
85
+ A client that reads a JSON configuration, such as Claude Desktop's
86
+ `claude_desktop_config.json`, takes the same command as an `mcpServers` entry:
87
+
88
+ <!-- example: mcp_client/config -->
89
+ ```json
90
+ {
91
+ "mcpServers": {
92
+ "tmux": {
93
+ "command": "libtmux-mcp",
94
+ "args": ["--socket-name", "agent", "--endpoint", "dev"]
95
+ }
96
+ }
97
+ }
98
+ ```
99
+ <!-- /example -->
100
+
101
+ If the client does not start servers with your shell's `PATH`, set `command` to
102
+ the absolute path that `command -v libtmux-mcp` prints.
103
+
104
+ ## Choose which tmux server it serves
105
+
106
+ ### `--socket` and `--socket-name`: pick the daemon
14
107
 
15
108
  Set `TMUX_SOCKET` to an existing tmux socket. This command borrows that daemon
16
- and serves MCP on stdin/stdout:
109
+ and serves MCP on stdin and stdout:
17
110
 
18
111
  ```console
19
112
  $ libtmux-mcp \
@@ -21,18 +114,49 @@ $ libtmux-mcp \
21
114
  --endpoint local
22
115
  ```
23
116
 
24
- Use `--socket-name NAME` instead of `--socket PATH` to select a named socket.
25
- `--endpoint` sets the public alias used in discovery and resource URIs; it does
26
- not select the socket. EOF retires owned clients and preserves the daemon.
117
+ Use `--socket-name NAME` instead of `--socket PATH` to select a named socket,
118
+ as the quick start does:
119
+
120
+ ```console
121
+ $ libtmux-mcp \
122
+ --socket-name agent
123
+ ```
124
+
125
+ - EOF retires the clients the server owns and leaves the daemon running.
126
+ - `--endpoint` sets the public alias in discovery and resource URIs, and
127
+ defaults to `local`.
128
+
129
+ ## The tools
130
+
131
+ Each tool name links to its page on libtmux.org, with the arguments, limits and
132
+ result schema. Repeat `--enable-tool` for each additional tool.
27
133
 
28
134
  | Tools | Default | Purpose |
29
135
  | --- | --- | --- |
30
- | `tmux_capabilities`, `tmux_snapshot` | Enabled | Discover capabilities and query captured metadata |
31
- | `tmux_capture`, `tmux_wait` | Disabled | Capture a screen or wait for text/process exit |
32
- | `tmux_create`, `tmux_send`, `tmux_close` | Disabled | Create entities, send text/keys and tear down exact targets |
33
- | `tmux_run` | Disabled | Run a script in an explicitly enrolled zsh shell |
136
+ | [`tmux_capabilities`](https://libtmux.org/en/ruby/latest/mcp/tools/tmux_capabilities/), [`tmux_snapshot`](https://libtmux.org/en/ruby/latest/mcp/tools/tmux_snapshot/) | Enabled | Discover capabilities and query captured metadata |
137
+ | [`tmux_capture`](https://libtmux.org/en/ruby/latest/mcp/tools/tmux_capture/), [`tmux_wait`](https://libtmux.org/en/ruby/latest/mcp/tools/tmux_wait/) | Disabled | Capture a screen or wait for text/process exit |
138
+ | [`tmux_create`](https://libtmux.org/en/ruby/latest/mcp/tools/tmux_create/), [`tmux_send`](https://libtmux.org/en/ruby/latest/mcp/tools/tmux_send/), [`tmux_close`](https://libtmux.org/en/ruby/latest/mcp/tools/tmux_close/) | Disabled | Create entities, send text/keys and tear down exact targets |
139
+ | [`tmux_run`](https://libtmux.org/en/ruby/latest/mcp/tools/tmux_run/) | Disabled | Run a script in an explicitly enrolled zsh shell |
34
140
 
35
- Repeat `--enable-tool` for each additional tool. For screen capture and waits:
141
+ - Disabled tools are absent from discovery and denied on direct application
142
+ calls.
143
+ - An unknown tool name is refused when the server starts.
144
+
145
+ The [complete protocol recipe](../../examples/mcp_protocol.rb) exercises
146
+ discovery, snapshots, default denial, enabled mutations, cancellation and EOF
147
+ cleanup through actual pipes, including the installed executable.
148
+
149
+ ## Read the layout and the screen
150
+
151
+ ### [`tmux_snapshot`][tool-tmux_snapshot]: the layout, in pages
152
+
153
+ - Pages come from one immutable capture, and a cursor continues that capture.
154
+ - An expired, evicted or foreign cursor returns an error. The server never
155
+ substitutes a fresh listing.
156
+
157
+ ### [`tmux_capture`][tool-tmux_capture]: a pane's screen
158
+
159
+ Enable the screen tools together:
36
160
 
37
161
  ```console
38
162
  $ libtmux-mcp \
@@ -41,99 +165,223 @@ $ libtmux-mcp \
41
165
  --enable-tool tmux_wait
42
166
  ```
43
167
 
44
- Disabled tools are absent from discovery and denied on direct application calls.
45
- The [complete protocol recipe](../../examples/mcp_protocol.rb) exercises
46
- discovery, snapshots, default denial, enabled mutations, cancellation and EOF
47
- cleanup through actual pipes, including the installed executable.
168
+ - It reads a bounded screen and keeps its line endings. Bytes that are not
169
+ valid UTF-8 come back base64-encoded.
170
+ - With tracking, a call returns a cursor, and later calls return a splice
171
+ against that exact captured state. A cursor from another server generation
172
+ or from a respawned pane is refused.
173
+ - It refuses a pane whose effective `after-capture-pane` hook is not empty,
174
+ including inherited entries.
175
+ - On tmux 3.3a to 3.4 the hook is checked before the capture command runs, so
176
+ keep hook configuration stable while observing.
177
+
178
+ ### [`tmux_wait`][tool-tmux_wait]: wait for text or exit
179
+
180
+ - It waits for screen text, or for the pane process to exit. Screen text
181
+ comes from control events on tmux 3.7 and newer, and from polling the pane
182
+ every 50 ms on older daemons, which take no control client. Process exit
183
+ uses process descriptors.
184
+ - Wrapped rows are joined, so text that the pane width split across rows
185
+ still matches.
186
+ - If the pane, or the tmux server holding it, ends before the text appears,
187
+ the call fails with `pane_died`, `delivery: observed` and the last rows it
188
+ read in the error's `capture`. If only the control stream ends while the
189
+ pane lives, such as after the client detaches, the failure is
190
+ `observation_lost`.
191
+ - A cancelled or expired wait closes its observers and never signals the pane
192
+ program.
193
+ - Process tracking needs a native identity backend: Linux peer pidfds with
194
+ matching PID namespaces, or Darwin kqueue. Without one, the call is refused.
195
+
196
+ ### [Resource templates][mcp-resources]: the same data as URIs
197
+
198
+ [`tmux_metadata`][resource-tmux_metadata],
199
+ [`tmux_metadata_page`][resource-tmux_metadata_page] and
200
+ [`tmux_screen`][resource-tmux_screen] expose metadata pages and pane screens
201
+ under endpoint and generation URIs, with the same policy and limits as their
202
+ tools.
203
+
204
+ - The server does not advertise resource subscriptions.
205
+ - [Snapshots, cursors and references][mcp-snapshots-topic] has the full
206
+ contract.
207
+
208
+ ## Create, type and close
209
+
210
+ ### [`tmux_create`][tool-tmux_create]: new sessions, windows and panes
211
+
212
+ Enable the three tools together:
213
+
214
+ ```console
215
+ $ libtmux-mcp \
216
+ --socket-name agent \
217
+ --enable-tool tmux_create \
218
+ --enable-tool tmux_send \
219
+ --enable-tool tmux_close
220
+ ```
221
+
222
+ - It takes argument arrays as `argv`.
223
+ - Results carry the references tmux returned.
224
+
225
+ ### [`tmux_send`][tool-tmux_send]: literal text or named keys
226
+
227
+ - Text and keys are separate variants.
228
+ - It reports `dispatch_only`: the input was dispatched, and the command has not
229
+ necessarily finished.
230
+ - After a cancellation, the effect stays unknown and is not reported as none.
231
+
232
+ ### [`tmux_close`][tool-tmux_close]: an exact target
233
+
234
+ - It takes an exact session, window or pane reference, and never selects by
235
+ name.
236
+
237
+ ## Run a script in a pane
238
+
239
+ ### [`tmux_run`][tool-tmux_run]: authored commands in an enrolled zsh
240
+
241
+ [`tmux_run`][tool-tmux_run] runs a POSIX script in a pane's interactive zsh
242
+ 5.9. It needs both the tool and an enrolled pane.
243
+
244
+ List the pane IDs of the server:
245
+
246
+ ```console
247
+ $ tmux -L agent list-panes -a -F '#{pane_id}'
248
+ ```
249
+
250
+ ```text
251
+ %0
252
+ ```
253
+
254
+ Enable the tool and write a setup file for one exact pane:
255
+
256
+ ```console
257
+ $ libtmux-mcp \
258
+ --socket-name agent \
259
+ --enable-tool tmux_run \
260
+ --enroll-pane %0=enroll.zsh
261
+ ```
262
+
263
+ Then, in that pane's zsh, run `source enroll.zsh`. A relative path resolves
264
+ from the directory where the server starts.
265
+
266
+ - `--enroll-pane` requires `--enable-tool tmux_run`, and each pane and each
267
+ file may appear once.
268
+ - The server writes a private file (mode `0600`) and never types into the
269
+ terminal. It refuses an existing file or a symlink.
270
+ - Setup files expire. `--enrollment-timeout SECONDS` sets the lifetime, at most
271
+ 300.
272
+ - EOF retires pending enrollment and removes only the files the server wrote.
273
+ - A call takes an exact pane `target`, a POSIX `script`, and optional
274
+ `stdout_limit` and `stderr_limit` byte counts.
275
+ - The script runs with the shell's working directory and exported environment.
276
+ - Stdout and stderr come back separately, as UTF-8 or base64.
277
+ - A nonzero exit or a signal is a completion result. Output over its limit is
278
+ an error with completion unobserved, and never a truncated success.
279
+ - Cancelling a run does not prove that its child processes stopped.
280
+
281
+ ## Embed it in Ruby
282
+
283
+ Require `libtmux/mcp`. Imports start no tmux process, scheduler or MCP server.
284
+ The [execution guide](../../docs/modes.md) covers result and ownership
285
+ boundaries.
286
+
287
+ ### [`LibTmux::MCP::Application`][mcp-application]: serve from your own program
288
+
289
+ [`Application`][mcp-application] borrows an application-owned
290
+ [`LibTmux::Async::Server`][async-server]. Its
291
+ [`sdk_server`][mcp-application-sdk_server] supplies the SDK server that
292
+ [`StdioTransport`][mcp-stdiotransport] consumes:
293
+
294
+ <!-- example: mcp_protocol/main -->
295
+ ```ruby
296
+ application = LibTmux::MCP::Application.new(server: scope.server, endpoint_name: "example")
297
+ sdk = application.sdk_server
298
+ input, client_input = IO.pipe
299
+ client_output, output = IO.pipe
300
+ transport = LibTmux::MCP::StdioTransport.new(server: sdk, parent: parent, input: input, output: output)
301
+ runner = parent.async { transport.run }
302
+ ```
303
+ <!-- /example -->
304
+
305
+ The [complete program](../../examples/mcp_protocol.rb) runs this excerpt inside
306
+ an Async task with an open [`LibTmux::Async`][async] scope. It sends real
307
+ JSON-RPC messages through the pipes, checks the replies, and prints
308
+ `PASS mcp_protocol`.
309
+
310
+ ### [`Application#invite_shell`][mcp-application-invite_shell]: enroll a shell from Ruby
311
+
312
+ Call `invite_shell` with a pane reference, `timeout:` and `expires_in:`, then
313
+ pass the invitation to [`accept_shell`][mcp-application-accept_shell].
314
+
315
+ - The invitation's `shell_arguments` make up the setup command to source in
316
+ that pane's zsh.
317
+ - `timeout:` bounds acquisition, and `expires_in:` sets the enrollment
318
+ lifetime.
319
+ - The application owns invitations and accepted connections until
320
+ [`close`][mcp-application-close]. Direct enrollment calls enforce tool
321
+ policy.
322
+
323
+ ## What it leaves out
324
+
325
+ - **Tools you have not enabled.** Disabled tools are absent from discovery,
326
+ and a direct application call is denied. See [The tools](#the-tools).
327
+ - **Resource subscriptions.** The server does not advertise them. See the
328
+ [resource templates][mcp-resources].
329
+ - **Typing into your terminal.** The server writes a private setup file and
330
+ never types into a pane's terminal to enroll it. See
331
+ [`tmux_run`][tool-tmux_run].
332
+ - **A guaranteed stop.** Cancelling a run does not prove that its child
333
+ processes stopped. See [`tmux_run`][tool-tmux_run].
334
+
335
+ ## Support and status
336
+
337
+ **Alpha.** APIs may change between releases.
338
+
339
+ - **Ruby 3.3 or newer.** The gem depends on `libtmux` and `libtmux-async` at
340
+ the same version, the `mcp` SDK 1.5, `digest` and `optparse`.
341
+ - **tmux 3.3a or newer.** The server refuses an older daemon when it starts,
342
+ before it serves any tool, and prints the reason and the remedy on stderr.
343
+ - **A daemon that changes while it runs.** A tool call returns an
344
+ `unsupported` result with `delivery: not_sent`. The result names the floor
345
+ and says to upgrade tmux and restart the server.
346
+ - **The `screen_contains` wait.** It uses control events on tmux 3.7 and
347
+ newer and polls the pane screen on older daemons, which take no control
348
+ client.
349
+ - **Linux, including WSL, and macOS.** The core
350
+ [compatibility workflow][compat] runs Ruby 3.3, 3.4 and 4.0 with tmux 3.3a
351
+ through 3.8 on Ubuntu 24.04. macOS is supported on a best-effort basis, and
352
+ CI coverage for it is highly limited.
353
+
354
+ ## Documentation
355
+
356
+ | | |
357
+ | --- | --- |
358
+ | [Reference](https://libtmux.org/en/ruby/latest/mcp/reference/) | The embedding types and methods |
359
+ | [Tools](https://libtmux.org/en/ruby/latest/mcp/tools/) | One page per tool, with arguments, limits and result schema |
360
+ | [Guides][mcp-guides] | Connect a client to a private tmux server and select its tools |
361
+ | [Resources][mcp-resources] | Metadata pages and pane screens as URIs |
362
+ | [Public behavior][behavior-mcp-application] | The application, transport and command |
48
363
 
49
- ## Snapshots, capture and waits
50
-
51
- Snapshot pages retain one immutable capture and query. Cursor expiry or eviction
52
- returns an error; it never substitutes a new live listing. Defaults retain up to
53
- 16 captures and 8 MiB for 30 seconds, with a five-second acquisition deadline and
54
- one-MiB structured response limit. A cursor pages captured metadata; it does not
55
- claim that an old pane process still exists. Schema validation supplements the
56
- core decoder's stricter byte, depth, node and duplicate-key rules.
57
-
58
- Opt into `tmux_capture` for a bounded screen snapshot. Results retain line
59
- endings, encode invalid UTF-8 as base64, and distinguish truncated screen
60
- content from unknown history continuity. Tracking produces a retained cursor;
61
- subsequent calls return a splice against that exact captured state. A screen
62
- delta does not establish that every intervening output byte was observed.
63
-
64
- Capture refuses nonempty effective `after-capture-pane` hooks, including
65
- inherited sparse entries. On tmux 3.2a–3.4, callers must keep capture-hook
66
- configuration stable throughout observation: those versions require a separate
67
- hook preflight. tmux 3.5+ checks the hook in the capture command queue. Both paths
68
- retain an explicit session/pane context and refuse its removal instead of
69
- switching to another session's hooks. Process tracking retains its native
70
- identity checks on every version that supports it.
71
-
72
- `tmux_wait` observes screen text or process exit through events. Canceling it
73
- retires its observation resources without signaling the pane program. Strong
74
- process tracking requires tmux 3.3 or later and a native identity backend:
75
- Linux peer pidfds with matching process namespaces, or Darwin kqueue process
76
- observation. Acquisition verifies the live daemon and pane before retaining
77
- a cursor; unavailable evidence produces an explicit refusal. The
78
- [compatibility workflow](https://github.com/libtmux/libtmux-ruby/actions/workflows/compatibility.yml)
79
- records each exact platform/version result, including the required tmux 3.2a
80
- refusal and positive identity cases on later versions.
81
-
82
- Resource templates expose metadata pages and pane screens under encoded
83
- endpoint/generation URIs. They enforce the same policy and response limits as
84
- their tools. Metadata pages preserve capture identity; screen resources
85
- include interval, truncation and history-continuity metadata. Resource
86
- subscriptions are not advertised.
87
-
88
- ## Create, send and close
89
-
90
- Add `--enable-tool tmux_create`, `--enable-tool tmux_send` or
91
- `--enable-tool tmux_close` to authorize those tools. Creation accepts argument
92
- arrays; sending text and sending named keys are separate variants. Mutation
93
- results contain delivery evidence and positively returned references.
94
- `dispatch_only` input results do not claim program completion, and unknown
95
- effects remain unknown after cancellation.
96
-
97
- ## Run authored commands
98
-
99
- `tmux_run` requires separate policy and shell enrollment. Add
100
- `--enable-tool tmux_run --enroll-pane %ID=FILE` for each exact pane, then
101
- explicitly source the generated file in that pane's interactive zsh 5.9.
102
- The CLI creates a private setup file and never types into the terminal.
103
- It refuses existing files and symlinks. Invitations expire after 60 seconds;
104
- `--enrollment-timeout` accepts at most 300 seconds. At most eight panes may be
105
- enrolled. EOF retires pending enrollment and removes only files the CLI owns.
106
-
107
- The tool accepts an exact pane target, a POSIX `script`, and separate
108
- `stdout_limit`/`stderr_limit` byte counts. Scripts may contain at most 65,536
109
- bytes. Each output defaults to 65,536 bytes and is capped at 262,144; the
110
- application reserves its worst-case serialized response before authorization.
111
- The helper inherits the enrolled shell's cwd and exported environment, uses
112
- closed stdin, and reports separate UTF-8 or base64 outputs. Nonzero exit and
113
- signal termination are completion results. Output overflow is an error with
114
- completion unobserved, not silently truncated success. Shell variables,
115
- functions, options and cwd changes do not persist in the interactive parent.
116
-
117
- An idle, empty primary ZLE editor receives the request through a private socket.
118
- A guarded tmux queue operation authorizes one script digest for one retained
119
- server, pane process and enrollment generation. Execution may follow that
120
- authorization; a later respawn does not redirect the prepared helper to its
121
- replacement. Error responses retain known authorization and native completion
122
- receipts. Cancellation does not prove that arbitrary descendants stopped.
123
- The Linux and macOS compatibility jobs exercise enrollment and the installed
124
- helper dependency closure; consult their results for the revision being used.
125
-
126
- ## Embed in Ruby
127
-
128
- Require `libtmux/mcp`; imports start no tmux process, scheduler or MCP server.
129
- `Application` borrows an application-owned `LibTmux::Async::Server`. Its
130
- `sdk_server` supplies the SDK server consumed by `StdioTransport`. Both objects
131
- stay on that application's reactor thread. See the
132
- [execution guide](../../docs/modes.md) for result and ownership boundaries.
133
-
134
- For shell enrollment, call `Application#invite_shell(reference, timeout:,
135
- expires_in:)` and pass the returned invitation to `accept_shell`. The invitation
136
- exposes an immutable `shell_arguments` array for an explicitly sourced setup
137
- command and a monotonic `expires_at`. Its acquisition deadline is separate from
138
- its enrollment lifetime. The application owns invitations and accepted
139
- connections until `close`; direct enrollment calls enforce tool policy.
364
+ [async]: https://libtmux.org/en/ruby/latest/reference/libtmux-async/
365
+ [async-server]: https://libtmux.org/en/ruby/latest/reference/libtmux-async-server/
366
+ [behavior-mcp-application]: ../../docs/reference/behavior.md#mcp-application
367
+ [compat]: https://github.com/libtmux/libtmux-ruby/actions/workflows/compatibility.yml
368
+ [mcp-application]: https://libtmux.org/en/ruby/latest/mcp/reference/libtmux-mcp-application/
369
+ [mcp-application-accept_shell]: https://libtmux.org/en/ruby/latest/mcp/reference/libtmux-mcp-application-accept_shell/
370
+ [mcp-application-close]: https://libtmux.org/en/ruby/latest/mcp/reference/libtmux-mcp-application-close/
371
+ [mcp-application-invite_shell]: https://libtmux.org/en/ruby/latest/mcp/reference/libtmux-mcp-application-invite_shell/
372
+ [mcp-application-sdk_server]: https://libtmux.org/en/ruby/latest/mcp/reference/libtmux-mcp-application-sdk_server/
373
+ [mcp-guides]: https://libtmux.org/en/ruby/latest/mcp/guides/
374
+ [mcp-resources]: https://libtmux.org/en/ruby/latest/mcp/resources/
375
+ [mcp-snapshots-topic]: https://libtmux.org/en/ruby/latest/mcp/topics/snapshots-and-references/
376
+ [mcp-stdiotransport]: https://libtmux.org/en/ruby/latest/mcp/reference/libtmux-mcp-stdiotransport/
377
+ [resource-tmux_metadata]: https://libtmux.org/en/ruby/latest/mcp/resources/templates/tmux_metadata/
378
+ [resource-tmux_metadata_page]: https://libtmux.org/en/ruby/latest/mcp/resources/templates/tmux_metadata_page/
379
+ [resource-tmux_screen]: https://libtmux.org/en/ruby/latest/mcp/resources/templates/tmux_screen/
380
+ [tool-tmux_capabilities]: https://libtmux.org/en/ruby/latest/mcp/tools/tmux_capabilities/
381
+ [tool-tmux_capture]: https://libtmux.org/en/ruby/latest/mcp/tools/tmux_capture/
382
+ [tool-tmux_close]: https://libtmux.org/en/ruby/latest/mcp/tools/tmux_close/
383
+ [tool-tmux_create]: https://libtmux.org/en/ruby/latest/mcp/tools/tmux_create/
384
+ [tool-tmux_run]: https://libtmux.org/en/ruby/latest/mcp/tools/tmux_run/
385
+ [tool-tmux_send]: https://libtmux.org/en/ruby/latest/mcp/tools/tmux_send/
386
+ [tool-tmux_snapshot]: https://libtmux.org/en/ruby/latest/mcp/tools/tmux_snapshot/
387
+ [tool-tmux_wait]: https://libtmux.org/en/ruby/latest/mcp/tools/tmux_wait/
data/assets/logo.png ADDED
Binary file
data/assets/logo.svg ADDED
@@ -0,0 +1,2 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 135.46667 135.46666" role="img" aria-label="libtmux for Ruby — MCP" class="libtmux-logo" data-language="ruby" data-variant="mcp" focusable="false"><title>libtmux for Ruby — MCP</title><g transform="translate(-210.28959,-409.98108)" data-part="artwork"><path d="m 334.66493,533.88598 v 11.56176 h 3.76501 c 4.04619,2.1e-4 7.32623,-3.28012 7.32632,-7.32631 v -4.23545 z" fill="#333333" data-part="base-edge"></path><g transform="matrix(1.024,0,0,1.024,-5.0469422,-9.8395302)" data-part="primary-tile"><path d="M 227.78906,0 213.98242,3.6992188 V 460.62109 H 241.4082 V 15.564453 Z" transform="matrix(0.26458333,0,0,0.26458333,210.28959,409.98108)" fill="#2D0002" data-part="primary-edge"></path><path d="m 210.28981,417.13553 v 113.86425 l 60.26885,2.60089 V 409.98109 h -53.11441 c -3.95129,0 -7.15444,3.20315 -7.15444,7.15444 z" fill="#701516" data-part="primary"></path></g><g transform="matrix(1.024,0,0,1.024,-5.0469422,-9.8395302)" data-part="lower-tile"><path d="m 486.38086,246.03906 -5.24805,4.99805 v 207.2207 l 5.24805,-0.93164 L 500,465.18945 V 261.60352 Z" transform="matrix(0.26458333,0,0,0.26458333,210.28959,409.98108)" fill="#67221F" data-part="lower-edge"></path><path d="m 278.70874,475.07917 v 64.8875 l 60.26883,-8.98455 v -55.90295 z" fill="#984640" data-part="lower"></path></g><path d="m 210.28959,533.88598 v 4.23545 c 9e-5,4.04619 3.28012,7.32651 7.32631,7.32631 h 117.12417 c 4.04619,2e-4 7.32621,-3.28012 7.32631,-7.32631 v -4.23545 z" fill="#231F20" data-part="base"></path><g transform="matrix(1.024,0,0,1.024,-5.0469422,-9.8395302)" data-part="upper-tile"><path d="m 459.33984,0 -25.33593,2.1074219 h 21.38281 c 14.93397,0 27.04101,12.1070061 27.04101,27.0410161 l 1.51727,183.649512 2.43542,2.38046 -1.84468,1.91057 15.46424,13.65284 L 500,42.603516 C 499.99913,8.8176763 474.27408,-6.8031496e-4 459.33984,0 Z" transform="matrix(0.26458333,0,0,0.26458333,210.28959,409.98108)" fill="#854A46" data-part="upper-side"></path><path d="m 331.82331,409.98109 h -53.11442 v 56.93261 h 60.26885 v -49.77817 c 0,-3.95129 -3.20315,-7.15444 -7.15443,-7.15444 z" fill="#B6706A" data-part="upper"></path><path d="m 278.7089,466.9137 h 60.26885 l 3.6035,4.11782 h -60.26932 z" fill="#733A36" data-part="upper-bottom"></path></g></g><g transform="scale(0.264583339844)" data-part="mcp-emblem"><g transform="translate(102,82) scale(2.133333333333)"><g fill="none" stroke="#999999" stroke-width="12" stroke-linecap="round" data-part="mcp-shadow"><path d="M25 97.8528L92.8823 29.9706C102.255 20.598 117.451 20.598 126.823 29.9706V29.9706C136.196 39.3431 136.196 54.5391 126.823 63.9117L75.5581 115.177" data-part="mcp-shadow-1"></path><path d="M76.2653 114.47L126.823 63.9117C136.196 54.5391 151.392 54.5391 160.765 63.9117L161.118 64.2652C170.491 73.6378 170.491 88.8338 161.118 98.2063L99.7248 159.6C96.6006 162.724 96.6006 167.789 99.7248 170.913L112.331 183.52" data-part="mcp-shadow-2"></path><path d="M109.853 46.9411L59.6482 97.1457C50.2757 106.518 50.2757 121.714 59.6482 131.087V131.087C69.0208 140.459 84.2168 140.459 93.5894 131.087L143.794 80.8822" data-part="mcp-shadow-3"></path></g></g><g transform="translate(102,72) scale(2.133333333333)"><g fill="none" stroke="#E6E6E6" stroke-width="12" stroke-linecap="round" data-part="mcp-symbol"><path d="M25 97.8528L92.8823 29.9706C102.255 20.598 117.451 20.598 126.823 29.9706V29.9706C136.196 39.3431 136.196 54.5391 126.823 63.9117L75.5581 115.177" data-part="mcp-symbol-1"></path><path d="M76.2653 114.47L126.823 63.9117C136.196 54.5391 151.392 54.5391 160.765 63.9117L161.118 64.2652C170.491 73.6378 170.491 88.8338 161.118 98.2063L99.7248 159.6C96.6006 162.724 96.6006 167.789 99.7248 170.913L112.331 183.52" data-part="mcp-symbol-2"></path><path d="M109.853 46.9411L59.6482 97.1457C50.2757 106.518 50.2757 121.714 59.6482 131.087V131.087C69.0208 140.459 84.2168 140.459 93.5894 131.087L143.794 80.8822" data-part="mcp-symbol-3"></path></g></g></g></svg>
@@ -162,7 +162,7 @@ module LibTmux
162
162
  IncompleteSnapshotError => "incomplete_snapshot", Cancelled => "cancelled",
163
163
  DeadlineExceeded => "deadline", CapacityError => "capacity", TargetNotFoundError => "stale_target",
164
164
  CommandError => "command_failed", UnsupportedFeatureError => "unsupported", ClosedError => "closed",
165
- Observation::LostObservation => "observation_lost"}.fetch(error.class, "transport_error")
165
+ Observation::LostObservation => "observation_lost", Observation::PaneDied => "pane_died"}.fetch(error.class, "transport_error")
166
166
  delivery = error.respond_to?(:delivery) ? (mutation ? mutation.delivery(error) : error.delivery) : :possibly_sent
167
167
  delivery = :observed if result
168
168
  effects = if name == "tmux_run"
@@ -175,7 +175,15 @@ module LibTmux
175
175
  created = result && result["created"]
176
176
  {"state" => created ? "known" : delivery == :not_sent ? "none" : "unknown", "created" => created || []}
177
177
  end
178
- failure_response(code, "The operation could not establish its requested result.", delivery.to_s, effects: effects)
178
+ message = if delivery == :not_sent && LibTmux::Internal::TmuxVersion.refusal?(error)
179
+ LibTmux::Internal::TmuxVersion.hint
180
+ elsif error.is_a?(Observation::PaneDied)
181
+ "The pane, or its tmux server, ended before the awaited text appeared."
182
+ else
183
+ "The operation could not establish its requested result."
184
+ end
185
+ failure_response(code, message, delivery.to_s, effects: effects,
186
+ capture: (error.capture if error.is_a?(Observation::PaneDied)))
179
187
  end
180
188
 
181
189
  def inspect
@@ -504,9 +512,10 @@ module LibTmux
504
512
  value.freeze
505
513
  end
506
514
 
507
- def failure_response(code, message, delivery = "not_sent", effects: nil)
515
+ def failure_response(code, message, delivery = "not_sent", effects: nil, capture: nil)
508
516
  details = {"code" => code, "message" => message, "delivery" => delivery}
509
517
  details["effects"] = effects if effects
518
+ details["capture"] = capture if capture
510
519
  ::MCP::Tool::Response.new([{type: "text", text: message}], error: true,
511
520
  structured_content: {"ok" => false, "error" => details})
512
521
  end
@@ -183,10 +183,11 @@ module LibTmux
183
183
  "truncated" => {"type" => "boolean"}, "next_cursor" => text},
184
184
  %w[capture_id server_identity entity items coverage interval truncated])
185
185
  end
186
- {"oneOf" => [object({"ok" => {"const" => true}, "data" => payload}),
186
+ {"type" => "object", "oneOf" => [object({"ok" => {"const" => true}, "data" => payload}),
187
187
  object({"ok" => {"const" => false}, "error" => object({"code" => text,
188
188
  "message" => text, "delivery" => {"enum" => %w[not_sent possibly_sent observed]},
189
- "effects" => name == "tmux_run" ? run_effects : effects}, %w[code message delivery])})]}
189
+ "effects" => name == "tmux_run" ? run_effects : effects}.merge(name == "tmux_wait" ? {"capture" => capture_output} : {}),
190
+ %w[code message delivery])})]}
190
191
  end
191
192
 
192
193
  def self.authorization
@@ -237,9 +238,9 @@ module LibTmux
237
238
  if name == "tmux_run"
238
239
  "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
240
  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
+ "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.3a 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
242
  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
+ "Wait for observed literal screen text or the initial pane process exit. Deadline in seconds is capped by the application limit. Pane exit is observed through process descriptors. The screen_contains condition joins wrapped rows, uses control events on tmux>=3.7 and polls the pane screen on older daemons, which take no control client. A pane or tmux server that ends first fails with pane_died and the last rows read in error.capture; a lost control stream fails with observation_lost. Requires tmux>=3.3a 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
244
  elsif name == "tmux_send"
244
245
  "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
246
  elsif name == "tmux_create"
@@ -36,7 +36,11 @@ module LibTmux
36
36
  rescue Interrupt, LibTmux::Cancelled => error
37
37
  report("MCP interrupted; dispatched effects may remain.", 130, failure: error)
38
38
  rescue StandardError => error
39
- report("MCP execution failed (#{error.class}); dispatched effects may remain.", 1, failure: error)
39
+ if error.is_a?(LibTmux::UnsupportedFeatureError) && error.phase == :admission && error.delivery == :not_sent
40
+ report("MCP refused to start: #{error.message}.", 1, failure: error)
41
+ else
42
+ report("MCP execution failed (#{error.class}); dispatched effects may remain.", 1, failure: error)
43
+ end
40
44
  end
41
45
  end.wait
42
46
  rescue OptionParser::ParseError, ArgumentError => error
@@ -5,8 +5,19 @@ require "libtmux/mcp/process_identity"
5
5
  module LibTmux
6
6
  module MCP
7
7
  class Observation
8
+ POLL_SECONDS = 0.05
8
9
  StaleCursor = Class.new(LibTmux::Error)
9
10
  LostObservation = Class.new(LibTmux::Error)
11
+ # The pane, or the tmux server holding it, ended before the awaited text
12
+ # appeared. +capture+ is the last screen state read before it ended.
13
+ class PaneDied < LibTmux::Error
14
+ attr_reader :capture
15
+
16
+ def initialize(message, capture:, **details)
17
+ super(message, **details)
18
+ @capture = capture
19
+ end
20
+ end
10
21
  module CleanupDetails
11
22
  attr_reader :mcp_cleanup_errors
12
23
  end
@@ -30,19 +41,32 @@ module LibTmux
30
41
  end
31
42
  end
32
43
 
44
+ def self.supported_release?(version)
45
+ LibTmux::Internal::TmuxVersion.require_supported(version)
46
+ true
47
+ rescue UnsupportedFeatureError
48
+ false
49
+ end
50
+
51
+ # Before tmux 3.5, whole-array formats are empty even for sparse hooks,
52
+ # so the capture hook is read with show-options instead.
53
+ def self.preflight_hooks?(release)
54
+ release < LibTmux::Internal::TmuxVersion.parse("3.5")
55
+ end
56
+
33
57
  def self.capabilities(version)
34
- parsed = /\A(\d+)\.(\d+)/.match(version)
35
58
  native = case RUBY_PLATFORM
36
59
  when /\A(?:x86_64|aarch64)-linux/
37
60
  ["64-bit Linux with peer pidfds (kernel >= 6.6)", "same PID namespace and matching procfs"]
38
61
  when /\A(?:x86_64|arm64)-darwin/
39
62
  ["64-bit Darwin with kqueue NOTE_EXIT/NOTE_REAP", "fresh pinned-route daemon identity"]
40
63
  end
41
- conditional = native && Fiddle::SIZEOF_LONG == 8 && parsed && ([parsed[1].to_i, parsed[2].to_i] <=> [3, 3]) >= 0
64
+ conditional = native && Fiddle::SIZEOF_LONG == 8 && supported_release?(version)
42
65
  {"screen" => "bounded_rows", "history_continuity" => "unknown",
43
66
  "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"],
67
+ "requirements" => ["tmux >= #{LibTmux::Internal::TmuxVersion::MINIMUM}", *(native || ["supported 64-bit Linux or Darwin process identity backend"]),
68
+ "live pane process", "empty effective capture hook", "stable capture-hook configuration on tmux < 3.5",
69
+ "screen_contains waits use control events from tmux #{LibTmux::Internal::TmuxVersion::CONTROL_MINIMUM} and poll the screen before it"],
46
70
  "wait_conditions" => %w[screen_contains process_exit]}
47
71
  end
48
72
 
@@ -150,7 +174,7 @@ module LibTmux
150
174
  end
151
175
 
152
176
  def wait
153
- identity = control = subscription = nil
177
+ identity = control = subscription = latest = nil
154
178
  tasks = []
155
179
  failure = response = nil
156
180
  begin
@@ -159,8 +183,10 @@ module LibTmux
159
183
  condition = @arguments.fetch("condition")
160
184
  snapshot, pane = resolve(reference)
161
185
  require_tracking(snapshot)
186
+ # Daemons below 3.7 take no control client, so a screen wait polls the pane.
187
+ polling = condition.fetch("type") == "screen_contains" && !@server.supports_control?
162
188
  identity = acquire_identity(snapshot, pane)
163
- if condition.fetch("type") == "screen_contains"
189
+ if condition.fetch("type") == "screen_contains" && !polling
164
190
  link = snapshot.window_links.find { |item| item.window_id == pane.window_id }
165
191
  raise TargetNotFoundError.new("pane has no observable session", phase: :admission) unless link
166
192
 
@@ -172,18 +198,38 @@ module LibTmux
172
198
  control.exchange(line, **@budget.options)
173
199
  end
174
200
  limits = defaults
175
- rows, encoding, truncated = read_rows(snapshot, pane, limits, identity)
201
+ # A pane or server that ends mid-read ends the wait as PaneDied, with
202
+ # the last rows read, unless the read failed for another reason.
203
+ pane_died = lambda do
204
+ rows, encoding, truncated = latest
205
+ PaneDied.new("observed pane process exited", phase: :observation, delivery: :observed,
206
+ capture: state(reference, rows, encoding, truncated, limits, identity, SecureRandom.hex(16)))
207
+ end
208
+ read = lambda do
209
+ begin
210
+ latest = read_rows(snapshot, pane, limits, identity, true)
211
+ rescue TargetNotFoundError, CommandError, TransportError
212
+ raise if latest.nil?
213
+
214
+ raise unless ended?(identity)
215
+
216
+ raise pane_died.call
217
+ end
218
+ end
219
+ rows, encoding, truncated = read.call
176
220
  if condition.fetch("type") == "screen_contains" && contains?(rows, encoding, condition.fetch("text"))
177
221
  next {"target" => reference, "condition" => "screen_contains",
178
222
  "capture" => state(reference, rows, encoding, truncated, limits, identity, SecureRandom.hex(16))}
179
223
  end
180
224
  changed = ::Async::Notification.new
181
225
  pending = nil
226
+ weights = {read: 0, stream_ended: 1, exited: 2, server_exited: 2}
227
+ weigh = ->(value) { value.is_a?(Exception) ? 3 : weights.fetch(value) }
182
228
  wake = lambda do |value|
183
- pending = value if pending.nil? || (!pending.is_a?(Exception) && (value.is_a?(Exception) || value == :exited))
229
+ pending = value if pending.nil? || weigh.call(value) > weigh.call(pending)
184
230
  changed.signal
185
231
  end
186
- [[identity.io, :exited], [identity.peer, LostObservation.new("tmux process ended", phase: :observation)]].each do |io, event|
232
+ [[identity.io, :exited], [identity.peer, :server_exited]].each do |io, event|
187
233
  task = ::Async::Task.new(::Async::Task.current) do
188
234
  Fiber.scheduler.io_wait(io, IO::READABLE)
189
235
  wake.call(event)
@@ -195,10 +241,30 @@ module LibTmux
195
241
  tasks << task
196
242
  task.run
197
243
  end
198
- if subscription
244
+ if polling
199
245
  task = ::Async::Task.new(::Async::Task.current) do
200
246
  loop do
201
- event = subscription.next(timeout: @budget.options.fetch(:timeout))
247
+ ::Async::Task.current.sleep(POLL_SECONDS)
248
+ wake.call(:read)
249
+ end
250
+ rescue ::Async::Cancel
251
+ nil
252
+ rescue StandardError => error
253
+ wake.call(error)
254
+ end
255
+ tasks << task
256
+ task.run
257
+ end
258
+ if subscription
259
+ task = ::Async::Task.new(::Async::Task.current) do
260
+ # Kernel#loop would swallow the StopIteration that ends the stream.
261
+ while true
262
+ begin
263
+ event = subscription.next(timeout: @budget.options.fetch(:timeout))
264
+ rescue StopIteration
265
+ wake.call(:stream_ended)
266
+ break
267
+ end
202
268
  if event.kind == :gap
203
269
  wake.call(LostObservation.new("screen observation has a gap", phase: :observation, delivery: :observed))
204
270
  break
@@ -207,8 +273,6 @@ module LibTmux
207
273
  end
208
274
  rescue ::Async::Cancel
209
275
  nil
210
- rescue StopIteration
211
- wake.call(LostObservation.new("screen observation ended", phase: :observation, delivery: :observed))
212
276
  rescue StandardError => error
213
277
  wake.call(error)
214
278
  end
@@ -221,15 +285,32 @@ module LibTmux
221
285
  event, pending = pending, nil
222
286
  raise event if event.is_a?(Exception)
223
287
 
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)
288
+ if event == :stream_ended
289
+ # A stopped second client keeps a killed server alive after the
290
+ # stream ends, so ask the daemon instead of waiting for its process.
291
+ raise pane_died.call if ended?(identity)
292
+
293
+ raise LostObservation.new("screen observation ended", phase: :observation, delivery: :observed)
294
+ end
295
+ if event == :exited || event == :server_exited
296
+ if condition.fetch("type") == "process_exit"
297
+ begin
298
+ identity.peer_alive!
299
+ rescue TargetNotFoundError
300
+ raise LostObservation.new("tmux process ended", phase: :observation, delivery: :observed), cause: nil
301
+ end
302
+ break {"target" => reference, "condition" => "process_exit", "process_generation" => identity.generation,
303
+ "observed_at" => clock, "exit_status" => "unobserved"}
304
+ end
305
+ # The last output may have landed just before the end.
306
+ rows, encoding, truncated = read.call
307
+ if contains?(rows, encoding, condition.fetch("text"))
308
+ break {"target" => reference, "condition" => "screen_contains",
309
+ "capture" => state(reference, rows, encoding, truncated, limits, identity, SecureRandom.hex(16))}
228
310
  end
229
- break {"target" => reference, "condition" => "process_exit", "process_generation" => identity.generation,
230
- "observed_at" => clock, "exit_status" => "unobserved"}
311
+ raise pane_died.call
231
312
  end
232
- rows, encoding, truncated = read_rows(snapshot, pane, limits, identity)
313
+ rows, encoding, truncated = read.call
233
314
  if contains?(rows, encoding, condition.fetch("text"))
234
315
  break {"target" => reference, "condition" => "screen_contains",
235
316
  "capture" => state(reference, rows, encoding, truncated, limits, identity, SecureRandom.hex(16))}
@@ -259,7 +340,44 @@ module LibTmux
259
340
 
260
341
  private
261
342
 
343
+ # What a daemon that is killed, or shutting down behind a stopped client,
344
+ # tells a client.
345
+ SERVER_GONE = /\A(?:no server running|server exited unexpectedly|error connecting to .*\((?:No such file or directory|Connection refused)\))/
346
+
347
+ # True when the pane process is gone or the daemon says it is gone.
348
+ # An inconclusive probe, such as a failed spawn, is not an end.
349
+ def ended?(identity)
350
+ gone?(identity) || daemon_state == :gone
351
+ end
352
+
353
+ # :alive when the daemon answers an ordinary command, :gone when it
354
+ # reports that it is gone, and :unknown when the probe itself fails. The
355
+ # identity read is a run-shell -C probe, which fires no hook.
356
+ def daemon_state
357
+ argv, marker = LibTmux::Internal::TmuxVersion.identity_argv(%w[pid])
358
+ result = @server.run(argv, **@budget.options)
359
+ begin
360
+ LibTmux::Internal::TmuxVersion.parse_identity(result, marker, 1)
361
+ :alive
362
+ rescue ProtocolError
363
+ SERVER_GONE.match?(result.stderr.b.force_encoding(Encoding::UTF_8)) ? :gone : :unknown
364
+ end
365
+ rescue Cancelled, DeadlineExceeded
366
+ raise
367
+ rescue LibTmux::Error, SystemCallError, IOError
368
+ :unknown
369
+ end
370
+
371
+ def gone?(identity)
372
+ identity.exited?
373
+ rescue TargetNotFoundError
374
+ true
375
+ end
376
+
262
377
  def acquire_identity(snapshot, pane)
378
+ # tmux 3.8 reports no PID for a dead pane; there is no process to bind.
379
+ raise TargetNotFoundError.new("pane process has exited", phase: :admission) if pane.pid.nil?
380
+
263
381
  identity = ProcessIdentity.acquire(@server, server_pid: snapshot.server_info.fetch(:pid), pane_pid: pane.pid,
264
382
  budget: @budget, on_retire: ->(resource) { @owned << resource })
265
383
  @owned << identity
@@ -285,13 +403,15 @@ module LibTmux
285
403
  end
286
404
 
287
405
  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
406
+ return if self.class.supported_release?(snapshot.server_info.fetch(:version))
407
+
408
+ raise UnsupportedFeatureError.new("process cursors require tmux #{LibTmux::Internal::TmuxVersion::MINIMUM} or newer",
409
+ phase: :admission)
292
410
  end
293
411
 
294
- def read_rows(snapshot, pane, limits, identity)
412
+ # +joined+ joins wrapped rows into the lines the program wrote, so a wait
413
+ # finds text that the pane width split across rows.
414
+ def read_rows(snapshot, pane, limits, identity, joined = false)
295
415
  identity&.ensure_live!
296
416
  names = spellings
297
417
  link = snapshot.window_links.find { |item| item.window_id == pane.window_id }
@@ -300,7 +420,7 @@ module LibTmux
300
420
  target = "#{link.session_id}:.#{pane.id}"
301
421
  guard = "\#{&&:\#{==:\#{session_id},#{link.session_id}},\#{==:\#{pane_id},#{pane.id}}}"
302
422
  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,
423
+ body = @server.__send__(:tmux_command, [names.fetch("capture-pane"), "-p", *(joined ? ["-J"] : []), "-t", target,
304
424
  "-S", (-limits.fetch("history_lines")).to_s, "-E", "-"])
305
425
  # The selected false branch fails parsing, independently of screen bytes.
306
426
  failure = @server.__send__(:tmux_command, [names.fetch("capture-pane"), "-t"])
@@ -308,10 +428,9 @@ module LibTmux
308
428
  hook_error = "libtmux-hook-refused-#{SecureRandom.hex(16)}"
309
429
  hook_failure = @server.__send__(:tmux_command, [hook_error])
310
430
  begin
311
- # Before tmux 3.5, whole-array formats are empty even for sparse hooks.
312
431
  # 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
432
+ release = LibTmux::Internal::TmuxVersion.parse(snapshot.server_info.fetch(:version))
433
+ if self.class.preflight_hooks?(release)
315
434
  hooks = @server.__send__(:execute_typed,
316
435
  [names.fetch("show-options"), "-A", "-v", "-t", target, "after-capture-pane"], **@budget.options)
317
436
  unless hooks.stdout.empty?
@@ -193,9 +193,9 @@ module LibTmux
193
193
  end
194
194
  # Registration may find a recycled PID. A fresh pinned-route reply must
195
195
  # name that same daemon while its retained process observer stays live.
196
- name = server.__send__(:builtin_spellings, "display-message", budget: budget).fetch("display-message")
197
- result = server.__send__(:execute_typed, [name, "-p", '#{pid}'], **budget.options)
198
- unless result.stdout == "#{server_pid}\n".b && !readable?(peer)
196
+ argv, marker = LibTmux::Internal::TmuxVersion.identity_argv(%w[pid])
197
+ reported = LibTmux::Internal::TmuxVersion.parse_identity(server.run(argv, **budget.options), marker, 1).first
198
+ unless reported == server_pid.to_s && !readable?(peer)
199
199
  raise UnsupportedFeatureError.new("bound response does not establish the tmux process", phase: :admission)
200
200
  end
201
201
  budget.options
@@ -2,6 +2,6 @@
2
2
 
3
3
  module LibTmux
4
4
  module MCP
5
- VERSION = "0.1.0.alpha.1"
5
+ VERSION = "0.1.0.alpha.3"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: libtmux-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.alpha.1
4
+ version: 0.1.0.alpha.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - libtmux contributors
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.1.0.alpha.1
18
+ version: 0.1.0.alpha.3
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.1.0.alpha.1
25
+ version: 0.1.0.alpha.3
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: libtmux-async
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - '='
31
31
  - !ruby/object:Gem::Version
32
- version: 0.1.0.alpha.1
32
+ version: 0.1.0.alpha.3
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - '='
38
38
  - !ruby/object:Gem::Version
39
- version: 0.1.0.alpha.1
39
+ version: 0.1.0.alpha.3
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: mcp
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -94,6 +94,8 @@ extra_rdoc_files: []
94
94
  files:
95
95
  - LICENSE
96
96
  - README.md
97
+ - assets/logo.png
98
+ - assets/logo.svg
97
99
  - exe/libtmux-mcp
98
100
  - lib/libtmux/mcp.rb
99
101
  - lib/libtmux/mcp/application.rb
@@ -116,6 +118,7 @@ licenses:
116
118
  - MIT
117
119
  metadata:
118
120
  source_code_uri: https://github.com/libtmux/libtmux-ruby
121
+ documentation_uri: https://libtmux.org/en/ruby/v0.1.0.alpha.3/mcp/
119
122
  rdoc_options: []
120
123
  require_paths:
121
124
  - lib