| name | surrogate |
| description | Send keystrokes to any terminal app via zmx sessions. Use when you need to type into TUI apps, talk to other agents, or drive interactive programs programmatically. |
Surrogate
Surrogate enables programmatic keystroke injection into any terminal application running in a zmx session. It uses tmux internally as a bridge. The chain: tmux send-keys -> tmux pane (zmx attach) -> zmx IPC -> PTY -> target app.
An AI agent uses surrogate to act as a "surrogate user" -- typing into TUI apps like Claude Code, vim, python REPL, htop, etc.
Security Model
Surrogate is intentionally deterministic and does not offer unlimited authority just because it is ambiently available.
- Built-in structural guardrails apply even if DCG is not installed.
- DCG is an optional second layer for content scanning.
- Some actions are reserved for direct human control rather than surrogate automation.
Current built-in guardrails:
surrogate type normalizes embedded newlines to spaces and must actually submit, not just stage text
surrogate type, surrogate send, and surrogate submit reject self-targeting and tell you the current alias/session
surrogate prune-sessions rejects the current live session and any attached session with clients still present
surrogate send rejects C-c, C-d, and C-z
surrogate send rejects prose (arguments with spaces or >40 chars) — use surrogate type for messages, which bookends prose with [SURROGATE ...] and [/SURROGATE] deterministically for agent-like targets
- there is no global guard-disable mode
- there is no persistent unsafe mode
If DCG is installed, surrogate type may also be blocked by DCG for destructive command-like payloads.
Surrogate also writes JSONL audit records for type and send actions. By default this goes to /tmp/surrogate-audit.jsonl, and it can be overridden with SURROGATE_AUDIT_FILE.
Session Aliases
Every session gets a deterministic adjective-noun alias (e.g. shiny-dolphin, robo-quokka). Aliases are derived from the session name via hash — no state, no config. They never collide.
All commands that take a <session> argument accept either the full zmx name or the alias:
surrogate type shiny-dolphin "hello"
surrogate type 2026-03-08_20-44-12_EDT-539343 "hello"
surrogate alias 2026-03-08_20-44-12_EDT-539343
Quick Reference
Start here: get full context
surrogate prime
surrogate prime --json
Discover sessions
surrogate list
surrogate list --bare
surrogate list --json
Search across all sessions
surrogate find <query> [-n LINES] [-C CONTEXT]
surrogate find "auth error"
surrogate find "TODO" -n 500 -C 3
Show all sessions with snippets
surrogate who [-n LINES] [--recent N|2h] [--project NAME] [--cwd PATH] [--json]
surrogate who
surrogate who --recent 20
surrogate who --project surrogate
surrogate who --cwd ~/Documents/GitHub/surrogate
surrogate who --json
surrogate who -n 20
ui_hint is deterministic and generic: shell, agent, or unknown. It is derived from visible output only.
Show attached sessions
surrogate active [--all]
surrogate active
surrogate active --all
Show live, active sessions with less noise
surrogate live
surrogate live --here
surrogate live --all
surrogate live --json
surrogate live is the low-noise operator view. It only shows sessions with attached clients that have been active in the last 2h, ranks them by recent visible activity, and hides low-signal shell-prompt lanes by default when they have no visible repo or cwd hint. Use --recent N for a count-based wider view, or --all when you want all attached live lanes in the selected window, including low-signal ones. Detached sessions are handled by surrogate active --all, surrogate stale, and surrogate sweep. In JSON, current_shell:null means no current-shell ancestry anomaly was detected.
Show stale detached sessions
surrogate stale [--older-than HOURS] [--filter PATTERN]
surrogate stale
surrogate stale --older-than 72
surrogate stale --older-than 24 --limit 20
stale returns the oldest matching detached sessions first.
Batch read all sessions
surrogate peek [-n LINES] [--filter PATTERN]
surrogate peek
surrogate peek --filter "shoulder"
surrogate peek -n 2 --filter "error"
Rename a session
surrogate rename <old-session> <new-name>
Show alias for a session
surrogate alias <session>
Review and prune zmx sessions
zmx is still the source of truth. surrogate prune-sessions kills the zmx session and then cleans surrogate’s own bridge/alias/lock/watermark state. Batch stale pruning previews by default. Add --yes to execute.
surrogate stale --older-than 48
surrogate sweep --older-than 48
surrogate prune-sessions <session>...
surrogate prune-sessions --stale [--older-than HOURS] [--filter PATTERN] [--dry-run|--yes]
surrogate prune-sessions --stale --older-than 24 --limit 10 --yes
Schedule recurring pruning (auto-prune)
auto-prune wires prune-sessions --stale --yes into a per-user systemd timer (Linux) or launchd LaunchAgent (macOS) so detached sessions don't accumulate forever. It is a thin scheduling shim — no new prune logic — so attached sessions and the caller's current live session are still never deleted.
surrogate auto-prune install
surrogate auto-prune install --older-than 168
surrogate auto-prune install --every 30min
surrogate auto-prune status
surrogate auto-prune disable
Use --scheduler {systemd|launchd|cron|none} to override auto-detection. cron is print-only (prints the line you'd add to crontab -e) and none is print-only (just shows the underlying surrogate command), for hosts without a supported user-level scheduler. When auto-prune auto-detects cron because nothing else is available, it exits with status 2 so callers can branch on "scheduler unsupported"; when the user explicitly asks for --scheduler cron, it exits 0.
For hermetic installs (CI, SSH without a user bus): set SURROGATE_AUTO_PRUNE_NO_SYSTEMCTL=1 or SURROGATE_AUTO_PRUNE_NO_LAUNCHCTL=1. Unit files are still written; only activation is skipped.
Type text + Enter (most common)
surrogate type <session> "some text"
type auto-normalizes long prose by flattening embedded newlines to spaces and then submitting once. This is meant for conversational prompts, not scripts. A successful type should correspond to an actual submitted prompt, not staged input.
Default type is shell-safe. Surrogate bookends prose with [SURROGATE ...] at the front and [/SURROGATE] at the end only for agent-like targets; shell and unknown targets stay unbookended so commands still execute normally. It then warns if a non-agent target immediately reports command not found or a syntax error.
For long conversational prompts into agent TUIs, use message mode:
surrogate type --message <session> "Please review the patch plan above."
--message is safer than plain type for prose because it requires an agent ui_hint and refuses shell or unknown targets.
If the target TUI needs a slightly different cadence, type uses an adaptive submit pause by default (0.1s + 0.001s/char, capped at 2.0s). You can override it with SURROGATE_TYPE_ENTER_DELAY_SECS, which accepts only adaptive or a numeric seconds value.
For agent-like targets, type also sends bounded extra Enter presses with exponential backoff after the first Enter. An Enter-received probe runs alongside the cascade and early-exits it once the target's bottom rows visibly change (input cleared, prompt scrolled, spinner appeared), so agents on fast hosts don't pay for unused retries. The probe is TUI-agnostic — it compares pane snapshots, not strings. Toggle with SURROGATE_TYPE_ENTER_PROBE=off if a target's bottom rows mutate independently (e.g., a clock in the status bar). Either way, agents should keep using the same surrogate type --message <session> "..." desire path rather than learning a new submit sequence.
If text is visibly staged and only the missing Enter is needed, use:
surrogate submit my-session
Send special keys (tmux send-keys syntax)
surrogate send <session> <keys...>
Use send only for tmux key names like Enter, Escape, arrows, and short text literals. Never use send for messages — it rejects prose (spaces or >40 chars) and has no [SURROGATE ...]/[/SURROGATE] bookends. Use surrogate type for all messages. C-c, C-d, and C-z are intentionally blocked.
Read recent output
surrogate read <session> [-n N]
Optional remote operator briefs
If OPENROUTER_API_KEY is configured, you can triage and summarize where sessions left off with one OpenRouter call per zmx session:
surrogate brief --recent 15
surrogate brief 15
surrogate brief shiny-dolphin
surrogate-brief shiny-dolphin
surrogate-brief --show-config
surrogate-brief --openrouter-model openai/gpt-4.1-mini --inference-provider openai shiny-dolphin
surrogate brief reuses surrogate live --json, so the default brief targets the same attached sessions active in the last 2h shown by surrogate live. Add --all if you want briefs for every attached live session in the selected window, including low-signal shell lanes; use --recent N for a count-based wider view. Each brief now classifies ATTENTION REQUIRED, PRIORITY, and SIGNAL QUALITY before summarizing status and next steps, so idle shell prompts get demoted instead of looking urgent. It should also treat implicit operator handoff as meaningful when a lane stops at interrupted work, parked troubleshooting, or a human decision boundary even without an explicit ask, and it should weight the end-state tail more heavily than an earlier milestone. This path is optional and separate from core surrogate usage. If the key is missing, surrogate-brief prints the setup steps needed to enable it.
Wait for pattern in output
surrogate wait <session> <pattern> [-t SEC]
Pre-warm a bridge
surrogate bridge <session>
Clean up dead bridges
surrogate prune-bridges
surrogate prune-bridges --all
surrogate cull and surrogate cleanup still work as deprecated aliases, but primary docs should use prune-sessions, prune-bridges, and sweep.
Bridge cleanup does not remove zmx sessions. Use surrogate prune-sessions or surrogate sweep for that.
Show bridge health
surrogate status
Common Patterns
Send and wait for response
surrogate type my-session "explain this function"
surrogate wait my-session "●" -t 60
sleep 5
output=$(surrogate read my-session -n 50)
Drive vim
surrogate send my-session "vim main.go" Enter
sleep 1
surrogate send my-session "i"
surrogate send my-session "// new comment" Escape ":wq" Enter
Self-target guard
Surrogate refuses to send input to the current live session. If you need to confirm where you are, ask first:
surrogate whoami
If ZMX_SESSION is stale but the current process ancestry still includes zmx attach <session>, surrogate whoami reports that lane as ancestry-only and tells you it is not messageable via surrogate right now.
Inter-agent communication
surrogate find "shoulder" -n 50
surrogate peek --filter "shoulder"
surrogate type elfin-squid "Please review my changes"
Special Keys Reference
Since surrogate send passes through to tmux send-keys:
| Key | Description |
|---|
Enter | Enter key |
Escape | Escape key |
C-u | Ctrl+U (clear line) |
C-l | Ctrl+L (clear screen) |
Tab | Tab key |
Up, Down, Left, Right | Arrow keys |
BSpace | Backspace |
Space | Space (use when you need explicit space) |
When to Use Surrogate
- Sending input to TUI applications (Claude Code, vim, python, etc.)
- Communicating with other AI agents running in terminal sessions
- Automating interactive terminal workflows
- Testing TUI applications end-to-end
When NOT to Use Surrogate
- For non-interactive commands -- just use Bash tool directly
- For file I/O -- use Read/Write/Edit tools
- When you have a proper API -- prefer APIs over keystroke injection
- For sending messages between agents -- prefer Agent Mail for structured communication. Use surrogate only when you need to type into the agent's actual TUI.
- For your current live session -- surrogate rejects self-targeting; use
surrogate whoami if the agent seems confused about its identity.
Setup
surrogate-shell-setup supports two shell-integration modes:
all — wrap every new interactive shell in zmx
commands — leave plain shells alone and wrap only configured commands such as claude, pi, or codex
Recommended for agent-heavy desktops:
cp surrogate-shell.conf.example ~/.config/surrogate/shell.conf
surrogate-shell-setup --install
That example config uses commands mode, so only the listed commands get zmx sessions. The snippet also clears leaked ZMX_SESSION when the current shell is not actually running under a zmx parent; otherwise command wrappers would silently fail to wrap.
If you really want every new terminal wrapped, use:
surrogate-shell-setup --install --mode all
In both modes the snippet:
- Won't double-wrap (checks parent process name via
$PPID, not env vars)
- Keeps plain
zmx attach ... working by clearing leaked ZMX_SESSION only for nested attaches
- Supports bash, zsh, and fish
Local Install Sync
bash install.sh installs surrogate, surrogate-brief, surrogate-shell-setup, and surrogate-doctor into ~/.local/bin. For this repo checkout it also wires the configured Git post-commit hook to dispatch .githooks/post-commit.
After successful commits on main, that hook refreshes the local installed binaries from committed HEAD, so surrogate on PATH stays aligned with committed main. surrogate-doctor checks both binary sync and hook sync.
Dependencies
- zmx -- session persistence (source of truth for sessions)
- tmux -- internal keystroke bridge (users don't interact with tmux directly)