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 +4 -4
- data/README.md +356 -108
- data/assets/logo.png +0 -0
- data/assets/logo.svg +2 -0
- data/lib/libtmux/mcp/application.rb +12 -3
- data/lib/libtmux/mcp/catalog.rb +5 -4
- data/lib/libtmux/mcp/cli.rb +5 -1
- data/lib/libtmux/mcp/observation.rb +148 -29
- data/lib/libtmux/mcp/process_identity.rb +3 -3
- data/lib/libtmux/mcp/version.rb +1 -1
- metadata +8 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af3b345cc9efd2ed4fcb1c384d8ee9a0e8f14b0da9052eedafb5e88b285a2501
|
|
4
|
+
data.tar.gz: f6c4f2647b58a02730b17ab57190f7cfc5ef2927c923961608d2a192d9f1b077
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
31
|
-
| `tmux_capture
|
|
32
|
-
| `tmux_create
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
data/lib/libtmux/mcp/catalog.rb
CHANGED
|
@@ -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}
|
|
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.
|
|
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.
|
|
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"
|
data/lib/libtmux/mcp/cli.rb
CHANGED
|
@@ -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
|
-
|
|
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 &&
|
|
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 >=
|
|
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
|
-
|
|
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? ||
|
|
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,
|
|
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
|
|
244
|
+
if polling
|
|
199
245
|
task = ::Async::Task.new(::Async::Task.current) do
|
|
200
246
|
loop do
|
|
201
|
-
|
|
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 == :
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
"observed_at" => clock, "exit_status" => "unobserved"}
|
|
311
|
+
raise pane_died.call
|
|
231
312
|
end
|
|
232
|
-
rows, encoding, truncated =
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
unless
|
|
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
|
data/lib/libtmux/mcp/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|