Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
c38f527
Libtmux(feat[runtime]): Add typed tmux operations
tony Sep 5, 2026
8934111
Mcp(feat[capabilities]): Freeze the tool surface
tony Sep 5, 2026
567dd73
Mcp(fix[batch]): Bound complete wire response
tony Sep 5, 2026
746fb9a
Mcp(fix[effects]): Classify cursor reads
tony Sep 5, 2026
9e9f851
Mcp(fix[protocol]): Reject oversized request IDs
tony Sep 5, 2026
75f1ec2
Docs(docs[mcp]): Preserve capability workflows
tony Sep 5, 2026
fe2e9e2
Docs(docs[changelog]): Record MCP capability sync
tony Sep 5, 2026
f74bf40
Mcp(fix[surface]): Exclude copy-mode tools
tony Sep 5, 2026
145f7d2
Docs(docs[changelog]): Record MCP mode boundary
tony Sep 5, 2026
3fe7b52
Mcp(fix[command]): Isolate command framing
tony Sep 5, 2026
740aa0a
Mcp(fix[input]): Guard effective pane cohorts
tony Sep 5, 2026
005cfe8
Mcp(docs[input]): Define guarded input contract
tony Sep 5, 2026
77fd334
Docs(docs[changelog]): Record MCP input fixes
tony Sep 5, 2026
6645673
Build(fix[test]): Shorten tmux quarantine
tony Sep 5, 2026
c376196
Build(fix[test]): Preserve stale quarantines
tony Sep 5, 2026
477a509
Build(fix[test]): Reclaim named sockets
tony Sep 5, 2026
ff64d54
Style(chore[format]): Apply Java formatter
tony Sep 5, 2026
a62c993
Junit5(fix[named]): Reclaim named sockets
tony Sep 5, 2026
8e7b9cc
Pane(fix[input]): Bound send-keys options
tony Sep 5, 2026
16bc3a5
Pane(fix[breakOut]): Preserve hash names
tony Sep 5, 2026
fd3f401
Mcp(fix[capability]): Declare output risks
tony Sep 5, 2026
7d27c4c
Docs(style[mcp]): Wrap guide prose
tony Sep 5, 2026
37282b9
Docs(docs[changelog]): Record review fixes
tony Sep 5, 2026
679a76c
Transport(test[lifecycle]): Arm TERM fixture
tony Sep 5, 2026
00122b9
Junit5(fix[fixture]): Guard server teardown
tony Sep 5, 2026
404d443
Junit5(feat[fixture]): Own exact socket paths
tony Sep 5, 2026
41abac3
Docs(style[mcp]): Wrap guide prose
tony Sep 5, 2026
fdebb44
Junit5(fix[fixture]): Fence server teardown
tony Sep 5, 2026
4a59399
Scripts(feat[mcp-swap]): Add remaining clients
tony Sep 5, 2026
bd88007
Docs(docs[changelog]): Cover MCP swap clients
tony Sep 5, 2026
b87d4b5
Mcp(fix[input]): Guard caller and attended panes
tony Sep 5, 2026
cbce0e5
Mcp(test[paste]): Exercise disconnect hook
tony Sep 5, 2026
3ae1c2d
Mcp(fix[route]): Reject ASCII controls
tony Sep 5, 2026
5c27a71
Mcp(fix[swap]): Make swaps transactional
tony Sep 5, 2026
bf5f61a
Docs(docs[changelog]): Record swap transactions
tony Sep 5, 2026
55adc31
Scripts(fix[mcp-swap]): Own recovery state
tony Sep 5, 2026
03f2931
Docs(docs[scripts]): Explain recovery ownership
tony Sep 5, 2026
eb2687a
Docs(docs[changelog]): Record swap recovery state
tony Sep 5, 2026
c06c89d
Scripts(fix[mcp-swap]): Preserve state identity
tony Sep 6, 2026
d1f7c8e
Scripts(fix[mcp-swap]): Retain prior state
tony Sep 6, 2026
49d1c3f
Mcp(fix[paste]): Recheck before dispatch
tony Sep 6, 2026
2fd3913
Scripts(fix[mcp-swap]): Fail on cleanup residue
tony Sep 6, 2026
07e1209
Scripts(fix[mcp-swap]): Bind transaction paths
tony Sep 6, 2026
29a17f1
Mcp(fix[input]): Authenticate pane authority
tony Sep 6, 2026
98a00cb
Mcp(fix[teardown]): Authenticate self override
tony Sep 6, 2026
4b7262c
Mcp(fix[input]): Authenticate client placement
tony Sep 6, 2026
79e9459
Mcp(fix[input]): Reserve transient dispatch
tony Sep 6, 2026
5083e65
Mcp(fix[run]): Retain uncertain ownership
tony Sep 6, 2026
25f892c
Mcp(fix[input]): Bind linked placements
tony Sep 6, 2026
eeaf447
Build(feat[mcp-swap]): Add native client table
tony Sep 6, 2026
cc8c1e9
Build(feat[mcp-swap]): Preserve client configs
tony Sep 6, 2026
06a17a5
Build(feat[mcp-swap]): Add exact transactions
tony Sep 6, 2026
43cf514
Build(fix[mcp-swap]): Bind recovery identity
tony Sep 6, 2026
9d0ed8d
Build(feat[mcp-swap]): Add native command line
tony Sep 6, 2026
3b6c775
Build(fix[mcp-swap]): Keep capability environment
tony Sep 6, 2026
399c9d5
Build(fix[mcp-swap]): Tighten preflight
tony Sep 6, 2026
76d9878
Build(fix[mcp-swap]): Accept omitted arguments
tony Sep 6, 2026
70804ec
Mcp(fix[mcp-swap]): Bind directory identity
tony Sep 6, 2026
d024042
Mcp(fix[mcp-swap]): Reject malformed UTF-8
tony Sep 6, 2026
8167d54
Tools(feat[swap]): Complete native switcher
tony Sep 6, 2026
75f4cba
Tools(refactor[swap]): Retire Python switcher
tony Sep 6, 2026
9cb9c06
Mcp(fix[transport]): Drain sends without the lock
tony Sep 6, 2026
c7a0876
Docs(docs[changelog]): Name the native switcher
tony Sep 6, 2026
f492e20
Build(fix[test]): Quarantine under the chosen root
tony Sep 6, 2026
3dff75a
Examples(feat[mcp]): Show the served tool surface
tony Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions .github/WRITING.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,14 @@ An entry opens with a bold clause naming what changed, then gives the prose that
makes it decidable:

