libtmux-mcp 0.1.0.alpha.2 → 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: 67e80d372c18bcd568c338b521a4511cd8ef7ab96e4d0cfcc7ddca3433a2cad5
4
- data.tar.gz: c2a828bb53e812bf8c2de9ee3ddba7a09eb6064d31d075633bb57248a494bd9b
3
+ metadata.gz: af3b345cc9efd2ed4fcb1c384d8ee9a0e8f14b0da9052eedafb5e88b285a2501
4
+ data.tar.gz: f6c4f2647b58a02730b17ab57190f7cfc5ef2927c923961608d2a192d9f1b077
5
5
  SHA512:
6
- metadata.gz: fe3c16a4ad48bb285b4081e4be8221b1f254ed7dfb77708d90c00bbdce2551ecf2ee6ce4f0e833f9ee0dac84e3042e9bc0ac472098917d8e8b42abe986deba2a
7
- data.tar.gz: 3274acee7920109154be895df17286b50fc7f34fc5939f85a20891c63253e266c2a841b5239fe4b8466aa48c49cf2e2fbed0be649e5fe9066691362aa99e7ef9
6
+ metadata.gz: 62dddd73fc6080af123ef9b4b5aeb5dca20c72d8798232d69df61777f3e1aaa9b44db30d1853949e2e5d266d063b6827ed2b3f4feb4950072418d2283739bba2
7
+ data.tar.gz: 37bffa621af62f041a08dffa029d2f060874686983982e70a1906343fd2755bfc6b81daefeb1e2914f99a53a38ba7d88c1b90edb1982f3f48349396bdb1a91e9
data/README.md CHANGED
@@ -12,27 +12,101 @@
12
12
  # libtmux-mcp
13
13
 
14
14
  Expose an existing tmux server over MCP stdio. Read snapshots, capture pane
15
- output, wait for events, or explicitly enable creation, input and shell commands.
16
- 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)
17
22
 
18
23
  </div>
19
24
 
20
- Requires tmux 3.3a or newer. The server refuses an older daemon when it
21
- starts, before serving any tool, and prints the reason and the remedy on
22
- stderr. If the daemon changes while the server runs, a tool call returns an
23
- `unsupported` result with `delivery: not_sent` and a fixed line that names the
24
- floor and says to upgrade tmux and restart the server.
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)
25
40
 
26
- 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:
27
45
 
28
46
  ```console
29
47
  $ gem install libtmux-mcp --pre
30
48
  ```
31
49
 
32
- ## 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
33
107
 
34
108
  Set `TMUX_SOCKET` to an existing tmux socket. This command borrows that daemon
35
- and serves MCP on stdin/stdout:
109
+ and serves MCP on stdin and stdout:
36
110
 
37
111
  ```console
38
112
  $ libtmux-mcp \
@@ -40,18 +114,49 @@ $ libtmux-mcp \
40
114
  --endpoint local
41
115
  ```
42
116
 
43
- Use `--socket-name NAME` instead of `--socket PATH` to select a named socket.
44
- `--endpoint` sets the public alias used in discovery and resource URIs; it does
45
- 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.
46
133
 
47
134
  | Tools | Default | Purpose |
48
135
  | --- | --- | --- |
49
- | `tmux_capabilities`, `tmux_snapshot` | Enabled | Discover capabilities and query captured metadata |
50
- | `tmux_capture`, `tmux_wait` | Disabled | Capture a screen or wait for text/process exit |
51
- | `tmux_create`, `tmux_send`, `tmux_close` | Disabled | Create entities, send text/keys and tear down exact targets |
52
- | `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 |
140
+
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.
53
156
 
54
- Repeat `--enable-tool` for each additional tool. For screen capture and waits:
157
+ ### [`tmux_capture`][tool-tmux_capture]: a pane's screen
158
+
159
+ Enable the screen tools together:
55
160
 
56
161
  ```console
57
162
  $ libtmux-mcp \
@@ -60,100 +165,223 @@ $ libtmux-mcp \
60
165
  --enable-tool tmux_wait
