From 1e86b42dab46c15e1a8abaac1e86e5b0d855aade Mon Sep 17 00:00:00 2001 From: local Date: Tue, 2 Jun 2026 04:59:56 +0800 Subject: [PATCH 1/5] feat: add native tmux tools Co-authored-by: multica-agent --- README.md | 10 + .../.openspec.yaml | 2 + .../design.md | 58 + .../proposal.md | 44 + .../specs/tmux-tools/spec.md | 113 ++ .../2026-06-01-add-native-tmux-tools/tasks.md | 7 + openspec/specs/tmux-tools/spec.md | 116 ++ spec/ra.toml.example | 7 +- spec/tools.md | 141 ++ src/init.rs | 3 +- src/lib.rs | 3 +- src/session_runner.rs | 48 +- src/tools/mod.rs | 44 + src/tools/tmux.rs | 1364 +++++++++++++++++ tests/tmux_tools.rs | 347 +++++ 15 files changed, 2300 insertions(+), 7 deletions(-) create mode 100644 openspec/changes/archive/2026-06-01-add-native-tmux-tools/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-01-add-native-tmux-tools/design.md create mode 100644 openspec/changes/archive/2026-06-01-add-native-tmux-tools/proposal.md create mode 100644 openspec/changes/archive/2026-06-01-add-native-tmux-tools/specs/tmux-tools/spec.md create mode 100644 openspec/changes/archive/2026-06-01-add-native-tmux-tools/tasks.md create mode 100644 openspec/specs/tmux-tools/spec.md create mode 100644 src/tools/tmux.rs create mode 100644 tests/tmux_tools.rs diff --git a/README.md b/README.md index 2c38f0a..d176658 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,11 @@ Resolution order: `--config ` → `$RA_CONFIG` → `./ra.toml` → | `webfetch_fetch` | Fetches one web page as Markdown through `webfetch-cli`, writes `.md/`, and returns bounded JSON. | | `webfetch_crawl` | Crawls a bounded documentation subtree through `webfetch-cli`, mirrors `.md/`, and returns bounded JSON. | | `openspec` | Drives the agent-own OpenSpec SDD loop through the `openspec` CLI as structured actions (`status`, `list`, `show`, `instructions`, `validate`, `init`, `update`, `new_change`, `archive`, `workflow_state`); non-interactive, with bounded JSON output. | +| `tmux_run` | Starts or reuses a Ra-owned tmux session/window and runs a command, blocking or non-blocking. | +| `tmux_send` | Sends literal input or tmux key names to a target pane. | +| `tmux_capture` | Captures visible pane content or scrollback from a target pane. | +| `tmux_kill` | Kills a Ra-owned tmux session/window/pane, or all `ra__*` sessions. | +| `tmux_listen` | Polls a pane until output changes or an optional substring/regex appears. | | `graphify_ensure` / `graphify_impact` / `graphify_update` / `graphify_query` / `graphify_path` / `graphify_explain` | Added when `[graphify]` is enabled; maintains and uses Graphify as Ra's R2A project graph. | Toggle the catalog via `[tools] builtin = […]`; an empty allow-list @@ -247,6 +252,11 @@ interactive prompts), forces `--strict` validation and explicit `error.kind:"missing_openspec"` with install guidance when the CLI is absent. Ra consumes the OpenSpec convention; it does not reimplement the CLI. +`tmux_run`, `tmux_send`, `tmux_capture`, `tmux_kill`, and `tmux_listen` +operate on Ra-owned tmux sessions named `ra__{session}`. If `tmux` is +missing, they return structured install guidance instead of an opaque +spawn error. + ## Protocols & specs Authoritative schemas live in [`spec/`](spec/) — see diff --git a/openspec/changes/archive/2026-06-01-add-native-tmux-tools/.openspec.yaml b/openspec/changes/archive/2026-06-01-add-native-tmux-tools/.openspec.yaml new file mode 100644 index 0000000..a2168c3 --- /dev/null +++ b/openspec/changes/archive/2026-06-01-add-native-tmux-tools/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-01 diff --git a/openspec/changes/archive/2026-06-01-add-native-tmux-tools/design.md b/openspec/changes/archive/2026-06-01-add-native-tmux-tools/design.md new file mode 100644 index 0000000..67f248b --- /dev/null +++ b/openspec/changes/archive/2026-06-01-add-native-tmux-tools/design.md @@ -0,0 +1,58 @@ +# Design + +## Tool Model + +The tmux tools live in `src/tools/tmux.rs` and implement the existing `Tool` +trait. Ra owns typed parameters, session/window target construction, +dependency detection, JSON envelopes, and output bounding. tmux remains the +terminal multiplexer and command runner. + +All spawned `tmux` invocations use `tokio::process::Command` with explicit argv +arrays. The user-provided `tmux_run.command` is intentionally a shell command +executed inside tmux, because tmux panes model interactive shells rather than +direct process argv. + +## Session Namespacing + +Callers pass logical session names such as `dev`. Ra maps those to tmux session +names as `ra__dev`. To keep tmux target strings unambiguous, logical session, +window, and pane identifiers are non-empty and limited to ASCII +letters/digits/`_`/`-`/`.`. This avoids accidental targeting of operator-owned +sessions and keeps `session:window.pane` construction deterministic. + +## Command Execution + +`tmux_run` ensures the namespaced session/window exists before running a +command. + +- `wait=false` creates a detached session/window with the command when the + target does not exist. When the target already exists, it sends the command + plus Enter to the pane, matching terminal interaction semantics. +- `wait=true` respawns the target pane with a temporary shell script that runs + the caller command and signals completion via `tmux wait-for -S `. + Ra waits for the signal with the caller's timeout, captures the pane output, + and returns a bounded JSON envelope. The pane remains available for later + capture. + +This design chooses deterministic blocking behavior for `wait=true` rather +than attempting to infer whether an existing prompt is idle. + +## Capture, Listen, and Kill + +`tmux_capture` maps directly to `tmux capture-pane -p -t ` with +optional `-S`/`-E` line bounds and a Ra-side `max_output_bytes` cap. + +`tmux_listen` is a bounded polling helper. It takes an initial capture and then +polls until the pane output changes or an optional substring/regex pattern is +found. It returns the latest capture, a best-effort delta when available, and a +timeout flag instead of opening a daemonized stream. + +`tmux_kill` only targets Ra-owned namespaced sessions. It can kill a named +session, window, pane, or all `ra__*` sessions. + +## Error Handling + +If `tmux` is missing, every tool returns JSON with `ok:false`, +`error.kind:"missing_tmux"`, and installation guidance. tmux command failures +also return structured JSON with exit status, stdout, and stderr so callers can +distinguish missing targets from process errors without parsing anyhow strings. diff --git a/openspec/changes/archive/2026-06-01-add-native-tmux-tools/proposal.md b/openspec/changes/archive/2026-06-01-add-native-tmux-tools/proposal.md new file mode 100644 index 0000000..bf0b7c7 --- /dev/null +++ b/openspec/changes/archive/2026-06-01-add-native-tmux-tools/proposal.md @@ -0,0 +1,44 @@ +## Why + +Ra agents need a persistent terminal surface for long-running dev servers, +watchers, REPLs, and interactive CLIs. The existing `bash` tool is a +one-shot shell execution path, so agents cannot reliably start a process, +inspect later output, or send input across turns. + +## What Changes + +- Add native tmux-backed tools: + - `tmux_run` starts or reuses a namespaced tmux session/window and runs a + command in blocking or non-blocking mode. + - `tmux_send` sends input or tmux key names to a pane. + - `tmux_capture` captures visible pane content or scrollback ranges. + - `tmux_kill` terminates a Ra-owned session/window/pane. + - `tmux_listen` polls a pane until output changes or an optional pattern is + observed. +- Namespace user session names as `ra__{session}` to avoid collisions with + operator-owned tmux sessions. +- Return structured JSON envelopes for tmux command results and structured + install guidance when `tmux` is not on `PATH`. +- Register the tools in the default built-in catalog while respecting the + `[tools].builtin` allow-list. +- Document the tool schemas in `spec/tools.md`, README, and the sample config. + +## Capabilities + +### New Capabilities +- `tmux-tools`: Native tmux tool support for persistent terminal sessions. + +### Modified Capabilities + +None. + +## Impact + +- Adds `src/tools/tmux.rs` and new exports/registrations in `src/tools/mod.rs` + and `src/lib.rs`. +- Updates tool UI hints in `src/session_runner.rs`. +- Updates public tool documentation in `spec/tools.md`, README, and + `spec/ra.toml.example`. +- Adds unit and integration tests for parameter handling, missing binary + guidance, default catalog registration, argv-safe tmux invocation, and a + tmux round-trip when `tmux` is installed. diff --git a/openspec/changes/archive/2026-06-01-add-native-tmux-tools/specs/tmux-tools/spec.md b/openspec/changes/archive/2026-06-01-add-native-tmux-tools/specs/tmux-tools/spec.md new file mode 100644 index 0000000..ae18eee --- /dev/null +++ b/openspec/changes/archive/2026-06-01-add-native-tmux-tools/specs/tmux-tools/spec.md @@ -0,0 +1,113 @@ +# Tmux Tools Delta + +## ADDED Requirements + +### Requirement: Native Tmux Tool Catalog + +Ra SHALL include `tmux_run`, `tmux_send`, `tmux_capture`, `tmux_kill`, and +`tmux_listen` in the default built-in catalog when `[tools].builtin` is empty. + +#### Scenario: Empty allow-list exposes tmux tools + +- **GIVEN** `[tools].builtin` is empty +- **WHEN** Ra builds the default built-in tool catalog +- **THEN** the catalog includes `tmux_run`, `tmux_send`, `tmux_capture`, + `tmux_kill`, and `tmux_listen` + +#### Scenario: Non-empty allow-list remains exact + +- **GIVEN** `[tools].builtin` contains only `tmux_capture` +- **WHEN** Ra builds the default built-in tool catalog +- **THEN** the catalog contains `tmux_capture` and omits the other tmux tools + +### Requirement: Namespaced Tmux Sessions + +Ra SHALL map logical tmux session names to Ra-owned tmux sessions using the +`ra__{session}` namespace. + +#### Scenario: User session name is namespaced + +- **GIVEN** a caller uses session `dev` +- **WHEN** any tmux tool builds a tmux target +- **THEN** the target session name is `ra__dev` + +### Requirement: Tmux Run Tool + +Ra SHALL provide a `tmux_run` tool that creates or reuses a named tmux +session/window and runs a command. + +#### Scenario: Non-blocking run returns target + +- **GIVEN** the target session/window does not exist +- **WHEN** `tmux_run` is called with `wait:false` +- **THEN** Ra creates the detached target and returns JSON containing the pane + target without waiting for the process to exit + +#### Scenario: Blocking run returns captured output + +- **GIVEN** `tmux_run` is called with `wait:true` +- **WHEN** the command exits before the timeout +- **THEN** Ra returns JSON containing `ok:true`, the exit/capture status, and + the captured pane output + +### Requirement: Tmux Send Tool + +Ra SHALL provide a `tmux_send` tool that sends input or tmux key names to a +target pane. + +#### Scenario: Send input with Enter + +- **GIVEN** a running tmux pane +- **WHEN** `tmux_send` is called with `keys:"q"` and `enter:true` +- **THEN** Ra invokes `tmux send-keys` for the target and appends Enter + +### Requirement: Tmux Capture Tool + +Ra SHALL provide a `tmux_capture` tool that captures target pane content with +optional scrollback line bounds. + +#### Scenario: Capture recent history + +- **GIVEN** a target pane has output in scrollback +- **WHEN** `tmux_capture` is called with `start_line:-50` +- **THEN** Ra returns a bounded JSON envelope containing the captured text + +### Requirement: Tmux Kill Tool + +Ra SHALL provide a `tmux_kill` tool that terminates Ra-owned tmux targets. + +#### Scenario: Kill a session + +- **GIVEN** a namespaced tmux session exists +- **WHEN** `tmux_kill` is called for its logical session name +- **THEN** Ra kills the corresponding `ra__*` tmux session + +### Requirement: Tmux Listen Tool + +Ra SHALL provide a `tmux_listen` tool that polls a pane until output changes or +an optional pattern is observed. + +#### Scenario: Listen sees new output + +- **GIVEN** a pane later emits new output +- **WHEN** `tmux_listen` is called without a pattern +- **THEN** Ra returns after the capture changes and includes the latest content + +#### Scenario: Listen pattern timeout + +- **GIVEN** a pane does not emit the requested pattern +- **WHEN** `tmux_listen` reaches its timeout +- **THEN** Ra returns JSON with `timed_out:true` instead of blocking + indefinitely + +### Requirement: Missing Tmux Guidance + +Ra SHALL return a structured, actionable response when `tmux` is not available +instead of surfacing an opaque spawn failure. + +#### Scenario: tmux is missing + +- **GIVEN** `tmux` is not found on `PATH` +- **WHEN** any tmux tool executes +- **THEN** the tool returns JSON with `ok:false`, `error.kind:"missing_tmux"`, + and installation guidance diff --git a/openspec/changes/archive/2026-06-01-add-native-tmux-tools/tasks.md b/openspec/changes/archive/2026-06-01-add-native-tmux-tools/tasks.md new file mode 100644 index 0000000..dc2c44b --- /dev/null +++ b/openspec/changes/archive/2026-06-01-add-native-tmux-tools/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] Add tmux tool implementations with argv-safe tmux process execution. +- [x] Register `tmux_run`, `tmux_send`, `tmux_capture`, `tmux_kill`, and `tmux_listen` in the default built-in catalog. +- [x] Update README, `spec/tools.md`, and init/config examples. +- [x] Add unit/integration tests for registration, params/target handling, missing-tmux structured errors, argv mapping, and tmux round-trip behavior. +- [x] Run focused and full verification. diff --git a/openspec/specs/tmux-tools/spec.md b/openspec/specs/tmux-tools/spec.md new file mode 100644 index 0000000..327e9f6 --- /dev/null +++ b/openspec/specs/tmux-tools/spec.md @@ -0,0 +1,116 @@ +# tmux-tools Specification + +## Purpose +Built-in Ra tools that wrap tmux for persistent terminal sessions, +interactive input, pane capture, bounded listening, and Ra-owned target +cleanup. +## Requirements +### Requirement: Native Tmux Tool Catalog + +Ra SHALL include `tmux_run`, `tmux_send`, `tmux_capture`, `tmux_kill`, and +`tmux_listen` in the default built-in catalog when `[tools].builtin` is empty. + +#### Scenario: Empty allow-list exposes tmux tools + +- **GIVEN** `[tools].builtin` is empty +- **WHEN** Ra builds the default built-in tool catalog +- **THEN** the catalog includes `tmux_run`, `tmux_send`, `tmux_capture`, + `tmux_kill`, and `tmux_listen` + +#### Scenario: Non-empty allow-list remains exact + +- **GIVEN** `[tools].builtin` contains only `tmux_capture` +- **WHEN** Ra builds the default built-in tool catalog +- **THEN** the catalog contains `tmux_capture` and omits the other tmux tools + +### Requirement: Namespaced Tmux Sessions + +Ra SHALL map logical tmux session names to Ra-owned tmux sessions using the +`ra__{session}` namespace. + +#### Scenario: User session name is namespaced + +- **GIVEN** a caller uses session `dev` +- **WHEN** any tmux tool builds a tmux target +- **THEN** the target session name is `ra__dev` + +### Requirement: Tmux Run Tool + +Ra SHALL provide a `tmux_run` tool that creates or reuses a named tmux +session/window and runs a command. + +#### Scenario: Non-blocking run returns target + +- **GIVEN** the target session/window does not exist +- **WHEN** `tmux_run` is called with `wait:false` +- **THEN** Ra creates the detached target and returns JSON containing the pane + target without waiting for the process to exit + +#### Scenario: Blocking run returns captured output + +- **GIVEN** `tmux_run` is called with `wait:true` +- **WHEN** the command exits before the timeout +- **THEN** Ra returns JSON containing `ok:true`, the exit/capture status, and + the captured pane output + +### Requirement: Tmux Send Tool + +Ra SHALL provide a `tmux_send` tool that sends input or tmux key names to a +target pane. + +#### Scenario: Send input with Enter + +- **GIVEN** a running tmux pane +- **WHEN** `tmux_send` is called with `keys:"q"` and `enter:true` +- **THEN** Ra invokes `tmux send-keys` for the target and appends Enter + +### Requirement: Tmux Capture Tool + +Ra SHALL provide a `tmux_capture` tool that captures target pane content with +optional scrollback line bounds. + +#### Scenario: Capture recent history + +- **GIVEN** a target pane has output in scrollback +- **WHEN** `tmux_capture` is called with `start_line:-50` +- **THEN** Ra returns a bounded JSON envelope containing the captured text + +### Requirement: Tmux Kill Tool + +Ra SHALL provide a `tmux_kill` tool that terminates Ra-owned tmux targets. + +#### Scenario: Kill a session + +- **GIVEN** a namespaced tmux session exists +- **WHEN** `tmux_kill` is called for its logical session name +- **THEN** Ra kills the corresponding `ra__*` tmux session + +### Requirement: Tmux Listen Tool + +Ra SHALL provide a `tmux_listen` tool that polls a pane until output changes or +an optional pattern is observed. + +#### Scenario: Listen sees new output + +- **GIVEN** a pane later emits new output +- **WHEN** `tmux_listen` is called without a pattern +- **THEN** Ra returns after the capture changes and includes the latest content + +#### Scenario: Listen pattern timeout + +- **GIVEN** a pane does not emit the requested pattern +- **WHEN** `tmux_listen` reaches its timeout +- **THEN** Ra returns JSON with `timed_out:true` instead of blocking + indefinitely + +### Requirement: Missing Tmux Guidance + +Ra SHALL return a structured, actionable response when `tmux` is not available +instead of surfacing an opaque spawn failure. + +#### Scenario: tmux is missing + +- **GIVEN** `tmux` is not found on `PATH` +- **WHEN** any tmux tool executes +- **THEN** the tool returns JSON with `ok:false`, `error.kind:"missing_tmux"`, + and installation guidance diff --git a/spec/ra.toml.example b/spec/ra.toml.example index 8758351..a46dc8f 100644 --- a/spec/ra.toml.example +++ b/spec/ra.toml.example @@ -48,9 +48,14 @@ api_key_env = "ANTHROPIC_API_KEY" # webfetch_fetch — fetch one page as Markdown via webfetch-cli # webfetch_crawl — crawl bounded docs as Markdown via webfetch-cli # openspec — drive the agent-own OpenSpec SDD loop via the openspec CLI +# tmux_run — run a command in a Ra-owned tmux session/window +# tmux_send — send input or key names to a tmux pane +# tmux_capture — capture tmux pane content or scrollback +# tmux_kill — kill Ra-owned tmux targets +# tmux_listen — poll a tmux pane for new output or a pattern # An empty list (or omitted section) ships every built-in tool. [tools] -# builtin = ["read", "write", "edit", "bash", "ast_grep", "git", "gh", "jq", "grep", "glob", "ls", "fuzzy", "apply_patch", "webfetch_fetch", "webfetch_crawl", "openspec"] +# builtin = ["read", "write", "edit", "bash", "ast_grep", "git", "gh", "jq", "grep", "glob", "ls", "fuzzy", "apply_patch", "webfetch_fetch", "webfetch_crawl", "openspec", "tmux_run", "tmux_send", "tmux_capture", "tmux_kill", "tmux_listen"] builtin = [] # ─── Skills (Claude Code / agentskills.io) ────────────────────────── diff --git a/spec/tools.md b/spec/tools.md index 928e110..82455b9 100644 --- a/spec/tools.md +++ b/spec/tools.md @@ -117,6 +117,12 @@ Prefer `jq` over `bash` pipelines for JSON filtering. It preserves argv boundaries, feeds input through stdin, returns bounded JSON, and surfaces missing-`jq` installation guidance in a structured response. +Prefer `tmux_run`, `tmux_send`, `tmux_capture`, `tmux_kill`, and +`tmux_listen` over `bash` when the task needs persistent terminal state, +interactive input, or later output inspection. They operate only on +Ra-owned tmux sessions named `ra__{session}` and surface missing-`tmux` +installation guidance in a structured response. + ## Native CLI tools These wrappers execute common developer CLIs without asking the model to @@ -296,6 +302,141 @@ best-effort envelope budget; Ra always preserves valid JSON, so very small budgets may still return a minimal envelope larger than the requested byte count. +## Tmux tools + +These wrappers operate on Ra-owned tmux sessions. User-facing session +names are logical names such as `dev`; Ra maps them to tmux sessions +named `ra__dev`. Session/window/pane identifiers are restricted to +simple ASCII target names so tool calls cannot accidentally address +operator-owned tmux sessions. + +Every tool returns a JSON envelope: + +```json +{ + "ok": true, + "tool": "tmux_capture", + "target": { + "logical_session": "dev", + "session": "ra__dev", + "window": "main", + "pane": null, + "target": "ra__dev:main" + }, + "command": { "program": "tmux", "args": [], "display": "tmux ..." }, + "exit_code": 0, + "stdout": "...", + "stderr": null, + "truncated": false +} +``` + +If `tmux` is missing, the envelope has `ok:false` and +`error.kind:"missing_tmux"` with installation guidance. + +### `tmux_run` + +Create or reuse a named session/window and run a command. Use +`wait:false` for long-running processes; the tool returns after tmux +accepts the command. Use `wait:true` for a deterministic blocking run; +Ra respawns the target pane with a wrapper script, waits for completion, +captures the pane, and returns `command_exit_code`. + +```json +{ + "session": "dev", + "window": "tests", + "command": "cargo watch -x test", + "wait": false +} +``` + +| Field | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| session | string | yes | | logical name; mapped to `ra__{session}` | +| command | string | yes | | shell command executed inside tmux | +| window | string | no | `main` | target window | +| pane | string | no | | optional pane id/index | +| wait | boolean | no | `false` | wait for command exit and capture output | +| timeout_ms | number | no | `30000` | only applies when `wait:true` | +| max_output_bytes | number | no | `100000` | bounds returned captured output | + +### `tmux_send` + +Send literal input or tmux key names to a target pane. + +```json +{ "session": "dev", "window": "tests", "keys": "q", "enter": true } +``` + +| Field | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| session | string | yes | | logical name; mapped to `ra__{session}` | +| window | string | no | `main` | target window | +| pane | string | no | | optional pane id/index | +| keys | string | yes | | literal text or whitespace-separated tmux key names | +| enter | boolean | no | `false` | send Enter after `keys` | +| literal | boolean | no | `true` | set `false` for tmux key names such as `C-c` | + +### `tmux_capture` + +Capture visible pane content or scrollback with optional line bounds. + +```json +{ "session": "dev", "window": "tests", "start_line": -50 } +``` + +| Field | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| session | string | yes | | logical name; mapped to `ra__{session}` | +| window | string | no | `main` | target window | +| pane | string | no | | optional pane id/index | +| start_line | number | no | | passed to `tmux capture-pane -S` | +| end_line | number | no | | passed to `tmux capture-pane -E` | +| max_output_bytes | number | no | `100000` | bounds returned capture | + +### `tmux_kill` + +Kill a Ra-owned tmux session/window/pane, or all `ra__*` sessions. + +```json +{ "session": "dev", "window": "tests" } +``` + +| Field | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| all | boolean | no | `false` | kill every `ra__*` session | +| session | string | unless `all` | | logical name; mapped to `ra__{session}` | +| window | string | no | | kill the window when set without `pane` | +| pane | string | no | | kill the target pane | + +### `tmux_listen` + +Poll a pane until output changes or an optional substring/regex appears. +This is a bounded tool call, not a background stream. + +```json +{ + "session": "dev", + "window": "tests", + "pattern": "Finished", + "timeout_ms": 10000 +} +``` + +| Field | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| session | string | yes | | logical name; mapped to `ra__{session}` | +| window | string | no | `main` | target window | +| pane | string | no | | optional pane id/index | +| pattern | string | no | | substring or regex to wait for | +| regex | boolean | no | `false` | interpret `pattern` as regex | +| start_line | number | no | | passed to `tmux capture-pane -S` each poll | +| end_line | number | no | | passed to `tmux capture-pane -E` each poll | +| timeout_ms | number | no | `30000` | maximum listen duration | +| poll_ms | number | no | `500` | minimum is clamped to 10ms | +| max_output_bytes | number | no | `100000` | bounds returned capture/delta | + ## Structural search tools ### `ast_grep` diff --git a/src/init.rs b/src/init.rs index 798fd52..0ffca01 100644 --- a/src/init.rs +++ b/src/init.rs @@ -39,7 +39,8 @@ banner = true # Extended tools: grep, glob, ls, fuzzy, apply_patch. # Web docs tools: webfetch_fetch, webfetch_crawl. # OpenSpec tool: openspec. -# builtin = ["read", "write", "edit", "bash", "ast_grep", "git", "gh", "jq", "grep", "glob", "ls", "fuzzy", "apply_patch", "webfetch_fetch", "webfetch_crawl", "openspec"] +# Tmux tools: tmux_run, tmux_send, tmux_capture, tmux_kill, tmux_listen. +# builtin = ["read", "write", "edit", "bash", "ast_grep", "git", "gh", "jq", "grep", "glob", "ls", "fuzzy", "apply_patch", "webfetch_fetch", "webfetch_crawl", "openspec", "tmux_run", "tmux_send", "tmux_capture", "tmux_kill", "tmux_listen"] builtin = [] [skills] diff --git a/src/lib.rs b/src/lib.rs index d2814b9..18ee577 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,5 +34,6 @@ pub use tool_ctx::{ pub use tools::{ default_builtins, default_builtins_with_cfg, ApplyPatchTool, AstGrepTool, BashTool, EditTool, FuzzyTool, GhTool, GitTool, GlobTool, GrepTool, JqTool, JustTool, LsTool, LspTool, MiseTool, - ReadTool, RtkRewriter, Tool, WebfetchCrawlTool, WebfetchFetchTool, WriteTool, WrkflwTool, + ReadTool, RtkRewriter, TmuxCaptureTool, TmuxKillTool, TmuxListenTool, TmuxRunTool, + TmuxSendTool, Tool, WebfetchCrawlTool, WebfetchFetchTool, WriteTool, WrkflwTool, }; diff --git a/src/session_runner.rs b/src/session_runner.rs index b16237c..53b657b 100644 --- a/src/session_runner.rs +++ b/src/session_runner.rs @@ -643,11 +643,10 @@ async fn run_shell_context(command: &str, runtime: &SkillRuntimeOptions, cwd: &P fn tool_kind(name: &str) -> ToolKindHint { match name { - "read" | "jq" | "grep" | "glob" | "ls" | "fuzzy" | "webfetch_fetch" | "webfetch_crawl" => { - ToolKindHint::Read - } + "read" | "jq" | "grep" | "glob" | "ls" | "fuzzy" | "webfetch_fetch" | "webfetch_crawl" + | "tmux_capture" | "tmux_listen" => ToolKindHint::Read, "ast_grep" => ToolKindHint::Search, - "bash" | "git" | "gh" => ToolKindHint::Execute, + "bash" | "git" | "gh" | "tmux_run" | "tmux_send" | "tmux_kill" => ToolKindHint::Execute, _ => ToolKindHint::Other, } } @@ -736,6 +735,37 @@ fn tool_title(name: &str, input: &serde_json::Value) -> String { format!("Crawl {s}") }) .unwrap_or_else(|| "Crawl docs".into()), + "tmux_run" => input + .get("command") + .and_then(|v| v.as_str()) + .map(|command| { + let s = truncate_chars(command, 60); + format!("tmux run {s}") + }) + .unwrap_or_else(|| "Run tmux command".into()), + "tmux_send" => input + .get("keys") + .and_then(|v| v.as_str()) + .map(|keys| { + let s = truncate_chars(keys, 60); + format!("tmux send {s}") + }) + .unwrap_or_else(|| "Send tmux keys".into()), + "tmux_capture" => input + .get("session") + .and_then(|v| v.as_str()) + .map(|session| format!("Capture tmux {session}")) + .unwrap_or_else(|| "Capture tmux pane".into()), + "tmux_kill" => input + .get("session") + .and_then(|v| v.as_str()) + .map(|session| format!("Kill tmux {session}")) + .unwrap_or_else(|| "Kill tmux target".into()), + "tmux_listen" => input + .get("session") + .and_then(|v| v.as_str()) + .map(|session| format!("Listen tmux {session}")) + .unwrap_or_else(|| "Listen tmux pane".into()), "apply_patch" => { if input .get("check_only") @@ -815,4 +845,14 @@ mod tests { assert!(title.ends_with('…')); assert!(title.contains('界')); } + + #[test] + fn tool_title_truncates_multibyte_tmux_command_safely() { + let command = format!("{}界tail", "a".repeat(59)); + let title = tool_title("tmux_run", &json!({ "command": command })); + + assert!(title.starts_with("tmux run ")); + assert!(title.ends_with('…')); + assert!(title.contains('界')); + } } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index dab2d9f..c953fc0 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -24,6 +24,11 @@ //! - `webfetch_fetch` — fetch one web page as Markdown via webfetch-cli //! - `webfetch_crawl` — crawl bounded documentation via webfetch-cli //! - `openspec` — drive the agent-own OpenSpec SDD loop via the openspec CLI +//! - `tmux_run` — run commands in persistent tmux sessions +//! - `tmux_send` — send input to tmux panes +//! - `tmux_capture` — capture tmux pane output +//! - `tmux_kill` — kill Ra-owned tmux targets +//! - `tmux_listen` — poll tmux panes for new output mod cli; mod core; @@ -35,6 +40,7 @@ mod openspec; mod rtk; mod search; mod task_workflow; +mod tmux; mod webfetch; pub use cli::{GhTool, GitTool}; @@ -47,6 +53,7 @@ pub use openspec::OpenSpecTool; pub use rtk::RtkRewriter; pub use search::AstGrepTool; pub use task_workflow::{JustTool, MiseTool, WrkflwTool}; +pub use tmux::{TmuxCaptureTool, TmuxKillTool, TmuxListenTool, TmuxRunTool, TmuxSendTool}; pub use webfetch::{WebfetchCrawlTool, WebfetchFetchTool}; use crate::config::OpenlspSection; @@ -129,6 +136,21 @@ pub fn default_builtins_with_cfg( if want("openspec") { out.push(Arc::new(OpenSpecTool)); } + if want("tmux_run") { + out.push(Arc::new(TmuxRunTool)); + } + if want("tmux_send") { + out.push(Arc::new(TmuxSendTool)); + } + if want("tmux_capture") { + out.push(Arc::new(TmuxCaptureTool)); + } + if want("tmux_kill") { + out.push(Arc::new(TmuxKillTool)); + } + if want("tmux_listen") { + out.push(Arc::new(TmuxListenTool)); + } if want("lsp") { if let Some(binary) = resolve_openlsp_binary(openlsp_cfg) { out.push(Arc::new(LspTool { @@ -234,6 +256,28 @@ mod tests { assert_eq!(builtin_names(&["openspec"]), vec!["openspec"]); } + #[test] + fn default_catalog_includes_tmux_tools() { + let names = builtin_names(&[]); + for name in [ + "tmux_run", + "tmux_send", + "tmux_capture", + "tmux_kill", + "tmux_listen", + ] { + assert!( + names.contains(&name.to_string()), + "missing {name}: {names:?}" + ); + } + } + + #[test] + fn allowlist_can_select_tmux_tools_exactly() { + assert_eq!(builtin_names(&["tmux_capture"]), vec!["tmux_capture"]); + } + #[test] fn lsp_absent_when_disabled_in_config() { let cfg = OpenlspSection { diff --git a/src/tools/tmux.rs b/src/tools/tmux.rs new file mode 100644 index 0000000..7d6bb36 --- /dev/null +++ b/src/tools/tmux.rs @@ -0,0 +1,1364 @@ +//! Native tmux wrappers for persistent terminal sessions. +//! +//! These tools keep session/window/pane targeting typed while delegating the +//! terminal multiplexing behavior to tmux. All tmux invocations are built as +//! argv arrays. The only shell string is `tmux_run.command`, because the +//! command intentionally executes inside an interactive tmux pane. + +use crate::events::Event; +use crate::tool_ctx::ToolCtx; +use crate::tools::core::Tool; +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use regex::Regex; +use schemars::{schema_for, JsonSchema}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::process::Command; + +const TMUX_BINARY: &str = "tmux"; +const DEFAULT_WINDOW: &str = "main"; +const DEFAULT_MAX_OUTPUT_BYTES: usize = 100_000; +const DEFAULT_LISTEN_TIMEOUT_MS: u64 = 30_000; +const DEFAULT_LISTEN_POLL_MS: u64 = 500; +const DEFAULT_RUN_WAIT_TIMEOUT_MS: u64 = 30_000; +const RUN_WAIT_CAPTURE_START: i64 = -2_000; +const RA_SESSION_PREFIX: &str = "ra__"; +const EXIT_MARKER_PREFIX: &str = "__RA_TMUX_EXIT:"; +const EXIT_MARKER_SUFFIX: &str = "__"; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TmuxRunParams { + /// Logical session name. Ra maps this to a tmux session named + /// `ra__{session}`. + pub session: String, + /// Shell command to run inside the tmux pane. + pub command: String, + /// Window name. Defaults to `main`. + #[serde(default)] + pub window: Option, + /// Optional pane index/id within the window. + #[serde(default)] + pub pane: Option, + /// Wait for the command to finish and return captured pane output. + #[serde(default)] + pub wait: bool, + /// Blocking wait timeout in milliseconds. Defaults to 30000. + #[serde(default)] + pub timeout_ms: Option, + /// Maximum bytes returned in captured stdout fields. + #[serde(default = "default_max_output_bytes")] + pub max_output_bytes: usize, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TmuxSendParams { + /// Logical session name. Ra maps this to a tmux session named + /// `ra__{session}`. + pub session: String, + /// Window name. Defaults to `main`. + #[serde(default)] + pub window: Option, + /// Optional pane index/id within the window. + #[serde(default)] + pub pane: Option, + /// Text or tmux key name(s) to send. + pub keys: String, + /// Append Enter after sending `keys`. + #[serde(default)] + pub enter: bool, + /// Send `keys` as literal text. Set false for tmux key names such as + /// `C-c`, `Escape`, or `Up`. + #[serde(default = "default_true")] + pub literal: bool, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TmuxCaptureParams { + /// Logical session name. Ra maps this to a tmux session named + /// `ra__{session}`. + pub session: String, + /// Window name. Defaults to `main`. + #[serde(default)] + pub window: Option, + /// Optional pane index/id within the window. + #[serde(default)] + pub pane: Option, + /// Start line for `tmux capture-pane -S`, e.g. `-50` for recent history. + #[serde(default)] + pub start_line: Option, + /// End line for `tmux capture-pane -E`. + #[serde(default)] + pub end_line: Option, + /// Maximum bytes returned in captured stdout fields. + #[serde(default = "default_max_output_bytes")] + pub max_output_bytes: usize, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TmuxKillParams { + /// Kill every Ra-owned `ra__*` tmux session. + #[serde(default)] + pub all: bool, + /// Logical session name. Required unless `all` is true. + #[serde(default)] + pub session: Option, + /// Window name. When set without `pane`, kills the window. + #[serde(default)] + pub window: Option, + /// Optional pane index/id. When set, kills the pane. + #[serde(default)] + pub pane: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TmuxListenParams { + /// Logical session name. Ra maps this to a tmux session named + /// `ra__{session}`. + pub session: String, + /// Window name. Defaults to `main`. + #[serde(default)] + pub window: Option, + /// Optional pane index/id within the window. + #[serde(default)] + pub pane: Option, + /// Optional substring or regex to wait for in captured pane output. + #[serde(default)] + pub pattern: Option, + /// Interpret `pattern` as a regex. + #[serde(default)] + pub regex: bool, + /// Start line for each `tmux capture-pane -S`. + #[serde(default)] + pub start_line: Option, + /// End line for each `tmux capture-pane -E`. + #[serde(default)] + pub end_line: Option, + /// Listen timeout in milliseconds. Defaults to 30000. + #[serde(default)] + pub timeout_ms: Option, + /// Poll interval in milliseconds. Defaults to 500. + #[serde(default)] + pub poll_ms: Option, + /// Maximum bytes returned in captured stdout fields. + #[serde(default = "default_max_output_bytes")] + pub max_output_bytes: usize, +} + +pub struct TmuxRunTool; +pub struct TmuxSendTool; +pub struct TmuxCaptureTool; +pub struct TmuxKillTool; +pub struct TmuxListenTool; + +#[async_trait] +impl Tool for TmuxRunTool { + fn name(&self) -> &str { + "tmux_run" + } + + fn description(&self) -> &str { + "Run a shell command inside a Ra-owned tmux session/window. Creates \ + or reuses `ra__{session}`; use `wait:false` for persistent commands \ + and `wait:true` to block until completion and return captured output." + } + + fn schema(&self) -> serde_json::Value { + serde_json::to_value(schema_for!(TmuxRunParams)).unwrap() + } + + async fn execute( + &self, + call_id: &str, + input: serde_json::Value, + ctx: &ToolCtx, + ) -> Result { + let _scope = crate::nemo_obs::tool_scope("tmux_run"); + let params: TmuxRunParams = + serde_json::from_value(input).context("invalid params for tmux_run")?; + execute_tmux_run(call_id, params, ctx).await + } +} + +#[async_trait] +impl Tool for TmuxSendTool { + fn name(&self) -> &str { + "tmux_send" + } + + fn description(&self) -> &str { + "Send literal input or tmux key names to a pane in a Ra-owned tmux \ + session. Use `literal:false` for tmux key names and `enter:true` to \ + append Enter." + } + + fn schema(&self) -> serde_json::Value { + serde_json::to_value(schema_for!(TmuxSendParams)).unwrap() + } + + async fn execute( + &self, + call_id: &str, + input: serde_json::Value, + ctx: &ToolCtx, + ) -> Result { + let _scope = crate::nemo_obs::tool_scope("tmux_send"); + let params: TmuxSendParams = + serde_json::from_value(input).context("invalid params for tmux_send")?; + execute_tmux_send(call_id, params, ctx).await + } +} + +#[async_trait] +impl Tool for TmuxCaptureTool { + fn name(&self) -> &str { + "tmux_capture" + } + + fn description(&self) -> &str { + "Capture visible content or scrollback from a pane in a Ra-owned tmux \ + session. Supports `start_line`/`end_line` bounds matching \ + `tmux capture-pane -S/-E`." + } + + fn schema(&self) -> serde_json::Value { + serde_json::to_value(schema_for!(TmuxCaptureParams)).unwrap() + } + + async fn execute( + &self, + call_id: &str, + input: serde_json::Value, + ctx: &ToolCtx, + ) -> Result { + let _scope = crate::nemo_obs::tool_scope("tmux_capture"); + let params: TmuxCaptureParams = + serde_json::from_value(input).context("invalid params for tmux_capture")?; + execute_tmux_capture(call_id, params, ctx).await + } +} + +#[async_trait] +impl Tool for TmuxKillTool { + fn name(&self) -> &str { + "tmux_kill" + } + + fn description(&self) -> &str { + "Kill a Ra-owned tmux session, window, pane, or all `ra__*` sessions. \ + This tool never targets non-namespaced tmux sessions." + } + + fn schema(&self) -> serde_json::Value { + serde_json::to_value(schema_for!(TmuxKillParams)).unwrap() + } + + async fn execute( + &self, + call_id: &str, + input: serde_json::Value, + ctx: &ToolCtx, + ) -> Result { + let _scope = crate::nemo_obs::tool_scope("tmux_kill"); + let params: TmuxKillParams = + serde_json::from_value(input).context("invalid params for tmux_kill")?; + execute_tmux_kill(call_id, params, ctx).await + } +} + +#[async_trait] +impl Tool for TmuxListenTool { + fn name(&self) -> &str { + "tmux_listen" + } + + fn description(&self) -> &str { + "Poll a tmux pane until captured output changes or an optional \ + substring/regex appears. Returns the latest bounded capture and \ + timeout state; it does not create a daemonized stream." + } + + fn schema(&self) -> serde_json::Value { + serde_json::to_value(schema_for!(TmuxListenParams)).unwrap() + } + + async fn execute( + &self, + call_id: &str, + input: serde_json::Value, + ctx: &ToolCtx, + ) -> Result { + let _scope = crate::nemo_obs::tool_scope("tmux_listen"); + let params: TmuxListenParams = + serde_json::from_value(input).context("invalid params for tmux_listen")?; + execute_tmux_listen(call_id, params, ctx).await + } +} + +async fn execute_tmux_run(call_id: &str, params: TmuxRunParams, ctx: &ToolCtx) -> Result { + if params.command.trim().is_empty() { + return Err(anyhow!("tmux_run requires a non-empty command")); + } + let target = TmuxTarget::new( + ¶ms.session, + params.window.as_deref(), + params.pane.as_deref(), + )?; + let tmux = match find_tmux_binary() { + Some(tmux) => tmux, + None => return Ok(missing_tmux_json("tmux_run", &["run command".to_string()])), + }; + + if params.wait { + run_blocking(call_id, &tmux, &target, ¶ms, ctx).await + } else { + run_nonblocking(call_id, &tmux, &target, ¶ms, ctx).await + } +} + +async fn execute_tmux_send(call_id: &str, params: TmuxSendParams, ctx: &ToolCtx) -> Result { + if params.keys.is_empty() && !params.enter { + return Err(anyhow!("tmux_send requires keys or enter=true")); + } + let target = TmuxTarget::new( + ¶ms.session, + params.window.as_deref(), + params.pane.as_deref(), + )?; + let tmux = match find_tmux_binary() { + Some(tmux) => tmux, + None => { + return Ok(missing_tmux_json( + "tmux_send", + &send_args(&target, ¶ms, false), + )) + } + }; + + let mut last = None; + if !params.keys.is_empty() { + let args = send_args(&target, ¶ms, false); + emit_invocation(ctx, call_id, &args); + let output = run_tmux(&tmux, &args).await?; + let exit_code = output.exit_code; + emit_exit(ctx, call_id, exit_code); + if exit_code != 0 { + return process_response( + "tmux_send", + &args, + &target, + output, + DEFAULT_MAX_OUTPUT_BYTES, + ); + } + last = Some((args, output)); + } + + if params.enter { + let args = vec![ + "send-keys".to_string(), + "-t".to_string(), + target.target.clone(), + "Enter".to_string(), + ]; + emit_invocation(ctx, call_id, &args); + let output = run_tmux(&tmux, &args).await?; + let exit_code = output.exit_code; + emit_exit(ctx, call_id, exit_code); + if exit_code != 0 { + return process_response( + "tmux_send", + &args, + &target, + output, + DEFAULT_MAX_OUTPUT_BYTES, + ); + } + last = Some((args, output)); + } + + let (args, output) = last.expect("tmux_send executes at least one send-keys command"); + process_response( + "tmux_send", + &args, + &target, + output, + DEFAULT_MAX_OUTPUT_BYTES, + ) +} + +async fn execute_tmux_capture( + call_id: &str, + params: TmuxCaptureParams, + ctx: &ToolCtx, +) -> Result { + let target = TmuxTarget::new( + ¶ms.session, + params.window.as_deref(), + params.pane.as_deref(), + )?; + let args = capture_args(&target, params.start_line, params.end_line); + let tmux = match find_tmux_binary() { + Some(tmux) => tmux, + None => return Ok(missing_tmux_json("tmux_capture", &args)), + }; + + emit_invocation(ctx, call_id, &args); + let output = run_tmux(&tmux, &args).await?; + emit_exit(ctx, call_id, output.exit_code); + process_response( + "tmux_capture", + &args, + &target, + output, + params.max_output_bytes, + ) +} + +async fn execute_tmux_kill(call_id: &str, params: TmuxKillParams, ctx: &ToolCtx) -> Result { + let tmux = match find_tmux_binary() { + Some(tmux) => tmux, + None => { + return Ok(missing_tmux_json( + "tmux_kill", + &["kill-session".to_string()], + )) + } + }; + + if params.all { + return kill_all_ra_sessions(call_id, &tmux, ctx).await; + } + + let session = params + .session + .as_deref() + .ok_or_else(|| anyhow!("tmux_kill requires session unless all=true"))?; + let target = TmuxTarget::new(session, params.window.as_deref(), params.pane.as_deref())?; + let args = if params.pane.is_some() { + vec![ + "kill-pane".to_string(), + "-t".to_string(), + target.target.clone(), + ] + } else if params.window.is_some() { + vec![ + "kill-window".to_string(), + "-t".to_string(), + target.target.clone(), + ] + } else { + vec![ + "kill-session".to_string(), + "-t".to_string(), + target.session.clone(), + ] + }; + + emit_invocation(ctx, call_id, &args); + let output = run_tmux(&tmux, &args).await?; + emit_exit(ctx, call_id, output.exit_code); + process_response( + "tmux_kill", + &args, + &target, + output, + DEFAULT_MAX_OUTPUT_BYTES, + ) +} + +async fn execute_tmux_listen( + call_id: &str, + params: TmuxListenParams, + ctx: &ToolCtx, +) -> Result { + let target = TmuxTarget::new( + ¶ms.session, + params.window.as_deref(), + params.pane.as_deref(), + )?; + let args = capture_args(&target, params.start_line, params.end_line); + let tmux = match find_tmux_binary() { + Some(tmux) => tmux, + None => return Ok(missing_tmux_json("tmux_listen", &args)), + }; + let matcher = OutputMatcher::new(params.pattern.as_deref(), params.regex)?; + let timeout = Duration::from_millis(params.timeout_ms.unwrap_or(DEFAULT_LISTEN_TIMEOUT_MS)); + let poll = Duration::from_millis(params.poll_ms.unwrap_or(DEFAULT_LISTEN_POLL_MS).max(10)); + + let initial = + capture_for_listen(call_id, &tmux, &args, &target, ctx, params.max_output_bytes).await?; + if initial.exit_code != 0 { + return process_response( + "tmux_listen", + &args, + &target, + initial, + params.max_output_bytes, + ); + } + + if matcher.matches(&initial.stdout) { + return listen_response( + &args, + &target, + initial.stdout, + "", + false, + true, + false, + params.max_output_bytes, + ); + } + + let started = tokio::time::Instant::now(); + let mut latest = initial.stdout.clone(); + while started.elapsed() < timeout { + let remaining = timeout.saturating_sub(started.elapsed()); + tokio::time::sleep(poll.min(remaining)).await; + let output = + capture_for_listen(call_id, &tmux, &args, &target, ctx, params.max_output_bytes) + .await?; + if output.exit_code != 0 { + return process_response( + "tmux_listen", + &args, + &target, + output, + params.max_output_bytes, + ); + } + latest = output.stdout; + let matched = matcher.matches(&latest); + let changed = latest != initial.stdout; + if matched || (matcher.is_none() && changed) { + let delta = text_delta(&initial.stdout, &latest); + return listen_response( + &args, + &target, + latest, + &delta, + changed, + matched, + false, + params.max_output_bytes, + ); + } + } + + let delta = text_delta(&initial.stdout, &latest); + let changed = latest != initial.stdout; + let matched = matcher.matches(&latest); + listen_response( + &args, + &target, + latest, + &delta, + changed, + matched, + true, + params.max_output_bytes, + ) +} + +async fn run_nonblocking( + call_id: &str, + tmux: &Path, + target: &TmuxTarget, + params: &TmuxRunParams, + ctx: &ToolCtx, +) -> Result { + let (args, output) = if !session_exists(tmux, target).await? { + let args = new_session_args(target, Some(¶ms.command), &ctx.cwd); + emit_invocation(ctx, call_id, &args); + let output = run_tmux(tmux, &args).await?; + emit_exit(ctx, call_id, output.exit_code); + (args, output) + } else if !window_exists(tmux, target).await? { + let args = new_window_args(target, Some(¶ms.command), &ctx.cwd); + emit_invocation(ctx, call_id, &args); + let output = run_tmux(tmux, &args).await?; + emit_exit(ctx, call_id, output.exit_code); + (args, output) + } else { + let send = TmuxSendParams { + session: params.session.clone(), + window: params.window.clone(), + pane: params.pane.clone(), + keys: params.command.clone(), + enter: true, + literal: true, + }; + let args = send_args(target, &send, false); + emit_invocation(ctx, call_id, &args); + let first = run_tmux(tmux, &args).await?; + emit_exit(ctx, call_id, first.exit_code); + if first.exit_code != 0 { + return process_response("tmux_run", &args, target, first, params.max_output_bytes); + } + let enter_args = vec![ + "send-keys".to_string(), + "-t".to_string(), + target.target.clone(), + "Enter".to_string(), + ]; + emit_invocation(ctx, call_id, &enter_args); + let output = run_tmux(tmux, &enter_args).await?; + emit_exit(ctx, call_id, output.exit_code); + (enter_args, output) + }; + + process_response("tmux_run", &args, target, output, params.max_output_bytes) +} + +async fn run_blocking( + call_id: &str, + tmux: &Path, + target: &TmuxTarget, + params: &TmuxRunParams, + ctx: &ToolCtx, +) -> Result { + if let Some(failure) = ensure_session_window(tmux, target, &ctx.cwd).await? { + let args = failure.args.clone(); + return process_response("tmux_run", &args, target, failure, params.max_output_bytes); + } + + let tempdir = tempfile::tempdir().context("create tmux wait script directory")?; + let script_path = tempdir.path().join("ra-tmux-run.sh"); + let token = format!("ra_tmux_{}", unique_token()); + let script = wait_script(¶ms.command, &token); + tokio::fs::write(&script_path, script) + .await + .with_context(|| format!("write {}", script_path.display()))?; + + let shell_command = format!("/bin/sh {}", shell_word(&script_path.to_string_lossy())); + let args = vec![ + "respawn-pane".to_string(), + "-k".to_string(), + "-t".to_string(), + target.target.clone(), + "-c".to_string(), + ctx.cwd.to_string_lossy().to_string(), + shell_command, + ]; + emit_invocation(ctx, call_id, &args); + let output = run_tmux(tmux, &args).await?; + emit_exit(ctx, call_id, output.exit_code); + if output.exit_code != 0 { + return process_response("tmux_run", &args, target, output, params.max_output_bytes); + } + + let wait_args = vec!["wait-for".to_string(), token.clone()]; + emit_invocation(ctx, call_id, &wait_args); + let timeout = Duration::from_millis(params.timeout_ms.unwrap_or(DEFAULT_RUN_WAIT_TIMEOUT_MS)); + let wait = run_tmux_timeout(tmux, &wait_args, timeout).await?; + emit_exit(ctx, call_id, wait.output.exit_code); + + let capture_args = capture_args(target, Some(RUN_WAIT_CAPTURE_START), None); + let capture = run_tmux(tmux, &capture_args).await?; + let (stdout, command_exit_code) = strip_exit_marker(&capture.stdout); + let ok = !wait.timed_out + && wait.output.exit_code == 0 + && capture.exit_code == 0 + && command_exit_code == Some(0); + let (stdout, stdout_truncated) = trim_to_byte_budget(stdout, params.max_output_bytes); + let stderr = join_non_empty(&[wait.output.stderr, capture.stderr]); + let (stderr, stderr_truncated) = trim_to_byte_budget(stderr, DEFAULT_MAX_OUTPUT_BYTES); + + serde_json::to_string_pretty(&json!({ + "ok": ok, + "tool": "tmux_run", + "target": target, + "command": { + "program": TMUX_BINARY, + "args": args, + "display": shell_words(TMUX_BINARY, &args), + }, + "wait": true, + "timed_out": wait.timed_out, + "exit_code": wait.output.exit_code, + "command_exit_code": command_exit_code, + "capture_exit_code": capture.exit_code, + "stdout": stdout, + "stderr": if stderr.is_empty() { serde_json::Value::Null } else { json!(stderr) }, + "truncated": stdout_truncated || stderr_truncated, + })) + .context("serialize tmux_run output") +} + +async fn ensure_session_window( + tmux: &Path, + target: &TmuxTarget, + cwd: &Path, +) -> Result> { + if !session_exists(tmux, target).await? { + let args = new_session_args(target, None, cwd); + let output = run_tmux(tmux, &args).await?; + if output.exit_code != 0 { + return Ok(Some(output)); + } + return Ok(None); + } + + if !window_exists(tmux, target).await? { + let args = new_window_args(target, None, cwd); + let output = run_tmux(tmux, &args).await?; + if output.exit_code != 0 { + return Ok(Some(output)); + } + } + Ok(None) +} + +async fn session_exists(tmux: &Path, target: &TmuxTarget) -> Result { + let args = vec![ + "has-session".to_string(), + "-t".to_string(), + target.session.clone(), + ]; + let output = run_tmux(tmux, &args).await?; + Ok(output.exit_code == 0) +} + +async fn window_exists(tmux: &Path, target: &TmuxTarget) -> Result { + let args = vec![ + "list-windows".to_string(), + "-t".to_string(), + target.session.clone(), + "-F".to_string(), + "#{window_name}".to_string(), + ]; + let output = run_tmux(tmux, &args).await?; + if output.exit_code != 0 { + return Ok(false); + } + Ok(output.stdout.lines().any(|line| line == target.window)) +} + +async fn kill_all_ra_sessions(call_id: &str, tmux: &Path, ctx: &ToolCtx) -> Result { + let list_args = vec![ + "list-sessions".to_string(), + "-F".to_string(), + "#{session_name}".to_string(), + ]; + emit_invocation(ctx, call_id, &list_args); + let list_output = run_tmux(tmux, &list_args).await?; + emit_exit(ctx, call_id, list_output.exit_code); + if list_output.exit_code != 0 { + return serde_json::to_string_pretty(&json!({ + "ok": true, + "tool": "tmux_kill", + "all": true, + "killed": [], + "command": command_metadata(&list_args), + "exit_code": list_output.exit_code, + "stdout": "", + "stderr": if list_output.stderr.is_empty() { + serde_json::Value::Null + } else { + json!(list_output.stderr) + }, + "truncated": false, + })) + .context("serialize tmux_kill all output"); + } + + let mut killed = Vec::new(); + let mut failures = Vec::new(); + for session in list_output + .stdout + .lines() + .filter(|line| line.starts_with(RA_SESSION_PREFIX)) + { + let args = vec![ + "kill-session".to_string(), + "-t".to_string(), + session.to_string(), + ]; + emit_invocation(ctx, call_id, &args); + let output = run_tmux(tmux, &args).await?; + emit_exit(ctx, call_id, output.exit_code); + if output.exit_code == 0 { + killed.push(session.to_string()); + } else { + failures.push(json!({ + "session": session, + "exit_code": output.exit_code, + "stderr": output.stderr, + })); + } + } + + serde_json::to_string_pretty(&json!({ + "ok": failures.is_empty(), + "tool": "tmux_kill", + "all": true, + "killed": killed, + "failures": failures, + "command": command_metadata(&list_args), + "exit_code": if failures.is_empty() { 0 } else { 1 }, + "stdout": "", + "stderr": null, + "truncated": false, + })) + .context("serialize tmux_kill all output") +} + +async fn capture_for_listen( + call_id: &str, + tmux: &Path, + args: &[String], + _target: &TmuxTarget, + ctx: &ToolCtx, + _max_output_bytes: usize, +) -> Result { + emit_invocation(ctx, call_id, args); + let output = run_tmux(tmux, args).await?; + emit_exit(ctx, call_id, output.exit_code); + Ok(output) +} + +fn new_session_args(target: &TmuxTarget, command: Option<&str>, cwd: &Path) -> Vec { + let mut args = vec![ + "new-session".to_string(), + "-d".to_string(), + "-s".to_string(), + target.session.clone(), + "-n".to_string(), + target.window.clone(), + "-c".to_string(), + cwd.to_string_lossy().to_string(), + ]; + if let Some(command) = command { + args.push(command.to_string()); + } + args +} + +fn new_window_args(target: &TmuxTarget, command: Option<&str>, cwd: &Path) -> Vec { + let mut args = vec![ + "new-window".to_string(), + "-d".to_string(), + "-t".to_string(), + target.session.clone(), + "-n".to_string(), + target.window.clone(), + "-c".to_string(), + cwd.to_string_lossy().to_string(), + ]; + if let Some(command) = command { + args.push(command.to_string()); + } + args +} + +fn send_args(target: &TmuxTarget, params: &TmuxSendParams, include_enter: bool) -> Vec { + let mut args = vec![ + "send-keys".to_string(), + "-t".to_string(), + target.target.clone(), + ]; + if params.literal { + args.push("-l".to_string()); + args.push(params.keys.clone()); + } else { + args.extend( + params + .keys + .split_whitespace() + .filter(|key| !key.is_empty()) + .map(ToString::to_string), + ); + } + if include_enter && params.enter { + args.push("Enter".to_string()); + } + args +} + +fn capture_args( + target: &TmuxTarget, + start_line: Option, + end_line: Option, +) -> Vec { + let mut args = vec![ + "capture-pane".to_string(), + "-p".to_string(), + "-t".to_string(), + target.target.clone(), + ]; + if let Some(start_line) = start_line { + args.push("-S".to_string()); + args.push(start_line.to_string()); + } + if let Some(end_line) = end_line { + args.push("-E".to_string()); + args.push(end_line.to_string()); + } + args +} + +fn wait_script(command: &str, token: &str) -> String { + format!( + "#!/bin/sh\n\ + /bin/sh -c {} 2>&1\n\ + status=$?\n\ + printf '\\n{EXIT_MARKER_PREFIX}%s{EXIT_MARKER_SUFFIX}\\n' \"$status\"\n\ + tmux wait-for -S {}\n\ + exec /bin/sh\n", + shell_word(command), + shell_word(token) + ) +} + +async fn run_tmux(tmux: &Path, args: &[String]) -> Result { + let output = Command::new(tmux) + .args(args) + .stdin(Stdio::null()) + .output() + .await + .with_context(|| format!("spawn `{}`", shell_words(TMUX_BINARY, args)))?; + Ok(TmuxOutput { + args: args.to_vec(), + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +async fn run_tmux_timeout( + tmux: &Path, + args: &[String], + timeout: Duration, +) -> Result { + let child = Command::new(tmux) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("spawn `{}`", shell_words(TMUX_BINARY, args)))?; + + match tokio::time::timeout(timeout, child.wait_with_output()).await { + Ok(output) => { + let output = + output.with_context(|| format!("wait `{}`", shell_words(TMUX_BINARY, args)))?; + Ok(TimedTmuxOutput { + timed_out: false, + output: TmuxOutput { + args: args.to_vec(), + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }, + }) + } + Err(_) => Ok(TimedTmuxOutput { + timed_out: true, + output: TmuxOutput { + args: args.to_vec(), + exit_code: -1, + stdout: String::new(), + stderr: format!("timed out after {} ms", timeout.as_millis()), + }, + }), + } +} + +fn process_response( + tool: &str, + args: &[String], + target: &TmuxTarget, + output: TmuxOutput, + max_output_bytes: usize, +) -> Result { + let (stdout, stdout_truncated) = trim_to_byte_budget(output.stdout, max_output_bytes); + let (stderr, stderr_truncated) = trim_to_byte_budget(output.stderr, DEFAULT_MAX_OUTPUT_BYTES); + serde_json::to_string_pretty(&json!({ + "ok": output.exit_code == 0, + "tool": tool, + "target": target, + "command": command_metadata(args), + "exit_code": output.exit_code, + "stdout": stdout, + "stderr": if stderr.is_empty() { serde_json::Value::Null } else { json!(stderr) }, + "truncated": stdout_truncated || stderr_truncated, + })) + .context("serialize tmux tool output") +} + +fn listen_response( + args: &[String], + target: &TmuxTarget, + stdout: String, + delta: &str, + changed: bool, + matched: bool, + timed_out: bool, + max_output_bytes: usize, +) -> Result { + let (stdout, stdout_truncated) = trim_to_byte_budget(stdout, max_output_bytes); + let (delta, delta_truncated) = trim_to_byte_budget(delta.to_string(), max_output_bytes); + serde_json::to_string_pretty(&json!({ + "ok": !timed_out, + "tool": "tmux_listen", + "target": target, + "command": command_metadata(args), + "changed": changed, + "matched": matched, + "timed_out": timed_out, + "exit_code": 0, + "stdout": stdout, + "delta": delta, + "stderr": null, + "truncated": stdout_truncated || delta_truncated, + })) + .context("serialize tmux_listen output") +} + +fn missing_tmux_json(tool: &str, args: &[String]) -> String { + serde_json::to_string_pretty(&json!({ + "ok": false, + "tool": tool, + "error": { + "kind": "missing_tmux", + "message": "tmux was not found on PATH, so the tmux tool could not be run.", + "install": [ + "Install tmux with your system package manager, for example: apt install tmux, brew install tmux, or dnf install tmux.", + "After tmux is available on PATH, rerun the tool." + ] + }, + "command": command_metadata(args), + "exit_code": null, + "stdout": "", + "stderr": null, + "truncated": false, + })) + .expect("missing tmux JSON is serializable") +} + +fn command_metadata(args: &[String]) -> serde_json::Value { + json!({ + "program": TMUX_BINARY, + "args": args, + "display": shell_words(TMUX_BINARY, args), + }) +} + +fn emit_invocation(ctx: &ToolCtx, call_id: &str, args: &[String]) { + let _ = ctx.events.send(Event::ToolCallUpdate { + id: call_id.to_string(), + chunk: format!("[tmux] {}", shell_words(TMUX_BINARY, args)), + }); +} + +fn emit_exit(ctx: &ToolCtx, call_id: &str, exit_code: i32) { + let _ = ctx.events.send(Event::ToolCallUpdate { + id: call_id.to_string(), + chunk: format!("[exit={exit_code}]"), + }); +} + +fn find_tmux_binary() -> Option { + which::which(TMUX_BINARY).ok() +} + +#[derive(Debug, Clone, Serialize)] +struct TmuxTarget { + logical_session: String, + session: String, + window: String, + pane: Option, + target: String, +} + +impl TmuxTarget { + fn new(session: &str, window: Option<&str>, pane: Option<&str>) -> Result { + let logical_session = validate_name("session", session, false)?; + let session = format!("{RA_SESSION_PREFIX}{logical_session}"); + let window = match window { + Some(window) => validate_name("window", window, false)?, + None => DEFAULT_WINDOW.to_string(), + }; + let pane = pane + .map(|pane| validate_name("pane", pane, true)) + .transpose()?; + let target = match &pane { + Some(pane) => format!("{session}:{window}.{pane}"), + None => format!("{session}:{window}"), + }; + Ok(Self { + logical_session, + session, + window, + pane, + target, + }) + } +} + +#[derive(Debug)] +struct TmuxOutput { + args: Vec, + exit_code: i32, + stdout: String, + stderr: String, +} + +#[derive(Debug)] +struct TimedTmuxOutput { + timed_out: bool, + output: TmuxOutput, +} + +enum OutputMatcher { + None, + Substring(String), + Regex(Regex), +} + +impl OutputMatcher { + fn new(pattern: Option<&str>, regex: bool) -> Result { + let Some(pattern) = pattern else { + return Ok(Self::None); + }; + if pattern.is_empty() { + return Ok(Self::None); + } + if regex { + Ok(Self::Regex(Regex::new(pattern).with_context(|| { + format!("invalid tmux_listen regex: {pattern}") + })?)) + } else { + Ok(Self::Substring(pattern.to_string())) + } + } + + fn matches(&self, text: &str) -> bool { + match self { + Self::None => false, + Self::Substring(pattern) => text.contains(pattern), + Self::Regex(regex) => regex.is_match(text), + } + } + + fn is_none(&self) -> bool { + matches!(self, Self::None) + } +} + +fn validate_name(field: &str, value: &str, allow_percent: bool) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(anyhow!("tmux {field} must not be empty")); + } + if value.len() > 80 { + return Err(anyhow!("tmux {field} must be at most 80 bytes")); + } + let valid = value.chars().all(|ch| { + ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') || (allow_percent && ch == '%') + }); + if !valid { + return Err(anyhow!( + "tmux {field} may only contain ASCII letters, digits, `_`, `-`, `.`{}", + if allow_percent { ", or `%`" } else { "" } + )); + } + Ok(value.to_string()) +} + +fn strip_exit_marker(text: &str) -> (String, Option) { + let mut exit_code = None; + let mut kept = Vec::new(); + for line in text.lines() { + let trimmed = line.trim_end_matches('\r').trim(); + if let Some(rest) = trimmed.strip_prefix(EXIT_MARKER_PREFIX) { + if let Some(code) = rest.strip_suffix(EXIT_MARKER_SUFFIX) { + exit_code = code.parse::().ok(); + continue; + } + } + kept.push(line); + } + let mut out = kept.join("\n"); + if text.ends_with('\n') && !out.is_empty() { + out.push('\n'); + } + (out, exit_code) +} + +fn trim_to_byte_budget(mut text: String, max_bytes: usize) -> (String, bool) { + if text.len() <= max_bytes { + return (text, false); + } + + let mut end = 0; + for (idx, ch) in text.char_indices() { + let next = idx + ch.len_utf8(); + if next > max_bytes { + break; + } + end = next; + } + text.truncate(end); + if !text.ends_with('\n') { + text.push('\n'); + } + text.push_str("[truncated]\n"); + (text, true) +} + +fn text_delta(initial: &str, latest: &str) -> String { + if latest.starts_with(initial) { + latest[initial.len()..].to_string() + } else if latest == initial { + String::new() + } else { + latest.to_string() + } +} + +fn join_non_empty(parts: &[String]) -> String { + parts + .iter() + .filter(|part| !part.is_empty()) + .cloned() + .collect::>() + .join("") +} + +fn shell_words(binary: &str, args: &[String]) -> String { + std::iter::once(binary.to_string()) + .chain(args.iter().map(|arg| shell_word(arg))) + .collect::>() + .join(" ") +} + +fn shell_word(s: &str) -> String { + if s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '_' | '-' | '=' | ':' | '%')) + { + s.to_string() + } else { + format!("'{}'", s.replace('\'', "'\\''")) + } +} + +fn unique_token() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("{}_{}", std::process::id(), nanos) +} + +fn default_max_output_bytes() -> usize { + DEFAULT_MAX_OUTPUT_BYTES +} + +fn default_true() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_namespaces_logical_session() { + let target = TmuxTarget::new("dev", Some("tests"), Some("0")).unwrap(); + + assert_eq!(target.logical_session, "dev"); + assert_eq!(target.session, "ra__dev"); + assert_eq!(target.window, "tests"); + assert_eq!(target.target, "ra__dev:tests.0"); + } + + #[test] + fn target_rejects_ambiguous_names() { + assert!(TmuxTarget::new("dev:other", None, None).is_err()); + assert!(TmuxTarget::new("dev", Some("bad.window:name"), None).is_err()); + assert!(TmuxTarget::new("dev", None, Some("%1")).is_ok()); + } + + #[test] + fn run_params_default_to_nonblocking_main_window() { + let params: TmuxRunParams = + serde_json::from_value(json!({ "session": "dev", "command": "echo ok" })).unwrap(); + let target = TmuxTarget::new(¶ms.session, params.window.as_deref(), None).unwrap(); + + assert!(!params.wait); + assert_eq!(target.target, "ra__dev:main"); + assert_eq!(params.max_output_bytes, DEFAULT_MAX_OUTPUT_BYTES); + } + + #[test] + fn send_literal_builds_argv_without_shell() { + let target = TmuxTarget::new("dev", None, None).unwrap(); + let params = TmuxSendParams { + session: "dev".into(), + window: None, + pane: None, + keys: "hello; rm -rf /".into(), + enter: false, + literal: true, + }; + + assert_eq!( + send_args(&target, ¶ms, false), + vec!["send-keys", "-t", "ra__dev:main", "-l", "hello; rm -rf /"] + ); + } + + #[test] + fn capture_builds_line_bounds() { + let target = TmuxTarget::new("dev", Some("logs"), None).unwrap(); + + assert_eq!( + capture_args(&target, Some(-50), Some(-1)), + vec![ + "capture-pane", + "-p", + "-t", + "ra__dev:logs", + "-S", + "-50", + "-E", + "-1" + ] + ); + } + + #[test] + fn missing_tmux_response_is_structured_json() { + let rendered = missing_tmux_json("tmux_capture", &["capture-pane".into()]); + let value: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + + assert_eq!(value["ok"], json!(false)); + assert_eq!(value["error"]["kind"], "missing_tmux"); + assert!(value["error"]["install"][0] + .as_str() + .unwrap() + .contains("Install tmux")); + } + + #[test] + fn exit_marker_is_removed_and_parsed() { + let (text, code) = strip_exit_marker("one\n__RA_TMUX_EXIT:7__\ntwo\n"); + + assert_eq!(text, "one\ntwo\n"); + assert_eq!(code, Some(7)); + } + + #[test] + fn wait_script_shell_quotes_command() { + let script = wait_script("printf 'hi'; exit 7", "token"); + + assert!(script.contains("/bin/sh -c 'printf '\\''hi'\\''; exit 7' 2>&1")); + assert!(script.contains("tmux wait-for -S token")); + } +} diff --git a/tests/tmux_tools.rs b/tests/tmux_tools.rs new file mode 100644 index 0000000..01a13d2 --- /dev/null +++ b/tests/tmux_tools.rs @@ -0,0 +1,347 @@ +//! Integration tests for native tmux tools. + +use ra::{ + tools::{TmuxCaptureTool, TmuxKillTool, TmuxListenTool, TmuxRunTool, TmuxSendTool, Tool}, + Event, ToolCtx, +}; +use serde_json::Value; +use std::{ffi::OsString, fs, os::unix::fs::PermissionsExt, path::Path, sync::OnceLock}; +use tokio::sync::Mutex; + +struct EnvRestore { + key: &'static str, + old_value: Option, +} + +impl EnvRestore { + fn set>(key: &'static str, value: K) -> Self { + let old_value = std::env::var_os(key); + std::env::set_var(key, value.into()); + Self { key, old_value } + } +} + +impl Drop for EnvRestore { + fn drop(&mut self) { + if let Some(value) = &self.old_value { + std::env::set_var(self.key, value); + } else { + std::env::remove_var(self.key); + } + } +} + +fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +fn make_ctx() -> ToolCtx { + let (events, _) = tokio::sync::broadcast::channel(32); + ToolCtx::local(events) +} + +fn make_ctx_with_events() -> (ToolCtx, tokio::sync::broadcast::Receiver) { + let (events, rx) = tokio::sync::broadcast::channel(32); + (ToolCtx::local(events), rx) +} + +fn json_output(output: &str) -> Value { + serde_json::from_str(output).unwrap_or_else(|err| panic!("invalid json: {err}: {output}")) +} + +fn write_fake_tmux(dir: &Path, body: &str) { + let path = dir.join("tmux"); + fs::write( + &path, + format!("#!/bin/sh\nprintf '%s\\n' \"$@\" >> \"$TMUX_ARGS_FILE\"\n{body}\n"), + ) + .unwrap(); + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).unwrap(); +} + +#[test] +fn default_catalog_contains_tmux_tools_and_allowlist_is_exact() { + let names = ra::default_builtins(&[]) + .into_iter() + .map(|tool| tool.name().to_string()) + .collect::>(); + for name in [ + "tmux_run", + "tmux_send", + "tmux_capture", + "tmux_kill", + "tmux_listen", + ] { + assert!( + names.contains(&name.to_string()), + "missing {name}: {names:?}" + ); + } + + let filtered = ra::default_builtins(&["tmux_capture".to_string()]) + .into_iter() + .map(|tool| tool.name().to_string()) + .collect::>(); + assert_eq!(filtered, vec!["tmux_capture"]); +} + +#[tokio::test] +async fn capture_returns_structured_missing_tmux_guidance() { + let _guard = env_lock().lock().await; + let empty_path = tempfile::tempdir().unwrap(); + let _path = EnvRestore::set("PATH", empty_path.path().as_os_str()); + + let output = TmuxCaptureTool + .execute( + "tc", + serde_json::json!({ "session": "dev", "start_line": -50 }), + &make_ctx(), + ) + .await + .unwrap(); + + let output = json_output(&output); + assert_eq!(output["ok"], false); + assert_eq!(output["tool"], "tmux_capture"); + assert_eq!(output["error"]["kind"], "missing_tmux"); + assert!(output["error"]["install"][0] + .as_str() + .unwrap() + .contains("Install tmux")); +} + +#[tokio::test] +async fn send_executes_fake_tmux_with_expected_argv_and_events() { + let _guard = env_lock().lock().await; + let dir = tempfile::tempdir().unwrap(); + let args_file = dir.path().join("args.txt"); + write_fake_tmux(dir.path(), "exit 0"); + let _path = EnvRestore::set("PATH", dir.path().as_os_str()); + let _args_file = EnvRestore::set("TMUX_ARGS_FILE", args_file.as_os_str()); + let (ctx, mut rx) = make_ctx_with_events(); + + let output = TmuxSendTool + .execute( + "ts", + serde_json::json!({ + "session": "dev", + "window": "repl", + "keys": "hello; rm -rf /", + "enter": true + }), + &ctx, + ) + .await + .unwrap(); + + let args = fs::read_to_string(args_file).unwrap(); + assert_eq!( + args.lines().collect::>(), + vec![ + "send-keys", + "-t", + "ra__dev:repl", + "-l", + "hello; rm -rf /", + "send-keys", + "-t", + "ra__dev:repl", + "Enter", + ] + ); + + let output = json_output(&output); + assert_eq!(output["ok"], true); + assert_eq!(output["target"]["session"], "ra__dev"); + assert_eq!(output["target"]["target"], "ra__dev:repl"); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!(events.iter().any(|event| matches!( + event, + Event::ToolCallUpdate { chunk, .. } if chunk.contains("[tmux] tmux send-keys") + ))); +} + +#[tokio::test] +async fn capture_executes_fake_tmux_with_line_bounds_and_truncates_output() { + let _guard = env_lock().lock().await; + let dir = tempfile::tempdir().unwrap(); + let args_file = dir.path().join("args.txt"); + write_fake_tmux(dir.path(), "printf '%02000d\\n' 0\nexit 0"); + let _path = EnvRestore::set("PATH", dir.path().as_os_str()); + let _args_file = EnvRestore::set("TMUX_ARGS_FILE", args_file.as_os_str()); + + let output = TmuxCaptureTool + .execute( + "tc", + serde_json::json!({ + "session": "dev", + "window": "logs", + "start_line": -10, + "end_line": -1, + "max_output_bytes": 200 + }), + &make_ctx(), + ) + .await + .unwrap(); + + let args = fs::read_to_string(args_file).unwrap(); + assert_eq!( + args.lines().collect::>(), + vec![ + "capture-pane", + "-p", + "-t", + "ra__dev:logs", + "-S", + "-10", + "-E", + "-1", + ] + ); + + let output = json_output(&output); + assert_eq!(output["ok"], true); + assert_eq!(output["truncated"], true); + assert!(output["stdout"].as_str().unwrap().contains("[truncated]")); +} + +#[tokio::test] +async fn listen_detects_pattern_from_fake_tmux() { + let _guard = env_lock().lock().await; + let dir = tempfile::tempdir().unwrap(); + let args_file = dir.path().join("args.txt"); + let counter_file = dir.path().join("counter.txt"); + write_fake_tmux( + dir.path(), + &format!( + "if [ -f {} ]; then IFS= read -r n < {}; else n=0; fi\n\ + n=$((n + 1))\n\ + printf '%s' \"$n\" > {}\n\ + if [ \"$n\" -ge 2 ]; then printf 'ready\\n'; else printf 'booting\\n'; fi\n\ + exit 0", + counter_file.display(), + counter_file.display(), + counter_file.display() + ), + ); + let _path = EnvRestore::set("PATH", dir.path().as_os_str()); + let _args_file = EnvRestore::set("TMUX_ARGS_FILE", args_file.as_os_str()); + + let output = TmuxListenTool + .execute( + "tl", + serde_json::json!({ + "session": "dev", + "pattern": "ready", + "timeout_ms": 1000, + "poll_ms": 10 + }), + &make_ctx(), + ) + .await + .unwrap(); + + let output = json_output(&output); + assert_eq!(output["ok"], true); + assert_eq!(output["matched"], true); + assert_eq!(output["timed_out"], false); + assert!(output["stdout"].as_str().unwrap().contains("ready")); +} + +#[tokio::test] +async fn real_tmux_round_trip_run_capture_send_listen_and_kill() { + let _guard = env_lock().lock().await; + if which::which("tmux").is_err() { + eprintln!("skipping real tmux round-trip test: tmux not on PATH"); + return; + } + + let session = format!("test{}", std::process::id()); + let ctx = make_ctx(); + + let run = TmuxRunTool + .execute( + "tr", + serde_json::json!({ + "session": session, + "window": "main", + "command": "printf 'alpha\\n'", + "wait": true, + "timeout_ms": 5000, + "max_output_bytes": 10000 + }), + &ctx, + ) + .await + .unwrap(); + let run = json_output(&run); + assert_eq!(run["ok"], true, "run={run}"); + assert!( + run["stdout"].as_str().unwrap().contains("alpha"), + "run={run}" + ); + + let capture = TmuxCaptureTool + .execute( + "tc", + serde_json::json!({ + "session": session, + "window": "main", + "start_line": -20 + }), + &ctx, + ) + .await + .unwrap(); + let capture = json_output(&capture); + assert_eq!(capture["ok"], true, "capture={capture}"); + assert!( + capture["stdout"].as_str().unwrap().contains("alpha"), + "capture={capture}" + ); + + let _send = TmuxSendTool + .execute( + "ts", + serde_json::json!({ + "session": session, + "window": "main", + "keys": "printf 'beta\\n'", + "enter": true + }), + &ctx, + ) + .await + .unwrap(); + + let listen = TmuxListenTool + .execute( + "tl", + serde_json::json!({ + "session": session, + "window": "main", + "pattern": "beta", + "timeout_ms": 5000, + "poll_ms": 100, + "start_line": -20 + }), + &ctx, + ) + .await + .unwrap(); + let listen = json_output(&listen); + assert_eq!(listen["ok"], true, "listen={listen}"); + assert_eq!(listen["matched"], true, "listen={listen}"); + + let kill = TmuxKillTool + .execute("tk", serde_json::json!({ "session": session }), &ctx) + .await + .unwrap(); + let kill = json_output(&kill); + assert_eq!(kill["ok"], true, "kill={kill}"); +} From 5f5e32603d61fe40ae3bc5b8816e64d486b1bfaa Mon Sep 17 00:00:00 2001 From: local Date: Tue, 2 Jun 2026 05:09:03 +0800 Subject: [PATCH 2/5] docs: sync tmux tool scope Co-authored-by: multica-agent --- docs/prd-tmux-native-tool.md | 111 +++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/prd-tmux-native-tool.md diff --git a/docs/prd-tmux-native-tool.md b/docs/prd-tmux-native-tool.md new file mode 100644 index 0000000..9cc317e --- /dev/null +++ b/docs/prd-tmux-native-tool.md @@ -0,0 +1,111 @@ +# PRD: Native Tmux Built-In Tools + +## Overview / Problem Statement + +Ra agents need a persistent terminal surface for long-running dev servers, +watchers, REPLs, and interactive CLIs. The existing `bash` tool is a one-shot +execution path: it runs a command, waits for exit, and returns combined +stdout/stderr. That makes it hard to start a process, inspect output later, +send input across turns, or clean up persistent terminal state. + +## Goals & Success Metrics + +- Agents can create and reuse named tmux sessions without colliding with user + tmux sessions. +- Agents can run a command in tmux in blocking or non-blocking mode. +- Agents can send input/key names to a running pane. +- Agents can capture visible pane content or scrollback ranges. +- Agents can terminate Ra-owned tmux sessions/windows/panes. +- Agents can wait for new pane output or a pattern without opening a daemonized + listener. +- Missing `tmux` returns structured install guidance instead of an opaque spawn + error. +- Focused tests cover parameter handling, missing-binary behavior, argv shape, + catalog registration, and a real tmux round trip when tmux is available. + +## User Personas & Stories + +- As an agent using a dev server, I want to start it once and inspect its output + later so that I do not block a turn while the process stays alive. +- As an agent using a REPL or interactive CLI, I want to send input to an + existing pane so that I can continue the same session across turns. +- As an operator, I want Ra-owned tmux sessions to be namespaced so that agent + tools cannot accidentally target my personal tmux sessions. +- As an operator, I want a cleanup tool so that agent-created terminal state can + be removed deliberately. + +## Functional Requirements + +| Priority | Requirement | +| --- | --- | +| Must | Provide a `tmux_run` built-in tool that creates or reuses a named session/window and runs a command. | +| Must | Support `tmux_run.wait=false` for non-blocking persistent commands and return the target pane metadata. | +| Must | Support `tmux_run.wait=true` for blocking execution with captured pane output and command exit status. | +| Must | Provide a `tmux_send` built-in tool that sends literal input or tmux key names to a target pane. | +| Must | Provide a `tmux_capture` built-in tool that captures pane content with optional `start_line` / `end_line` bounds. | +| Must | Provide a `tmux_kill` built-in tool that terminates Ra-owned sessions, windows, panes, or all `ra__*` sessions. | +| Must | Provide a `tmux_listen` built-in tool that polls until pane output changes or an optional substring/regex appears. | +| Must | Namespace logical session names as `ra__{session}`. | +| Must | Register all five tools in `default_builtins` and respect `[tools].builtin` allow-list filtering. | +| Must | Return structured JSON for tool output, tmux failures, truncation state, and missing-`tmux` guidance. | +| Must | Document JSON schemas and behavior in `spec/tools.md`, README, and sample config. | +| Should | Keep `tmux_listen` bounded by timeout and polling parameters rather than creating a lifecycle daemon. | +| Won't | Add a `[tmux]` config section in this change. | +| Won't | Replace `bash` for one-shot commands. | +| Won't | Inject tmux plugins or custom `tmux.conf` state. | + +## Non-Functional Requirements + +- Use `tokio::process::Command` and explicit argv arrays for tmux invocations. +- Avoid shell string composition for tmux argv; the user command is a shell + command only inside the tmux pane. +- Keep outputs bounded through `max_output_bytes` where pane content is + returned. +- Preserve existing built-in tool behavior and tool allow-list semantics. +- Keep tests deterministic by using fake tmux binaries where possible and + skipping or isolating real tmux integration behavior when tmux is absent. + +## Design Considerations + +Ra should treat tmux session state as local host state. The tools therefore run +tmux locally instead of routing through ACP `terminal/*` reverse calls. Logical +session names are validated and mapped to `ra__{session}` targets so all cleanup +and capture operations remain scoped to Ra-owned sessions. + +`tmux_listen` is a bounded polling primitive, not a background stream. It +captures the pane repeatedly until output changes, an optional pattern matches, +or the timeout expires. + +## Technical Considerations + +Implementation lives in `src/tools/tmux.rs` and follows the existing built-in +`Tool` trait pattern. Registration and exports are in `src/tools/mod.rs` and +`src/lib.rs`; UI title/kind hints are in `src/session_runner.rs`. + +OpenSpec source of truth is archived under +`openspec/changes/archive/2026-06-01-add-native-tmux-tools/`, and the promoted +capability spec is `openspec/specs/tmux-tools/spec.md`. + +## Timeline & Milestones + +| Milestone | Owner | Target | +| --- | --- | --- | +| PRD and GitHub issue scope update | Agent | Before implementation handoff completion | +| OpenSpec propose/apply/archive | Agent | Same change | +| Implementation and tests | Agent | Same PR | +| PR and other top model review | Agent / reviewer | After validation | + +## Open Questions & Risks + +- `tmux_run.wait=true` needs deterministic completion. Ra uses a wrapper script + and `tmux wait-for`; this intentionally respawns the target pane for blocking + runs. +- `tmux_listen` uses polling rather than tmux control mode. This keeps the tool + simple and bounded but is not a live event stream. +- Persistent tmux sessions remain after non-blocking runs until an operator or + agent calls `tmux_kill`. + +## Appendix + +- GitHub issue: `https://github.com/trotsky1997/ra/issues/24` +- Pull request: `https://github.com/trotsky1997/ra/pull/26` From aeecf464338736c498e6c16ba908eeb7437a6ebb Mon Sep 17 00:00:00 2001 From: local Date: Tue, 2 Jun 2026 05:36:08 +0800 Subject: [PATCH 3/5] feat: add tmux_wait tool Co-authored-by: multica-agent --- README.md | 11 +- docs/prd-tmux-native-tool.md | 31 +- .../.openspec.yaml | 2 + .../2026-06-01-add-tmux-wait-tool/design.md | 73 +++ .../2026-06-01-add-tmux-wait-tool/proposal.md | 33 + .../specs/tmux-tools/spec.md | 99 +++ .../2026-06-01-add-tmux-wait-tool/tasks.md | 18 + openspec/specs/tmux-tools/spec.md | 72 ++- spec/ra.toml.example | 3 +- spec/tools.md | 74 ++- src/init.rs | 4 +- src/lib.rs | 3 +- src/session_runner.rs | 9 +- src/tools/mod.rs | 9 +- src/tools/tmux.rs | 604 +++++++++++++++++- tests/tmux_tools.rs | 205 +++++- 16 files changed, 1203 insertions(+), 47 deletions(-) create mode 100644 openspec/changes/archive/2026-06-01-add-tmux-wait-tool/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-01-add-tmux-wait-tool/design.md create mode 100644 openspec/changes/archive/2026-06-01-add-tmux-wait-tool/proposal.md create mode 100644 openspec/changes/archive/2026-06-01-add-tmux-wait-tool/specs/tmux-tools/spec.md create mode 100644 openspec/changes/archive/2026-06-01-add-tmux-wait-tool/tasks.md diff --git a/README.md b/README.md index d176658..15f6dae 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,7 @@ Resolution order: `--config ` → `$RA_CONFIG` → `./ra.toml` → | `tmux_capture` | Captures visible pane content or scrollback from a target pane. | | `tmux_kill` | Kills a Ra-owned tmux session/window/pane, or all `ra__*` sessions. | | `tmux_listen` | Polls a pane until output changes or an optional substring/regex appears. | +| `tmux_wait` | Blocks until a tmux event, hook expression, program result, or sleep timeout resolves. | | `graphify_ensure` / `graphify_impact` / `graphify_update` / `graphify_query` / `graphify_path` / `graphify_explain` | Added when `[graphify]` is enabled; maintains and uses Graphify as Ra's R2A project graph. | Toggle the catalog via `[tools] builtin = […]`; an empty allow-list @@ -252,10 +253,12 @@ interactive prompts), forces `--strict` validation and explicit `error.kind:"missing_openspec"` with install guidance when the CLI is absent. Ra consumes the OpenSpec convention; it does not reimplement the CLI. -`tmux_run`, `tmux_send`, `tmux_capture`, `tmux_kill`, and `tmux_listen` -operate on Ra-owned tmux sessions named `ra__{session}`. If `tmux` is -missing, they return structured install guidance instead of an opaque -spawn error. +`tmux_run`, `tmux_send`, `tmux_capture`, `tmux_kill`, `tmux_listen`, and +`tmux_wait` operate on Ra-owned tmux sessions named `ra__{session}`. +`tmux_listen` and `tmux_wait` share event expression semantics for +`event`, `pattern`, `regex`, and `hook`; `tmux_wait` always requires +`timeout_ms`. If `tmux` is missing, they return structured install +guidance instead of an opaque spawn error. ## Protocols & specs diff --git a/docs/prd-tmux-native-tool.md b/docs/prd-tmux-native-tool.md index 9cc317e..6ca47ff 100644 --- a/docs/prd-tmux-native-tool.md +++ b/docs/prd-tmux-native-tool.md @@ -18,6 +18,8 @@ send input across turns, or clean up persistent terminal state. - Agents can terminate Ra-owned tmux sessions/windows/panes. - Agents can wait for new pane output or a pattern without opening a daemonized listener. +- Agents can actively block on tmux wait events, including program exit, + program output, pane output updates, hook expressions, and bounded sleep. - Missing `tmux` returns structured install guidance instead of an opaque spawn error. - Focused tests cover parameter handling, missing-binary behavior, argv shape, @@ -33,6 +35,9 @@ send input across turns, or clean up persistent terminal state. tools cannot accidentally target my personal tmux sessions. - As an operator, I want a cleanup tool so that agent-created terminal state can be removed deliberately. +- As an agent coordinating a long-running terminal workflow, I want one bounded + wait primitive so that I can wait for completion, output, hooks, or a sleep + interval without guessing with unbounded polling. ## Functional Requirements @@ -44,9 +49,12 @@ send input across turns, or clean up persistent terminal state. | Must | Provide a `tmux_send` built-in tool that sends literal input or tmux key names to a target pane. | | Must | Provide a `tmux_capture` built-in tool that captures pane content with optional `start_line` / `end_line` bounds. | | Must | Provide a `tmux_kill` built-in tool that terminates Ra-owned sessions, windows, panes, or all `ra__*` sessions. | -| Must | Provide a `tmux_listen` built-in tool that polls until pane output changes or an optional substring/regex appears. | +| Must | Provide a `tmux_listen` built-in tool that polls until a shared tmux event expression is observed. | +| Must | Provide a `tmux_wait` built-in tool that blocks until `output_update`, `output_match`, `program_exit`, `program_output`, `hook`, or `sleep` resolves or `timeout_ms` expires. | +| Must | Require `tmux_wait.timeout_ms` so active waits are always bounded. | +| Must | Keep `tmux_listen` and `tmux_wait` on the same event expression semantics for `event`, `pattern`, `regex`, and `hook`. | | Must | Namespace logical session names as `ra__{session}`. | -| Must | Register all five tools in `default_builtins` and respect `[tools].builtin` allow-list filtering. | +| Must | Register all six tools in `default_builtins` and respect `[tools].builtin` allow-list filtering. | | Must | Return structured JSON for tool output, tmux failures, truncation state, and missing-`tmux` guidance. | | Must | Document JSON schemas and behavior in `spec/tools.md`, README, and sample config. | | Should | Keep `tmux_listen` bounded by timeout and polling parameters rather than creating a lifecycle daemon. | @@ -72,9 +80,18 @@ tmux locally instead of routing through ACP `terminal/*` reverse calls. Logical session names are validated and mapped to `ra__{session}` targets so all cleanup and capture operations remain scoped to Ra-owned sessions. -`tmux_listen` is a bounded polling primitive, not a background stream. It -captures the pane repeatedly until output changes, an optional pattern matches, -or the timeout expires. +`tmux_listen` is a bounded polling primitive, not a background stream. +`tmux_wait` is the active blocking companion. Both tools use the same event +expression model: + +- `output_update`: the pane capture changes after the initial snapshot. +- `output_match`: the pane capture matches `pattern`. +- `program_exit`: a supplied command exits. +- `program_output`: a supplied command produces matching output, or any output + when no pattern is supplied. +- `hook`: the pane capture matches the same substring/regex expression with an + optional hook label. +- `sleep`: wait for `duration_ms` bounded by `timeout_ms`. ## Technical Considerations @@ -99,9 +116,11 @@ capability spec is `openspec/specs/tmux-tools/spec.md`. - `tmux_run.wait=true` needs deterministic completion. Ra uses a wrapper script and `tmux wait-for`; this intentionally respawns the target pane for blocking - runs. + runs. `tmux_wait` reuses this behavior for program waits. - `tmux_listen` uses polling rather than tmux control mode. This keeps the tool simple and bounded but is not a live event stream. +- Hook waits are expression-based labels over pane output, not tmux-native + `set-hook` integration. - Persistent tmux sessions remain after non-blocking runs until an operator or agent calls `tmux_kill`. diff --git a/openspec/changes/archive/2026-06-01-add-tmux-wait-tool/.openspec.yaml b/openspec/changes/archive/2026-06-01-add-tmux-wait-tool/.openspec.yaml new file mode 100644 index 0000000..a2168c3 --- /dev/null +++ b/openspec/changes/archive/2026-06-01-add-tmux-wait-tool/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-01 diff --git a/openspec/changes/archive/2026-06-01-add-tmux-wait-tool/design.md b/openspec/changes/archive/2026-06-01-add-tmux-wait-tool/design.md new file mode 100644 index 0000000..40313a6 --- /dev/null +++ b/openspec/changes/archive/2026-06-01-add-tmux-wait-tool/design.md @@ -0,0 +1,73 @@ +## Context + +The current tmux tools include `tmux_run`, `tmux_send`, `tmux_capture`, +`tmux_kill`, and `tmux_listen`. `tmux_listen` polls pane output until it +changes or a substring/regex appears. `tmux_run.wait=true` already has an +internal deterministic completion path using a temporary script and +`tmux wait-for`, but that completion behavior is only reachable as part of +`tmux_run`. + +The new requirement asks for `tmux_wait` as the active blocking version of +`tmux_listen`, with a timeout and support for program completion, program +output/result, tmux output updates, hook triggers, and sleep. + +## Goals / Non-Goals + +**Goals:** + +- Add `tmux_wait` as a first-class built-in tool. +- Keep `tmux_wait` bounded by a caller-supplied `timeout_ms`. +- Share event names and expression matching between `tmux_listen` and + `tmux_wait`. +- Preserve existing `tmux_listen` calls that omit an explicit event. + +**Non-Goals:** + +- Do not add a daemonized listener or tmux control-mode stream. +- Do not add a `[tmux]` config section. +- Do not allow tmux tools to target non-`ra__*` sessions. + +## Decisions + +### Shared Event Expression + +Use a single event enum for both listen and wait: + +- `output_update`: the pane capture changed after the initial snapshot. +- `output_match`: the pane capture matches `pattern`. +- `program_exit`: a supplied `command` finishes before timeout. +- `program_output`: a supplied `command` produces output matching `pattern`, + or any new output when no pattern is supplied. +- `hook`: the pane capture matches the same substring/regex expression, with + an optional `hook` name echoed in the response for caller correlation. +- `sleep`: wait for `duration_ms` without polling tmux. + +For compatibility, `tmux_listen` keeps its existing default: without an +explicit event, it behaves as `output_match` when `pattern` is present and +`output_update` otherwise. + +### Program Waits Reuse the Blocking Run Path + +`tmux_wait` handles `program_exit` and `program_output` by ensuring the target +session/window exists, respawning the pane with the same wrapper-script pattern +used by `tmux_run.wait=true`, and then polling capture output until the wait +event completes or times out. This keeps process completion deterministic and +keeps captured output in the pane for later inspection. + +### Hook Semantics + +This change treats hooks as named wait/listen expressions over tmux pane +output. The same `pattern` and `regex` fields determine when a hook fires, and +the optional `hook` string labels the returned event. This is intentionally a +tool-level event abstraction, not tmux `set-hook` integration. + +## Risks / Trade-offs + +- Polling can miss very transient output if the pane scrollback is too small. + Mitigation: keep `start_line`, `end_line`, and `max_output_bytes` available. +- `program_exit` respawns the target pane for deterministic completion, which + replaces whatever process was in that pane. Mitigation: document this as the + same behavior class as `tmux_run.wait=true`. +- Hook events are expression-based rather than tmux-native hooks. Mitigation: + return the event kind and optional hook label so callers can standardize on + one expression model now and evolve later if tmux-native hooks are needed. diff --git a/openspec/changes/archive/2026-06-01-add-tmux-wait-tool/proposal.md b/openspec/changes/archive/2026-06-01-add-tmux-wait-tool/proposal.md new file mode 100644 index 0000000..02a2b37 --- /dev/null +++ b/openspec/changes/archive/2026-06-01-add-tmux-wait-tool/proposal.md @@ -0,0 +1,33 @@ +## Why + +Agents need a blocking tmux primitive that can wait for concrete terminal +events without starting a separate listener or relying on ad hoc sleeps. The +existing `tmux_listen` covers bounded polling, but the current tmux tool set +does not expose a single active wait tool for program completion, program +output, pane updates, hooks, and deliberate sleep. + +## What Changes + +- Add a `tmux_wait` built-in tool with a required `timeout_ms`. +- Extend the shared tmux event expression model so `tmux_listen` and + `tmux_wait` use the same event names and substring/regex matching semantics. +- Support wait events for `output_update`, `output_match`, `program_exit`, + `program_output`, `hook`, and `sleep`. +- Update PRD, tool docs, config examples, schemas, implementation, and tests. + +## Capabilities + +### New Capabilities + +### Modified Capabilities + +- `tmux-tools`: add the active `tmux_wait` tool and shared event expression + semantics for `tmux_wait` and `tmux_listen`. + +## Impact + +- Affected code: `src/tools/tmux.rs`, built-in registration/export paths, + session runner tool hints, and tmux integration tests. +- Affected docs: tmux PRD, README, tool specification, config examples, and the + GitHub issue / PR scope text. +- No new external dependencies are required. diff --git a/openspec/changes/archive/2026-06-01-add-tmux-wait-tool/specs/tmux-tools/spec.md b/openspec/changes/archive/2026-06-01-add-tmux-wait-tool/specs/tmux-tools/spec.md new file mode 100644 index 0000000..a0068d8 --- /dev/null +++ b/openspec/changes/archive/2026-06-01-add-tmux-wait-tool/specs/tmux-tools/spec.md @@ -0,0 +1,99 @@ +## MODIFIED Requirements + +### Requirement: Native Tmux Tool Catalog + +Ra SHALL include `tmux_run`, `tmux_send`, `tmux_capture`, `tmux_kill`, +`tmux_listen`, and `tmux_wait` in the default built-in catalog when +`[tools].builtin` is empty. + +#### Scenario: Empty allow-list exposes tmux tools + +- **GIVEN** `[tools].builtin` is empty +- **WHEN** Ra builds the default built-in tool catalog +- **THEN** the catalog includes `tmux_run`, `tmux_send`, `tmux_capture`, + `tmux_kill`, `tmux_listen`, and `tmux_wait` + +#### Scenario: Non-empty allow-list remains exact + +- **GIVEN** `[tools].builtin` contains only `tmux_capture` +- **WHEN** Ra builds the default built-in tool catalog +- **THEN** the catalog contains `tmux_capture` and omits the other tmux tools + +### Requirement: Tmux Listen Tool + +Ra SHALL provide a `tmux_listen` tool that polls a pane until a shared tmux +event expression is observed or the timeout expires. + +#### Scenario: Listen sees new output + +- **GIVEN** a pane later emits new output +- **WHEN** `tmux_listen` is called with event `output_update` +- **THEN** Ra returns after the capture changes and includes the latest content + +#### Scenario: Listen pattern timeout + +- **GIVEN** a pane does not emit the requested pattern +- **WHEN** `tmux_listen` reaches its timeout +- **THEN** Ra returns JSON with `timed_out:true` instead of blocking + indefinitely + +#### Scenario: Listen hook expression + +- **GIVEN** a pane later emits text matching a hook expression +- **WHEN** `tmux_listen` is called with event `hook`, a `hook` label, and a + substring or regex pattern +- **THEN** Ra returns JSON identifying the hook event and the matched capture + +## ADDED Requirements + +### Requirement: Tmux Wait Tool + +Ra SHALL provide a `tmux_wait` tool that blocks until a selected tmux wait +event occurs or a required timeout expires. + +#### Scenario: Wait for program exit + +- **WHEN** `tmux_wait` is called with event `program_exit`, a command, and a + timeout +- **THEN** Ra runs the command in the target pane and returns after the command + exits or the timeout expires + +#### Scenario: Wait for program output + +- **WHEN** `tmux_wait` is called with event `program_output`, a command, a + pattern, and a timeout +- **THEN** Ra returns after the command output matches the expression, the + command exits, or the timeout expires + +#### Scenario: Wait for output update + +- **GIVEN** a tmux pane later changes output +- **WHEN** `tmux_wait` is called with event `output_update` and a timeout +- **THEN** Ra returns after the capture changes or the timeout expires + +#### Scenario: Wait for hook trigger + +- **GIVEN** a tmux pane later emits text matching a hook expression +- **WHEN** `tmux_wait` is called with event `hook`, a `hook` label, a pattern, + and a timeout +- **THEN** Ra returns after the hook expression matches or the timeout expires + +#### Scenario: Sleep with timeout + +- **WHEN** `tmux_wait` is called with event `sleep`, `duration_ms`, and + `timeout_ms` +- **THEN** Ra sleeps for the shorter bounded duration and returns structured + timeout state + +### Requirement: Shared Tmux Event Expressions + +Ra SHALL use the same event names and substring/regex expression semantics for +`tmux_listen` and `tmux_wait`. + +#### Scenario: Shared pattern matching + +- **GIVEN** `pattern` and `regex` fields are provided to either `tmux_listen` + or `tmux_wait` +- **WHEN** the selected event requires output matching +- **THEN** Ra evaluates the pattern with the same substring or regex semantics + for both tools diff --git a/openspec/changes/archive/2026-06-01-add-tmux-wait-tool/tasks.md b/openspec/changes/archive/2026-06-01-add-tmux-wait-tool/tasks.md new file mode 100644 index 0000000..2f53838 --- /dev/null +++ b/openspec/changes/archive/2026-06-01-add-tmux-wait-tool/tasks.md @@ -0,0 +1,18 @@ +## 1. Specification And Documentation + +- [x] 1.1 Update PRD, README, tool spec, and config examples for `tmux_wait`. +- [x] 1.2 Update GitHub issue and PR scope text to include `tmux_wait`. + +## 2. Implementation + +- [x] 2.1 Add shared tmux event expression parsing and response metadata. +- [x] 2.2 Implement `tmux_wait` for `output_update`, `output_match`, + `program_exit`, `program_output`, `hook`, and `sleep`. +- [x] 2.3 Register and export `tmux_wait` and add session runner hints. + +## 3. Verification + +- [x] 3.1 Add focused tests for catalog registration, schema behavior, fake tmux + event waits, sleep waits, and real tmux round trip coverage. +- [x] 3.2 Run formatting, Rust tests, and strict OpenSpec validation. +- [x] 3.3 Archive the OpenSpec change after implementation validates. diff --git a/openspec/specs/tmux-tools/spec.md b/openspec/specs/tmux-tools/spec.md index 327e9f6..b4fa3fb 100644 --- a/openspec/specs/tmux-tools/spec.md +++ b/openspec/specs/tmux-tools/spec.md @@ -7,15 +7,16 @@ cleanup. ## Requirements ### Requirement: Native Tmux Tool Catalog -Ra SHALL include `tmux_run`, `tmux_send`, `tmux_capture`, `tmux_kill`, and -`tmux_listen` in the default built-in catalog when `[tools].builtin` is empty. +Ra SHALL include `tmux_run`, `tmux_send`, `tmux_capture`, `tmux_kill`, +`tmux_listen`, and `tmux_wait` in the default built-in catalog when +`[tools].builtin` is empty. #### Scenario: Empty allow-list exposes tmux tools - **GIVEN** `[tools].builtin` is empty - **WHEN** Ra builds the default built-in tool catalog - **THEN** the catalog includes `tmux_run`, `tmux_send`, `tmux_capture`, - `tmux_kill`, and `tmux_listen` + `tmux_kill`, `tmux_listen`, and `tmux_wait` #### Scenario: Non-empty allow-list remains exact @@ -87,13 +88,13 @@ Ra SHALL provide a `tmux_kill` tool that terminates Ra-owned tmux targets. ### Requirement: Tmux Listen Tool -Ra SHALL provide a `tmux_listen` tool that polls a pane until output changes or -an optional pattern is observed. +Ra SHALL provide a `tmux_listen` tool that polls a pane until a shared tmux +event expression is observed or the timeout expires. #### Scenario: Listen sees new output - **GIVEN** a pane later emits new output -- **WHEN** `tmux_listen` is called without a pattern +- **WHEN** `tmux_listen` is called with event `output_update` - **THEN** Ra returns after the capture changes and includes the latest content #### Scenario: Listen pattern timeout @@ -103,6 +104,13 @@ an optional pattern is observed. - **THEN** Ra returns JSON with `timed_out:true` instead of blocking indefinitely +#### Scenario: Listen hook expression + +- **GIVEN** a pane later emits text matching a hook expression +- **WHEN** `tmux_listen` is called with event `hook`, a `hook` label, and a + substring or regex pattern +- **THEN** Ra returns JSON identifying the hook event and the matched capture + ### Requirement: Missing Tmux Guidance Ra SHALL return a structured, actionable response when `tmux` is not available @@ -114,3 +122,55 @@ instead of surfacing an opaque spawn failure. - **WHEN** any tmux tool executes - **THEN** the tool returns JSON with `ok:false`, `error.kind:"missing_tmux"`, and installation guidance + +### Requirement: Tmux Wait Tool + +Ra SHALL provide a `tmux_wait` tool that blocks until a selected tmux wait +event occurs or a required timeout expires. + +#### Scenario: Wait for program exit + +- **WHEN** `tmux_wait` is called with event `program_exit`, a command, and a + timeout +- **THEN** Ra runs the command in the target pane and returns after the command + exits or the timeout expires + +#### Scenario: Wait for program output + +- **WHEN** `tmux_wait` is called with event `program_output`, a command, a + pattern, and a timeout +- **THEN** Ra returns after the command output matches the expression, the + command exits, or the timeout expires + +#### Scenario: Wait for output update + +- **GIVEN** a tmux pane later changes output +- **WHEN** `tmux_wait` is called with event `output_update` and a timeout +- **THEN** Ra returns after the capture changes or the timeout expires + +#### Scenario: Wait for hook trigger + +- **GIVEN** a tmux pane later emits text matching a hook expression +- **WHEN** `tmux_wait` is called with event `hook`, a `hook` label, a pattern, + and a timeout +- **THEN** Ra returns after the hook expression matches or the timeout expires + +#### Scenario: Sleep with timeout + +- **WHEN** `tmux_wait` is called with event `sleep`, `duration_ms`, and + `timeout_ms` +- **THEN** Ra sleeps for the shorter bounded duration and returns structured + timeout state + +### Requirement: Shared Tmux Event Expressions + +Ra SHALL use the same event names and substring/regex expression semantics for +`tmux_listen` and `tmux_wait`. + +#### Scenario: Shared pattern matching + +- **GIVEN** `pattern` and `regex` fields are provided to either `tmux_listen` + or `tmux_wait` +- **WHEN** the selected event requires output matching +- **THEN** Ra evaluates the pattern with the same substring or regex semantics + for both tools diff --git a/spec/ra.toml.example b/spec/ra.toml.example index a46dc8f..91ecc6f 100644 --- a/spec/ra.toml.example +++ b/spec/ra.toml.example @@ -53,9 +53,10 @@ api_key_env = "ANTHROPIC_API_KEY" # tmux_capture — capture tmux pane content or scrollback # tmux_kill — kill Ra-owned tmux targets # tmux_listen — poll a tmux pane for new output or a pattern +# tmux_wait — block until a tmux event, hook expression, program result, or sleep timeout resolves # An empty list (or omitted section) ships every built-in tool. [tools] -# builtin = ["read", "write", "edit", "bash", "ast_grep", "git", "gh", "jq", "grep", "glob", "ls", "fuzzy", "apply_patch", "webfetch_fetch", "webfetch_crawl", "openspec", "tmux_run", "tmux_send", "tmux_capture", "tmux_kill", "tmux_listen"] +# builtin = ["read", "write", "edit", "bash", "ast_grep", "git", "gh", "jq", "grep", "glob", "ls", "fuzzy", "apply_patch", "webfetch_fetch", "webfetch_crawl", "openspec", "tmux_run", "tmux_send", "tmux_capture", "tmux_kill", "tmux_listen", "tmux_wait"] builtin = [] # ─── Skills (Claude Code / agentskills.io) ────────────────────────── diff --git a/spec/tools.md b/spec/tools.md index 82455b9..c407455 100644 --- a/spec/tools.md +++ b/spec/tools.md @@ -117,11 +117,12 @@ Prefer `jq` over `bash` pipelines for JSON filtering. It preserves argv boundaries, feeds input through stdin, returns bounded JSON, and surfaces missing-`jq` installation guidance in a structured response. -Prefer `tmux_run`, `tmux_send`, `tmux_capture`, `tmux_kill`, and -`tmux_listen` over `bash` when the task needs persistent terminal state, -interactive input, or later output inspection. They operate only on -Ra-owned tmux sessions named `ra__{session}` and surface missing-`tmux` -installation guidance in a structured response. +Prefer `tmux_run`, `tmux_send`, `tmux_capture`, `tmux_kill`, +`tmux_listen`, and `tmux_wait` over `bash` when the task needs +persistent terminal state, interactive input, later output inspection, +or bounded waits for tmux events. They operate only on Ra-owned tmux +sessions named `ra__{session}` and surface missing-`tmux` installation +guidance in a structured response. ## Native CLI tools @@ -334,6 +335,24 @@ Every tool returns a JSON envelope: If `tmux` is missing, the envelope has `ok:false` and `error.kind:"missing_tmux"` with installation guidance. +### Shared tmux wait/listen events + +`tmux_listen` and `tmux_wait` use the same event expression model: + +| Event | Meaning | +|-------|---------| +| `output_update` | current pane capture differs from the initial snapshot | +| `output_match` | current pane capture matches `pattern` | +| `program_exit` | `tmux_wait.command` exits before timeout | +| `program_output` | `tmux_wait.command` produces matching output, or any output when `pattern` is omitted | +| `hook` | pane capture matches `pattern`; `hook` labels the event in the response | +| `sleep` | `tmux_wait` sleeps for `duration_ms`, bounded by `timeout_ms` | + +Expression fields are shared: `pattern` is a substring by default, and +`regex:true` interprets it as a Rust regular expression. Hook events use +the same expression fields; they are tool-level labels over pane output, +not tmux-native `set-hook` integration. + ### `tmux_run` Create or reuse a named session/window and run a command. Use @@ -412,13 +431,16 @@ Kill a Ra-owned tmux session/window/pane, or all `ra__*` sessions. ### `tmux_listen` -Poll a pane until output changes or an optional substring/regex appears. -This is a bounded tool call, not a background stream. +Poll a pane until a shared tmux event expression is observed. This is a +bounded tool call, not a background stream. Without an explicit `event`, +the tool keeps compatibility with older calls: it waits for +`output_match` when `pattern` is set and `output_update` otherwise. ```json { "session": "dev", "window": "tests", + "event": "output_match", "pattern": "Finished", "timeout_ms": 10000 } @@ -429,14 +451,52 @@ This is a bounded tool call, not a background stream. | session | string | yes | | logical name; mapped to `ra__{session}` | | window | string | no | `main` | target window | | pane | string | no | | optional pane id/index | +| event | string | no | inferred | `output_update`, `output_match`, or `hook` | | pattern | string | no | | substring or regex to wait for | | regex | boolean | no | `false` | interpret `pattern` as regex | +| hook | string | no | | label returned when `event:"hook"` | | start_line | number | no | | passed to `tmux capture-pane -S` each poll | | end_line | number | no | | passed to `tmux capture-pane -E` each poll | | timeout_ms | number | no | `30000` | maximum listen duration | | poll_ms | number | no | `500` | minimum is clamped to 10ms | | max_output_bytes | number | no | `100000` | bounds returned capture/delta | +### `tmux_wait` + +Block until a selected tmux event occurs or the required `timeout_ms` +expires. Use it as the active blocking companion to `tmux_listen`. +Program waits respawn the target pane with a wrapper script, matching +`tmux_run.wait:true` behavior, so the command exit status is captured +deterministically. + +```json +{ + "session": "dev", + "window": "tests", + "event": "program_output", + "command": "cargo test", + "pattern": "test result:", + "timeout_ms": 120000 +} +``` + +| Field | Type | Required | Default | Notes | +|-------|------|----------|---------|-------| +| event | string | yes | | `output_update`, `output_match`, `program_exit`, `program_output`, `hook`, or `sleep` | +| session | string | unless `sleep` | | logical name; mapped to `ra__{session}` | +| command | string | for program events | | shell command executed inside tmux | +| window | string | no | `main` | target window | +| pane | string | no | | optional pane id/index | +| pattern | string | for `output_match`/`hook` | | substring or regex expression | +| regex | boolean | no | `false` | interpret `pattern` as regex | +| hook | string | no | | label returned when `event:"hook"` | +| start_line | number | no | | passed to `tmux capture-pane -S` each poll | +| end_line | number | no | | passed to `tmux capture-pane -E` each poll | +| timeout_ms | number | yes | | maximum wait duration | +| duration_ms | number | for `sleep` | | sleep duration, bounded by `timeout_ms` | +| poll_ms | number | no | `500` | minimum is clamped to 10ms | +| max_output_bytes | number | no | `100000` | bounds returned capture/delta | + ## Structural search tools ### `ast_grep` diff --git a/src/init.rs b/src/init.rs index 0ffca01..0a1f9ed 100644 --- a/src/init.rs +++ b/src/init.rs @@ -39,8 +39,8 @@ banner = true # Extended tools: grep, glob, ls, fuzzy, apply_patch. # Web docs tools: webfetch_fetch, webfetch_crawl. # OpenSpec tool: openspec. -# Tmux tools: tmux_run, tmux_send, tmux_capture, tmux_kill, tmux_listen. -# builtin = ["read", "write", "edit", "bash", "ast_grep", "git", "gh", "jq", "grep", "glob", "ls", "fuzzy", "apply_patch", "webfetch_fetch", "webfetch_crawl", "openspec", "tmux_run", "tmux_send", "tmux_capture", "tmux_kill", "tmux_listen"] +# Tmux tools: tmux_run, tmux_send, tmux_capture, tmux_kill, tmux_listen, tmux_wait. +# builtin = ["read", "write", "edit", "bash", "ast_grep", "git", "gh", "jq", "grep", "glob", "ls", "fuzzy", "apply_patch", "webfetch_fetch", "webfetch_crawl", "openspec", "tmux_run", "tmux_send", "tmux_capture", "tmux_kill", "tmux_listen", "tmux_wait"] builtin = [] [skills] diff --git a/src/lib.rs b/src/lib.rs index 18ee577..7f4e7be 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,5 +35,6 @@ pub use tools::{ default_builtins, default_builtins_with_cfg, ApplyPatchTool, AstGrepTool, BashTool, EditTool, FuzzyTool, GhTool, GitTool, GlobTool, GrepTool, JqTool, JustTool, LsTool, LspTool, MiseTool, ReadTool, RtkRewriter, TmuxCaptureTool, TmuxKillTool, TmuxListenTool, TmuxRunTool, - TmuxSendTool, Tool, WebfetchCrawlTool, WebfetchFetchTool, WriteTool, WrkflwTool, + TmuxSendTool, TmuxWaitTool, Tool, WebfetchCrawlTool, WebfetchFetchTool, WriteTool, + WrkflwTool, }; diff --git a/src/session_runner.rs b/src/session_runner.rs index 53b657b..e03c7fc 100644 --- a/src/session_runner.rs +++ b/src/session_runner.rs @@ -646,7 +646,9 @@ fn tool_kind(name: &str) -> ToolKindHint { "read" | "jq" | "grep" | "glob" | "ls" | "fuzzy" | "webfetch_fetch" | "webfetch_crawl" | "tmux_capture" | "tmux_listen" => ToolKindHint::Read, "ast_grep" => ToolKindHint::Search, - "bash" | "git" | "gh" | "tmux_run" | "tmux_send" | "tmux_kill" => ToolKindHint::Execute, + "bash" | "git" | "gh" | "tmux_run" | "tmux_send" | "tmux_kill" | "tmux_wait" => { + ToolKindHint::Execute + } _ => ToolKindHint::Other, } } @@ -766,6 +768,11 @@ fn tool_title(name: &str, input: &serde_json::Value) -> String { .and_then(|v| v.as_str()) .map(|session| format!("Listen tmux {session}")) .unwrap_or_else(|| "Listen tmux pane".into()), + "tmux_wait" => input + .get("event") + .and_then(|v| v.as_str()) + .map(|event| format!("Wait tmux {event}")) + .unwrap_or_else(|| "Wait tmux event".into()), "apply_patch" => { if input .get("check_only") diff --git a/src/tools/mod.rs b/src/tools/mod.rs index c953fc0..cc8bd3f 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -29,6 +29,7 @@ //! - `tmux_capture` — capture tmux pane output //! - `tmux_kill` — kill Ra-owned tmux targets //! - `tmux_listen` — poll tmux panes for new output +//! - `tmux_wait` — block until a tmux event or timeout mod cli; mod core; @@ -53,7 +54,9 @@ pub use openspec::OpenSpecTool; pub use rtk::RtkRewriter; pub use search::AstGrepTool; pub use task_workflow::{JustTool, MiseTool, WrkflwTool}; -pub use tmux::{TmuxCaptureTool, TmuxKillTool, TmuxListenTool, TmuxRunTool, TmuxSendTool}; +pub use tmux::{ + TmuxCaptureTool, TmuxKillTool, TmuxListenTool, TmuxRunTool, TmuxSendTool, TmuxWaitTool, +}; pub use webfetch::{WebfetchCrawlTool, WebfetchFetchTool}; use crate::config::OpenlspSection; @@ -151,6 +154,9 @@ pub fn default_builtins_with_cfg( if want("tmux_listen") { out.push(Arc::new(TmuxListenTool)); } + if want("tmux_wait") { + out.push(Arc::new(TmuxWaitTool)); + } if want("lsp") { if let Some(binary) = resolve_openlsp_binary(openlsp_cfg) { out.push(Arc::new(LspTool { @@ -265,6 +271,7 @@ mod tests { "tmux_capture", "tmux_kill", "tmux_listen", + "tmux_wait", ] { assert!( names.contains(&name.to_string()), diff --git a/src/tools/tmux.rs b/src/tools/tmux.rs index 7d6bb36..7e67565 100644 --- a/src/tools/tmux.rs +++ b/src/tools/tmux.rs @@ -30,6 +30,30 @@ const RA_SESSION_PREFIX: &str = "ra__"; const EXIT_MARKER_PREFIX: &str = "__RA_TMUX_EXIT:"; const EXIT_MARKER_SUFFIX: &str = "__"; +#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TmuxEventKind { + OutputUpdate, + OutputMatch, + ProgramExit, + ProgramOutput, + Hook, + Sleep, +} + +impl TmuxEventKind { + fn as_str(self) -> &'static str { + match self { + Self::OutputUpdate => "output_update", + Self::OutputMatch => "output_match", + Self::ProgramExit => "program_exit", + Self::ProgramOutput => "program_output", + Self::Hook => "hook", + Self::Sleep => "sleep", + } + } +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct TmuxRunParams { /// Logical session name. Ra maps this to a tmux session named @@ -131,6 +155,13 @@ pub struct TmuxListenParams { /// Interpret `pattern` as a regex. #[serde(default)] pub regex: bool, + /// Event to listen for. Defaults to `output_match` when `pattern` is set, + /// otherwise `output_update`. + #[serde(default)] + pub event: Option, + /// Optional hook label returned when `event` is `hook`. + #[serde(default)] + pub hook: Option, /// Start line for each `tmux capture-pane -S`. #[serde(default)] pub start_line: Option, @@ -148,11 +179,56 @@ pub struct TmuxListenParams { pub max_output_bytes: usize, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TmuxWaitParams { + /// Event to wait for. + pub event: TmuxEventKind, + /// Logical session name. Required unless `event` is `sleep`. + #[serde(default)] + pub session: Option, + /// Shell command used by `program_exit` and `program_output`. + #[serde(default)] + pub command: Option, + /// Window name. Defaults to `main`. + #[serde(default)] + pub window: Option, + /// Optional pane index/id within the window. + #[serde(default)] + pub pane: Option, + /// Optional substring or regex expression for output and hook events. + #[serde(default)] + pub pattern: Option, + /// Interpret `pattern` as a regex. + #[serde(default)] + pub regex: bool, + /// Optional hook label returned when `event` is `hook`. + #[serde(default)] + pub hook: Option, + /// Start line for each `tmux capture-pane -S`. + #[serde(default)] + pub start_line: Option, + /// End line for each `tmux capture-pane -E`. + #[serde(default)] + pub end_line: Option, + /// Required wait timeout in milliseconds. + pub timeout_ms: u64, + /// Sleep duration in milliseconds when `event` is `sleep`. + #[serde(default)] + pub duration_ms: Option, + /// Poll interval in milliseconds. Defaults to 500. + #[serde(default)] + pub poll_ms: Option, + /// Maximum bytes returned in captured stdout fields. + #[serde(default = "default_max_output_bytes")] + pub max_output_bytes: usize, +} + pub struct TmuxRunTool; pub struct TmuxSendTool; pub struct TmuxCaptureTool; pub struct TmuxKillTool; pub struct TmuxListenTool; +pub struct TmuxWaitTool; #[async_trait] impl Tool for TmuxRunTool { @@ -298,6 +374,36 @@ impl Tool for TmuxListenTool { } } +#[async_trait] +impl Tool for TmuxWaitTool { + fn name(&self) -> &str { + "tmux_wait" + } + + fn description(&self) -> &str { + "Block until a tmux wait event occurs or `timeout_ms` expires. Supports \ + output_update, output_match, program_exit, program_output, hook, and \ + sleep using the same substring/regex expression semantics as \ + tmux_listen." + } + + fn schema(&self) -> serde_json::Value { + serde_json::to_value(schema_for!(TmuxWaitParams)).unwrap() + } + + async fn execute( + &self, + call_id: &str, + input: serde_json::Value, + ctx: &ToolCtx, + ) -> Result { + let _scope = crate::nemo_obs::tool_scope("tmux_wait"); + let params: TmuxWaitParams = + serde_json::from_value(input).context("invalid params for tmux_wait")?; + execute_tmux_wait(call_id, params, ctx).await + } +} + async fn execute_tmux_run(call_id: &str, params: TmuxRunParams, ctx: &ToolCtx) -> Result { if params.command.trim().is_empty() { return Err(anyhow!("tmux_run requires a non-empty command")); @@ -485,7 +591,7 @@ async fn execute_tmux_listen( Some(tmux) => tmux, None => return Ok(missing_tmux_json("tmux_listen", &args)), }; - let matcher = OutputMatcher::new(params.pattern.as_deref(), params.regex)?; + let event = ListenEvent::from_listen(¶ms)?; let timeout = Duration::from_millis(params.timeout_ms.unwrap_or(DEFAULT_LISTEN_TIMEOUT_MS)); let poll = Duration::from_millis(params.poll_ms.unwrap_or(DEFAULT_LISTEN_POLL_MS).max(10)); @@ -501,14 +607,15 @@ async fn execute_tmux_listen( ); } - if matcher.matches(&initial.stdout) { + let initial_eval = event.evaluate(&initial.stdout, &initial.stdout); + if initial_eval.triggered { return listen_response( &args, &target, + &event, initial.stdout, "", - false, - true, + initial_eval, false, params.max_output_bytes, ); @@ -532,17 +639,16 @@ async fn execute_tmux_listen( ); } latest = output.stdout; - let matched = matcher.matches(&latest); - let changed = latest != initial.stdout; - if matched || (matcher.is_none() && changed) { + let eval = event.evaluate(&initial.stdout, &latest); + if eval.triggered { let delta = text_delta(&initial.stdout, &latest); return listen_response( &args, &target, + &event, latest, &delta, - changed, - matched, + eval, false, params.max_output_bytes, ); @@ -550,20 +656,253 @@ async fn execute_tmux_listen( } let delta = text_delta(&initial.stdout, &latest); - let changed = latest != initial.stdout; - let matched = matcher.matches(&latest); + let eval = event.evaluate(&initial.stdout, &latest); listen_response( &args, &target, + &event, latest, &delta, - changed, - matched, + eval, true, params.max_output_bytes, ) } +async fn execute_tmux_wait(call_id: &str, params: TmuxWaitParams, ctx: &ToolCtx) -> Result { + match params.event { + TmuxEventKind::Sleep => execute_tmux_wait_sleep(params).await, + TmuxEventKind::OutputUpdate | TmuxEventKind::OutputMatch | TmuxEventKind::Hook => { + execute_tmux_wait_pane(call_id, params, ctx).await + } + TmuxEventKind::ProgramExit | TmuxEventKind::ProgramOutput => { + execute_tmux_wait_program(call_id, params, ctx).await + } + } +} + +async fn execute_tmux_wait_sleep(params: TmuxWaitParams) -> Result { + let duration_ms = params + .duration_ms + .ok_or_else(|| anyhow!("tmux_wait sleep requires duration_ms"))?; + let timeout = Duration::from_millis(params.timeout_ms); + let duration = Duration::from_millis(duration_ms); + let started = tokio::time::Instant::now(); + let timed_out = duration > timeout; + tokio::time::sleep(duration.min(timeout)).await; + wait_response(WaitResponse { + args: &[], + target: None, + event: &WaitEvent::new(TmuxEventKind::Sleep, None, false, None)?, + stdout: String::new(), + delta: String::new(), + eval: EventEvaluation { + changed: false, + matched: false, + triggered: !timed_out, + }, + timed_out, + exit_code: 0, + command_exit_code: None, + capture_exit_code: None, + stderr: String::new(), + elapsed_ms: elapsed_millis(started.elapsed()), + max_output_bytes: params.max_output_bytes, + }) +} + +async fn execute_tmux_wait_pane( + call_id: &str, + params: TmuxWaitParams, + ctx: &ToolCtx, +) -> Result { + let target = wait_target(¶ms)?; + let args = capture_args(&target, params.start_line, params.end_line); + let tmux = match find_tmux_binary() { + Some(tmux) => tmux, + None => return Ok(missing_tmux_json("tmux_wait", &args)), + }; + let event = WaitEvent::from_wait(¶ms)?; + let timeout = Duration::from_millis(params.timeout_ms); + let poll = Duration::from_millis(params.poll_ms.unwrap_or(DEFAULT_LISTEN_POLL_MS).max(10)); + + let initial = + capture_for_listen(call_id, &tmux, &args, &target, ctx, params.max_output_bytes).await?; + if initial.exit_code != 0 { + return process_response( + "tmux_wait", + &args, + &target, + initial, + params.max_output_bytes, + ); + } + + let started = tokio::time::Instant::now(); + let mut latest = initial.stdout.clone(); + loop { + let eval = event.evaluate(&initial.stdout, &latest); + if eval.triggered || started.elapsed() >= timeout { + let delta = text_delta(&initial.stdout, &latest); + return wait_response(WaitResponse { + args: &args, + target: Some(&target), + event: &event, + stdout: latest, + delta, + eval, + timed_out: !eval.triggered, + exit_code: 0, + command_exit_code: None, + capture_exit_code: None, + stderr: String::new(), + elapsed_ms: elapsed_millis(started.elapsed()), + max_output_bytes: params.max_output_bytes, + }); + } + + let remaining = timeout.saturating_sub(started.elapsed()); + tokio::time::sleep(poll.min(remaining)).await; + let output = + capture_for_listen(call_id, &tmux, &args, &target, ctx, params.max_output_bytes) + .await?; + if output.exit_code != 0 { + return process_response("tmux_wait", &args, &target, output, params.max_output_bytes); + } + latest = output.stdout; + } +} + +async fn execute_tmux_wait_program( + call_id: &str, + params: TmuxWaitParams, + ctx: &ToolCtx, +) -> Result { + let command = params + .command + .as_deref() + .ok_or_else(|| anyhow!("tmux_wait {:?} requires command", params.event))?; + if command.trim().is_empty() { + return Err(anyhow!( + "tmux_wait {:?} requires a non-empty command", + params.event + )); + } + let target = wait_target(¶ms)?; + let tmux = match find_tmux_binary() { + Some(tmux) => tmux, + None => { + return Ok(missing_tmux_json( + "tmux_wait", + &["respawn-pane".to_string(), "-t".to_string(), target.target], + )) + } + }; + let event = WaitEvent::from_wait(¶ms)?; + + if let Some(failure) = ensure_session_window(&tmux, &target, &ctx.cwd).await? { + let args = failure.args.clone(); + return process_response( + "tmux_wait", + &args, + &target, + failure, + params.max_output_bytes, + ); + } + + let tempdir = tempfile::tempdir().context("create tmux wait script directory")?; + let script_path = tempdir.path().join("ra-tmux-wait.sh"); + let token = format!("ra_tmux_wait_{}", unique_token()); + let script = wait_script(command, &token); + tokio::fs::write(&script_path, script) + .await + .with_context(|| format!("write {}", script_path.display()))?; + + let shell_command = format!("/bin/sh {}", shell_word(&script_path.to_string_lossy())); + let start_args = vec![ + "respawn-pane".to_string(), + "-k".to_string(), + "-t".to_string(), + target.target.clone(), + "-c".to_string(), + ctx.cwd.to_string_lossy().to_string(), + shell_command, + ]; + emit_invocation(ctx, call_id, &start_args); + let start = run_tmux(&tmux, &start_args).await?; + emit_exit(ctx, call_id, start.exit_code); + if start.exit_code != 0 { + return process_response( + "tmux_wait", + &start_args, + &target, + start, + params.max_output_bytes, + ); + } + + let capture_start = params.start_line.or(Some(RUN_WAIT_CAPTURE_START)); + let capture_args = capture_args(&target, capture_start, params.end_line); + let timeout = Duration::from_millis(params.timeout_ms); + let poll = Duration::from_millis(params.poll_ms.unwrap_or(DEFAULT_LISTEN_POLL_MS).max(10)); + let started = tokio::time::Instant::now(); + + loop { + let capture = capture_for_listen( + call_id, + &tmux, + &capture_args, + &target, + ctx, + params.max_output_bytes, + ) + .await?; + let capture_exit_code = capture.exit_code; + if capture.exit_code != 0 { + return process_response( + "tmux_wait", + &capture_args, + &target, + capture, + params.max_output_bytes, + ); + } + + let (stdout, command_exit_code) = strip_exit_marker(&capture.stdout); + let eval = event.evaluate_program_output(&stdout, command_exit_code); + let terminal = + eval.triggered || command_exit_code.is_some() || started.elapsed() >= timeout; + if terminal { + let timed_out = !eval.triggered && command_exit_code.is_none(); + let exit_code = if timed_out { -1 } else { 0 }; + let stderr = if timed_out { + format!("timed out after {} ms", timeout.as_millis()) + } else { + capture.stderr + }; + return wait_response(WaitResponse { + args: &capture_args, + target: Some(&target), + event: &event, + stdout, + delta: String::new(), + eval, + timed_out, + exit_code, + command_exit_code, + capture_exit_code: Some(capture_exit_code), + stderr, + elapsed_ms: elapsed_millis(started.elapsed()), + max_output_bytes: params.max_output_bytes, + }); + } + + let remaining = timeout.saturating_sub(started.elapsed()); + tokio::time::sleep(poll.min(remaining)).await; + } +} + async fn run_nonblocking( call_id: &str, tmux: &Path, @@ -994,10 +1333,10 @@ fn process_response( fn listen_response( args: &[String], target: &TmuxTarget, + event: &ListenEvent, stdout: String, delta: &str, - changed: bool, - matched: bool, + eval: EventEvaluation, timed_out: bool, max_output_bytes: usize, ) -> Result { @@ -1008,8 +1347,10 @@ fn listen_response( "tool": "tmux_listen", "target": target, "command": command_metadata(args), - "changed": changed, - "matched": matched, + "event": event.metadata(), + "changed": eval.changed, + "matched": eval.matched, + "triggered": eval.triggered, "timed_out": timed_out, "exit_code": 0, "stdout": stdout, @@ -1020,6 +1361,64 @@ fn listen_response( .context("serialize tmux_listen output") } +struct WaitResponse<'a> { + args: &'a [String], + target: Option<&'a TmuxTarget>, + event: &'a WaitEvent, + stdout: String, + delta: String, + eval: EventEvaluation, + timed_out: bool, + exit_code: i32, + command_exit_code: Option, + capture_exit_code: Option, + stderr: String, + elapsed_ms: u128, + max_output_bytes: usize, +} + +fn wait_response(response: WaitResponse<'_>) -> Result { + let WaitResponse { + args, + target, + event, + stdout, + delta, + eval, + timed_out, + exit_code, + command_exit_code, + capture_exit_code, + stderr, + elapsed_ms, + max_output_bytes, + } = response; + let (stdout, stdout_truncated) = trim_to_byte_budget(stdout, max_output_bytes); + let (delta, delta_truncated) = trim_to_byte_budget(delta, max_output_bytes); + let (stderr, stderr_truncated) = trim_to_byte_budget(stderr, DEFAULT_MAX_OUTPUT_BYTES); + + serde_json::to_string_pretty(&json!({ + "ok": eval.triggered && !timed_out, + "tool": "tmux_wait", + "target": target, + "command": command_metadata(args), + "event": event.metadata(), + "changed": eval.changed, + "matched": eval.matched, + "triggered": eval.triggered, + "timed_out": timed_out, + "exit_code": exit_code, + "command_exit_code": command_exit_code, + "capture_exit_code": capture_exit_code, + "elapsed_ms": elapsed_ms, + "stdout": stdout, + "delta": delta, + "stderr": if stderr.is_empty() { serde_json::Value::Null } else { json!(stderr) }, + "truncated": stdout_truncated || delta_truncated || stderr_truncated, + })) + .context("serialize tmux_wait output") +} + fn missing_tmux_json(tool: &str, args: &[String]) -> String { serde_json::to_string_pretty(&json!({ "ok": false, @@ -1151,6 +1550,177 @@ impl OutputMatcher { } } +struct ListenEvent { + inner: WaitEvent, +} + +impl ListenEvent { + fn from_listen(params: &TmuxListenParams) -> Result { + let kind = params.event.unwrap_or_else(|| { + if params + .pattern + .as_deref() + .is_some_and(|pattern| !pattern.is_empty()) + { + TmuxEventKind::OutputMatch + } else { + TmuxEventKind::OutputUpdate + } + }); + match kind { + TmuxEventKind::OutputUpdate | TmuxEventKind::OutputMatch | TmuxEventKind::Hook => {} + TmuxEventKind::ProgramExit | TmuxEventKind::ProgramOutput | TmuxEventKind::Sleep => { + return Err(anyhow!( + "tmux_listen supports output_update, output_match, and hook events" + )); + } + } + Ok(Self { + inner: WaitEvent::new( + kind, + params.pattern.as_deref(), + params.regex, + params.hook.as_deref(), + )?, + }) + } + + fn evaluate(&self, initial: &str, latest: &str) -> EventEvaluation { + self.inner.evaluate_pane(initial, latest) + } + + fn metadata(&self) -> serde_json::Value { + self.inner.metadata() + } +} + +struct WaitEvent { + kind: TmuxEventKind, + matcher: OutputMatcher, + hook: Option, +} + +impl WaitEvent { + fn from_wait(params: &TmuxWaitParams) -> Result { + Self::new( + params.event, + params.pattern.as_deref(), + params.regex, + params.hook.as_deref(), + ) + } + + fn new( + kind: TmuxEventKind, + pattern: Option<&str>, + regex: bool, + hook: Option<&str>, + ) -> Result { + let matcher = OutputMatcher::new(pattern, regex)?; + match kind { + TmuxEventKind::OutputMatch | TmuxEventKind::Hook => { + if matcher.is_none() { + return Err(anyhow!("tmux {} event requires pattern", kind.as_str())); + } + } + TmuxEventKind::ProgramOutput => {} + TmuxEventKind::OutputUpdate | TmuxEventKind::ProgramExit | TmuxEventKind::Sleep => {} + } + Ok(Self { + kind, + matcher, + hook: hook.map(ToString::to_string), + }) + } + + fn evaluate(&self, initial: &str, latest: &str) -> EventEvaluation { + self.evaluate_pane(initial, latest) + } + + fn evaluate_pane(&self, initial: &str, latest: &str) -> EventEvaluation { + let changed = latest != initial; + let matched = self.matcher.matches(latest); + let triggered = match self.kind { + TmuxEventKind::OutputUpdate => changed, + TmuxEventKind::OutputMatch | TmuxEventKind::Hook => matched, + TmuxEventKind::ProgramOutput => { + if self.matcher.is_none() { + changed && !latest.is_empty() + } else { + matched + } + } + TmuxEventKind::ProgramExit | TmuxEventKind::Sleep => false, + }; + EventEvaluation { + changed, + matched, + triggered, + } + } + + fn evaluate_program_output( + &self, + stdout: &str, + command_exit_code: Option, + ) -> EventEvaluation { + let matched = self.matcher.matches(stdout); + let triggered = match self.kind { + TmuxEventKind::ProgramExit => command_exit_code.is_some(), + TmuxEventKind::ProgramOutput => { + if self.matcher.is_none() { + !stdout.is_empty() + } else { + matched + } + } + _ => self.evaluate_pane("", stdout).triggered, + }; + EventEvaluation { + changed: !stdout.is_empty(), + matched, + triggered, + } + } + + fn metadata(&self) -> serde_json::Value { + json!({ + "kind": self.kind.as_str(), + "hook": self.hook, + "matcher": self.matcher.metadata(), + }) + } +} + +#[derive(Clone, Copy)] +struct EventEvaluation { + changed: bool, + matched: bool, + triggered: bool, +} + +impl OutputMatcher { + fn metadata(&self) -> serde_json::Value { + match self { + Self::None => json!({ "type": "none", "pattern": null }), + Self::Substring(pattern) => json!({ "type": "substring", "pattern": pattern }), + Self::Regex(regex) => json!({ "type": "regex", "pattern": regex.as_str() }), + } + } +} + +fn wait_target(params: &TmuxWaitParams) -> Result { + let session = params + .session + .as_deref() + .ok_or_else(|| anyhow!("tmux_wait {} requires session", params.event.as_str()))?; + TmuxTarget::new(session, params.window.as_deref(), params.pane.as_deref()) +} + +fn elapsed_millis(duration: Duration) -> u128 { + duration.as_millis() +} + fn validate_name(field: &str, value: &str, allow_percent: bool) -> Result { let value = value.trim(); if value.is_empty() { diff --git a/tests/tmux_tools.rs b/tests/tmux_tools.rs index 01a13d2..95d81e6 100644 --- a/tests/tmux_tools.rs +++ b/tests/tmux_tools.rs @@ -1,7 +1,10 @@ //! Integration tests for native tmux tools. use ra::{ - tools::{TmuxCaptureTool, TmuxKillTool, TmuxListenTool, TmuxRunTool, TmuxSendTool, Tool}, + tools::{ + TmuxCaptureTool, TmuxKillTool, TmuxListenTool, TmuxRunTool, TmuxSendTool, TmuxWaitTool, + Tool, + }, Event, ToolCtx, }; use serde_json::Value; @@ -74,6 +77,7 @@ fn default_catalog_contains_tmux_tools_and_allowlist_is_exact() { "tmux_capture", "tmux_kill", "tmux_listen", + "tmux_wait", ] { assert!( names.contains(&name.to_string()), @@ -88,6 +92,23 @@ fn default_catalog_contains_tmux_tools_and_allowlist_is_exact() { assert_eq!(filtered, vec!["tmux_capture"]); } +#[tokio::test] +async fn wait_requires_timeout_in_schema_params() { + let err = TmuxWaitTool + .execute( + "tw", + serde_json::json!({ + "event": "sleep", + "duration_ms": 1 + }), + &make_ctx(), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("invalid params for tmux_wait")); +} + #[tokio::test] async fn capture_returns_structured_missing_tmux_guidance() { let _guard = env_lock().lock().await; @@ -113,6 +134,29 @@ async fn capture_returns_structured_missing_tmux_guidance() { .contains("Install tmux")); } +#[tokio::test] +async fn wait_sleep_respects_timeout() { + let output = TmuxWaitTool + .execute( + "tw", + serde_json::json!({ + "event": "sleep", + "duration_ms": 50, + "timeout_ms": 5 + }), + &make_ctx(), + ) + .await + .unwrap(); + + let output = json_output(&output); + assert_eq!(output["ok"], false); + assert_eq!(output["tool"], "tmux_wait"); + assert_eq!(output["event"]["kind"], "sleep"); + assert_eq!(output["timed_out"], true); + assert_eq!(output["triggered"], false); +} + #[tokio::test] async fn send_executes_fake_tmux_with_expected_argv_and_events() { let _guard = env_lock().lock().await; @@ -253,6 +297,119 @@ async fn listen_detects_pattern_from_fake_tmux() { assert!(output["stdout"].as_str().unwrap().contains("ready")); } +#[tokio::test] +async fn listen_and_wait_share_hook_expression_semantics() { + let _guard = env_lock().lock().await; + let dir = tempfile::tempdir().unwrap(); + let args_file = dir.path().join("args.txt"); + let counter_file = dir.path().join("counter.txt"); + write_fake_tmux( + dir.path(), + &format!( + "if [ -f {} ]; then IFS= read -r n < {}; else n=0; fi\n\ + n=$((n + 1))\n\ + printf '%s' \"$n\" > {}\n\ + if [ \"$n\" -ge 2 ]; then printf 'HOOK_READY job=42\\n'; else printf 'idle\\n'; fi\n\ + exit 0", + counter_file.display(), + counter_file.display(), + counter_file.display() + ), + ); + let _path = EnvRestore::set("PATH", dir.path().as_os_str()); + let _args_file = EnvRestore::set("TMUX_ARGS_FILE", args_file.as_os_str()); + + let listen = TmuxListenTool + .execute( + "tl", + serde_json::json!({ + "session": "dev", + "event": "hook", + "hook": "ready", + "pattern": "HOOK_READY job=\\d+", + "regex": true, + "timeout_ms": 1000, + "poll_ms": 10 + }), + &make_ctx(), + ) + .await + .unwrap(); + let listen = json_output(&listen); + assert_eq!(listen["ok"], true); + assert_eq!(listen["event"]["kind"], "hook"); + assert_eq!(listen["event"]["hook"], "ready"); + assert_eq!(listen["matched"], true); + + fs::write(&counter_file, "0").unwrap(); + let wait = TmuxWaitTool + .execute( + "tw", + serde_json::json!({ + "session": "dev", + "event": "hook", + "hook": "ready", + "pattern": "HOOK_READY job=\\d+", + "regex": true, + "timeout_ms": 1000, + "poll_ms": 10 + }), + &make_ctx(), + ) + .await + .unwrap(); + let wait = json_output(&wait); + assert_eq!(wait["ok"], true); + assert_eq!(wait["event"]["kind"], "hook"); + assert_eq!(wait["event"]["hook"], "ready"); + assert_eq!(wait["matched"], true); +} + +#[tokio::test] +async fn wait_detects_output_update_from_fake_tmux() { + let _guard = env_lock().lock().await; + let dir = tempfile::tempdir().unwrap(); + let args_file = dir.path().join("args.txt"); + let counter_file = dir.path().join("counter.txt"); + write_fake_tmux( + dir.path(), + &format!( + "if [ -f {} ]; then IFS= read -r n < {}; else n=0; fi\n\ + n=$((n + 1))\n\ + printf '%s' \"$n\" > {}\n\ + if [ \"$n\" -ge 2 ]; then printf 'new output\\n'; else printf 'old output\\n'; fi\n\ + exit 0", + counter_file.display(), + counter_file.display(), + counter_file.display() + ), + ); + let _path = EnvRestore::set("PATH", dir.path().as_os_str()); + let _args_file = EnvRestore::set("TMUX_ARGS_FILE", args_file.as_os_str()); + + let output = TmuxWaitTool + .execute( + "tw", + serde_json::json!({ + "session": "dev", + "event": "output_update", + "timeout_ms": 1000, + "poll_ms": 10 + }), + &make_ctx(), + ) + .await + .unwrap(); + + let output = json_output(&output); + assert_eq!(output["ok"], true); + assert_eq!(output["tool"], "tmux_wait"); + assert_eq!(output["event"]["kind"], "output_update"); + assert_eq!(output["changed"], true); + assert_eq!(output["timed_out"], false); + assert!(output["stdout"].as_str().unwrap().contains("new output")); +} + #[tokio::test] async fn real_tmux_round_trip_run_capture_send_listen_and_kill() { let _guard = env_lock().lock().await; @@ -338,6 +495,52 @@ async fn real_tmux_round_trip_run_capture_send_listen_and_kill() { assert_eq!(listen["ok"], true, "listen={listen}"); assert_eq!(listen["matched"], true, "listen={listen}"); + let wait = TmuxWaitTool + .execute( + "tw", + serde_json::json!({ + "session": session, + "window": "main", + "event": "program_output", + "command": "printf 'gamma\\n'", + "pattern": "gamma", + "timeout_ms": 5000, + "poll_ms": 100, + "max_output_bytes": 10000 + }), + &ctx, + ) + .await + .unwrap(); + let wait = json_output(&wait); + assert_eq!(wait["ok"], true, "wait={wait}"); + assert_eq!(wait["event"]["kind"], "program_output", "wait={wait}"); + assert_eq!(wait["matched"], true, "wait={wait}"); + assert!( + wait["stdout"].as_str().unwrap().contains("gamma"), + "wait={wait}" + ); + + let wait_exit = TmuxWaitTool + .execute( + "twx", + serde_json::json!({ + "session": session, + "window": "main", + "event": "program_exit", + "command": "exit 3", + "timeout_ms": 5000, + "poll_ms": 100, + "max_output_bytes": 10000 + }), + &ctx, + ) + .await + .unwrap(); + let wait_exit = json_output(&wait_exit); + assert_eq!(wait_exit["ok"], true, "wait_exit={wait_exit}"); + assert_eq!(wait_exit["command_exit_code"], 3, "wait_exit={wait_exit}"); + let kill = TmuxKillTool .execute("tk", serde_json::json!({ "session": session }), &ctx) .await From 319bcde7d9b37e42a5bf5c8b7fcdd48762674046 Mon Sep 17 00:00:00 2001 From: local Date: Tue, 2 Jun 2026 06:12:14 +0800 Subject: [PATCH 4/5] fix: address PR review findings - validate_name: disallow '.' in window names to prevent ambiguous tmux target parsing (session:window.pane). Sessions and panes still allow '.'; only windows are restricted. - send_args: insert '--' before literal keys payload so leading '-' characters are not parsed as tmux flags. - send_args: remove dead include_enter parameter (always false at all call sites; Enter is sent separately). - listen_response: wrap 8 positional args into ListenResponse struct to satisfy clippy's too-many-arguments lint. - text_delta: replace manual prefix-strip with strip_prefix(). - tests: update argv assertions to include '--' separator. Co-authored-by: multica-agent --- src/tools/tmux.rs | 137 +++++++++++++++++++++++++------------------- tests/tmux_tools.rs | 1 + 2 files changed, 78 insertions(+), 60 deletions(-) diff --git a/src/tools/tmux.rs b/src/tools/tmux.rs index 7e67565..52f154f 100644 --- a/src/tools/tmux.rs +++ b/src/tools/tmux.rs @@ -439,14 +439,14 @@ async fn execute_tmux_send(call_id: &str, params: TmuxSendParams, ctx: &ToolCtx) None => { return Ok(missing_tmux_json( "tmux_send", - &send_args(&target, ¶ms, false), + &send_args(&target, ¶ms), )) } }; let mut last = None; if !params.keys.is_empty() { - let args = send_args(&target, ¶ms, false); + let args = send_args(&target, ¶ms); emit_invocation(ctx, call_id, &args); let output = run_tmux(&tmux, &args).await?; let exit_code = output.exit_code; @@ -609,16 +609,16 @@ async fn execute_tmux_listen( let initial_eval = event.evaluate(&initial.stdout, &initial.stdout); if initial_eval.triggered { - return listen_response( - &args, - &target, - &event, - initial.stdout, - "", - initial_eval, - false, - params.max_output_bytes, - ); + return listen_response(ListenResponse { + args: &args, + target: &target, + event: &event, + stdout: initial.stdout, + delta: String::new(), + eval: initial_eval, + timed_out: false, + max_output_bytes: params.max_output_bytes, + }); } let started = tokio::time::Instant::now(); @@ -642,31 +642,31 @@ async fn execute_tmux_listen( let eval = event.evaluate(&initial.stdout, &latest); if eval.triggered { let delta = text_delta(&initial.stdout, &latest); - return listen_response( - &args, - &target, - &event, - latest, - &delta, + return listen_response(ListenResponse { + args: &args, + target: &target, + event: &event, + stdout: latest, + delta, eval, - false, - params.max_output_bytes, - ); + timed_out: false, + max_output_bytes: params.max_output_bytes, + }); } } let delta = text_delta(&initial.stdout, &latest); let eval = event.evaluate(&initial.stdout, &latest); - listen_response( - &args, - &target, - &event, - latest, - &delta, + listen_response(ListenResponse { + args: &args, + target: &target, + event: &event, + stdout: latest, + delta, eval, - true, - params.max_output_bytes, - ) + timed_out: true, + max_output_bytes: params.max_output_bytes, + }) } async fn execute_tmux_wait(call_id: &str, params: TmuxWaitParams, ctx: &ToolCtx) -> Result { @@ -931,7 +931,7 @@ async fn run_nonblocking( enter: true, literal: true, }; - let args = send_args(target, &send, false); + let args = send_args(target, &send); emit_invocation(ctx, call_id, &args); let first = run_tmux(tmux, &args).await?; emit_exit(ctx, call_id, first.exit_code); @@ -1194,7 +1194,7 @@ fn new_window_args(target: &TmuxTarget, command: Option<&str>, cwd: &Path) -> Ve args } -fn send_args(target: &TmuxTarget, params: &TmuxSendParams, include_enter: bool) -> Vec { +fn send_args(target: &TmuxTarget, params: &TmuxSendParams) -> Vec { let mut args = vec![ "send-keys".to_string(), "-t".to_string(), @@ -1202,6 +1202,8 @@ fn send_args(target: &TmuxTarget, params: &TmuxSendParams, include_enter: bool) ]; if params.literal { args.push("-l".to_string()); + // `--` prevents tmux from interpreting a leading `-` in the keys as a flag. + args.push("--".to_string()); args.push(params.keys.clone()); } else { args.extend( @@ -1212,9 +1214,6 @@ fn send_args(target: &TmuxTarget, params: &TmuxSendParams, include_enter: bool) .map(ToString::to_string), ); } - if include_enter && params.enter { - args.push("Enter".to_string()); - } args } @@ -1330,18 +1329,30 @@ fn process_response( .context("serialize tmux tool output") } -fn listen_response( - args: &[String], - target: &TmuxTarget, - event: &ListenEvent, +struct ListenResponse<'a> { + args: &'a [String], + target: &'a TmuxTarget, + event: &'a ListenEvent, stdout: String, - delta: &str, + delta: String, eval: EventEvaluation, timed_out: bool, max_output_bytes: usize, -) -> Result { +} + +fn listen_response(r: ListenResponse<'_>) -> Result { + let ListenResponse { + args, + target, + event, + stdout, + delta, + eval, + timed_out, + max_output_bytes, + } = r; let (stdout, stdout_truncated) = trim_to_byte_budget(stdout, max_output_bytes); - let (delta, delta_truncated) = trim_to_byte_budget(delta.to_string(), max_output_bytes); + let (delta, delta_truncated) = trim_to_byte_budget(delta, max_output_bytes); serde_json::to_string_pretty(&json!({ "ok": !timed_out, "tool": "tmux_listen", @@ -1477,14 +1488,16 @@ struct TmuxTarget { impl TmuxTarget { fn new(session: &str, window: Option<&str>, pane: Option<&str>) -> Result { - let logical_session = validate_name("session", session, false)?; + let logical_session = validate_name("session", session, true, false)?; let session = format!("{RA_SESSION_PREFIX}{logical_session}"); let window = match window { - Some(window) => validate_name("window", window, false)?, + // `.` is the pane separator in tmux target syntax (session:window.pane), + // so window names must not contain it to avoid ambiguous targets. + Some(window) => validate_name("window", window, false, false)?, None => DEFAULT_WINDOW.to_string(), }; let pane = pane - .map(|pane| validate_name("pane", pane, true)) + .map(|pane| validate_name("pane", pane, true, true)) .transpose()?; let target = match &pane { Some(pane) => format!("{session}:{window}.{pane}"), @@ -1721,7 +1734,7 @@ fn elapsed_millis(duration: Duration) -> u128 { duration.as_millis() } -fn validate_name(field: &str, value: &str, allow_percent: bool) -> Result { +fn validate_name(field: &str, value: &str, allow_dot: bool, allow_percent: bool) -> Result { let value = value.trim(); if value.is_empty() { return Err(anyhow!("tmux {field} must not be empty")); @@ -1730,13 +1743,20 @@ fn validate_name(field: &str, value: &str, allow_percent: bool) -> Result (String, bool) { } fn text_delta(initial: &str, latest: &str) -> String { - if latest.starts_with(initial) { - latest[initial.len()..].to_string() - } else if latest == initial { - String::new() - } else { - latest.to_string() - } + latest + .strip_prefix(initial) + .map(|s| s.to_string()) + .unwrap_or_else(|| latest.to_string()) } fn join_non_empty(parts: &[String]) -> String { @@ -1879,8 +1896,8 @@ mod tests { }; assert_eq!( - send_args(&target, ¶ms, false), - vec!["send-keys", "-t", "ra__dev:main", "-l", "hello; rm -rf /"] + send_args(&target, ¶ms), + vec!["send-keys", "-t", "ra__dev:main", "-l", "--", "hello; rm -rf /"] ); } diff --git a/tests/tmux_tools.rs b/tests/tmux_tools.rs index 95d81e6..6853bb5 100644 --- a/tests/tmux_tools.rs +++ b/tests/tmux_tools.rs @@ -189,6 +189,7 @@ async fn send_executes_fake_tmux_with_expected_argv_and_events() { "-t", "ra__dev:repl", "-l", + "--", "hello; rm -rf /", "send-keys", "-t", From 584a5df608df11050524ffe876af74aa18379de7 Mon Sep 17 00:00:00 2001 From: local Date: Tue, 2 Jun 2026 06:38:31 +0800 Subject: [PATCH 5/5] fix: make exit marker unforgeable by embedding per-call token strip_exit_marker now takes the call's unique token and only recognizes markers of the form __RA_TMUX_EXIT::____. A command that prints the old static marker __RA_TMUX_EXIT:N__ can no longer cause tmux_wait or tmux_run (wait:true) to return early with a false command_exit_code. wait_script updated to emit the token-scoped marker; both call sites (run_blocking, execute_tmux_wait_program) pass the token. Tests updated and a new regression test added for the wrong-token case. Co-authored-by: multica-agent --- src/lib.rs | 3 +-- src/tools/tmux.rs | 42 +++++++++++++++++++++++++++++------------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7f4e7be..effeb6e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,6 +35,5 @@ pub use tools::{ default_builtins, default_builtins_with_cfg, ApplyPatchTool, AstGrepTool, BashTool, EditTool, FuzzyTool, GhTool, GitTool, GlobTool, GrepTool, JqTool, JustTool, LsTool, LspTool, MiseTool, ReadTool, RtkRewriter, TmuxCaptureTool, TmuxKillTool, TmuxListenTool, TmuxRunTool, - TmuxSendTool, TmuxWaitTool, Tool, WebfetchCrawlTool, WebfetchFetchTool, WriteTool, - WrkflwTool, + TmuxSendTool, TmuxWaitTool, Tool, WebfetchCrawlTool, WebfetchFetchTool, WriteTool, WrkflwTool, }; diff --git a/src/tools/tmux.rs b/src/tools/tmux.rs index 52f154f..237a0c2 100644 --- a/src/tools/tmux.rs +++ b/src/tools/tmux.rs @@ -436,12 +436,7 @@ async fn execute_tmux_send(call_id: &str, params: TmuxSendParams, ctx: &ToolCtx) )?; let tmux = match find_tmux_binary() { Some(tmux) => tmux, - None => { - return Ok(missing_tmux_json( - "tmux_send", - &send_args(&target, ¶ms), - )) - } + None => return Ok(missing_tmux_json("tmux_send", &send_args(&target, ¶ms))), }; let mut last = None; @@ -869,7 +864,7 @@ async fn execute_tmux_wait_program( ); } - let (stdout, command_exit_code) = strip_exit_marker(&capture.stdout); + let (stdout, command_exit_code) = strip_exit_marker(&capture.stdout, &token); let eval = event.evaluate_program_output(&stdout, command_exit_code); let terminal = eval.triggered || command_exit_code.is_some() || started.elapsed() >= timeout; @@ -998,7 +993,7 @@ async fn run_blocking( let capture_args = capture_args(target, Some(RUN_WAIT_CAPTURE_START), None); let capture = run_tmux(tmux, &capture_args).await?; - let (stdout, command_exit_code) = strip_exit_marker(&capture.stdout); + let (stdout, command_exit_code) = strip_exit_marker(&capture.stdout, &token); let ok = !wait.timed_out && wait.output.exit_code == 0 && capture.exit_code == 0 @@ -1244,10 +1239,11 @@ fn wait_script(command: &str, token: &str) -> String { "#!/bin/sh\n\ /bin/sh -c {} 2>&1\n\ status=$?\n\ - printf '\\n{EXIT_MARKER_PREFIX}%s{EXIT_MARKER_SUFFIX}\\n' \"$status\"\n\ + printf '\\n{EXIT_MARKER_PREFIX}{}:{EXIT_MARKER_SUFFIX}%s{EXIT_MARKER_SUFFIX}\\n' \"$status\"\n\ tmux wait-for -S {}\n\ exec /bin/sh\n", shell_word(command), + token, shell_word(token) ) } @@ -1761,12 +1757,15 @@ fn validate_name(field: &str, value: &str, allow_dot: bool, allow_percent: bool) Ok(value.to_string()) } -fn strip_exit_marker(text: &str) -> (String, Option) { +fn strip_exit_marker(text: &str, token: &str) -> (String, Option) { + // Marker format: __RA_TMUX_EXIT::____ + // The token makes the marker unique per call so command output cannot forge it. + let prefix = format!("{EXIT_MARKER_PREFIX}{token}:{EXIT_MARKER_SUFFIX}"); let mut exit_code = None; let mut kept = Vec::new(); for line in text.lines() { let trimmed = line.trim_end_matches('\r').trim(); - if let Some(rest) = trimmed.strip_prefix(EXIT_MARKER_PREFIX) { + if let Some(rest) = trimmed.strip_prefix(&prefix) { if let Some(code) = rest.strip_suffix(EXIT_MARKER_SUFFIX) { exit_code = code.parse::().ok(); continue; @@ -1897,7 +1896,14 @@ mod tests { assert_eq!( send_args(&target, ¶ms), - vec!["send-keys", "-t", "ra__dev:main", "-l", "--", "hello; rm -rf /"] + vec![ + "send-keys", + "-t", + "ra__dev:main", + "-l", + "--", + "hello; rm -rf /" + ] ); } @@ -1935,17 +1941,27 @@ mod tests { #[test] fn exit_marker_is_removed_and_parsed() { - let (text, code) = strip_exit_marker("one\n__RA_TMUX_EXIT:7__\ntwo\n"); + let (text, code) = strip_exit_marker("one\n__RA_TMUX_EXIT:mytoken:__7__\ntwo\n", "mytoken"); assert_eq!(text, "one\ntwo\n"); assert_eq!(code, Some(7)); } + #[test] + fn exit_marker_wrong_token_is_not_stripped() { + let input = "one\n__RA_TMUX_EXIT:othertoken:__7__\ntwo\n"; + let (text, code) = strip_exit_marker(input, "mytoken"); + + assert_eq!(text, input); + assert_eq!(code, None); + } + #[test] fn wait_script_shell_quotes_command() { let script = wait_script("printf 'hi'; exit 7", "token"); assert!(script.contains("/bin/sh -c 'printf '\\''hi'\\''; exit 7' 2>&1")); + assert!(script.contains("__RA_TMUX_EXIT:token:__")); assert!(script.contains("tmux wait-for -S token")); } }