diff --git a/README.md b/README.md index 2c38f0a..15f6dae 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,12 @@ 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. | +| `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 @@ -247,6 +253,13 @@ 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`, `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 Authoritative schemas live in [`spec/`](spec/) — see diff --git a/docs/prd-tmux-native-tool.md b/docs/prd-tmux-native-tool.md new file mode 100644 index 0000000..6ca47ff --- /dev/null +++ b/docs/prd-tmux-native-tool.md @@ -0,0 +1,130 @@ +# 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. +- 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, + 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. +- 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 + +| 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 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 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. | +| 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. +`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 + +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_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`. + +## Appendix + +- GitHub issue: `https://github.com/trotsky1997/ra/issues/24` +- Pull request: `https://github.com/trotsky1997/ra/pull/26` 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/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 new file mode 100644 index 0000000..b4fa3fb --- /dev/null +++ b/openspec/specs/tmux-tools/spec.md @@ -0,0 +1,176 @@ +# 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`, +`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: 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 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 + +### 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 + +### 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 8758351..91ecc6f 100644 --- a/spec/ra.toml.example +++ b/spec/ra.toml.example @@ -48,9 +48,15 @@ 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 +# 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"] +# 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 928e110..c407455 100644 --- a/spec/tools.md +++ b/spec/tools.md @@ -117,6 +117,13 @@ 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`, +`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 These wrappers execute common developer CLIs without asking the model to @@ -296,6 +303,200 @@ 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. + +### 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 +`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 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 +} +``` + +| 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 | +| 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 798fd52..0a1f9ed 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, 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 d2814b9..effeb6e 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, TmuxWaitTool, Tool, WebfetchCrawlTool, WebfetchFetchTool, WriteTool, WrkflwTool, }; diff --git a/src/session_runner.rs b/src/session_runner.rs index b16237c..e03c7fc 100644 --- a/src/session_runner.rs +++ b/src/session_runner.rs @@ -643,11 +643,12 @@ 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" | "tmux_wait" => { + ToolKindHint::Execute + } _ => ToolKindHint::Other, } } @@ -736,6 +737,42 @@ 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()), + "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") @@ -815,4 +852,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..cc8bd3f 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -24,6 +24,12 @@ //! - `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 +//! - `tmux_wait` — block until a tmux event or timeout mod cli; mod core; @@ -35,6 +41,7 @@ mod openspec; mod rtk; mod search; mod task_workflow; +mod tmux; mod webfetch; pub use cli::{GhTool, GitTool}; @@ -47,6 +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, TmuxWaitTool, +}; pub use webfetch::{WebfetchCrawlTool, WebfetchFetchTool}; use crate::config::OpenlspSection; @@ -129,6 +139,24 @@ 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("tmux_wait") { + out.push(Arc::new(TmuxWaitTool)); + } if want("lsp") { if let Some(binary) = resolve_openlsp_binary(openlsp_cfg) { out.push(Arc::new(LspTool { @@ -234,6 +262,29 @@ 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", + "tmux_wait", + ] { + 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..237a0c2 --- /dev/null +++ b/src/tools/tmux.rs @@ -0,0 +1,1967 @@ +//! 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, 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 + /// `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, + /// 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, + /// 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, +} + +#[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 { + 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_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")); + } + 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))), + }; + + let mut last = None; + if !params.keys.is_empty() { + 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; + 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 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)); + + 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, + ); + } + + let initial_eval = event.evaluate(&initial.stdout, &initial.stdout); + if initial_eval.triggered { + 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(); + 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 eval = event.evaluate(&initial.stdout, &latest); + if eval.triggered { + let delta = text_delta(&initial.stdout, &latest); + return listen_response(ListenResponse { + args: &args, + target: &target, + event: &event, + stdout: latest, + delta, + eval, + 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(ListenResponse { + args: &args, + target: &target, + event: &event, + stdout: latest, + delta, + eval, + timed_out: true, + max_output_bytes: 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, &token); + 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, + 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); + 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, &token); + 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) -> Vec { + let mut args = vec![ + "send-keys".to_string(), + "-t".to_string(), + target.target.clone(), + ]; + 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( + params + .keys + .split_whitespace() + .filter(|key| !key.is_empty()) + .map(ToString::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}{}:{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) + ) +} + +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") +} + +struct ListenResponse<'a> { + args: &'a [String], + target: &'a TmuxTarget, + event: &'a ListenEvent, + stdout: String, + delta: String, + eval: EventEvaluation, + timed_out: bool, + max_output_bytes: usize, +} + +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, max_output_bytes); + serde_json::to_string_pretty(&json!({ + "ok": !timed_out, + "tool": "tmux_listen", + "target": target, + "command": command_metadata(args), + "event": event.metadata(), + "changed": eval.changed, + "matched": eval.matched, + "triggered": eval.triggered, + "timed_out": timed_out, + "exit_code": 0, + "stdout": stdout, + "delta": delta, + "stderr": null, + "truncated": stdout_truncated || delta_truncated, + })) + .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, + "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, true, false)?; + let session = format!("{RA_SESSION_PREFIX}{logical_session}"); + let window = match window { + // `.` 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, 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) + } +} + +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_dot: bool, 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_dot && ch == '.') + || (allow_percent && ch == '%') + }); + if !valid { + let mut allowed = "ASCII letters, digits, `_`, `-`".to_string(); + if allow_dot { + allowed.push_str(", `.`"); + } + if allow_percent { + allowed.push_str(", `%`"); + } + return Err(anyhow!("tmux {field} may only contain {allowed}")); + } + Ok(value.to_string()) +} + +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(&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 { + latest + .strip_prefix(initial) + .map(|s| s.to_string()) + .unwrap_or_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), + 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: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")); + } +} diff --git a/tests/tmux_tools.rs b/tests/tmux_tools.rs new file mode 100644 index 0000000..6853bb5 --- /dev/null +++ b/tests/tmux_tools.rs @@ -0,0 +1,551 @@ +//! Integration tests for native tmux tools. + +use ra::{ + tools::{ + TmuxCaptureTool, TmuxKillTool, TmuxListenTool, TmuxRunTool, TmuxSendTool, TmuxWaitTool, + 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", + "tmux_wait", + ] { + 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 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; + 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 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; + 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 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; + 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 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 + .unwrap(); + let kill = json_output(&kill); + assert_eq!(kill["ok"], true, "kill={kill}"); +}