```markdown
- **`tmux_whoami` failed on a socket with no server behind it.** It is the tool
the instructions tell a model to call first, and it asked tmux for its
version, which needs a running server. It now says there is no server and
points at `tmux_list_servers`.
- **`capture_since` now reports an observe-only tmux effect.** Reading from a
cursor leaves tmux state unchanged, so the capability metadata no longer
overstates what the call changes.
```

Name identifiers literally: `Pane.capture`, `LIBTMUX_WATCH`, `--rerun-tasks`,
`tmux://panes/{pane}`. Lead with a concrete verbadd, fix, remove, reject,
`now`, `no longer`.
Name identifiers literally: `Pane.capture`, `LIBTMUX_TOOLSETS`,
`--rerun-tasks`, `tmux://capabilities`. Lead with a concrete verb: add, fix,
remove, reject, `now`, or `no longer`.

State a changed default explicitly, and an incompatibility more explicitly
still, with the way forward in the same entry.
Expand Down Expand Up @@ -354,8 +353,8 @@ has nothing but the string:
"JDK 21" both appear upstream — this project writes **JDK 21**.
- A **pane**, **window**, **session**, and **server** are what tmux calls them.
Do not introduce a synonym for one.
- Write the identifier, not a description of it: `LIBTMUX_WATCH=true`, not
"the watch environment variable"; `--rerun-tasks`, not "the rerun flag";
- Write the identifier, not a description of it: `LIBTMUX_TOOLSETS=inspect`,
not "the toolset environment variable"; `--rerun-tasks`, not "the rerun flag";
`/tmp/libtmux-java-test/`, not "the test socket directory".

Treat AI slop as review-hostile noise. The goal is information density:
Expand Down
67 changes: 67 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ production.

### Added