61
166
  ```
62
167
 
63
- Disabled tools are absent from discovery and denied on direct application calls.
64
- The [complete protocol recipe](../../examples/mcp_protocol.rb) exercises
65
- discovery, snapshots, default denial, enabled mutations, cancellation and EOF
66
- 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 |
67
363
 
68
- ## Snapshots, capture and waits
69
-
70
- Snapshot pages retain one immutable capture and query. Cursor expiry or eviction
71
- returns an error; it never substitutes a new live listing. Defaults retain up to
72
- 16 captures and 8 MiB for 30 seconds, with a five-second acquisition deadline and
73
- one-MiB structured response limit. A cursor pages captured metadata; it does not
74
- claim that an old pane process still exists. Schema validation supplements the
75
- core decoder's stricter byte, depth, node and duplicate-key rules.
76
-
77
- Opt into `tmux_capture` for a bounded screen snapshot. Results retain line
78
- endings, encode invalid UTF-8 as base64, and distinguish truncated screen
79
- content from unknown history continuity. Tracking produces a retained cursor;
80
- subsequent calls return a splice against that exact captured state. A screen
81
- delta does not establish that every intervening output byte was observed.
82
-
83
- Capture refuses nonempty effective `after-capture-pane` hooks, including
84
- inherited sparse entries. On tmux 3.3a–3.4, callers must keep capture-hook
85
- configuration stable throughout observation: those versions require a separate
86
- hook preflight. tmux 3.5+ checks the hook in the capture command queue. Both paths
87
- retain an explicit session/pane context and refuse its removal instead of
88
- switching to another session's hooks. Process tracking retains its native
89
- identity checks on every version that supports it.
90
-
91
- `tmux_wait` observes screen text or process exit through events. Canceling it
92
- retires its observation resources without signaling the pane program. Strong
93
- process tracking requires tmux 3.3a or later and a native identity backend:
94
- Linux peer pidfds with matching process namespaces, or Darwin kqueue process
95
- observation. Acquisition verifies the live daemon and pane before retaining
96
- a cursor; unavailable evidence produces an explicit refusal. The
97
- [compatibility workflow](https://github.com/libtmux/libtmux-ruby/actions/workflows/compatibility.yml)
98
- records each exact platform/version result: positive identity cases on
99
- supported releases, and refusal jobs for tmux 3.0a, 3.1c, 3.2a and unpatched
100
- 3.3.
101
-
102
- Resource templates expose metadata pages and pane screens under encoded
103
- endpoint/generation URIs. They enforce the same policy and response limits as
104
- their tools. Metadata pages preserve capture identity; screen resources
105
- include interval, truncation and history-continuity metadata. Resource
106
- subscriptions are not advertised.
107
-
108
- ## Create, send and close
109
-
110
- Add `--enable-tool tmux_create`, `--enable-tool tmux_send` or
111
- `--enable-tool tmux_close` to authorize those tools. Creation accepts argument
112
- arrays; sending text and sending named keys are separate variants. Mutation
113
- results contain delivery evidence and positively returned references.
114
- `dispatch_only` input results do not claim program completion, and unknown
115
- effects remain unknown after cancellation.
116
-
117
- ## Run authored commands
118
-
119
- `tmux_run` requires separate policy and shell enrollment. Add
120
- `--enable-tool tmux_run --enroll-pane %ID=FILE` for each exact pane, then
121
- explicitly source the generated file in that pane's interactive zsh 5.9.
122
- The CLI creates a private setup file and never types into the terminal.
123
- It refuses existing files and symlinks. Invitations expire after 60 seconds;
124
- `--enrollment-timeout` accepts at most 300 seconds. At most eight panes may be
125
- enrolled. EOF retires pending enrollment and removes only files the CLI owns.
126
-
127
- The tool accepts an exact pane target, a POSIX `script`, and separate
128
- `stdout_limit`/`stderr_limit` byte counts. Scripts may contain at most 65,536
129
- bytes. Each output defaults to 65,536 bytes and is capped at 262,144; the
130
- application reserves its worst-case serialized response before authorization.
131
- The helper inherits the enrolled shell's cwd and exported environment, uses
132
- closed stdin, and reports separate UTF-8 or base64 outputs. Nonzero exit and
133
- signal termination are completion results. Output overflow is an error with
134
- completion unobserved, not silently truncated success. Shell variables,
135
- functions, options and cwd changes do not persist in the interactive parent.
136
-
137
- An idle, empty primary ZLE editor receives the request through a private socket.
138
- A guarded tmux queue operation authorizes one script digest for one retained
139
- server, pane process and enrollment generation. Execution may follow that
140
- authorization; a later respawn does not redirect the prepared helper to its
141
- replacement. Error responses retain known authorization and native completion
142
- receipts. Cancellation does not prove that arbitrary descendants stopped.
143
- The Linux and macOS compatibility jobs exercise enrollment and the installed
144
- helper dependency closure; consult their results for the revision being used.
145
-
146
- ## Embed in Ruby
147
-
148
- Require `libtmux/mcp`; imports start no tmux process, scheduler or MCP server.
149
- `Application` borrows an application-owned `LibTmux::Async::Server`. Its
150
- `sdk_server` supplies the SDK server consumed by `StdioTransport`. Both objects
151
- stay on that application's reactor thread. See the
152
- [execution guide](../../docs/modes.md) for result and ownership boundaries.
153
-
154
- For shell enrollment, call `Application#invite_shell(reference, timeout:,
155
- expires_in:)` and pass the returned invitation to `accept_shell`. The invitation
156
- exposes an immutable `shell_arguments` array for an explicitly sourced setup
157
- command and a monotonic `expires_at`. Its acquisition deadline is separate from
158
- its enrollment lifetime. The application owns invitations and accepted
159
- 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/
@@ -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"
@@ -177,10 +177,13 @@ module LibTmux
177
177
  end
178
178
  message = if delivery == :not_sent && LibTmux::Internal::TmuxVersion.refusal?(error)
179
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."
180
182
  else
181
183
  "The operation could not establish its requested result."
182
184
  end
183
- failure_response(code, message, delivery.to_s, effects: effects)
185
+ failure_response(code, message, delivery.to_s, effects: effects,
186
+ capture: (error.capture if error.is_a?(Observation::PaneDied)))
184
187
  end
185
188
 
186
189
  def inspect
@@ -509,9 +512,10 @@ module LibTmux
509
512
  value.freeze
510
513
  end
511
514
 
512
- def failure_response(code, message, delivery = "not_sent", effects: nil)
515
+ def failure_response(code, message, delivery = "not_sent", effects: nil, capture: nil)
513
516
  details = {"code" => code, "message" => message, "delivery" => delivery}
514
517
  details["effects"] = effects if effects
518
+ details["capture"] = capture if capture
515
519
  ::MCP::Tool::Response.new([{type: "text", text: message}], error: true,
516
520
  structured_content: {"ok" => false, "error" => details})
517
521
  end
@@ -186,7 +186,8 @@ module LibTmux
186
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
@@ -239,7 +240,7 @@ module LibTmux
239
240
  elsif name == "tmux_capture"
240
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.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
+ "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"
@@ -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
@@ -54,7 +65,8 @@ module LibTmux
54
65
  {"screen" => "bounded_rows", "history_continuity" => "unknown",
55
66
  "process_cursor" => conditional ? "conditional" : "unsupported",
56
67
  "requirements" => ["tmux >= #{LibTmux::Internal::TmuxVersion::MINIMUM}", *(native || ["supported 64-bit Linux or Darwin process identity backend"]),
57
- "live pane process", "empty effective capture hook", "stable capture-hook configuration on tmux < 3.5"],
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"],
58
70
  "wait_conditions" => %w[screen_contains process_exit]}
59
71
  end
60
72
 
@@ -162,7 +174,7 @@ module LibTmux
162
174
  end
163
175
 
164
176
  def wait
165
- identity = control = subscription = nil
177
+ identity = control = subscription = latest = nil
166
178
  tasks = []
167
179
  failure = response = nil
168
180
  begin
@@ -171,8 +183,10 @@ module LibTmux
171
183
  condition = @arguments.fetch("condition")
172
184
  snapshot, pane = resolve(reference)
173
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?
174
188
  identity = acquire_identity(snapshot, pane)
175
- if condition.fetch("type") == "screen_contains"
189
+ if condition.fetch("type") == "screen_contains" && !polling
176
190
  link = snapshot.window_links.find { |item| item.window_id == pane.window_id }
177
191
  raise TargetNotFoundError.new("pane has no observable session", phase: :admission) unless link
178
192
 
@@ -184,18 +198,38 @@ module LibTmux
184
198
  control.exchange(line, **@budget.options)
185
199
  end
186
200
  limits = defaults
187
- 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
188
220
  if condition.fetch("type") == "screen_contains" && contains?(rows, encoding, condition.fetch("text"))
189
221
  next {"target" => reference, "condition" => "screen_contains",
190
222
  "capture" => state(reference, rows, encoding, truncated, limits, identity, SecureRandom.hex(16))}
191
223
  end
192
224
  changed = ::Async::Notification.new
193
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) }
194
228
  wake = lambda do |value|
195
- 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)
196
230
  changed.signal
197
231
  end
198
- [[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|
199
233
  task = ::Async::Task.new(::Async::Task.current) do
200
234
  Fiber.scheduler.io_wait(io, IO::READABLE)
201
235
  wake.call(event)
@@ -207,10 +241,30 @@ module LibTmux
207
241
  tasks << task
208
242
  task.run
209
243
  end
210
- if subscription
244
+ if polling
211
245
  task = ::Async::Task.new(::Async::Task.current) do
212
246
  loop do
213
- 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
214
268
  if event.kind == :gap
215
269
  wake.call(LostObservation.new("screen observation has a gap", phase: :observation, delivery: :observed))
216
270
  break
@@ -219,8 +273,6 @@ module LibTmux
219
273
  end
220
274
  rescue ::Async::Cancel
221
275
  nil
222
- rescue StopIteration
223
- wake.call(LostObservation.new("screen observation ended", phase: :observation, delivery: :observed))
224
276
  rescue StandardError => error
225
277
  wake.call(error)
226
278
  end
@@ -233,15 +285,32 @@ module LibTmux
233
285
  event, pending = pending, nil
234
286
  raise event if event.is_a?(Exception)
235
287
 
236
- if event == :exited
237
- identity.peer_alive!
238
- unless condition.fetch("type") == "process_exit"
239
- 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))}
240
310
  end
241
- break {"target" => reference, "condition" => "process_exit", "process_generation" => identity.generation,
242
- "observed_at" => clock, "exit_status" => "unobserved"}
311
+ raise pane_died.call
243
312
  end
244
- rows, encoding, truncated = read_rows(snapshot, pane, limits, identity)
313
+ rows, encoding, truncated = read.call
245
314
  if contains?(rows, encoding, condition.fetch("text"))
246
315
  break {"target" => reference, "condition" => "screen_contains",
247
316
  "capture" => state(reference, rows, encoding, truncated, limits, identity, SecureRandom.hex(16))}
@@ -271,6 +340,40 @@ module LibTmux
271
340
 
272
341
  private
273
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
+
274
377
  def acquire_identity(snapshot, pane)
275
378
  # tmux 3.8 reports no PID for a dead pane; there is no process to bind.
276
379
  raise TargetNotFoundError.new("pane process has exited", phase: :admission) if pane.pid.nil?
@@ -306,7 +409,9 @@ module LibTmux
306
409
  phase: :admission)
307
410
  end
308
411
 
309
- 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)
310
415
  identity&.ensure_live!
311
416
  names = spellings
312
417
  link = snapshot.window_links.find { |item| item.window_id == pane.window_id }
@@ -315,7 +420,7 @@ module LibTmux
315
420
  target = "#{link.session_id}:.#{pane.id}"
316
421
  guard = "\#{&&:\#{==:\#{session_id},#{link.session_id}},\#{==:\#{pane_id},#{pane.id}}}"
317
422
  guard = "\#{&&:#{guard},\#{&&:\#{==:\#{pane_pid},#{identity.pid}},\#{&&:\#{==:\#{pane_dead_status},},\#{==:\#{pane_dead_signal},}}}}" if identity
318
- 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,
319
424
  "-S", (-limits.fetch("history_lines")).to_s, "-E", "-"])
320
425
  # The selected false branch fails parsing, independently of screen bytes.
321
426
  failure = @server.__send__(:tmux_command, [names.fetch("capture-pane"), "-t"])
@@ -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.2"
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.2
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.2
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.2
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.2
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.2
39
+ version: 0.1.0.alpha.3
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: mcp
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -118,7 +118,7 @@ licenses:
118
118
  - MIT
119
119
  metadata:
120
120
  source_code_uri: https://github.com/libtmux/libtmux-ruby
121
- documentation_uri: https://libtmux.org/en/ruby/v0.1.0.alpha.2/mcp/
121
+ documentation_uri: https://libtmux.org/en/ruby/v0.1.0.alpha.3/mcp/
122
122
  rdoc_options: []
123
123
  require_paths:
124
124
  - lib