Content
# zellij-agent-tools
User-approved Zellij pane tools for coding agents.
This project is a hybrid Zellij plugin plus local sidecar. The plugin is the user-visible consent and capture surface inside Zellij. The sidecar exposes bounded, primitive read/status commands that match the intended MCP tool contract for panes the user has explicitly chosen to watch.
The current sidecar surface includes CLI commands, a Rust service layer, and an MCP stdio server mode for local client integration.
The v1 crate, binary, and pipe names still use the `zellij-mcp-*` prefix for compatibility.
## V1 Boundaries
- No pane is watched by default.
- The user must opt in per pane from Zellij.
- Watched state must be visible and revocable by the user.
- Agents can read bounded snapshots and deltas from watched panes only.
- Agents cannot send text, keystrokes, or commands into panes in v1.
- The sidecar fails closed when plugin state is missing, unauthorized, revoked, stale for status diagnostics, or identity-mismatched.
- Watched panes can contain secrets; v1 does not promise automatic secret redaction.
- Watched output returned to an MCP client may be retained by that client outside this process.
## Workspace
- `crates/shared`: shared protocol and buffer types used by both runtimes.
- `crates/zellij-plugin`: WASI Zellij plugin that owns consent and pane capture.
- `crates/mcp-server`: native sidecar process that exposes the current CLI/Rust service surface.
- `docs/architecture/pane-watching.md`: architecture, safety model, and lifecycle.
- `docs/mcp-tools.md`: intended MCP tool contract boundaries.
- `docs/v1-runbook.md`: end-to-end v1 setup, MCP client config, acceptance, and threat model.
- `spikes/ipc-capture-feasibility.md`: feasibility notes for plugin-to-sidecar capture.
## Build Checks
The sidecar is a normal native binary:
```bash
cargo build -p zellij-mcp-sidecar
```
The Zellij plugin is a WASI/WebAssembly artifact loaded by Zellij:
```bash
cargo build -p zellij-mcp-plugin --target wasm32-wasip1
```
Do not use native `cargo build --workspace` as the primary check yet. The plugin links against imports provided by Zellij at runtime, so it is not expected to link as a normal host binary.
Run the targeted verification set before live testing:
```bash
cargo test -p zellij-mcp-plugin --lib
cargo test -p zellij-mcp-sidecar
cargo test -p zellij-mcp-shared
cargo build -p zellij-mcp-plugin --target wasm32-wasip1
```
## Operator Runbook
Use a fresh state directory per Zellij session or acceptance test:
```bash
STATE_DIR="$PWD/target/zellij-mcp-live-$(date +%s)"
cargo run -p zellij-mcp-sidecar -- init "$STATE_DIR"
```
Keep the printed token private. The token is required for every sidecar read/status command.
Launch a terminal pane to share:
```bash
zellij action new-pane --name acceptance-pane -- bash -lc 'printf "acceptance ready\n"; exec bash'
zellij action list-panes --json
```
Launch the plugin in the same Zellij session. Zellij mounts the launch working directory at `/host` inside the plugin, so pass the state directory as a `/host/...` path relative to the repository root:
```bash
zellij action launch-plugin \
--skip-plugin-cache \
--configuration "session_id=acceptance,state_dir=/host/target/$(basename "$STATE_DIR")" \
"file:$PWD/target/wasm32-wasip1/debug/zellij-mcp-plugin.wasm"
```
Use `launch-plugin` with a `file:` URL for the controller plugin. `new-pane --plugin /absolute/path.wasm` creates a blank pane because Zellij treats the path as an unresolved plugin alias.
If Zellij shows a native permission prompt, approve it manually in the plugin pane. This is separate from approving an individual pane watch. The native Zellij prompt may be styled differently from the zellij-mcp watch prompt. The plugin should then show `permissions: granted`. The permission set includes application-state reads, pane-content reads, CLI pipe reads, and application-state changes so the plugin can mark watched panes with a title prefix.
Request a watch for a target terminal pane:
```bash
cargo run -p zellij-mcp-sidecar -- request-watch "$STATE_DIR" "$TOKEN" <tab-id> <pane-id> acceptance-pane
cargo run -p zellij-mcp-sidecar -- request-status "$STATE_DIR" "$TOKEN"
```
Prefer `ZELLIJ_MCP_TOKEN` for repeated commands so the token is not copied through shell history more than necessary:
```bash
export ZELLIJ_MCP_TOKEN=<token>
cargo run -p zellij-mcp-sidecar -- request-status "$STATE_DIR"
```
The request starts as `Pending`. Focus the plugin pane and press `y` on the zellij-mcp watch prompt to approve, or `n` to deny. If the request asks the user to choose a pane, use `j/k` to select the pane first. If key focus is unreliable, use a targeted pipe for the pending request:
```bash
zellij action pipe \
--plugin "file:$PWD/target/wasm32-wasip1/debug/zellij-mcp-plugin.wasm" \
--plugin-configuration "session_id=acceptance,state_dir=/host/target/$(basename "$STATE_DIR")" \
--name zellij-mcp-approve-request -- ok
```
Use `--name zellij-mcp-deny-request` to deny instead. Target the plugin URL and configuration; an untargeted broadcast pipe may not reach a hidden plugin pane reliably. If the first `y` only approved Zellij permissions, the request will remain `Pending`; press `y` again once the zellij-mcp watch prompt is visible. After approval, the target pane title should show `WATCHED | ...`.
When no request prompt is active, the plugin pane acts as a compact controller dashboard: status chips at top, a selectable pane list, details for the selected pane, and a bottom keybar. Use `j/k` to move through the visible pane list, `w` to watch the selected pane, and `u` to unwatch the selected pane. These user-initiated actions are explicit consent actions and do not require a second prompt.
The pane list is scoped to the current Zellij session. Panes in other Zellij sessions or OS windows need their own plugin/session context.
Capture is render-report driven. For acceptance testing, produce fresh output in the watched terminal after approval before validating reads:
```bash
zellij action focus-pane-id terminal_<pane-id>
printf 'postwatch capture check\n'
```
Read watched output:
```bash
cargo run -p zellij-mcp-sidecar -- list-watched "$STATE_DIR" "$TOKEN"
```
Copy the `pane` JSON object from `list-watched`, then pass it to reads:
```bash
cargo run -p zellij-mcp-sidecar -- read-snapshot "$STATE_DIR" "$TOKEN" '<pane-json>' 20
cargo run -p zellij-mcp-sidecar -- read-events "$STATE_DIR" "$TOKEN" '<pane-json>' <after-seq> 20
```
Revoke a watch from the plugin pane with `u` on the selected row, or from the watched terminal pane:
```bash
zellij action focus-pane-id terminal_<pane-id>
zellij action pipe --name zellij-mcp-unwatch-focused -- ok
```
If the plugin is hidden, use a targeted pipe with the same `--plugin` and `--plugin-configuration` arguments shown above and `--name zellij-mcp-unwatch-focused`.
The target pane title should lose the `WATCHED |` prefix. Future reads for the same pane identity should fail closed with `PermissionRevoked` or an equivalent non-readable state.
## Sidecar Commands
- `init <state-dir>`: create private state and print a token.
- `status <state-dir> [token]`: report whether plugin readiness is fresh, including permission state, pane count, and watched count.
- `list-watched <state-dir> [token]`: list currently watched panes and pane identity JSON.
- `read-snapshot <state-dir> [token] <pane-json> [limit]`: read bounded latest output for a watched pane.
- `read-events <state-dir> [token] <pane-json> [after-seq] [limit]`: read bounded output after a cursor sequence.
- `request-watch <state-dir> [token] <tab-id> <pane-id> [label]`: ask the plugin to prompt the user to watch a pane.
- `request-status <state-dir> [token]`: report the latest request as `NoRequest`, `Pending`, `Approved`, `Denied`, `Expired`, `Revoked`, or `Stale`.
- `serve <state-dir>`: run the MCP stdio server. The server reads its token from `ZELLIJ_MCP_TOKEN`.
- `doctor <state-dir> [token]`: check state directory permissions, token availability, and plugin sync freshness without printing captured pane content.
`ZELLIJ_MCP_TOKEN` can be used instead of passing the token argument for commands that accept a token. Prefer the environment variable for MCP setup and repeated local commands; positional tokens are mainly for one-off debugging.
## MCP Stdio Server
Run the sidecar as an MCP stdio server after `init` and plugin launch:
```bash
ZELLIJ_MCP_TOKEN=<token> cargo run -p zellij-mcp-sidecar -- serve <state-dir>
```
The server currently implements newline-delimited JSON-RPC for `initialize`, `tools/list`, `tools/call`, and `notifications/initialized`. It exposes these tools:
- `get_watch_status`
- `get_watch_request_status`
- `list_watched_panes`
- `read_pane_snapshot`
- `read_pane_events`
- `request_watch`
For MCP clients, prefer the `pane_ref` returned by `list_watched_panes` when calling `read_pane_snapshot` or `read_pane_events`. The CLI still accepts the full pane identity JSON, but `pane_ref` avoids relying on MCP clients to preserve nested pane objects in tool arguments.
Configure MCP clients to pass the state directory as the `serve` argument and provide the per-session token through `ZELLIJ_MCP_TOKEN`. Do not pass the token as a command argument, hard-code it, or reuse it across state directories.
For full v1 setup, acceptance, and threat-model guidance, see `docs/v1-runbook.md`.
ToolHive users can build `Containerfile.sidecar`, run the workload with the state directory mounted at `/state`, and register OpenCode through `thv client register opencode --group default`; see `docs/v1-runbook.md` for the exact commands.
## Recovery
- `Unauthorized`: use the token printed by `init`; do not reuse tokens across state directories.
- `CaptureUnavailable`: plugin has not published readiness or capture state yet; confirm the plugin is running, visible, and permissions are granted.
- `CaptureStale`: plugin readiness is stale for status/doctor checks, or the pane itself is stale/suspended; focus/check the plugin pane, then relaunch it with the same state dir if needed.
- `PaneNotSelected`: request/approve a watch for that pane before reading it.
- `PaneIdentityMismatch`: refresh pane identity with `list-watched`; the original pane may have closed or been replaced.
- `PermissionRevoked`: the user revoked the watch; request a new watch if access is still needed.
- `RequestPending`: a watch request is already pending in Zellij; approve, deny, or let it expire before requesting another.
- `BoundsExceeded`: lower the requested line limit.
- `StoreInvalid`: re-run `init` in a private state directory; do not use world-readable state paths.
## Limitations
- The MCP server is a local stdio transport around primitive pane tools; it does not control terminals or send keys.
- Capture is based on rendered viewport reports, not a guaranteed append-only terminal log. Event reads are coalesced to reduce prompt redraw and per-keystroke noise; snapshots reconstruct the bounded retained post-consent output.
- Existing scrollback is not shared silently; capture is future-oriented after consent.
- The local capture file is periodically compacted to the latest 1024 JSONL records; MCP clients may retain returned output outside this process.
- There is no automatic secret redaction.
- Same-UID local processes that can read the state directory or token are outside the v1 threat boundary.
- Native plugin binary tests are not supported; use `cargo test -p zellij-mcp-plugin --lib` and the WASM build target.
## Manual Demo Path
This checkout is still a development scaffold, but the intended local demo path is:
1. Build the sidecar and plugin with the commands above.
2. Initialize a private state directory with `zellij-mcp-sidecar init <state-dir>` and keep the printed token private.
3. Launch the Zellij plugin with a `state_dir=/host/...` configuration pointing at the same directory.
4. Focus the plugin pane, select a pane with `j/k`, and press `w`, or focus the terminal pane to share and press the configured Zellij watch keybind/run `zellij action pipe --name zellij-mcp-watch-focused -- ok`.
5. The selected or focused pane is watched immediately because the user-initiated action is approval.
6. Run a command in that watched pane.
7. Read through the sidecar/tool layer using the token.
8. Revoke the watch with the configured unwatch keybind, or run `zellij action pipe --name zellij-mcp-unwatch-focused -- ok`, and verify future reads fail with `PermissionRevoked`.
Agent-requested watches use the MCP `request_watch` tool. The agent may provide both `tab_id` and `pane_id` for a known pane, or omit both so the Zellij plugin shows a pane picker for the user to choose from. It may also provide `requester` metadata such as `OpenCode`; the plugin displays this separately from the untrusted agent note. The sidecar CLI still supports targeted requests with `ZELLIJ_MCP_TOKEN=<token> zellij-mcp-sidecar request-watch <state-dir> <tab-id> <pane-id> [label]`. Agent-requested watches auto-show the plugin, require explicit approval, then hide the plugin after a completed decision. Check the current request lifecycle with `zellij-mcp-sidecar request-status <state-dir>` while `ZELLIJ_MCP_TOKEN` is set.
Use `zellij-mcp-sidecar list-watched <state-dir> <token>` to get pane identities, then pass a pane identity JSON object to `read-snapshot` or `read-events`.
Expected safety behavior:
- Startup exposes no pane output.
- Watching pane A does not expose pane B.
- Revocation stops future snapshot and event reads.
- Pane ID reuse does not inherit consent because compound identity must still match.
- Sidecar restart does not broaden access; it still needs fresh plugin state and the token.
Connection Info
You Might Also Like
buddy
Your persistent AI coding companion — the /buddy rescue mission. A...
Vera
Local code search combining BM25, vector similarity, and cross-encoder...
agent-base
Agent Base is a source-level research project on coding agents. It compares...
mitmproxy-mcp
MCP Server that wraps mitmproxy and exposes it as a tool to any MCP client,...
nothumanallowed
NotHumanAllowed — AI Agent Tools, CLI, Documentation & MCP Integration
bouvet
Sandbox for Agents