Programmatic keystroke injection for any terminal application, via zmx sessions.
Type into Claude Code, vim, REPL — anything running in a terminal — from scripts, AI agents, or other programs.
Caution
This tool is really dangerous. This is the YOLO mode of all YOLO modes. You could really get pwned.
Any process or agent that can run surrogate type can inject keystrokes into any zmx terminal session on the machine.
- tmux is powerful and amazing and my agents love it. I hate using tmux.
- I want to be able to walk away from my computer, and have a surrogate inject messages into my terminal sessions (specifically coding agent TUIs).
- zmx is very slick but doesn't have a way to inject keys.
surrogate type <session> "banana"
└── tmux send-keys (proper key events)
└── tmux pane (zmx attach bridge)
└── zmx IPC → PTY master
└── your app receives keystrokes
zmx is the source of truth for sessions. tmux is invisible plumbing. You use whatever terminal emulator you want (Ghostty, WezTerm, Alacritty, kitty, xterm).
One-liner:
git clone https://github.com/rawwerks/surrogate.git && cd surrogate && bash install.sh && surrogate-shell-setup --install && surrogate-doctorOr step by step:
git clone https://github.com/rawwerks/surrogate.git
cd surrogate
bash install.sh
surrogate-shell-setup --install
surrogate-doctorThis installs surrogate, surrogate-brief, surrogate-shell-setup, and surrogate-doctor to ~/.local/bin/. surrogate-brief is optional and only used for OpenRouter-backed remote summaries; core surrogate session control does not require any API key. It also tries to install dcg by default as a recommended safety guard. If dcg install fails, surrogate still installs and works.
For checkouts of this repo, install.sh also wires a repo-local post-commit hook through Git's configured hooks path. After any successful commit on main, that hook refreshes the local installed binaries from the committed HEAD automatically, so surrogate on PATH does not drift behind committed main.
If you want zmx only for selected agent CLIs instead of every shell, start from surrogate-shell.conf.example and install shell integration in commands mode.
For contributors working from a checkout, use dev-link mode so the installed CLI never drifts from the repo:
bash install.sh --dev-linkThis symlinks the installed binaries to the current checkout instead of copying them.
When publishing to main, use the helper below instead of safe-push directly:
bash bin/surrogate-push-mainIt safe-pushes main, converts any repo dev-links back to real copied binaries, reinstalls, and runs surrogate-doctor. Local commits on main also trigger the post-commit refresh hook installed by install.sh.
To teach Claude Code how to use surrogate, install the skill:
ln -s ~/Documents/GitHub/surrogate/SKILL.md ~/.claude/skills/surrogate.mdAgents will then know how to discover zmx sessions, inject keystrokes, read output, and wait for patterns.
- dcg — Destructive Command Guard for blocking dangerous commands before they execute
bash install.sh will try to install dcg automatically. This is strongly recommended but technically optional: surrogate still works without dcg.
To skip dcg auto-install:
SURROGATE_SKIP_DCG=1 bash install.shSurrogate stays ambiently available, but it no longer treats that as unlimited authority.
- Surrogate enforces a built-in deterministic safety floor of its own.
- DCG is an optional second layer for content scanning, not the only guardrail.
- Some actions are intentionally outside Surrogate's authority surface and require direct human control.
Current built-in structural guardrails:
typenormalizes embedded newlines to spaces and must actually submit, not just stage text in the target inputtype,send, andsubmitreject self-targeting and tell you the current alias/sessionprune-sessionsrejects the current live session and any attached session with clients still presentsendrejectsC-c,C-d, andC-z- there is no global "disable guards" switch
- there is no persistent unsafe mode
If DCG is installed, type also scans command-like payloads and blocks on DCG denials. On this machine, the current measured overhead is about 9ms average added latency on surrogate type.
Surrogate also writes a deterministic audit trail for type and send actions:
- default path:
/tmp/surrogate-audit.jsonl - override path:
SURROGATE_AUDIT_FILE=/path/to/file.jsonl - both allowed and blocked actions are logged
By default, surrogate can only talk to apps running inside zmx sessions. surrogate-shell-setup supports two deterministic modes:
all— wrap every new interactive shell in zmxcommands— leave plain shells alone and wrap only configured commands such asclaude,pi, orcodex
Public-safe example config:
cp surrogate-shell.conf.example ~/.config/surrogate/shell.conf
surrogate-shell-setup --installsurrogate-shell.conf.example uses:
SURROGATE_ZMX_MODE="commands"
SURROGATE_ZMX_COMMANDS="claude pi codex"For a private repo-local config, copy it to ./surrogate-shell.conf instead. That file is gitignored, so your personal command allowlist stays out of the public repo:
cp surrogate-shell.conf.example surrogate-shell.conf
surrogate-shell-setup --install --config "$PWD/surrogate-shell.conf"In commands mode the snippet:
- Leaves ordinary interactive shells unwrapped
- Generates shell wrappers only for the configured commands
- Wraps those commands with
zmx attach <unique-name> <command...>and, before exec, printssurrogate: zmx session <name> alias <alias>so you see the deterministic alias for the session you're about to enter - Clears leaked
ZMX_SESSIONwhen the shell is not actually running under a zmx parent, so command wrappers still fire correctly - Keeps plain
zmx attach ...working by clearing leakedZMX_SESSIONonly for nested attaches - Stays silent at shell startup in all cases — including shells already spawned inside zmx by a terminal emulator or agent tooling (e.g.
pi's bash extension); the snippet announces only when one of the configured commands is actually wrapped - Installs the managed block at the end of your rc file and preserves your existing aliases and shell functions. Aliases get the full zmx wrap — the expansion runs inside a fresh zmx session. Shell functions are wrapped too: the captured function body is written to a private temporary script and executed inside the zmx session without using bash function exports. If both an alias and function share a command name, the alias wins, matching interactive shell behavior
To make every new terminal window a zmx session automatically:
surrogate-shell-setup --install --mode allThis mode:
- Wraps each new interactive shell in
zmx attach <unique-name> - Won't double-wrap (checks parent process name via
$PPID, not env vars which leak through window managers) - Keeps plain
zmx attach ...working inside an already-wrapped shell by clearing leakedZMX_SESSIONonly for nested attaches - Always prints a
surrogate:status line on the wrap and inherited-session paths in this mode (commands mode is silent at startup) - Refreshes the managed rc block in place when you rerun
surrogate-shell-setup --install - Can be opted out per-session with
SURROGATE_NO_ZMX=1 - Can be removed cleanly with
surrogate-shell-setup --uninstall
If your terminal already launches zmx, the snippet detects the zmx parent process and prints the status line without double-wrapping.
Preview before installing:
surrogate-shell-setup --show
surrogate-shell-setup --show --mode commands --commands claude,pi,codexCheck if installed:
surrogate-shell-setup --checkEvery session gets a deterministic adjective-noun alias derived from its name — no config, no state files. Aliases never collide.
surrogate list
# shiny-dolphin 2026-03-08_20-44-12_EDT-539343
# robo-quokka 2026-03-09_13-53-24_EDT-2132820
# whimsy-capybara 2026-03-09_13-28-42_EDT-1872169
surrogate type shiny-dolphin "hello" # same as using the full timestamp
surrogate alias 2026-03-08_20-44-12_EDT-539343 # → shiny-dolphinAll commands that take a <session> argument accept either the full zmx name or the alias.
surrogate help list # discoverable list help + flags
surrogate list # fast alias + full name view
surrogate list --cwd # repo, cwd, and shell/agent hint
surrogate list --json # machine-readable repo/cwd/ui metadatasurrogate find "auth error" # search last 200 lines of every session
surrogate find "TODO" -n 500 -C 3 # deeper search with context linessurrogate who # newest 20 first: age, ui, session, repo, last visible line
surrogate who --recent 20 # show the 20 most recent sessions
surrogate who --recent 2h # only sessions seen in the last 2 hours
surrogate who --project surrogate # filter by visible repo basename hint
surrogate who --cwd ~/Documents/GitHub/surrogate
surrogate who --json # machine-readable output for agents/scripts
surrogate who -n 20 # inspect more recent history for snippet/hints--project, --cwd, and the shell-vs-agent UI hint are deterministic visibility hints derived from recent visible output. They are convenient hints, not authoritative process introspection.
surrogate active # only sessions with clients attached
surrogate active --all # include non-empty detached sessionssurrogate live # high-signal live sessions only
surrogate live --here # same, but scoped to the current repo name
surrogate live --all # include low-signal shell-like live lanes too
surrogate live --jsonsurrogate live is the operator-facing discovery 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.
This path is optional and requires an OpenRouter API key. Core surrogate usage remains local and has no API cost.
If OPENROUTER_API_KEY is set, you can triage and summarize active windows with one API call per zmx session. By default it uses model z-ai/glm-4.7 with preferred provider cerebras.
Recommended entrypoint:
surrogate brief --recent 15
surrogate brief 15
surrogate brief silly-pixelsurrogate 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.
Lower-level helper:
surrogate-brief # all attached sessions, 500 lines each
surrogate-brief glossy-hedgehog # one session by alias
surrogate-brief -n 800 --max-completion-tokens 1800 silly-pixel
surrogate-brief --openrouter-model openai/gpt-4.1-mini --inference-provider openai silly-pixel
surrogate-brief --show-configEach session brief includes:
ATTENTION REQUIREDPRIORITYSIGNAL QUALITYWHY NOWSTATUSLAST COMPLETEDUSER ACTION REQUIREDNEXT UNBLOCKING STEPPROPOSED NEXT STEPSUSER INPUT NEEDEDBLOCKERS
Low-signal shell-prompt lanes are explicitly demoted: if the visible evidence is only an idle prompt with no blocker, failure, approval request, or pending user decision, the brief should classify it as ATTENTION REQUIRED: NO, PRIORITY: P3, and SIGNAL QUALITY: LOW.
Attention is broader than explicit questions. The brief should also treat implicit operator handoff as important when the visible stopping point shows interrupted work, parked troubleshooting, or a meaningful subtask completed and then handed back to the user prompt for the next decision. In practice, the end-state tail matters more than earlier milestones.
Defaults:
- Scrollback window:
500lines - Completion budget:
1200tokens per session - Sampling temperature:
0.0 - OpenRouter model:
z-ai/glm-4.7 - Preferred inference provider:
cerebras
Config file:
mkdir -p ~/.config/surrogate
cp surrogate-brief.conf.example ~/.config/surrogate/brief.confCLI flags override config values.
If the key is missing, surrogate-brief prints the exact setup steps needed to enable it.
surrogate stale # oldest detached sessions older than 24h
surrogate stale --older-than 72 # oldest detached sessions older than 72h
surrogate stale --older-than 24 --filter "2026-03-08"
surrogate stale --older-than 24 --limit 20stale is ordered oldest-first.
surrogate peek # last 5 lines from every session
surrogate peek --filter "shoulder" # only sessions matching pattern
surrogate peek -n 2 --filter "error"surrogate rename <old-session> <new-name>zmx remains the source of truth. surrogate prune-sessions delegates deletion to zmx kill, then removes surrogate’s bridge/alias/lock/watermark plumbing for that session. Batch stale pruning previews by default. Add --yes to execute.
surrogate stale --older-than 48
surrogate sweep --older-than 48
surrogate prune-sessions sleepy-otter
surrogate prune-sessions --stale --older-than 48 --dry-run
surrogate prune-sessions --stale --older-than 48 --yes
surrogate prune-sessions --stale --older-than 24 --filter "2026-03-08" --yes
surrogate prune-sessions --stale --older-than 24 --limit 10 --yesBatch stale pruning is ordered oldest-first and prints explicit skip reasons for old sessions that are still attached or are your current live session.
surrogate auto-prune runs prune-sessions --stale --yes on a recurring schedule so detached zmx sessions never accumulate beyond the threshold you pick. It is a thin scheduling shim around the existing prune logic — attached sessions and your current live session are still never deleted.
surrogate auto-prune install # 72h threshold, hourly tick (defaults)
surrogate auto-prune install --older-than 168 # 1 week threshold
surrogate auto-prune install --every 30min # 30-minute tick
surrogate auto-prune status # what's installed, what's active
surrogate auto-prune disable # remove the scheduleauto-prune auto-detects the host scheduler:
- Linux — writes
~/.config/systemd/user/surrogate-prune.{service,timer}and enables the timer viasystemctl --user. Inspect withsystemctl --user list-timers surrogate-prune.timerandjournalctl --user -u surrogate-prune.service. - macOS — writes
~/Library/LaunchAgents/works.raw.surrogate-prune.plistand loads it vialaunchctl bootstrap(orlaunchctl load -won older systems). - Other Unix — refuses to install and prints the equivalent crontab line. Pin a backend explicitly with
--scheduler {systemd|launchd|cron|none}.
When auto-prune cannot install on the current host (no supported scheduler) it exits with status 2, distinct from 1 for real errors. Explicit --scheduler cron always exits 0 since that is the user-requested behavior.
For hermetic installs without a user bus (CI, SSH-only hosts), set SURROGATE_AUTO_PRUNE_NO_SYSTEMCTL=1 or SURROGATE_AUTO_PRUNE_NO_LAUNCHCTL=1 — unit files are still written, only the activation step is skipped.
Deprecated aliases:
surrogate cull ...->surrogate prune-sessions ...surrogate cleanup ...->surrogate prune-bridges ...
The most common operation. Types literal text and presses Enter.
surrogate type <session> "echo hello world"type auto-handles long prose by flattening embedded newlines into spaces and submitting once. A successful type means the text was actually submitted, not just left sitting in the target input buffer.
Default type is now shell-safe:
- surrogate bookends the message with
[SURROGATE ...]at the front and[/SURROGATE]at the end only for agent-like targets, so receiving agents can detect both boundaries - shell and unknown targets stay unbookended so literal shell commands still execute normally
- after submission, surrogate checks fresh shell output and warns on immediate failures like
command not foundor syntax errors - the warning points you to
surrogate read <session> -n 40
For agent-to-agent prose, use the explicit message mode:
surrogate type --message <session> "Long conversational prompt..."--message requires an agent-like target and refuses shell or unknown contexts. Use it when you want safer long-form prose delivery into a coding-agent TUI.
The submit pause is configurable. By default it is adaptive, which scales with text length (0.1s + 0.001s/char, capped at 2.0s). You can still force a fixed delay when needed:
SURROGATE_TYPE_ENTER_DELAY_SECS=adaptive surrogate type my-session "hello"
SURROGATE_TYPE_ENTER_DELAY_SECS=0.02 surrogate type my-session "hello"SURROGATE_TYPE_ENTER_DELAY_SECS accepts only adaptive or a numeric seconds value.
For agent-like targets, type sends bounded extra Enter presses with exponential backoff after the first Enter. This covers TUIs that visibly receive long or wrapped text but occasionally miss the first submit key while they are busy. The defaults send 5 extra Enter attempts at delays of 0.2s, 0.4s, 0.8s, 1.0s, and 1.0s.
An Enter-received probe runs alongside the blind cascade as an early-exit optimization. After the first Enter, surrogate snapshots the bottom rows of the target pane via tmux capture-pane (the [/SURROGATE] closing bookend lives there while the prompt is staged); after each subsequent Enter, surrogate compares the bottom rows to the pre-Enter snapshot. If they changed at all — input cleared, prompt scrolled into the transcript, spinner spun up — surrogate stops sending extra Enters. If they are byte-identical, the next blind Enter fires as before. This is TUI-agnostic because we control the marker; no per-tool string matching is involved. Toggle with SURROGATE_TYPE_ENTER_PROBE=off (default on), or widen/narrow the snapshot window with SURROGATE_TYPE_ENTER_PROBE_ROWS (default 12). Set SURROGATE_TYPE_ENTER_RETRY_COUNT=0 to disable it, or tune SURROGATE_TYPE_ENTER_RETRY_DELAY_SECS and SURROGATE_TYPE_ENTER_RETRY_MAX_DELAY_SECS if a target needs a different cadence.
If a prompt is visibly staged and just needs the missing Enter, the obvious repair path is:
surrogate submit my-sessionIf the target resolves to your current live zmx session, surrogate refuses and tells you who you are instead of typing into itself.
Full tmux send-keys syntax for low-risk keys such as Enter, Escape, arrows, and text literals.
surrogate send <session> "banana" Enter
surrogate send <session> Escape ":wq" Enter # vim save+quit
surrogate send <session> Up Up Enter # repeat 2 commands agoDangerous control keys C-c, C-d, and C-z are reserved for direct human control and are rejected by surrogate.
surrogate read <session> # last 20 lines
surrogate read <session> -n 50 # last 50 linesWaits for a regex pattern to appear in new output (after the last send/type). Useful for automation loops.
surrogate type my-session "make test"
surrogate wait my-session "PASS|FAIL" -t 60 # wait up to 60sSurrogate creates ephemeral tmux "bridge" sessions behind the scenes. You rarely need to manage them, but:
surrogate bridge <session> # pre-warm a bridge
surrogate status # show all bridges and health
surrogate prune-bridges # remove bridges for dead zmx sessions
surrogate prune-bridges --all # remove all bridgesprune-bridges only touches tmux plumbing. Use surrogate prune-sessions or surrogate sweep to remove old zmx sessions.
| Key | Description |
|---|---|
Enter |
Enter/Return |
Escape |
Escape |
C-u |
Ctrl+U (clear line) |
C-l |
Ctrl+L (clear screen) |
Tab |
Tab |
Up Down Left Right |
Arrow keys |
BSpace |
Backspace |
Space |
Explicit space |
Full list: man tmux → KEYS section.
surrogate type robo-quokka "explain the auth module"
surrogate wait robo-quokka "●" -t 60
sleep 10
surrogate read robo-quokka -n 100surrogate send my-session "vim main.go" Enter
sleep 1
surrogate send my-session "i" # insert mode
surrogate send my-session "// TODO: fix this"
surrogate send my-session Escape ":wq" Enter # save and quit
surrogate submit my-session # submit staged promptSurrogate refuses to type into the current live session. If you are unsure which session you are in:
surrogate whoamiIf ZMX_SESSION is stale but your process tree still runs under a zmx attach <session> parent, surrogate whoami reports that session as ancestry-only and tells you it is not currently messageable via surrogate.
SESSION="my-dev-session"
surrogate type "$SESSION" "make build"
if surrogate wait "$SESSION" "error" -t 30 2>/dev/null; then
echo "Build failed"
surrogate read "$SESSION" -n 30
else
echo "Build succeeded"
fiThese are enforced by automated tests and must hold for every change:
| Invariant | Description |
|---|---|
| Always prints status | Every terminal session prints surrogate: on startup, whether zmx was wrapped by the snippet or inherited from the terminal emulator |
| Inherited status shows alias | When a shell starts inside an existing zmx session, the startup line includes both the zmx session name and the surrogate alias when lookup succeeds |
| All shells supported | bash, zsh, and fish snippets all have both wrap and inherit code paths |
| Terminal-agnostic | Zero references to specific terminal emulators in snippets or CLI |
| Full path to zmx | Snippet uses $HOME/.local/bin/zmx, not command -v zmx (PATH isn't set when rc files run) |
| Full path to surrogate | Snippet uses $HOME/.local/bin/surrogate for inherited-session alias lookup, not command -v surrogate, and seeds a minimal PATH so lookup works before shell init finishes |
| Parent process check | Double-wrap prevention checks parent process name (ps -o comm= -p $PPID), not $ZMX_SESSION env var (which leaks through window managers to all children) |
| Nested attach works | The managed shell snippet shims zmx attach so manual attaches from inside zmx clear leaked ZMX_SESSION and still work |
| Deterministic aliases | Every session gets a collision-free adjective-noun alias derived from its name via cksum — no state files, no config |
| Deterministic search | find, who, active, peek use only rg/grep + zmx + tmux — no provider-specific parsing or ML |
| Input validation | All numeric flags (-n, -C, -t) reject non-integer values before reaching internal commands |
| Security floor | type normalizes embedded newlines to spaces and must actually submit, self-targeted type/send/submit are rejected with identity context, send rejects C-c/C-d/C-z, and DCG denials block type when DCG is installed |
| Security overhead tracked | The test harness reports baseline vs guarded type latency as a metric, not a pass/fail gate |
| Audit trail | type and send append JSONL audit records for both allowed and blocked actions |
bash tests/test_surrogate_e2e.sh # fast smoke suite (default)
bash tests/test_surrogate_e2e.sh --full # complete suiteThe default smoke run covers the core end-to-end paths and safety regressions quickly. Use --full for the complete functional and invariant suite.
# Remove shell snippet
surrogate-shell-setup --uninstall
# Remove binaries
rm ~/.local/bin/surrogate ~/.local/bin/surrogate-brief ~/.local/bin/surrogate-shell-setup