Skip to content

Repository files navigation

Surrogate

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.

Surrogate

WARNING!!!

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.

Motivation

  1. tmux is powerful and amazing and my agents love it. I hate using tmux.
  2. 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).
  3. zmx is very slick but doesn't have a way to inject keys.

How it works

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).

Install

One-liner:

git clone https://github.com/rawwerks/surrogate.git && cd surrogate && bash install.sh && surrogate-shell-setup --install && surrogate-doctor

Or step by step:

git clone https://github.com/rawwerks/surrogate.git
cd surrogate
bash install.sh
surrogate-shell-setup --install
surrogate-doctor

This 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-link

This 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-main

It 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.

Agent skill (for Claude Code)

To teach Claude Code how to use surrogate, install the skill:

ln -s ~/Documents/GitHub/surrogate/SKILL.md ~/.claude/skills/surrogate.md

Agents will then know how to discover zmx sessions, inject keystrokes, read output, and wait for patterns.

Dependencies

  • zmx — session persistence
  • tmux — used internally for keystroke injection

Recommended safety dependency

  • 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.sh

Security Model

Surrogate 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:

  • type normalizes embedded newlines to spaces and must actually submit, not just stage text in the target input
  • type, send, and submit reject self-targeting and tell you the current alias/session
  • prune-sessions rejects the current live session and any attached session with clients still present
  • send rejects C-c, C-d, and C-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

Shell integration: every shell or selected commands

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 zmx
  • commands — leave plain shells alone and wrap only configured commands such as claude, pi, or codex

Recommended: wrap selected commands only

Public-safe example config:

cp surrogate-shell.conf.example ~/.config/surrogate/shell.conf
surrogate-shell-setup --install

surrogate-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, prints surrogate: zmx session <name> alias <alias> so you see the deterministic alias for the session you're about to enter
  • Clears leaked ZMX_SESSION when the shell is not actually running under a zmx parent, so command wrappers still fire correctly
  • Keeps plain zmx attach ... working by clearing leaked ZMX_SESSION only 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

Legacy / maximal mode: auto-wrap every shell

To make every new terminal window a zmx session automatically:

surrogate-shell-setup --install --mode all

This 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 leaked ZMX_SESSION only 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,codex

Check if installed:

surrogate-shell-setup --check

Session Aliases

Every 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-dolphin

All commands that take a <session> argument accept either the full zmx name or the alias.

Usage

List available sessions

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 metadata

Search all sessions

surrogate find "auth error"             # search last 200 lines of every session
surrogate find "TODO" -n 500 -C 3       # deeper search with context lines

Show sessions with snippets

surrogate 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.

Show attached sessions

surrogate active                         # only sessions with clients attached
surrogate active --all                   # include non-empty detached sessions

Show live, active sessions with less noise

surrogate 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 --json

surrogate 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.

Remote operator briefs with OpenRouter

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-pixel

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.

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-config

Each session brief includes:

  • ATTENTION REQUIRED
  • PRIORITY
  • SIGNAL QUALITY
  • WHY NOW
  • STATUS
  • LAST COMPLETED
  • USER ACTION REQUIRED
  • NEXT UNBLOCKING STEP
  • PROPOSED NEXT STEPS
  • USER INPUT NEEDED
  • BLOCKERS

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: 500 lines
  • Completion budget: 1200 tokens 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.conf

CLI flags override config values.

If the key is missing, surrogate-brief prints the exact setup steps needed to enable it.

Show stale detached sessions

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 20

stale is ordered oldest-first.

Batch read all sessions

surrogate peek                           # last 5 lines from every session
surrogate peek --filter "shoulder"       # only sessions matching pattern
surrogate peek -n 2 --filter "error"

Rename a session

surrogate rename <old-session> <new-name>

Review and prune old zmx sessions

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 --yes

Batch stale pruning is ordered oldest-first and prints explicit skip reasons for old sessions that are still attached or are your current live session.

Schedule recurring pruning

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 schedule

auto-prune auto-detects the host scheduler:

  • Linux — writes ~/.config/systemd/user/surrogate-prune.{service,timer} and enables the timer via systemctl --user. Inspect with systemctl --user list-timers surrogate-prune.timer and journalctl --user -u surrogate-prune.service.
  • macOS — writes ~/Library/LaunchAgents/works.raw.surrogate-prune.plist and loads it via launchctl bootstrap (or launchctl load -w on 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 ...

Type text + Enter

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 found or 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-session

If the target resolves to your current live zmx session, surrogate refuses and tells you who you are instead of typing into itself.

Send special keys

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 ago

Dangerous control keys C-c, C-d, and C-z are reserved for direct human control and are rejected by surrogate.

Read output

surrogate read <session>            # last 20 lines
surrogate read <session> -n 50      # last 50 lines

Wait for pattern

Waits 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 60s

Bridge management

Surrogate 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 bridges

prune-bridges only touches tmux plumbing. Use surrogate prune-sessions or surrogate sweep to remove old zmx sessions.

Special keys reference

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.

Examples

Send a prompt to Claude Code

surrogate type robo-quokka "explain the auth module"
surrogate wait robo-quokka "●" -t 60
sleep 10
surrogate read robo-quokka -n 100

Drive vim

surrogate 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 prompt

Self-target guard

Surrogate refuses to type into the current live session. If you are unsure which session you are in:

surrogate whoami

If 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.

Automation loop

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"
fi

Design Invariants

These 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

Tests

bash tests/test_surrogate_e2e.sh         # fast smoke suite (default)
bash tests/test_surrogate_e2e.sh --full  # complete suite

The default smoke run covers the core end-to-end paths and safety regressions quickly. Use --full for the complete functional and invariant suite.

Uninstall

# Remove shell snippet
surrogate-shell-setup --uninstall

# Remove binaries
rm ~/.local/bin/surrogate ~/.local/bin/surrogate-brief ~/.local/bin/surrogate-shell-setup

About

Programmatic keystroke injection for agents to control any terminal application

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages