#!/usr/bin/env python3
"""etch — drive a persistent EternalTerminal session programmatically.
Wraps `et` in a PTY (via a small built-in engine), neutralizes the prompt to get a
clean byte stream, and runs commands framed by unique sentinels so output and
exit codes extract reliably regardless of the remote prompt's rendering.
Library: EtSession (one-shot) / open_session(key=...) (warm daemon).
CLI: `etch <run|open|attach|send|sendline|expect|read|peep|ls|close|gc> KEY ...` (see --help).
"""
from __future__ import annotations
import base64
import codecs
import fcntl
import getpass
import glob
import json
import os
import pty
import re
import select
import shlex
import signal
import socket
import struct
import subprocess
import sys
import termios
import time
import tty
import uuid
from typing import Any, Callable, Union, cast
# The remote PTY runs in canonical mode, which truncates a single input line at
# the kernel's cap (~1KB). A framed run() line longer than this is silently cut,
# the end sentinel never matches, and the leftover poisons the session. run()
# reroutes anything longer than this through run_script()'s chunked path.
_LINE_CAP = 900
# ─────────────────────────────────────────────────────────────────────────────
# PTY/expect engine
# -----------------
# A small, self-contained pseudo-terminal driver: spawn a command under a PTY and
# drive it with an expect-style read buffer. This is all etch needs to make `et`
# believe a human is on the other end, with no third-party dependencies.
#
# spawn(cmd, encoding=, timeout=, dimensions=, codec_errors=) -> child
# child.expect(pattern | list, timeout=) child.expect_exact(s, timeout=)
# child.read_nonblocking(size=, timeout=) child.send(s) child.sendline(s)
# child.before child.after child.match child.close(force=)
# TIMEOUT / EOF (raised, and usable as expect() patterns to match instead of raise)
# ─────────────────────────────────────────────────────────────────────────────
class TIMEOUT(Exception):
"""No match or data arrived before the deadline. Usable as an expect() pattern
to match-on-timeout instead of raising."""
class EOF(Exception):
"""The child's PTY hit end-of-file / the process exited. Usable as an expect()
pattern to match-on-EOF instead of raising."""
_Pattern = Union[str, "re.Pattern[str]", type[TIMEOUT], type[EOF]]
_ExpectArg = Union[_Pattern, list[_Pattern]]
class _PtyProcess:
"""Spawn a command under a pseudo-terminal and drive it with an expect-style
read buffer. A minimal driver: only the behavior etch needs."""
# A brief pause before each write avoids losing input to a remote PTY that is
# not yet ready to read. Tunable if latency ever matters.
delaybeforesend: float = 0.05
def __init__(
self,
command: str,
encoding: str = "utf-8",
timeout: float = 30,
dimensions: tuple[int, int] = (24, 80),
codec_errors: str = "strict",
) -> None:
self.timeout = timeout
self.before: str = ""
self.after: str | type[TIMEOUT] | type[EOF] = ""
self.match: re.Match[str] | None = None
self._buf: str = ""
self._decoder = codecs.getincrementaldecoder(encoding)(codec_errors)
argv = shlex.split(command)
pid, fd = pty.fork()
if pid == 0: # child: replace ourselves with the command on the PTY slave
try:
os.execvp(argv[0], argv)
except Exception:
os._exit(127)
self.pid: int = pid
self.fd: int = fd
rows, cols = dimensions
try:
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
except OSError:
pass
# ── low-level I/O ─────────────────────────────────────────────────────────
def _read_fd(self, timeout: float) -> str | None:
"""Wait up to `timeout` for output; decode and return a chunk (possibly ''
on a partial multibyte char). Return None on timeout; raise EOF when gone."""
try:
empty: list[int] = []
ready, _, _ = select.select([self.fd], empty, empty, max(0.0, timeout))
except (OSError, ValueError):
raise EOF()
if not ready:
return None
try:
data = os.read(self.fd, 4096)
except OSError: # Linux reports the PTY master EOF as EIO
raise EOF()
if not data: # macOS reports EOF as an empty read
raise EOF()
return self._decoder.decode(data)
def send(self, s: str) -> int:
if self.delaybeforesend:
time.sleep(self.delaybeforesend)
return os.write(self.fd, s.encode("utf-8", "replace"))
def sendline(self, s: str = "") -> int:
return self.send(s + "\n")
def read_nonblocking(self, size: int = 1, timeout: float | None = None) -> str:
"""Up to `size` chars: drain the expect buffer first, else read the fd.
Raise TIMEOUT if nothing arrives in time, EOF at end of file."""
if self._buf:
out, self._buf = self._buf[:size], self._buf[size:]
return out
chunk = self._read_fd(self.timeout if timeout is None else timeout)
if chunk is None:
raise TIMEOUT()
if size and len(chunk) > size:
self._buf = chunk[size:] + self._buf
chunk = chunk[:size]
return chunk
# ── expect machinery ──────────────────────────────────────────────────────
@staticmethod
def _compile(pattern: _ExpectArg) -> list[object]:
items = pattern if isinstance(pattern, list) else [pattern]
out: list[object] = []
for p in items:
out.append(re.compile(p) if isinstance(p, str) else p)
return out
def _set(
self,
before: str,
after: str | type[TIMEOUT] | type[EOF],
match: re.Match[str] | None,
rest: str,
) -> None:
self.before, self.after, self.match, self._buf = before, after, match, rest
def expect(self, pattern: _ExpectArg, timeout: float | None = None) -> int:
"""Read until one of `pattern` (a regex, or a list mixing regexes with the
EOF/TIMEOUT classes) matches; return its index and set before/after/match.
EOF/TIMEOUT in the list match instead of raising."""
compiled = self._compile(pattern)
eof_idx: int | None = None
tmo_idx: int | None = None
regexes: list[tuple[int, re.Pattern[str]]] = []
for i, p in enumerate(compiled):
if p is EOF:
eof_idx = i
elif p is TIMEOUT:
tmo_idx = i
elif isinstance(p, re.Pattern):
regexes.append((i, cast("re.Pattern[str]", p)))
deadline = time.time() + (self.timeout if timeout is None else timeout)
while True:
best: tuple[int, int, re.Match[str]] | None = None
for i, rx in regexes:
m = rx.search(self._buf)
if m is not None and (best is None or m.start() < best[0]):
best = (m.start(), i, m)
if best is not None:
_, i, m = best
self._set(self._buf[: m.start()], m.group(0), m, self._buf[m.end() :])
return i
remaining = deadline - time.time()
if remaining <= 0:
if tmo_idx is not None:
self._set(self._buf, TIMEOUT, None, "")
return tmo_idx
raise TIMEOUT()
try:
chunk = self._read_fd(remaining)
except EOF:
if eof_idx is not None:
self._set(self._buf, EOF, None, "")
return eof_idx
raise
if chunk:
self._buf += chunk
def expect_exact(self, s: str, timeout: float | None = None) -> int:
"""Like expect() but matches a literal substring (no regex)."""
deadline = time.time() + (self.timeout if timeout is None else timeout)
while True:
idx = self._buf.find(s)
if idx >= 0:
self._set(self._buf[:idx], s, None, self._buf[idx + len(s) :])
return 0
remaining = deadline - time.time()
if remaining <= 0:
raise TIMEOUT()
chunk = self._read_fd(remaining) # raises EOF when the child is gone
if chunk:
self._buf += chunk
def close(self, force: bool = True) -> None:
try:
os.close(self.fd)
except OSError:
pass
if self.pid:
try:
os.kill(self.pid, signal.SIGKILL if force else signal.SIGHUP)
except ProcessLookupError:
pass
try:
os.waitpid(self.pid, 0)
except OSError:
pass
self.pid = 0
# Alias so call sites read `spawn(...)`.
spawn = _PtyProcess
_ANSI = re.compile(
r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[=>]"
)
def _clean(s: str) -> str:
return _ANSI.sub("", s).replace("\r", "")
_TRANSPORTS = {"et": "et {host}", "ssh": "ssh -tt {host}"}
def _transport_cmd(via: str, host: str) -> str:
"""The command that opens an interactive PTY shell on HOST. `via` is a known
transport name (et/ssh) or a literal 'CMD {host}' template (host is appended if
it has no {host}). The default, et, gives the resilient eternal session."""
tmpl = _TRANSPORTS.get(via, via)
return tmpl.format(host=host) if "{host}" in tmpl else "%s %s" % (tmpl, host)
class EtSession:
def __init__(
self,
host: str,
via: str = "et",
connect_timeout: float = 60,
dims: tuple[int, int] = (50, 200),
remote_histfile: str | None = "/dev/null",
cmdlog: str | None = None,
) -> None:
self.host = host
self.timeout = connect_timeout
# remote_histfile: where the remote shell writes history.
# '/dev/null' (default) = discard; or a path = dedicated audit file;
# set None to leave the shell's default untouched.
self.remote_histfile = remote_histfile
# cmdlog: optional LOCAL file; the wrapper appends every command it runs.
self.cmdlog = cmdlog
self.prompt = "" # verbatim remote prompt line from the last expect()
# before/after/match are populated dynamically; keep the child typed Any and
# lean on the sentinel framing. The engine itself is fully typed above.
self.child: Any = spawn(
_transport_cmd(via, host),
encoding="utf-8",
timeout=connect_timeout,
dimensions=dims,
codec_errors="replace",
)
def connect(self) -> EtSession:
c = self.child
c.expect("❯", timeout=self.timeout)
self._drain(0.6) # flush the full p10k prompt render
hf = self.remote_histfile
# zsh side: stop it saving the exec line / anything to the real history
if hf is not None:
c.sendline("HISTFILE=%s; fc -p /dev/null 2>/dev/null" % hf)
self._drain(0.3)
# replace noisy interactive zsh+p10k with a bare, quiet bash
c.sendline("exec bash --norc --noprofile")
self._drain(0.6)
# clean-room env: empty prompts, dumb term, no \r, no pagers/interactive prompts
setup = (
"export PS1='' PS2='' PROMPT_COMMAND='' TERM=dumb "
"PAGER=cat GIT_PAGER=cat GIT_TERMINAL_PROMPT=0 "
"DEBIAN_FRONTEND=noninteractive; stty -echo -onlcr 2>/dev/null"
)
if hf is None:
pass
elif hf == "/dev/null":
setup = "set +o history; " + setup # don't record at all
else:
setup += "; export HISTFILE=%s" % hf # dedicated audit file
c.sendline(setup)
self._drain(0.6)
nonce = "RDY_" + uuid.uuid4().hex[:8]
for _ in range(8):
c.sendline("echo %s" % nonce)
try:
c.expect_exact(nonce, timeout=4) # stty -echo => appears once (output)
except TIMEOUT:
continue
except EOF:
raise RuntimeError("et session closed during connect")
else:
self._drain(0.4)
return self
raise RuntimeError("sync failed; tail=%r" % (c.before[-300:],))
def _drain(self, idle: float = 0.3) -> None:
"""Discard whatever output is buffered right now (settle the stream)."""
self.read_available(idle)
def _resync(self, attempts: int = 3) -> bool:
"""Recover a desynced session after a framing failure (a command that
hung reading stdin, or a truncated over-long line). Interrupt whatever is
running with Ctrl-C, drain, and confirm a clean prompt with a nonce.
Returns True if the session is usable again. This keeps one bad command
from poisoning the whole warm key."""
c = self.child
try:
for _ in range(attempts):
c.send("\x03") # Ctrl-C: kill a hung/partial command
self._drain(0.4)
nonce = "RSY_" + uuid.uuid4().hex[:8]
c.sendline("echo %s" % nonce)
try:
c.expect_exact(nonce, timeout=4)
self._drain(0.3)
return True
except TIMEOUT:
continue
except EOF:
return False
except Exception:
return False
return False
def run(self, cmd: str, timeout: float = 60) -> tuple[str, int]:
c = self.child
if self.cmdlog:
with open(self.cmdlog, "a") as fh:
fh.write(cmd + chr(10))
s = "S_" + uuid.uuid4().hex[:8]
e = "E_" + uuid.uuid4().hex[:8]
framed = "printf '%%s\\n' '%s'; %s; printf '%s%%d\\n' \"$?\"" % (s, cmd, e)
# Over-long lines would be truncated by the PTY's canonical cap and wedge
# the session; route them through the chunk-safe run_script path instead.
if len(framed) > _LINE_CAP:
return self.run_script(cmd, timeout=timeout)
c.sendline(framed)
try:
c.expect(re.escape(e) + r"(\d+)", timeout=timeout)
except TIMEOUT:
ok = self._resync()
raise RuntimeError(
"run() timed out waiting for the end sentinel (the command may "
"read stdin or hang; run() does not forward stdin, and is for "
"short commands, use run_script/`script` for input or long "
"payloads). session %s. cmd=%r"
% ("resynced" if ok else "may be wedged", cmd[:120])
)
code = int(c.match.group(1))
raw = _clean(c.before)
if s in raw:
raw = raw.split(s, 1)[1]
return raw.strip("\n"), code
def run_script(
self, script: str, timeout: float = 180, chunk: int = 400
) -> tuple[str, int]:
"""Inject an arbitrarily long/multi-line script safely. PTY canonical mode
caps a single input line (~1KB), so we base64-encode and append in small
chunks to a remote file, then decode and run it (preserving its exit code)."""
blob = base64.b64encode(script.encode()).decode()
remote = "/tmp/etch_%s.b64" % uuid.uuid4().hex[:8]
_, code = self.run(": > %s" % remote)
if code != 0:
raise RuntimeError("could not create remote temp file")
for i in range(0, len(blob), chunk):
_, code = self.run(
"printf '%%s' '%s' >> %s" % (blob[i : i + chunk], remote)
)
if code != 0:
raise RuntimeError("chunk append failed at offset %d" % i)
return self.run(
"base64 -d %s | bash; rc=$?; rm -f %s; (exit $rc)" % (remote, remote),
timeout=timeout,
)
# ── Interactive driving (write_bash-style) ────────────────────────────────
# run()/run_script() are for commands that finish and return to the prompt.
# For programs that PROMPT mid-run (sudo, [y/n], REPLs, mysql>), use these
# raw primitives: send input, expect a prompt pattern, read the live stream.
# Output here is raw (the program's own prompts/echo) — you match prompts
# yourself. Don't interleave run() while an interactive program is mid-flight;
# expect it back to the shell first. (Never hardcode a password — for an auth
# prompt, route the secret through secure input, not the script.)
def send(self, data: str) -> None:
"""Write raw bytes/text to the remote stdin (no trailing newline)."""
self.child.send(data)
def sendline(self, data: str = "") -> None:
"""Write a line (adds newline) to the remote stdin."""
self.child.sendline(data)
def _prompt_line(self) -> str:
"""Reconstruct the verbatim prompt the program is waiting at: the last
non-empty line of what preceded the match, plus the matched text itself
(e.g. '[sudo] password for gmendezb:'). Show this to a human who has to
answer it, so they know exactly what's being asked."""
before = _clean(self.child.before or "")
after = self.child.after if isinstance(self.child.after, str) else ""
last = ""
for ln in before.splitlines():
if ln.strip():
last = ln
return (last + _clean(after)).strip()
def expect(self, pattern: str | list[str], timeout: float = 30) -> tuple[str, int]:
"""Wait for a regex pattern (or list); returns (cleaned_text_before, index).
Also sets self.prompt to the verbatim prompt line that matched."""
idx = self.child.expect(pattern, timeout=timeout)
self.prompt = self._prompt_line()
return _clean(self.child.before), idx
def expect_exact(self, s: str, timeout: float = 30) -> str:
"""Like expect() but matches a literal string (no regex). Returns text before."""
self.child.expect_exact(s, timeout=timeout)
self.prompt = self._prompt_line()
return _clean(self.child.before)
def read_available(self, idle: float = 0.3) -> str:
"""Drain and return whatever output is currently buffered."""
buf = ""
while True:
try:
buf += self.child.read_nonblocking(4096, timeout=idle)
except (TIMEOUT, EOF):
break
return _clean(buf)
def read(self, timeout: float = 10, quiet: float = 0.4) -> str:
"""Show whatever the other end is saying when you DON'T have a prompt to
expect: wait up to `timeout` for output to start, then drain until `quiet`
seconds of silence. Returns cleaned text. Pattern-free 'peek at the screen'.
Line/stream output works; full-screen TUIs (vim/top) that redraw via cursor
escapes won't linearize cleanly — those need a terminal emulator (out of scope)."""
deadline = time.time() + timeout
buf = ""
while True:
remaining = deadline - time.time()
if remaining <= 0:
break
try:
buf += self.child.read_nonblocking(4096, timeout=min(quiet, remaining))
except TIMEOUT:
if buf:
break # had output, now silent => done
except EOF:
break
return _clean(buf)
def close(self) -> None:
try:
self.child.sendline("exit")
self.child.expect(EOF, timeout=5)
except Exception:
pass
finally:
self.child.close(force=True)
# ─────────────────────────────────────────────────────────────────────────────
# Daemon mode — keep ONE warm EtSession alive across separate processes/one-shot
# calls, so any later invocation reuses it (no ~13s reconnect) and shell state
# persists for the whole experiment/session.
#
# CONCURRENCY MODEL (important): a single EtSession is ONE serial conversation —
# interleaving run()s would tangle their sentinels. So the daemon is keyed: each
# `key` is its own daemon → its own `et` client → its own remote shell.
# * Same key = same warm shell, requests SERIALIZED (safe, but not parallel).
# * Diff key = isolated shell, runs in PARALLEL.
# => give each agent/subagent its own key (e.g. ETCH_KEY). Never share one key
# across actors that run concurrently.
# ─────────────────────────────────────────────────────────────────────────────
# ── Daemon: paths, process liveness & filesystem introspection ──
def _safe(key: str) -> str:
"""Filesystem-safe form of a daemon key."""
return re.sub(r"[^A-Za-z0-9_.-]", "_", key)
def _user_prefix() -> str:
"""Shared /tmp path prefix for this user's daemon files."""
return "/tmp/etch-%s-" % getpass.getuser()
def _sock_path(key: str) -> str:
return _user_prefix() + _safe(key) + ".sock"
def _pid_path(key: str) -> str:
return _sock_path(key)[:-5] + ".pid" # /tmp/etch-<user>-<key>.pid
def _trace_path(key: str) -> str:
return _sock_path(key)[:-5] + ".trace" # /tmp/etch-<user>-<key>.trace (peep)
def _alive(pid: int) -> bool:
# zombie-aware: a process that exists only as a defunct/zombie (state 'Z')
# is treated as NOT alive, so reap() isn't fooled by a just-killed daemon.
try:
st = subprocess.run(
["ps", "-p", str(pid), "-o", "state="],
capture_output=True,
text=True,
timeout=5,
).stdout.strip()
except Exception:
# ps unavailable: fall back to signal-0 probe
try:
os.kill(pid, 0)
return True
except OSError:
return False
return bool(st) and not st.startswith("Z")
def reap() -> None:
"""Sweep leftovers from crashed daemons: if a daemon PID is dead, kill its
orphaned `et` child (by recorded PID, verified) and remove its socket+pidfile.
Cheap; call opportunistically (open_session does)."""
for pf in glob.glob(_user_prefix() + "*.pid"):
try:
parts = open(pf).read().split()
dpid, epid = int(parts[0]), int(parts[1]) # pidfile: "<dpid> <epid> [host]"
except Exception:
try:
os.unlink(pf)
except OSError:
pass
continue
if _alive(dpid):
continue # daemon still running; leave it
if _alive(epid):
# verify the PID is really an `et` client before killing (PID reuse guard)
try:
cmd = subprocess.run(
["ps", "-p", str(epid), "-o", "command="],
capture_output=True,
text=True,
timeout=5,
).stdout
except Exception:
cmd = ""
if "/et " in cmd or cmd.strip().startswith("et ") or " et " in cmd:
try:
os.kill(epid, signal.SIGTERM)
except OSError:
pass
for p in (pf, pf[:-4] + ".sock", pf[:-4] + ".trace"):
try:
os.unlink(p)
except OSError:
pass
# Also sweep orphaned sockets with no pidfile (a daemon that crashed after
# binding but before writing its pidfile, e.g. connect() failed). The pidfile
# loop above can't see those. A socket with a live listener is a daemon still
# starting up, so probe it and only remove the ones nothing answers on.
for sk in glob.glob(_user_prefix() + "*.sock"):
if os.path.exists(sk[:-5] + ".pid"):
continue # has a pidfile (handled above, or a running daemon)
try:
probe = socket.socket(socket.AF_UNIX)
probe.settimeout(1)
probe.connect(sk)
probe.close()
continue # someone is listening (a daemon starting up): leave it
except OSError:
for p in (sk, sk[:-5] + ".trace"):
try:
os.unlink(p)
except OSError:
pass
def _live_keys() -> list[str]:
"""Keys of currently-bound daemons (from their socket files)."""
prefix = _user_prefix()
return [s[len(prefix) : -5] for s in sorted(glob.glob(prefix + "*.sock"))]
def _pidfile_fields(key: str) -> tuple[str, str, str | None]:
"""(daemon_pid_str, et_pid_str, host) from a daemon's pidfile, or ('?','?',None)."""
try:
parts = open(_pid_path(key)).read().split()
return parts[0], parts[1], (parts[2] if len(parts) > 2 else None)
except Exception:
return "?", "?", None
def _daemon_host(key: str) -> str | None:
"""The host a live daemon for KEY serves (from its pidfile), or None."""
return _pidfile_fields(key)[2]
# ── Daemon: length-prefixed JSON wire protocol (4-byte big-endian length + body) ──
def _recvn(conn: socket.socket, n: int) -> bytes | None:
buf = b""
while len(buf) < n:
chunk = conn.recv(n - len(buf))
if not chunk:
return None
buf += chunk
return buf
def _send_msg(conn: socket.socket, obj: Any) -> None:
data = json.dumps(obj).encode()
conn.sendall(struct.pack(">I", len(data)) + data)
def _recv_msg(conn: socket.socket) -> dict[str, Any] | None:
hdr = _recvn(conn, 4)
if not hdr:
return None
(n,) = struct.unpack(">I", hdr)
body = _recvn(conn, n)
return None if body is None else json.loads(body.decode())
def _xlate_pat(pat: str | list[str]) -> Any:
# the wire can't carry TIMEOUT/EOF objects, so callers pass the
# sentinels "<<TIMEOUT>>"/"<<EOF>>" and we translate them back here.
def one(p: str) -> Any:
if p == "<<TIMEOUT>>":
return TIMEOUT
if p == "<<EOF>>":
return EOF
return p
return [one(p) for p in pat] if isinstance(pat, list) else one(pat)
# ── Daemon: client (warm session reuse across processes) ──
class DaemonClient:
"""Thin client to a per-key etch daemon. Auto-starts the daemon on first
use. Same .run()/.run_script() API as EtSession, but warm across processes."""
def __init__(
self,
key: str,
host: str | None = None,
via: str = "et",
idle: int = 900,
autostart: bool = True,
connect_timeout: float = 90,
) -> None:
self.key, self.host, self.via, self.idle = key, host, via, idle
self.path = _sock_path(key)
self.conn: socket.socket | None = None # set once connected
self.prompt = "" # verbatim remote prompt line from the last expect()
self.started = (
False # True if this client spawned the daemon (vs reused a warm one)
)
if not self._connect():
if not autostart:
raise RuntimeError("no daemon for key %r" % key)
self._start(connect_timeout)
self.started = True
def _connect(self, timeout: float = 10) -> bool:
try:
c = socket.socket(socket.AF_UNIX)
c.settimeout(timeout)
c.connect(self.path)
self.conn = c
return True
except OSError:
return False
def _start(self, connect_timeout: float) -> None:
if not self.host:
raise RuntimeError("no host to start a daemon for key %r" % self.key)
try:
os.unlink(self.path) # clear a stale socket
except OSError:
pass
log = open("/tmp/etch-%s.log" % _safe(self.key), "a")
subprocess.Popen(
[
sys.executable,
os.path.realpath(__file__),
"--daemon",
self.key,
self.host,
self.via,
str(self.idle),
],
start_new_session=True,
stdin=subprocess.DEVNULL,
stdout=log,
stderr=log,
)
# The daemon binds the socket quickly but only accepts after its ~13s
# EtSession.connect(). So: wait for the socket to appear, then send one
# ping with a long timeout — the daemon answers once the shell is ready.
deadline = time.time() + connect_timeout
while time.time() < deadline:
if self._connect(timeout=connect_timeout):
break
time.sleep(0.3)
else:
raise RuntimeError("daemon socket for key %r never appeared" % self.key)
try:
_send_msg(cast(socket.socket, self.conn), {"op": "ping"})
r = _recv_msg(cast(socket.socket, self.conn))
except OSError as e:
raise RuntimeError("daemon ping failed for key %r: %r" % (self.key, e))
if not (r and r.get("ok")):
raise RuntimeError("daemon for key %r not ready" % self.key)
def _rpc(self, msg: dict[str, Any], timeout: float = 60) -> dict[str, Any]:
assert self.conn is not None, "not connected"
self.conn.settimeout(
timeout + 15
) # generous margin over the command's own timeout
_send_msg(self.conn, msg)
r = _recv_msg(self.conn)
if r is None:
raise RuntimeError("daemon closed the connection")
if "error" in r:
raise RuntimeError(r["error"])
return r
def run(self, cmd: str, timeout: float = 60) -> tuple[str, int]:
r = self._rpc({"op": "run", "cmd": cmd, "timeout": timeout}, timeout=timeout)
return r["out"], r["code"]
def run_script(self, script: str, timeout: float = 180) -> tuple[str, int]:
r = self._rpc(
{"op": "run_script", "script": script, "timeout": timeout}, timeout=timeout
)
return r["out"], r["code"]
# ── Interactive driving over the daemon (mirrors EtSession's primitives) ──
def send(self, data: str, secret: bool = False) -> None:
self._rpc({"op": "send", "data": data, "secret": secret}, timeout=10)
def sendline(self, data: str = "", secret: bool = False) -> None:
self._rpc({"op": "sendline", "data": data, "secret": secret}, timeout=10)
def expect(self, pattern: str | list[str], timeout: float = 30) -> tuple[str, int]:
# pass TIMEOUT/EOF as the sentinels "<<TIMEOUT>>"/"<<EOF>>"
r = self._rpc(
{"op": "expect", "pattern": pattern, "timeout": timeout}, timeout=timeout
)
self.prompt = r.get("prompt", "")
return r["out"], r["idx"]
def expect_exact(self, s: str, timeout: float = 30) -> str:
r = self._rpc(
{"op": "expect_exact", "s": s, "timeout": timeout}, timeout=timeout
)
self.prompt = r.get("prompt", "")
return r["out"]
def read(self, timeout: float = 10, quiet: float = 0.4) -> str:
r = self._rpc(
{"op": "read", "timeout": timeout, "quiet": quiet}, timeout=timeout
)
return r["out"]
def read_available(self, idle: float = 0.3) -> str:
r = self._rpc({"op": "read_available", "idle": idle}, timeout=max(5, idle * 3))
return r["out"]
def peep_start(self) -> str:
"""Enable the daemon's live trace; returns the trace file path to tail."""
return cast(str, self._rpc({"op": "peep_start"}, timeout=10).get("path"))
def peep_stop(self) -> None:
try:
self._rpc({"op": "peep_stop"}, timeout=10)
except Exception:
pass
def shutdown(self) -> None:
"""Stop the daemon (closes its et session + remote shell)."""
try:
_send_msg(cast(socket.socket, self.conn), {"op": "shutdown"})
_recv_msg(cast(socket.socket, self.conn))
except OSError:
pass
self.close()
def close(self) -> None:
"""Disconnect the client; the daemon stays warm for the next caller."""
if self.conn:
try:
self.conn.close()
except OSError:
pass
self.conn = None
def _random_key() -> str:
"""A short, opaque session handle (used when `open` gets no --key)."""
return uuid.uuid4().hex[:8]
def open_session(
host: str, key: str | None = None, via: str = "et", idle: int = 900
) -> DaemonClient:
"""Open (spawn-or-reuse) a warm per-key daemon to HOST and return a client.
KEY is a logical handle for the session (random if omitted). The host lives
with the daemon, not the key. `via` is the transport (et/ssh or a 'CMD {host}'
template). Operate on the result with .run()/.send()/etc.; a later caller
reattaches with DaemonClient(key, autostart=False).
CONCURRENCY: a daemon is ONE serial conversation. One logical actor per key;
give concurrent actors (e.g. subagents) distinct keys, or use EtSession()
directly for an isolated one-shot shell.
"""
reap() # sweep any crashed-daemon orphans first
return DaemonClient(key=key or _random_key(), host=host, via=via, idle=idle)
# ── Daemon: server (the long-lived worker that owns one warm EtSession) ──
def _daemon_main(key: str, host: str, via: str, idle: int) -> None:
idle = int(idle)
path = _sock_path(key)
pidpath = _pid_path(key)
srv = socket.socket(socket.AF_UNIX)
try:
srv.bind(path) # fails if another daemon already won
except OSError:
return
os.chmod(path, 0o600)
srv.listen(8)
try:
sess = EtSession(
host=host,
via=via,
cmdlog="/tmp/etch-%s.cmdlog" % _safe(key),
).connect()
except Exception:
# connect() failed (e.g. VM unreachable): don't leave the bound socket
# behind with no pidfile, that would show in `ls` as a dead orphan.
try:
srv.close()
finally:
try:
os.unlink(path)
except OSError:
pass
os._exit(1)
# record daemon PID + et-client PID (for reap) + the host this daemon serves
try:
open(pidpath, "w").write("%d %d %s" % (os.getpid(), sess.child.pid, host))
except OSError:
pass
tracepath = _trace_path(key)
trace: dict[str, Any] = {"fh": None, "n": 0} # peep: refcounted op-level live trace
def _trace(arrow: str, text: str) -> None:
if not trace["fh"]:
return # zero cost when nobody is peeping
t = text.replace("\n", "\\n")
if len(t) > 4000:
t = t[:4000] + "...(+%d)" % (len(t) - 4000)
try:
trace["fh"].write("%s %s %s\n" % (time.strftime("%H:%M:%S"), arrow, t))
trace["fh"].flush()
except Exception:
pass
def _graceful(*_: Any) -> None:
# Unlink the fs entries FIRST so a stopped daemon vanishes from ls/peers
# immediately, before the (slow) session teardown waits on et to exit.
for p in (path, pidpath, tracepath):
try:
os.unlink(p)
except OSError:
pass
try:
srv.close()
except Exception:
pass
try:
if trace["fh"]:
trace["fh"].close()
except Exception:
pass
try:
sess.close()
except Exception:
pass
os._exit(0)
# Session ops: each turns a request into a response dict; the loop wraps any
# exception into {"error": ...}. (Control ops ping/peep/shutdown are inline.)
def _op_run(msg: dict[str, Any]) -> dict[str, Any]:
_trace("»", "run: " + msg["cmd"])
out, code = sess.run(msg["cmd"], timeout=msg.get("timeout", 60))
_trace("«", "(rc=%d) %s" % (code, out))
return {"out": out, "code": code}
def _op_run_script(msg: dict[str, Any]) -> dict[str, Any]:
_trace("»", "run_script: <%d bytes>" % len(msg.get("script", "")))
out, code = sess.run_script(msg["script"], timeout=msg.get("timeout", 180))
_trace("«", "(rc=%d) %s" % (code, out))
return {"out": out, "code": code}
def _op_send(msg: dict[str, Any]) -> dict[str, Any]:
sess.send(msg.get("data", ""))
_trace("»", "<secret>" if msg.get("secret") else repr(msg.get("data", "")))
return {"ok": True}
def _op_sendline(msg: dict[str, Any]) -> dict[str, Any]:
sess.sendline(msg.get("data", ""))
_trace("»", "<secret>" if msg.get("secret") else msg.get("data", ""))
return {"ok": True}
def _op_expect(msg: dict[str, Any]) -> dict[str, Any]:
out, idx = sess.expect(
_xlate_pat(msg["pattern"]), timeout=msg.get("timeout", 30)
)
_trace("«", "[matched %d] %s" % (idx, sess.prompt or out[-160:]))
return {"out": out, "idx": idx, "prompt": sess.prompt}
def _op_expect_exact(msg: dict[str, Any]) -> dict[str, Any]:
out = sess.expect_exact(msg["s"], timeout=msg.get("timeout", 30))
_trace("«", sess.prompt or out[-160:])
return {"out": out, "prompt": sess.prompt}
def _op_read(msg: dict[str, Any]) -> dict[str, Any]:
out = sess.read(timeout=msg.get("timeout", 10), quiet=msg.get("quiet", 0.4))
_trace("«", out)
return {"out": out}
def _op_read_available(msg: dict[str, Any]) -> dict[str, Any]:
out = sess.read_available(idle=msg.get("idle", 0.3))
_trace("«", out)
return {"out": out}
session_ops: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
"run": _op_run,
"run_script": _op_run_script,
"send": _op_send,
"sendline": _op_sendline,
"expect": _op_expect,
"expect_exact": _op_expect_exact,
"read": _op_read,
"read_available": _op_read_available,
}
def _op_peep_start() -> dict[str, Any]:
if trace["n"] == 0:
try:
trace["fh"] = open(tracepath, "a")
except Exception:
trace["fh"] = None
trace["n"] += 1
return {"ok": True, "path": tracepath}
def _op_peep_stop() -> dict[str, Any]:
if trace["n"] > 0:
trace["n"] -= 1
if trace["n"] == 0 and trace["fh"]:
try:
trace["fh"].close()
except Exception:
pass
trace["fh"] = None
try:
os.unlink(tracepath)
except OSError:
pass
return {"ok": True}
# SIGALRM fires the idle timeout in ANY blocking state (accept OR recv), so a
# client that holds a connection open-but-idle can't pin the daemon forever.
# SIGTERM (e.g. from reap() on a sibling, or a manual stop) cleans up too.
signal.signal(signal.SIGALRM, _graceful)
signal.signal(signal.SIGTERM, _graceful)
try:
while True:
signal.alarm(idle) # idle countdown while waiting to accept
conn, _ = srv.accept()
signal.alarm(0)
with conn:
try:
while True:
signal.alarm(idle) # idle countdown while waiting for a request
msg = _recv_msg(conn)
signal.alarm(0) # request in hand; never interrupt a command
if msg is None:
break
op = msg.get("op")
if op in session_ops:
try:
_send_msg(conn, session_ops[op](msg))
except Exception as e:
_send_msg(conn, {"error": repr(e)})
elif op == "ping":
_send_msg(conn, {"ok": True})
elif op == "peep_start":
_send_msg(conn, _op_peep_start())
elif op == "peep_stop":
_send_msg(conn, _op_peep_stop())
elif op == "shutdown":
_send_msg(conn, {"ok": True})
_graceful()
else:
_send_msg(conn, {"error": "unknown op %r" % op})
except OSError:
pass # client vanished mid-request; back to accept()
finally:
_graceful()
# ═════════════════════════════════════════════════════════════════════════════
# Command-line interface
# helpers (host guard, open/attach, payload) -> command funcs -> argparse
# ═════════════════════════════════════════════════════════════════════════════
def _use(key: str, host: str | None = None, via: str = "et") -> DaemonClient:
"""Open KEY's session for an operation: reuse the warm daemon if it exists,
else autospawn it on HOST (the optional --host) via the chosen transport. With
no warm daemon and no host, exit with a hint. Shared by every session verb."""
try:
s = DaemonClient(key=key, host=host, via=via, autostart=host is not None)
except RuntimeError:
sys.exit(
"etch: no session %r; pass --host <user@host> to start it, "
"or run `etch open <user@host>` first" % key
)
if host and not s.started:
# reused a warm daemon; refuse a --host that contradicts its real host
live = _daemon_host(key)
if live and live != host:
s.close()
sys.exit(
"etch: session %r is live on %s, not %s; close it (`etch close %s`) "
"or use a different key" % (key, live, host, key)
)
if s.started:
sys.stderr.write(
"(started a warm daemon for %r on %s; `close %s` to end it)\n"
% (key, s.host, key)
)
return s
def _payload(key: str, secret: bool, message: str | None) -> str:
"""Source the bytes to send: -m literal, --secret (silent getpass), a stdin
pipe, or an interactive prompt."""
if message is not None:
return message
if secret:
return getpass.getpass("secret for %s: " % key)
if not sys.stdin.isatty():
return sys.stdin.read()
return input("send to %s: " % key)
# ── CLI: command implementations (one per subcommand) ──
def _do_send(
key: str,
newline: bool,
secret: bool = False,
message: str | None = None,
host: str | None = None,
via: str = "et",
) -> None:
"""`send`/`sendline`: feed input into a running daemon's warm session from
YOUR own shell. The value goes terminal -> daemon -> remote stdin and is
never seen by the agent (not in its transcript, checkpoints, logs, cmdlog,
or the remote shell history). With --secret it's also redacted from peep."""
payload = _payload(key, secret, message)
if newline and payload.endswith("\n"):
payload = payload[:-1] # sendline re-adds exactly one newline
cli = _use(key, host, via)
(cli.sendline if newline else cli.send)(payload, secret=secret)
cli.close()
sys.stderr.write(
"sent %d chars to %r%s%s\n"
% (
len(payload),
key,
" + newline" if newline else "",
" (silent, redacted in peep)" if secret else "",
)
)
def _cmd_expect(
key: str,
pattern: str,
timeout: float = 30,
exact: bool = False,
host: str | None = None,
via: str = "et",
) -> None:
"""Wait for PATTERN (a regex, or a literal with --exact). Prints the matched
prompt line and exits 0 on a match, or exits 1 on timeout."""
cli = _use(key, host, via)
pat = re.escape(pattern) if exact else pattern
out, idx = cli.expect([pat, "<<TIMEOUT>>"], timeout=timeout)
prompt = cli.prompt
cli.close()
if idx == 0:
if prompt:
print(prompt)
sys.exit(0)
sys.stderr.write("(timeout after %gs waiting for %r)\n" % (timeout, pattern))
sys.exit(1)
def _cmd_open(host: str, key: str | None = None, via: str = "et") -> None:
key = key or _random_key()
existing = _daemon_host(key)
if existing and existing != host:
sys.exit(
"etch: key %r is already open on %s; close it or pick a different --key"
% (key, existing)
)
open_session(host, key=key, via=via).close() # spawn-or-reuse, then leave warm
print(key) # the handle to reuse; a printed key means the session is open
def _cmd_run(
key: str, cmd: str, timeout: float, host: str | None = None, via: str = "et"
) -> None:
s = _use(key, host, via)
try:
out, code = s.run(cmd, timeout=timeout)
finally:
s.close()
if out:
print(out)
sys.exit(code)
def _cmd_script(
key: str,
path: str | None,
timeout: float,
host: str | None = None,
via: str = "et",
) -> None:
"""Run a multi-line script on the warm session via run_script (line-cap-safe,
chunked base64). Reads from FILE, or stdin if FILE is omitted or '-'."""
text = sys.stdin.read() if path in (None, "-") else open(path).read()
s = _use(key, host, via)
try:
out, code = s.run_script(text, timeout=timeout)
finally:
s.close()
if out:
print(out)
sys.exit(code)
def _cmd_attach(key: str, host: str | None = None, via: str = "et") -> None:
"""Raw interactive terminal bridged into the warm session: drive prompts,
REPLs, sudo, etc. live. Ctrl-] detaches (the daemon stays warm). Exclusive:
holds the serial daemon for the whole session, so other callers (e.g. an agent
run) block until you detach. Don't drive one session from two places at once."""
if not sys.stdin.isatty():
sys.exit("attach: needs an interactive terminal (stdin is not a tty)")
s = _use(key, host, via)
# turn the clean-room shell into a normal interactive terminal for the attach
s.sendline(
"stty sane 2>/dev/null; export TERM=xterm-256color PS1='[et:%s] \\w\\$ ' PS2='> '"
% key
)
s.read(timeout=1.0)
sys.stderr.write(
"== attached to %r on %s — press Ctrl-] to detach ==\r\n" % (key, s.host or "?")
)
sys.stderr.flush()
s.send("\n") # draw a fresh prompt
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
_no_fds: list[int] = []
try:
tty.setraw(fd)
while True:
r, _, _ = select.select([fd], _no_fds, _no_fds, 0.03)
if r:
data = os.read(fd, 4096)
if not data or b"\x1d" in data: # EOF or Ctrl-] detaches
head = data.split(b"\x1d", 1)[0] if data else b""
if head:
s.send(head.decode("utf-8", "replace"))
break
s.send(data.decode("utf-8", "replace"))
out = s.read_available(idle=0.02)
if out:
sys.stdout.write(out.replace("\n", "\r\n")) # raw tty needs CRLF
sys.stdout.flush()
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
try: # restore clean-room so scripted run()s keep working
s.sendline("stty -echo -onlcr 2>/dev/null; export PS1='' PS2='' TERM=dumb")
s.read(timeout=1.0)
except Exception:
pass
s.close()
sys.stderr.write("\r\n== detached from %r (still warm) ==\r\n" % key)
sys.stderr.flush()
def _cmd_peep(
key: str,
duration: float | None = None,
host: str | None = None,
via: str = "et",
) -> None:
cli = _use(key, host, via)
path = cli.peep_start()
cli.close() # release the daemon: it stays serial, we just tail the file
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
suffix = (" for %gs" % duration) if duration else ""
print(
"== peep %r%s (» sent « received) — Ctrl-C to stop ==" % (key, suffix),
file=sys.stderr,
)
for _ in range(20):
if path and os.path.exists(path):
break
time.sleep(0.1)
deadline = (time.time() + duration) if duration else None
try:
with open(path) as fh:
fh.seek(0, 2) # only traffic from now on
while deadline is None or time.time() < deadline:
line = fh.readline()
if line:
sys.stdout.write(line)
sys.stdout.flush()
else:
time.sleep(0.2)
except (KeyboardInterrupt, FileNotFoundError):
pass
finally:
try:
DaemonClient(key=key, autostart=False).peep_stop()
except Exception:
pass
print("\n== peep stopped ==", file=sys.stderr)
def _cmd_read(
key: str,
timeout: float | None = None,
host: str | None = None,
via: str = "et",
) -> None:
"""Show what the session has emitted, without attaching: a pattern-free peek.
Default grabs whatever is buffered right now; --timeout waits for output to
start then drains until quiet. Pairs with `send`/`sendline` for step-by-step
interactive driving from the shell."""
cli = _use(key, host, via)
out = cli.read(timeout=timeout) if timeout else cli.read_available()
cli.close()
sys.stdout.write(out if (not out or out.endswith("\n")) else out + "\n")
def _cmd_ls() -> None:
prefix = _user_prefix()
socks = sorted(glob.glob(prefix + "*.sock"))
if not socks:
print("no etch daemons running")
return
now = time.time()
print(
"%-22s %-10s %-7s %-6s %-34s %s"
% ("KEY", "DAEMON", "ET", "AGE", "HOST", "SOCKET")
)
for sk in socks:
key = sk[len(prefix) : -5]
dpid, epid, host = _pidfile_fields(key)
alive = dpid.isdigit() and _alive(int(dpid))
try:
age = "%ds" % int(now - os.path.getmtime(sk))
except OSError:
age = "?"
print(
"%-22s %-10s %-7s %-6s %-34s %s"
% (key, dpid + ("" if alive else "!dead"), epid, age, host or "?", sk)
)
def _cmd_stop(key: str | None, all_: bool) -> None:
if all_:
keys = _live_keys()
elif key:
keys = [key]
else:
sys.exit("close: pass a KEY or --all")
if not keys:
print("no sessions to close")
return
for k in keys:
try:
DaemonClient(key=k, autostart=False).shutdown()
print("closed %r" % k)
except Exception as e:
print("could not close %r: %s" % (k, e))
def _cmd_selftest(host: str, via: str = "et") -> None:
t0 = time.time()
sess = EtSession(host, via=via).connect()
print(f"== connected in {time.time() - t0:.1f}s ==", flush=True)
for cmd in [
"hostname",
"whoami",
"pwd",
"echo $((6*7))",
"uname -sm",
"echo multi; echo line; echo test",
"false",
"ls /nonexistent 2>&1",
]:
out, code = sess.run(cmd)
print(f" rc={code:<3} {cmd!r:38} -> {out!r}", flush=True)
sess.close()
print("== done ==")
# ── CLI: argument parsing & dispatch ──
def _main_cli(argv: list[str]) -> None:
import argparse
p = argparse.ArgumentParser(
prog="etch",
description="Drive a persistent EternalTerminal session to the VM: warm daemon, "
"clean output + exit codes, interactive send/read, live peep.",
epilog=(
"command groups:\n"
" run a command: run, script clean output + exit codes\n"
" drive a session: attach, send, sendline, expect, read interactive (live or step-by-step)\n"
" observe: peep live tap of what's sent/received\n"
" daemons: open, close/stop, ls/status, gc open / list / close warm shells\n"
"\nexamples:\n"
" etch open user@vm prints a key; or: etch open user@vm --key main\n"
" etch run main uname -a reuse the open session\n"
" etch run --host user@vm main uname -a autospawn 'main' on first use, reuse after\n"
" etch run main -- some-tool --timeout 5 -- makes the rest a literal command\n"
" etch sendline main --secret feed a password into a parked prompt\n"
"\nKEY is a logical handle for a session; the host lives with the daemon, not\n"
"the key. Set it once at `open`, or pass --host on any verb to autospawn a\n"
"session that isn't open yet (optional once it is). `ls` shows each host.\n"
"\nlibrary: from Python, `from etch import open_session, EtSession`.\n"
" open_session(host, key=...) opens/reuses a warm daemon; EtSession(host) is\n"
" a one-shot with no daemon.\n"
"\nadvanced: `--daemon KEY HOST IDLE` is the internal daemon worker,\n"
"spawned automatically by open_session(); do not run it by hand.\n"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = p.add_subparsers(dest="cmd", metavar="<command>")
def via_arg(sp: argparse.ArgumentParser) -> None:
sp.add_argument(
"--via",
default="et",
metavar="TRANSPORT",
help="transport to open with: et (default), ssh, or a 'CMD {host}' template (only when starting)",
)
def host_arg(sp: argparse.ArgumentParser) -> None:
sp.add_argument(
"--host",
metavar="USER@HOST",
help="autospawn the session here if it isn't open yet (optional once open)",
)
via_arg(sp)
def key_arg(sp: argparse.ArgumentParser) -> None:
sp.add_argument("key", help="session key (a handle from `etch open`)")
# ── run a command (clean output + exit codes) ──
pr = sub.add_parser(
"run",
help="run one command on the session (open it first, or --host to autospawn); exit code = remote rc",
)
pr.add_argument("--timeout", type=float, default=60)
host_arg(pr)
key_arg(pr)
pr.add_argument(
"command",
nargs=argparse.REMAINDER,
help="the command (rest of the line, flags and all); etch's own options go "
"before the key, or put `--` first for a fully literal command",
)
psc = sub.add_parser(
"script",
help="run a multi-line script (from FILE or stdin) on the warm session; exit code = remote rc",
)
psc.add_argument("--timeout", type=float, default=180)
host_arg(psc)
key_arg(psc)
psc.add_argument("file", nargs="?", help="script file (default: stdin)")
# ── drive a session interactively (live, or step-by-step send/expect/read) ──
pat = sub.add_parser(
"attach",
help="interactive terminal bridged into the warm session: prompts/REPLs/sudo, Ctrl-] detaches (exclusive until you detach)",
)
host_arg(pat)
key_arg(pat)
for name, nl in (("send", False), ("sendline", True)):
sp = sub.add_parser(
name,
help=(
"%s to the session (%s); -m TEXT, --secret (silent), or stdin"
% (name, "appends a newline" if nl else "raw, no newline")
),
)
sp.add_argument("key", help="session key to send to")
host_arg(sp)
sp.add_argument(
"-s",
"--secret",
action="store_true",
help="read the payload via a silent no-echo prompt",
)
sp.add_argument(
"-m",
"--message",
help="payload on the command line (visible; not for secrets)",
)
pex = sub.add_parser(
"expect",
help="wait for a regex (or literal with --exact) to appear; exit 0 on match, 1 on timeout",
)
pex.add_argument("key", help="daemon key to watch")
pex.add_argument("pattern", help="regex to wait for (literal if --exact)")
pex.add_argument(
"--timeout",
type=float,
default=30,
metavar="SECS",
help="give up after SECS (default 30)",
)
pex.add_argument(
"--exact", action="store_true", help="match PATTERN literally, not as a regex"
)
host_arg(pex)
prd = sub.add_parser(
"read",
help="drain session output without attaching: bare = peek what's buffered now (read_available), --timeout = wait for output then drain (read)",
)
prd.add_argument("key", help="daemon key to read from")
prd.add_argument(
"--timeout",
type=float,
default=None,
metavar="SECS",
help="wait up to SECS for output to start, then drain until quiet "
"(default: just grab what's buffered now)",
)
host_arg(prd)
# ── observe ──
ppe = sub.add_parser(
"peep", help="live tap: show what's sent/received on the session"
)
ppe.add_argument("key", help="daemon key to tap")
ppe.add_argument(
"--for",
dest="duration",
type=float,
default=None,
metavar="SECS",
help="auto-stop after SECS (for non-interactive/scripted taps)",
)
host_arg(ppe)
# ── sessions: open / close ──
po = sub.add_parser(
"open",
help="open a session on HOST (pre-warm); prints the key to reuse with run/send/...",
)
po.add_argument("host", metavar="USER@HOST", help="user@host to connect")
po.add_argument("--key", help="name for the session (default: a random handle)")
via_arg(po)
for nm, h in (
("close", "close a session (shut down its daemon)"),
("stop", "alias for close"),
):
psx = sub.add_parser(nm, help=h)
psx.add_argument("key", nargs="?", help="session key to close (or use --all)")
psx.add_argument(
"--all", action="store_true", help="close every running session"
)
# ── inspect / maintain ──
sub.add_parser("ls", help="list running sessions")
sub.add_parser("status", help="alias for ls")
sub.add_parser("gc", help="reap dead daemons / orphaned et processes")
pse = sub.add_parser(
"selftest", help="connect to HOST and run a few sample commands"
)
pse.add_argument("host", metavar="USER@HOST", help="user@host to connect")
via_arg(pse)
args = p.parse_args(argv)
if not args.cmd:
p.print_help()
return
if args.cmd == "run":
cmd = " ".join(args.command).strip()
if not cmd:
sys.exit("run: no command given")
if "--" not in argv:
_flags = ("--timeout", "--host", "--via")
stray = [
t
for t in args.command
if t in _flags or any(t.startswith(f + "=") for f in _flags)
]
if stray:
sys.stderr.write(
"etch: note: %s is inside the command, not applied as an option. "
"etch's flags go before the key (e.g. `run --timeout 5 KEY ...`); "
"use `--` for a command that legitimately contains them.\n"
% ", ".join(stray)
)
_cmd_run(args.key, cmd, args.timeout, args.host, args.via)
elif args.cmd == "script":
_cmd_script(args.key, args.file, args.timeout, args.host, args.via)
elif args.cmd == "open":
_cmd_open(args.host, args.key, args.via)
elif args.cmd == "attach":
_cmd_attach(args.key, args.host, args.via)
elif args.cmd in ("send", "sendline"):
_do_send(
args.key,
args.cmd == "sendline",
args.secret,
args.message,
args.host,
args.via,
)
elif args.cmd == "expect":
_cmd_expect(
args.key, args.pattern, args.timeout, args.exact, args.host, args.via
)
elif args.cmd == "peep":
_cmd_peep(args.key, args.duration, args.host, args.via)
elif args.cmd == "read":
_cmd_read(args.key, args.timeout, args.host, args.via)
elif args.cmd in ("ls", "status"):
_cmd_ls()
elif args.cmd in ("close", "stop"):
_cmd_stop(args.key, args.all)
elif args.cmd == "gc":
reap()
print("reaped stale etch daemons/orphans")
elif args.cmd == "selftest":
_cmd_selftest(args.host, args.via)
if __name__ == "__main__":
a = sys.argv[1:]
if a and a[0] == "--daemon": # internal worker, spawned by DaemonClient._start
_daemon_main(a[1], a[2], a[3], int(a[4]))
sys.exit(0)
_main_cli(a)