English | Русский
A skill for agentic harnesses (Claude Code first and foremost) that keeps a
live SSH session to a server and runs commands over it. One connection, one
remote shell for the whole job: cd, export, an activated venv and background
jobs all survive between calls.
A plain ssh host "cmd" pays a full handshake per command and starts a new
shell every time — so an agent that needs ten steps on a server loses its state
after each one and keeps gluing it back together with one-liners like
cd /srv/app && source venv/bin/activate && ….
Measured on a real host: 10 commands over a fresh ssh each time — 25.2 s, over a live session — 3.5 s.
A human at a keyboard already has a live session — the terminal. The problem is
that SSH tooling is built for that human: when a [Y/n], a password prompt or a
pager shows up, they press a key. Nobody is there to rescue an agent, and the
typical ending is a command hanging inside less until the timeout — or worse,
a wrapper typed into a running top as keystrokes.
So this is not just "the session stays alive". There are three layers keeping the work from stalling:
- Muting interactivity. Right after connecting, a non-interactive
environment is applied (
PAGER=cat,DEBIAN_FRONTEND=noninteractive,EDITOR=false, …). This closes the nastiest trap — the pager: with--no-harden,git logruns into the timeout becauselessis waiting forq. - Auto-answers. Passwords, pagers and "Press ENTER" are answered
immediately; confirmations that actually change something (
[Y/n], adpkgconffile conflict) only with an explicit--yes. A rule fires only if the match sits at the very end of the output, i.e. the program really is waiting:echo "Do you want to continue? [Y/n]"as ordinary output is not answered. - Refusing to type into something that is not a prompt. Before sending,
the bracketed-paste state in the log already received is inspected — for
free, without touching the server. If a TUI is open in the session, or a
command is still running,
runexits with code 126 and tells you which key to press instead of blindly dictating text intovim.
On top of that: every refusal has its own exit code (124 timeout, 125 busy
with a parallel run, 126 not at a prompt, 130 interrupted) — an agent can
tell "slow" from "broken" without parsing prose.
The skill is the skills/ssh-session/ directory with a SKILL.md following the
Agent Skills specification; inside it there is only a
stdlib Python script, so installing it amounts to copying a folder. The
repository doubles as a Claude Code plugin marketplace, which gives it a
shorter path.
Claude Code, as a plugin (versioned, with /plugin update):
/plugin marketplace add Flexlug/ssh-session
/plugin install ssh-session@flexlug
By hand, into any harness — clone and drop the skill folder where it belongs:
git clone https://github.com/Flexlug/ssh-session /tmp/ssh-session
cp -r /tmp/ssh-session/skills/ssh-session ~/.claude/skills/| Harness | Where it goes |
|---|---|
| Claude Code, Claude Desktop | ~/.claude/skills/ (or .claude/skills/ in a project) |
| Codex CLI | ~/.codex/skills/ |
| Gemini CLI | ~/.gemini/skills/ |
| GitHub Copilot / VS Code | ~/.config/skills/ (or .github/skills/ in a repository) |
| Cursor, OpenCode, Goose, Amp | see the client's docs — they all read SKILL.md |
There is also the universal gh skill install Flexlug/ssh-session (GitHub CLI
2.90+), but I have not used it — I have not verified that it works against this
repository.
Check the driver is alive (it runs perfectly well on its own, without any agent):
~/.claude/skills/ssh-session/scripts/sshsess.py ls
# no sessionspython3 (3.8+) and ssh — nothing else. No pip packages, no tmux, no Node.
On the server side the dependency is not the distribution but the shell:
| Needed for | Requirement | Where it breaks |
|---|---|---|
run (markers, $?) |
any POSIX shell | fish, csh → fixed by --shell 'bash -i' |
| layer 3 (busy check) | bracketed paste, i.e. readline/zle | dash, ash/busybox → the check answers "unknown" and does not protect |
S=~/.claude/skills/ssh-session/scripts/sshsess.py # a plugin install puts it elsewhere
$S new box myhost.example.com # open a session named box
$S run box uname -sr # run it, get the output and the exit code
$S read box --tail 40 # look at what is going on in there
$S send box --key C-c # send raw input
$S kill box # close itState persists — that is the entire point:
$S run box 'cd /etc && export MYVAR=hello'
$S run box 'pwd; echo "MYVAR=$MYVAR"'
# /etc
# MYVAR=hellorun returns clean output (no prompt, no echo) and the real exit code
of the remote command:
$S run box 'ls /nope'; echo "rc=$?"
# ls: cannot access '/nope': No such file or directory
# rc=2A session outlives the process that created it: do whatever you like between calls, it stays where it was.
# --- sessions ---
$S new NAME TARGET [--timeout 40] [--shell 'bash -i'] [--force] [--no-harden] [-- SSH_ARGS...]
$S ls | info NAME | reconnect NAME | truncate NAME | kill NAME|--all | prune
# --- execution ---
$S run [--timeout 120] [--yes] [--no-auto] [--force] [--allow-exit] NAME CMD...
$S expect NAME [--once] [--yes] [--dry-run] [--list-rules]
$S send NAME [TEXT] [--key KEY] [--no-enter] [--paste] [--wait SEC]
$S interrupt NAME
$S read NAME [--tail N] [--all] [--since OFFSET] [--raw] [--no-filter]
$S wait NAME REGEX [--timeout 60] [--from-start]What to use for what:
| Task | Tool |
|---|---|
| Run a command, get output and an exit code | run — almost always this one |
Answer a prompt (sudo, host key, yes/no) |
send |
Work in a REPL (python3, psql, mysql) |
send + read |
| Unstick a session that is stuck | expect, interrupt |
| Wait for a line in the output of an already running process | wait |
REPLs are supported (--paste for multi-line input — it sidesteps PyREPL's
auto-indent), TUIs through keys (send --key Down --key Enter picks an entry in
a whiptail menu), and any number of parallel sessions, including several to the
same host.
They live in ~/.config/sshsess/secrets.json, mode strictly 0600 — otherwise
sshsess refuses to run. The password is never passed into the chat, never ends
up on a command line, and is not written to the session log (password prompts
turn terminal echo off); the report prints <secret:sudo> in place of the value.
mkdir -p ~/.config/sshsess
printf '%s\n' '{"secrets": {"sudo": "…"}}' > ~/.config/sshsess/secrets.json
chmod 600 ~/.config/sshsess/secrets.jsonCustom auto-answer rules go into ~/.config/sshsess/rules.json (a regexp plus
exactly one of send / key / secret; the confirm flag ties a rule to
--yes).
Accepting a host key is deliberately not automated — that is a security
decision and it belongs to a human: new exits with code 4 and shows the
fingerprint.
- Windows is not supported, and that is a decision rather than an open debt:
there is no pty and no
fork()there, and ConPTY would require a third-party dependency such aspywinpty, breaking the main property — stdlib only, nothing to install. The script exits with code 1 and a clear message. WSL covers Windows: inside it this is ordinary Linux and everything works as is. - Sessions do not survive a reboot. State lives in
$XDG_RUNTIME_DIR/sshsess(Linux) or$TMPDIR(macOS) — the system wipes both. Override withSSHSESS_DIR. - There is no auto-reconnect. A dropped link is detected in about a minute
(
ServerAliveInterval=15) and the session goesdead;reconnect NAMEreopens it with the same arguments, but the remote state is genuinely gone — andreconnectsays so outright. - A session name is a machine-wide resource. Anyone using the same name lands
in the same shell;
ls/infoshow the owner, andnewrefuses a name held by a live session. Real isolation comes fromSSHSESS_OWNER. - Full-screen TUIs read approximately:
readis a line-by-line history, not a screen snapshot. For data, use batch modes (top -b -n1,journalctl --no-pager).
The client side is tested on Linux; macOS is untested, but the parts specific to
it are covered explicitly — the choice of state directory, 0700/0600 modes,
and the AF_UNIX path length limit (104 bytes there against 108 on Linux; checked
on an emulation of the same length: 104 fails with a clear message rather than
"daemon failed to start").
The test stand needs no remote host at all: tests/local-sshd.sh brings up a
temporary sshd on 127.0.0.1 — no sudo, no system-wide changes, ~/.ssh is
never touched, everything lives in one work directory and is deleted with it.
cd skills/ssh-session
bash tests/local-sshd.sh start # prints the target to connect to
bash tests/local-sshd.sh stop # kills it and removes the work directoryThe smoke set is in tests/TESTCASES.md.
The daemon is a double-forked process that owns the pty master and appends every
byte coming from the server to out.log. Clients read that file directly, so
read works even while a command is still streaming. Only side-effecting
operations go through the unix socket: send / info / kill.
run rests on two tricks, and both are mandatory:
- The marker literals are split by quotes (
'__SS''B...'), so the echo of the line we send does not contain the text we are searching for. Otherwise the search would always hit the echo and cut the output in the wrong place. - Everything is wrapped in a single
{ ... }group. The shell will not start executing until it has read the closing brace, so all the echo ends up before the first marker's output — that is, outside the region being cut.
The full description — pitfalls, the diagnostics table and the exit codes — is in
SKILL.md. That is the very file the agent reads.
Developed as a pair: Flexlug — idea, requirements, testing; Claude (Anthropic) — implementation, shakedown on live hosts, documentation. Co-authorship is reflected in the commits.
Licensed under MIT.