Drive remote terminal
Skill 88plug/drive-remote-terminal/skills/drive-remote-terminal
Operate and observe an interactive, full-screen terminal program (a TUI) on a REMOTE machine over SSH by hosting it in a tmux (or screen) session and driving it like a human: TYPE with `tmux send-keys` and SCREENSHOT with `tmux capture-pane -p`. The loop — send input, wait, capture, read, decide the next input — IS the technique. Use it whenever you must act as the human at a remote terminal: launching or resuming an interactive app (Claude Code TUI, vim, top, an installer, a REPL, a curses menu), answering its prompts, typing into it, or reading what's on screen over SSH. Reach for it the moment a plain `ssh host 'cmd'` or a pipe isn't enough because the program needs a real terminal/PTY and stays running. Triggers: drive the TUI on my server, answer the prompt in that remote session, screenshot the terminal, resume my claude session on the server, type into the running program over ssh, automate keystrokes. Prefer this over one-shot SSH for anything interactive.From its SKILL.md
npx -y skills add 88plug/drive-remote-terminal --skill drive-remote-terminalAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
23.3 KB, ~6.0k tokens by cl100k_base, as published. Nobody here has run it
Driving a remote terminal as a human would
You cannot operate an interactive TUI with one-shot SSH. ssh host 'cmd' runs a command
and exits; piping into a full-screen program (Claude Code's interactive UI, vim, top,
a curses installer, a REPL) fails because that program needs a real terminal (a PTY),
stays running, redraws the screen, and reads keystrokes. To be the human at the keyboard
you need two things a human has: a way to type, and eyes to see the screen. tmux gives
you both remotely.
The heart of this skill: the type → wait → screenshot → read loop
This loop is the whole technique. Everything else is setup around it. You drive a remote TUI exactly like a human plays a turn-based game: make one move, look at the screen, decide the next move.
- Type (input):
tmux send-keys -t SESSION 'what the human would type' Enter— but for a long line, send the text andEnteras two separate calls (type, briefsleep, thenEnter); a fast text+Enter burst often letsEnterarrive before the composer has settled, so the text just sits there unsubmitted (see rule 4). For arbitrary or untrusted text, usesend-keys -l -- "$text"(then a separateEnter):-lforces literal characters so a payload that equals a key name (Enter,Up,C-c) is typed verbatim instead of interpreted, and--stops a leading-parsing as a flag. - Wait:
sleep N— let the app process and redraw. - Screenshot (observe):
tmux capture-pane -t SESSION -p— this prints the current visible screen to stdout.capture-paneIS your screenshot. It's how you see what a human would see. - Read and decide: look at the captured screen, then send the next input.
tmux send-keys -t rc 'summarize the last error in this log' Enter
sleep 15 # slow op (model/network); a menu keypress needs ~2s
tmux capture-pane -t rc -p | tail -30 # <-- the screenshot: read the response off it
# ...decide the next input based on what you just saw, then repeat
Three rules that make this loop reliable:
- Always screenshot after acting. Never send an input and assume it worked — capture and confirm the screen changed the way you expected before the next move. The screen is your only source of truth about remote state.
- Always wait before you screenshot. The pane updates asynchronously; capture too
early and you read a stale or half-drawn frame and make the wrong next move. A fixed
sleepis fine for a fast keystroke/menu move (~1–3s); for anything with variable latency (a model turn, a build, a boot) don't hard-code one big sleep — poll (see "Wait by polling" below). - If the screen isn't what you expected, screenshot again after a longer wait — do NOT re-send the input. Double-sending keystrokes is the #1 way to desync/corrupt a TUI. Give it more time and re-capture first. The same trap fires when the screen looks unchanged: an apparent "nothing happened" often means your capture beat the redraw, not that your input was a no-op — treat an unexpected no-change with the same suspicion (wait longer, re-capture) before concluding anything or re-sending.
- If your screenshot shows your typed text still sitting in the input box with no
spinner/response, it wasn't submitted — send a standalone
Enterto submit it. This is the common failure when text andEnterwere sent together too fast. Sending a loneEnterto submit already-typed text is fine and is NOT the same as re-typing the input (which rule 3 warns against). Verified in practice: a long prompt sat unsent until a separateEntersubmitted it, after which the TUI ran its tools and responded. - Confirm the program actually launched before you send ANY input — this is both a
correctness and a SAFETY rule. After the launch command, screenshot and verify the
program's UI is really on screen (its prompt, menu, or buffer). If it failed to start —
not installed, wrong path, crashed, permission denied — you'll instead see a shell
prompt or an error, and every keystroke you then send falls through to the shell,
where typed text +
EnterEXECUTES AS A SHELL COMMAND. That can be destructive (imagine your "input" happening to berm ...or any command). Never fire a scripted key sequence blind; gate it on a screenshot that confirms you're inside the program. Cheap pre-check:command -v <prog>(orwhich <prog>) before launching, so you don't assume a tool likevim/htopexists on that box. Learned the hard way:vimwasn't installed on a host, soi, the text, and:wqleaked to the shell. In an unattended single-sshdrive (see the gotcha below), make this an assert-or-abort inside the script —capture | grep -q '<ready-marker>' || exit 1before the first realsend-keys— so a failed launch aborts instead of firing your key sequence into the shell. (Run thecommand -vpre-check in the same PATH the launch will use, and usepgrep -x name, neverpgrep -f name, for "is it already up?" —-fself-matches your own ssh/bash command line.) - Gate that launch-check on a string UNIQUE to the program's READY/input state — never
on its name or banner, which often ALSO appear in its startup dialogs. A program's
title/logo usually renders on its splash, trust, and confirm screens and on its ready
screen — so matching the app name can falsely report "ready" while a dialog is still up,
and your next keystrokes then fire into that dialog instead of the input. Match the
actual ready marker: the input prompt/composer, a menu's selectable rows, an editor's
mode line — something that does NOT render during a confirm/onboarding screen. (Verified
this session: matching a TUI's banner text caught its trust-folder dialog as "ready", so
the input went to the wrong place.) Bare prompt glyphs (
>,❯,$) are just as unsafe a token — they decorate splashes and menus too; anchor on the glyph plus ready-only chrome (a hint line, a token counter). And exclude status-row chatter from your match: afocus-events off · add 'set -g focus-events on'…hint is cosmetic tmux noise, never an error to "fix" — but it lives on the same bottom row as real ready markers.
capture-pane -p prints the whole visible pane; pipe through tail -N to focus on the
bottom (where prompts and latest output live), or grep -v '^[[:space:]]*$' to drop blank
lines. Add -J to rejoin a long line that wrapped to the pane width — otherwise one
logical line (a wide rule, a path, a status row) arrives as ragged fragments that break
your grep. Captures strip ANSI escapes by default (good — keeps matching clean); add -e
only when you must assert on colour. To read output that scrolled off, capture scrollback:
capture-pane -p -S -300 — and reach for it the moment a long op finished and the visible
tail looks truncated or empty, because the result is now history, not the viewport. (A
full-screen app that has exited leaves nothing in the normal-buffer scrollback either —
capture it while it's still running.) An empty capture, or an empty grep over one, is
NOT proof of absence — re-capture the raw pane with no filter and no 2>/dev/null (which
can hide the real error) before acting on "nothing's there". See references/tmux-keys.md
for the full key + capture-pane reference.
Wait by polling (beats a guessed sleep)
For variable-latency work, poll instead of guessing: capture → test for a marker → small
sleep → repeat, under an overall ceiling. Match a busy marker disappearing (a
spinner / "working…" / "esc to interrupt" line) or a ready marker appearing — not a
duration. Require two consecutive idle reads before declaring done (the UI can flash
idle for a beat before work actually starts):
idle=0
for i in $(seq 1 40); do # ceiling ~40×3s=2min — size to the SLOW path
if tmux capture-pane -t rc -p | grep -q '<busy-marker>'; then idle=0
else idle=$((idle+1)); [ "$idle" -ge 2 ] && break; fi
sleep 3
done
tmux capture-pane -t rc -p | tail -20
Size the ceiling to the worst case (cold model load, slow mirror, busy host — real waits
run from tens of seconds to minutes) and make re-entry resume, not restart. This replaces
the escalating-blind-sleep antipattern (sleep 25 → "still spinning" → sleep 30 →
sleep 30 …). Fixed sleep stays only for fast, deterministic keystroke/menu moves.
Answering prompts (the loop in action)
TUIs gate on prompts — trust dialogs, "are you sure?", menu selections, login fields. Screenshot first to SEE the prompt, then send the matching input, then screenshot again to confirm it advanced. This is just the loop applied to a decision point.
# screenshot shows: ❯ 1. Yes, I trust this folder 2. No, exit (Enter to confirm)
tmux send-keys -t rc '1' Enter # type the choice
sleep 6 # wait
tmux capture-pane -t rc -p | tail -20 # screenshot: confirm we're now in the app
Numbered menu → send the number then Enter. Yes/no → send the highlighted choice.
Text field → send the text then Enter. Navigate with Up/Down/Tab and screenshot
to see the highlight move.
First-launch gauntlet (common to many TUIs): a program's first run — or its first
run in a new directory/profile — often gates the real UI behind one or more one-time
screens: a trust / "do you trust this folder?" prompt, an onboarding or "what's new"
welcome, a theme or login picker. Clear each (screenshot → answer → screenshot) before
sending real input. Tell a modal gate from a non-modal overlay: a trust/login/theme
prompt is modal — it swallows your keystrokes, so you must answer it first; an
informational "welcome / what's new / tips" panel usually sits above an already-active
input composer and does not block — if the composer is present and focused in the same
capture, just type. Decide by whether the real input prompt is reachable, not by the banner
(rule 6). After answering any prompt, screenshot and confirm the expected next state (the
dialog advanced / the app went busy) — if your "answer" instead just echoed into a composer
or scrolled into the shell, the gate wasn't where you thought. Flags that skip permission
gating don't necessarily skip these first-run prompts; to avoid the gauntlet entirely,
launch in a directory/profile the program has already initialised. A fixed launch → clear prompts → type → read flow is cleanest as a single in-session script (see the next gotcha).
Setup: start the session, then run the loop
tmux kill-session -t rc 2>/dev/null # clean any prior session of this name
tmux new-session -d -s rc -x 220 -y 50 # detached; wide + tall so the screen isn't truncated
# set up the environment the program needs, THEN launch it (see env gotchas below)
tmux send-keys -t rc 'export PATH=$HOME/.local/bin:$PATH; cd /path/to/project; clear' Enter
sleep 1
tmux send-keys -t rc 'theprogram --its-flags' Enter
sleep 14 # boot time, then screenshot:
tmux capture-pane -t rc -p | tail -25
# ...now you're in the type → wait → screenshot loop.
Pick a generous pane size (-x 220 -y 50). TUIs reflow to the pane, so a cramped pane
truncates the very screen you're trying to read in your screenshots.
Gotcha: the tmux server vanished between SSH calls ("no server running")
If you drive with one fresh ssh host '…' per step, the tmux server can be killed
the instant the SSH session that started it closes — your next call greets you with
no server running on /tmp/tmux-1000/default, and every step after silently no-ops
(keystrokes go nowhere). Cause: systemd-logind with KillUserProcesses=yes (the default
on many distros) reaps all of a user's session-scope processes — including that tmux
server — on logout when the user has no lingering. Verified in practice this session: a
multi-call drive died exactly this way mid-run, after the launch screenshot had already
succeeded — so it looks like it's working, then the next SSH call finds nothing.
Three fixes — pick one:
- Simplest & most reliable for a fixed sequence: do the entire drive inside ONE
sshinvocation. Put the launch + every send-keys/sleep/capture into a single heredoc script and run it over one connection. The server lives as long as that one session, which spans the whole drive. (This is also why a one-shotlaunch → answer prompts → type → readflow is best written as a single in-session script.) - Keep the connection alive across calls with SSH multiplexing:
ControlMaster auto+ aControlPath+ControlPersist 5m. Every send-keys/capture reuses the one live session, so the server is never orphaned — and it's much faster (no re-auth per call). - Make the server outlive the session: run
loginctl enable-linger "$USER"once, or start tmux escaped from the session scope:systemd-run --user --scope tmux new-session -d -s rc ….
Reach for ControlPersist (not single-script) only when you need a genuine look-decide-look loop whose later moves depend on what earlier screenshots showed.
When tmux is NOT needed
- Non-interactive / one-shot (read a file, run a build, tail a log, or any tool invoked
in a batch /
--print/ headless mode that returns its output and exits): just usessh host 'cmd'. No tmux, no loop. Only a program that stays running and redraws a full-screen UI needs the tmux loop. - Interactive / stays on screen / has prompts: use the type → wait → screenshot loop.
- A long job that just needs to RUN unattended (you'll read its logfile, not watch a
UI): do NOT wrap it in
tmux new-session -dover per-call SSH — that server dies with the SSH session and silently takes the job with it (verified: repeatedno server running, the job never persisted). Launch it genuinely detached —nohup cmd >log 2>&1 &,setsid,systemd-run --user, ordocker exec -d— and poll the logfile. tmux is for interactive driving, not background persistence.
SSH + auth, and the heredoc rule
When a step needs several remote commands, don't cram them into one
ssh host 'bash -lc "..."' — nested quoting breaks, and a vicious gotcha is that
parentheses inside an echo string abort bash -lc with a syntax error. Instead,
write a small script via a quoted heredoc, run it, remove it:
sshpass -p "$PW" ssh -o StrictHostKeyChecking=no user@host "cat > /tmp/step.sh <<'EOF'
# real multi-line bash — quoting is sane, parens are fine
tmux send-keys -t rc '1' Enter
sleep 6
tmux capture-pane -t rc -p | tail -20
EOF
bash /tmp/step.sh; rm -f /tmp/step.sh"
The quoted delimiter <<'EOF' stops $var/backtick expansion so the script arrives
verbatim. sshpass -p supplies a password non-interactively (fine for the user's own
boxes when that's what they've given you; otherwise prefer keys).
Two sharp traps with that pattern:
- The quoted
<<'EOF'only stops the remote shell from expanding — but the whole heredoc sits inside the outer double-quotedssh "…"arg, which your local shell expands first. So any$VAR,$(…),`…`you want the remote side to evaluate must be backslash-escaped (\$HOME,\$(id -u)), and a bare$$there is the local PID (useless for a unique remote temp name — usemktempremotely). Cleanest escape-free alternative: pipe the script over stdin —ssh host 'bash -s' < script.sh(or single-quote the outer arg) — then nothing takes a local-expansion pass. - For scripted (non-interactive) ssh steps add
-o BatchMode=yes -o ConnectTimeout=15and wrap the call intimeout 30:BatchModemakes auth fail fast instead of hanging on a password/passphrase prompt a script can't answer (the classic "the drive froze"). PreferStrictHostKeyChecking=accept-newover=no— it auto-trusts a first-seen host yet still refuses a changed key (recover a legitimately changed one withssh-keygen -R host). Don't combineBatchMode=yeswithsshpass(sshpass must feed the prompt).
Environment gotchas (these cause the most confusion)
A command run via ssh host 'cmd' or bash -c is a non-login, non-interactive shell
— it does not source ~/.bashrc/~/.bash_profile. So:
- PATH may be missing
~/.local/bin→theprogram"command not found" even though it's installed. Use the full path orexport PATH=$HOME/.local/bin:$PATHfirst. - Env vars exported in
.bashrcwon't be present — and worse, the export can be IN.bashrcand still not reach you: the standard top-of-file non-interactive guard (case $- in *i*) ;; *) return;; esac)returns before the export line for abash -lc/non-interactive ssh shell. So "but it's in my.bashrc" does not fix it. Set the var explicitly in the tmux pane before launch, or put the binary/var on a location already on the base PATH (e.g. symlink into~/.local/bin). - A program in tmux inherits the env of the pane/shell that launched it — not your SSH command's env unless you exported it in that pane.
- A program started by the systemd user manager or the GUI session ignores both
.bashrcand your tmuxexport. Set it for the manager (systemctl --user set-environment VAR=…, or a~/.config/environment.d/<name>.confdrop-in), and verify what a freshly-spawned managed process will actually see withsystemd-run --user --wait --pipe /usr/bin/env | grep VAR.
Verify what a running process actually sees (ground truth) from /proc — pick the PID
with pgrep -x name (not -f, which self-matches your ssh/bash line) and tolerate it
vanishing mid-read:
tr '\0' '\n' < /proc/$(pgrep -x theprogram | head -1)/environ 2>/dev/null | grep -E 'PATH|THE_VAR'
For systemctl --user over SSH, first export XDG_RUNTIME_DIR=/run/user/$(id -u).
Don't disturb running work
Acting as the human means being careful — real work and live processes are at stake.
- Observe before you touch. Screenshot, read logs/transcripts, understand state. Don't kill/restart a process to "see what happens" — that can destroy in-flight work.
- Don't double-open a session. Before resuming/opening something that may already be
running, confirm nothing live holds it:
pgrep/ps, and which files a process has open (ls -l /proc/PID/fd). Attaching two instances to one session/file can corrupt it. - Prefer the lossless path. If a program persists its state (e.g. a session transcript), relaunching/resuming loses nothing; mutating shared config other live processes read is risky.
Going further (heavy automation, sync, alternatives)
The loop above is enough for most jobs. When you're driving a lot, want to stop guessing
sleep durations, or aren't sure tmux is even the right tool, see
references/advanced-and-ssh.md. Highlights (all doc-verified):
- SSH connection reuse (
ControlMaster auto+ControlPath+ControlPersist) — the biggest speedup for a send-keys/capture loop: every call reuses one authenticated connection. And don't pass-tto yoursend-keys/capture-panecalls (a PTY adds noise that can corrupt the capture); reserve-t/-ttonly for attaching to watch — and from a non-TTY driver that attach needs-tt(a single-tsilently refuses to allocate a PTY). AddServerAliveInterval 30so a long-idle multiplexed master isn't dropped between bursts. - Deterministic sync instead of
sleepwhen driving a shell: append; tmux wait-for -S doneto the command you send and block ontmux wait-for done; or useremain-on-exit+#{pane_dead_status}for one-shot exit codes. - Idempotent session + env: prefer
tmux new-session -A -d -s NAME -x 220 -y 50(create-or-reuse) overhas-session || new-session. It's the bare attach (-Aalone) that needs a PTY and fails over non-PTY ssh — adding-d(or-A -Dto also boot a stale attached client) keeps it detached, so it never attaches and works headlessly. Start it viassh host 'bash -lc "…"'so panes inherit a login PATH/env; target by stable IDs. Running several jobs at once? Give each its own uniquely-named session and poll withtmux has-session -t NAME 2>/dev/null && echo alive || echo dead— one session per unit of work designs the wrong-pane-targeting problem out of existence. - Right tool? / no tmux on the box? Prompt-driven/line-oriented programs (REPLs,
password/y-n prompts) are more robust with
expect/pexpect(deterministic expect-then-send, no sleep-guessing); full-screen TUIs are tmux's job. If tmux isn't installed, GNUscreenis a fully capable first-class substitute — seereferences/screen.mdfor the verified playbook (raw-byte key table since screen has no symbolic key names, the ~750-bytestufflimit +readreg/paste,hardcopycapture, and the sentinel-file run-and-wait).
Hand back to the human
You drove it detached; the human can take over anytime by attaching to the same tmux session from their own terminal on that box:
tmux attach -t rc
Tell them the session name and the state you left it in. When it's no longer needed,
tmux kill-session -t rc — but ask first if it's hosting the user's work.
Worked example (the loop, end to end)
# SETUP: detached, well-sized session with the right env, launch the TUI
tmux kill-session -t rc 2>/dev/null
tmux new-session -d -s rc -x 220 -y 50
tmux send-keys -t rc 'export APP_API_BASE=http://127.0.0.1:9099; cd ~/project; clear' Enter
sleep 1
tmux send-keys -t rc 'theprogram --its-flags' Enter
sleep 16
# SCREENSHOT: a first-run prompt may be up (trust/onboarding)
tmux capture-pane -t rc -p | tail -25 # e.g. shows: ❯ 1. Yes, I trust this folder ...
# TYPE the answer → WAIT → SCREENSHOT to confirm it advanced
tmux send-keys -t rc '1' Enter
sleep 8
tmux capture-pane -t rc -p | tail -20 # confirm the real input prompt is now on screen
# TYPE a real instruction → WAIT (slow op) → SCREENSHOT the result
tmux send-keys -t rc 'the task the human would type' Enter
sleep 15
tmux capture-pane -t rc -p | tail -10 # the result is on the screen
That type → wait → screenshot → read rhythm, with the environment set correctly before launch, is the whole skill.
What ships with it: 3 files
21.1 KB alongside SKILL.md
references/
- advanced-and-ssh.md8.7 KB
- screen.md8.1 KB
- tmux-keys.md4.3 KB