- **`tools/mcp-swap` configures all eight supported agent clients.** It replaces
the retired `scripts/mcp_swap.py`, builds through
`./gradlew :tools:mcp-swap:installDist`, and decodes JSON, JSONC, and TOML
with strict UTF-8. OpenCode edits preserve JSONC comments and trailing commas,
Pi reports its adapter prerequisite, and `antigravity` selects canonical
`agy`. Multi-client use and revert preflight and stage one transaction,
preserve config symlinks, reverse proven writes on failure, and keep
`--dry-run` fully observational. Persistent, versioned recovery records bind
each backup to the exact swapped config, path topology, and server route;
drift fails closed without deleting recovery.
- **`NamedServerFixture` safely owns explicitly named test servers.** It binds
teardown to the reported process, socket path, and inode, then fails closed if
any of that identity changes before cleanup.
- **`Pane.findWindow` searches by name, title, or content, with
case-insensitive and regular-expression matching.** Build a `FindSpec` or
configure one inline. (#6)
Expand All @@ -26,6 +39,32 @@ production.

### Changed

- **`libtmux-mcp` now exposes a fixed 45-tool capability surface.** One native
registry drives tool registration, schemas, trust metadata, selection, and
the static `tmux://capabilities` resource. Unordered toolsets and named
include/exclude lists replace safety tiers; the retired `LIBTMUX_SAFETY`
variable and `--safety` option now fail with migration guidance. The retired
`LIBTMUX_WATCH` variable and `--watch` option also fail; use bounded wait and
capture tools instead of dynamic resource notifications. The server defaults
to the dedicated `libtmux-mcp` socket, supports separate socket-name and
absolute socket-path selectors, and enables teardown by default only for a
newly created minimal daemon.
- **Copy-mode entry and exit remain library-only.** The MCP surface reads pane
text through `capture_pane` history, `snapshot_pane`, `search_panes`, or
`capture_since` without taking ownership of an attached client's modal
interface. Java callers retain `Pane.copyMode` and `Pane.exitMode`.
- **The MCP guide maps every earlier public tool, resource URI, prompt workflow,
and completion path.** Each retired name now points to its current typed
route, composed workflow, or explicit no-replacement boundary.
- **MCP searches and read batches now have fixed work ceilings.** Search stops
after 200 panes, 20,000 lines, 1,000,000 bytes of matching input, or five
seconds. Read batches validate each nested call and cap the complete JSON-RPC
response, including line framing, at 1,000,000 bytes without dropping an
executed row. Request IDs over 512 KiB now fail before dispatch rather than
consuming that response budget.
- **`capture_since` and `call_read_tools_batch` now advertise observe-only tmux
effects.** Cursor capture and every batch-eligible inspect operation leave
tmux state unchanged.
- **`Pane.findWindowByName` and `Pane.findWindowByContent` are removed.** Use
`findWindow` with `inName()` or `inContent()`. (#6)
- **`Server.waitFor`, `waitForWithSignalCapacity`, `signal`, and `drain` move
Expand Down Expand Up @@ -60,6 +99,31 @@ production.

### Fixed

- **`Pane.sendKeys` preserves option-shaped input.** It ends tmux option parsing
before caller keys, so values such as `-X`, `-R`, and `-N` reach pane programs
through the core API and MCP single or batch routes.
- **`Pane.breakOut` preserves literal `#` in requested window names.** The
`break-pane -n` path receives the raw name; only the tmux 3.7 rename fallback
applies tmux format literalization.
- **MCP capability rows disclose both output risk dimensions.** Pane text,
environment and configured-command values, and names, titles, paths, or
current commands now advertise both secret and untrusted-content risk;
strictly structural results remain false for both.
- **MCP pane input now refuses effective recipients in a human-owned mode.**
`send_keys` and each `send_keys_batch` operation resolve pane-level
`synchronize-panes` overrides before dispatch; `paste_text` remains
target-only, dead configured recipients fail closed, and
`run_shell_command` checks a singular cohort before setup and again before
input because its completion, output, and status are singular.
- **`run_shell_command` no longer relies on mutable pane-shell framing state.**
Completion markers and signalling run in an isolated outer subshell through
an absolute selected tmux executable and the server's resolved `-S` socket,
so ordinary output-command aliases/functions, pane `PATH`/socket variables,
inherited `errexit`, and a readonly nonce name cannot lose completion or
close the pane; the frame also leaves no status variable behind. This assumes
the parent shell has not replaced the exact client word or
`trap`/`eval`/`exit` with functions, and marker commands honor trusted server
hooks.
- **Destructive MCP tools fail closed when caller-pane identity cannot be
proven.** Uncertain socket, server, or pane identity now requires explicit
self-confirmation instead of bypassing the guard. (#6)
Expand Down Expand Up @@ -128,6 +192,9 @@ production.

### Removed

- **MCP prompts, completions, watches, dynamic resources, per-call server
discovery, and workspace tools are removed.** Use the fixed tool surface and
its static `tmux://capabilities` resource.
- **`ExecutionMode`, `ControlTransport`, `VirtualThreadTransport`,
`LIBTMUX_MODE`, and their benchmark surface are removed.** `Server` uses
process execution; use `ControlClient` for event streams and batches or
Expand Down
38 changes: 35 additions & 3 deletions build-logic/src/main/kotlin/libtmux.java-library.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
// Shared Java conventions. A module script then declares only what makes it different.
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.Path
import java.security.MessageDigest
import java.util.HexFormat
import net.ltgt.gradle.errorprone.errorprone

plugins {
Expand Down Expand Up @@ -94,8 +100,7 @@ tasks.withType<Test>().configureEach {
// a real server and can kill it. Two environment values decide where a bare client lands: tmux
// resolves its default socket under TMUX_TMPDIR when it execs, and $TMUX takes precedence over
// that for a client started inside a pane — which the Gradle daemon may well have been.
val tmuxTmpDir = layout.buildDirectory.dir("tmux-tmpdir").get().asFile
environment("TMUX_TMPDIR", tmuxTmpDir.absolutePath)
// TMUX_TMPDIR is set per invocation in doFirst below, under the same root as the named sockets.
environment.remove("TMUX")
environment.remove("TMUX_PANE")

Expand All @@ -113,7 +118,34 @@ tasks.withType<Test>().configureEach {
require(socketRoot.length <= 40) {
"libtmuxSocketRoot is $socketRoot, too long to leave room for a socket under it"
}
tmuxTmpDir.mkdirs()
// The quarantine shares the configured root, so overriding libtmuxSocketRoot moves the
// bare-client sockets along with the named ones instead of splitting them across two roots.
// Owner identity separates concurrent invocations; 16 hex digits leave AF_UNIX room.
val quarantineIdentity = listOf(
rootProject.rootDir.canonicalPath,
path,
ProcessHandle.current().pid().toString(),
).joinToString("\u0000")
val quarantineDigest = MessageDigest.getInstance("SHA-256")
.digest(quarantineIdentity.toByteArray(StandardCharsets.UTF_8))
val quarantineName = HexFormat.of().formatHex(quarantineDigest, 0, 8)
val tmuxTmpDir = Path.of(socketRoot, quarantineName)

if (Files.exists(tmuxTmpDir, LinkOption.NOFOLLOW_LINKS)) {
require(Files.isDirectory(tmuxTmpDir, LinkOption.NOFOLLOW_LINKS)) {
"tmux quarantine is not a directory: $tmuxTmpDir"
}
val entries = Files.walk(tmuxTmpDir).use { paths -> paths.toList() }
val stale = entries.firstOrNull {
it != tmuxTmpDir && !Files.isDirectory(it, LinkOption.NOFOLLOW_LINKS)
}
require(stale == null) {
"tmux quarantine contains a stale entry: $stale"
}
entries.asReversed().filter { it != tmuxTmpDir }.forEach(Files::delete)
}
Files.createDirectories(tmuxTmpDir)
environment("TMUX_TMPDIR", tmuxTmpDir.toString())
File(socketRoot).mkdirs()
}

Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,6 @@ val kotlinStaysDownstream =
tasks.register("check") {
group = "verification"
description = "Every gate that must hold before publication."
dependsOn(subprojects.map { "${it.path}:check" })
dependsOn(subprojects.filter { it.buildFile.exists() }.map { "${it.path}:check" })
dependsOn(platformCoversEveryPublishedModule, kotlinStaysDownstream)
}
11 changes: 6 additions & 5 deletions docs/guide/filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,19 @@ Java names. The `matches` operand is the exception: its syntax and numeric flags
are those of `java.util.regex.Pattern`. A non-Java consumer must reproduce those
semantics or reject that operator.

`libtmux-mcp` is the worked example. Its `tmux_list_panes` tool takes an optional
`filter`, which is one of these documents:
An application that stores a pane predicate is the worked example. The wire
form is one of these documents:

```json
{"schema": "libtmux.filter/1", "model": "pane",
"expr": {"node": "compare", "field": "pane_current_command",
"op": "starts_with", "value": "nvim"}}
```

A model cannot write Java, so this is the only way it can say what it wants
narrowed. What it gets back costs the same one capture the unfiltered listing
would have, because the filter runs over what that capture returned.
Java applications can read that document with the matching `FilterModel` and
apply it to a captured hierarchy. `libtmux-mcp` deliberately does not accept
this open expression format: `list_panes` returns bounded typed metadata for a
client to filter, while `search_panes` searches only rendered terminal text.

## Filters that arrive as strings

Expand Down
Loading
Loading