termiod: durable PTY session host (Rust POC) — #170/#171/#172 - #177
Draft
jiweiyuan wants to merge 68 commits into
Draft
termiod: durable PTY session host (Rust POC) — #170/#171/#172#177jiweiyuan wants to merge 68 commits into
jiweiyuan wants to merge 68 commits into
Conversation
Rust POC of termiod — a session multiplexer that owns PTYs so sessions outlive every viewer. Detach ≠ kill. - protocol v0: framed [kind][len][payload]; control JSON + raw PTY bytes + resize; no VT/grid snapshot (out of scope per the design doc) - PTY spawned with the login_tty shape (setsid + TIOCSCTTY) so agents reflow on resize; async read/write, dedicated writer task avoids the documented PTY write deadlock - session actor: multi-client output fan-out, single-writer input with newest-client claim, TIOCSWINSZ resize, recent-output ring replay on (re)attach - daemon: session table + Unix-socket accept loop under XDG_RUNTIME_DIR or uid-scoped tmp; auto-starts on first client op - CLI: serve / create / list / kill / send / attach (create-on-missing) - smoke_test.py: 16 PTY-driven checks, all green (attach→detach→survive →reattach same pid; fan-out; single-writer; resize; inject; kill) Part of #164. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remote sessions over system OpenSSH only — no custom transport, no public listener. SSH is the transport and the ACL; the remote daemon still listens on a Unix socket. The daemon auto-starts detached (setsid) on the first client op, so a session survives SSH disconnect for free (remote detach ≠ kill). #171: - cross-compile to static musl Linux (x86_64 + aarch64) using the bundled rust-lld — no musl-gcc/zig/Docker (.cargo/config.toml) - `remote deploy <host>`: uname-detect arch → cross-compile (or --bin prebuilt) → scp to ~/.local/bin/termiod → verify; TERMIOD_REMOTE_BIN overrides the install path - `remote list` / `remote attach` over ssh -t stdio bridge - DEPLOY.md: cross-compile, optional systemd --user unit, security model #172: - `remote open <host> [--cwd --agent --name]`: ensure deployed → create durable remote session → attach, one command, ~/.ssh/config only - remote_smoke_test.py: 8 checks via a fake-ssh transport shim — list, open (create+attach), survives "disconnect", reattach same pid. Green. (Cross-arch scp/install needs a real Linux host; documented manually.) Part of #164. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…_args) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
15 tasks
Name the product core as the durable session host (not a CLI). Document three parts, local=remote-to-localhost, SSH as pipe only. Align design docs and crate README/ARCHITECTURE with that model.
Collaborator
Author
Architecture framing (Superlogical-clean)Pushed Three parts only (see
Rules: local = remote to localhost · SSH is a pipe · PTY only on the host · no nested WM · no invented crypto. Mirrors Superlogical’s durable session as the missing layer sequence, with termio’s narrower agent-native wedge. |
Composable: host/protocol/clients/pipes plug independently. Direct: one hop client → protocol → host → PTY; no second session owner.
Name the load-bearing model explicitly and harden it into a testable invariant: - §A: input replication (ship the byte log, each libghostty replays it) vs state synchronization (ship a server grid) — state-machine replication, with the anti-100x invariant: byte delivery MUST NOT block on host-side VT parse. - §C.6: state transfer is two edge cases only — attach/resize/resync bootstrap + opt-in bad-network degrade; spell out the S snapshot triggers; link the v1.1 grid_diff to the §D.1 mosh-SSP degrade. - §H: reject state sync on the hot path. §F: log the no-pipe-mode attach gap found live on ukvps. - mux §4.3: same invariant + benchmark evidence. Brought termiod-session-protocol.md onto the branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bench_100x.py floods a payload through a PTY and times the drain under direct / termiod-tee / termiod-client / tmux, across plain, ansi, truecolor and fullscreen payloads. Proves the core thesis empirically: cost that scales with VT-parse complexity is a parser; cost that does not is a tee. Results (bench/README.md): 4-6x on the Mac's P-cores, 16-24x on ukvps (aarch64 Ampere) — the parse tax is CPU-bound, the tee is memcpy, so the gap widens on the modest cloud cores where remote agents run. Also records WAN characteristics (detach!=kill live over SSH, 193ms RTT, ~15 MB/s ceiling) and the honest caveats (no pipe-mode attach). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review of the v0.1 POC against the Superlogical architecture Mitchell describes: faithful on the steady-state hot path (raw tee, client-side parse, single writer, no window manager), incomplete on synchronization. - §C.5: authoritative-PTY-dimensions is a correctness requirement, not a preference — input replication is only deterministic if every client parses at the same width; `attached`/`S` must carry rows/cols. - §F risk #10: unbounded per-client backlog — the shadow cost of the non-blocking hot path; fix pairs with the bytes::Bytes fan-out (shared chunk + per-client byte budget → drop/resnapshot slow client). - §F risk #11: resize is not a barrier and TIOCSWINSZ errors are ignored. - §F risk #9: sharper framing — the protocol does not yet cross SSH from a native client (ssh -t termiod attach keeps it remote-local). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… memcpy/syscall-bound) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Shell-injection fix (critical): the clone command no longer re-embeds the repo name unquoted in a printf; it prints the clone's absolute path via 'cd <quoted-name> && pwd', so a hostile origin URL whose last path component carries shell metacharacters can't execute code on the remote host. - Pipe fd leak: close the first pipe's fds when the second pipe() fails (the likely case under fd exhaustion). - Quit-hang fix: Transport.close() reaps the SSH child off-thread with a bounded WNOHANG poll + SIGKILL escalation instead of a blocking waitpid, so a network-stalled remote session can't beachball Cmd-Q on the main actor. - Only a writer flushes keystrokes buffered during connect (observer input would be rejected frame-by-frame). Verified: swift build clean; fixed clone command shape and injection-inertness confirmed against real ukvps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add termiod-device-architecture.md: there is no "remote" — there are
devices, each running one termiod, and every UI including the Mac app is
a client that attaches to one. A device's identity is its host_id, not an
SSH alias; SSH is one route among several to the same machine, so the
same box reachable over LAN, WAN and Tailscale stops forking into three
session lists.
Correct the state-authority section against primary sources. An earlier
revision, written from press paraphrase ("the server maintaining all
state"), concluded we should move to server-side state synchronisation.
Mitchell's architecture video and his reply to tmux maintainer Jonathan
Slenders say the opposite: Superlogical tees raw PTY bytes to clients
"like SSH", and while the server does parse, "the teeing happens ahead of
the server". That is termiod's existing anti-100x invariant, arrived at
independently — the architecture needs no course change.
Adopt their better argument against screen diffs: the problem is less
performance than that a diff-fed client owns no real scrollback and
cannot select across history. That is a capability limit no amount of
encoding work removes, so G stays opt-in even once the wire cell shrinks.
Also record §C.10 resumable subscriptions and the VT-sequence snapshot
format in the protocol spec, and the hot-path/client-class analysis.
Refs: #164, #177
…esumable The S snapshot carried packed 16-byte cells with colour already resolved against the *host's* palette. That made the host the style authority: a remote session ignored the viewer's theme and painted on a black background, while bold, underline and OSC 8 vanished entirely because the cell's attributes field was reserved-zero. Format v2 sends libghostty's own formatter output instead, with the palette extra off so no OSC 4 is emitted and colour arrives as indices the client's theme resolves. Measured on a 10x40 screen: 559 bytes against 6,504 of cells — more faithful and 11.6x smaller. The formatter emits its cursor CUP before state extras and some of those move the cursor (tabstops walks the row, DECSTBM homes it), so the true position is re-asserted last. Packed cells survive only for grid_diff clients, whose model is server-side state by design. Add the resource plane (§C.10): a resource is durable host state with an id, a monotonic seq, a bounded replay ring, and a lifetime independent of any connection *or* client. Reconnect stops being a special case — re-subscribe at the last applied seq and either the missed batches replay or a gap is reported. First kind is fs:<workspace>, one shared watcher per canonical root, with git object churn dropped, index/HEAD/refs flagged separately, and watcher overflow surfaced rather than silently losing paths. A live run caught the watch dying with its last subscriber, which made "resumable" true only while somebody was already watching; it now lingers, so changes made while fully detached replay on return. Add cross-host listing and a real protocol-over-SSH attach. The CLI had no way to speak the framed protocol to a remote daemon — `remote attach` runs the remote CLI under ssh -t, so what crosses the network is terminal bytes, not protocol frames. `attach --host` rides `ssh <host> termiod stdio`, making local and remote differ only in the pipe. Both paths now share one SSH ControlMaster; unlike Zed we leave a user-configured ControlPath alone. `list --host` sweeps a fleet concurrently and distinguishes "queried, idle" from "unreachable" — a host that is down is a row, not an abort. Raw bytes stay the default on every transport: measured against a real VPS, grid diffs cost 8.6x *more* for scrolling output, since every row goes dirty and each cell costs 16 bytes. Verified: 24 unit + 1 integration, 6 in the vt crate, 48 local smoke, 8 remote smoke, aarch64-musl cross-build, and end-to-end against a real aarch64 VPS (attach, 10 ms round trip, clean detach, session outliving the client, v2 snapshot with no OSC 4). Refs: #164, #177, #181
A local "New Terminal" could silently become a remote session. Session host resolution fell back to the TERMIO_TERMIOD_REMOTE environment variable whenever a session set none, which is every ordinary terminal — so with that variable exported the whole sidebar quietly ran on a VPS. The fallback is gone: a session's own host is the only source of truth, and that variable now only names which daemon the diagnostic roster inspects. A terminal must never become remote because of an environment variable. Teach the app format-v2 snapshots: when the host sends VT sequences the client feeds them straight into libghostty rather than synthesising a repaint, which is what lets the viewer's own theme, palette and SGR handling apply. Give remote terminals a scope that matches where they were opened from. "New Remote Terminal" hung only on sidebar project rows, yet every session it created landed in the loose-terminals bucket at the remote $HOME — the row you clicked and the thing you got did not match. A project now records where it lives on each host, established by "Clone on Remote…" (which already resolved the absolute clone path and threw it away), so opening from a project row means "this repo, over there" and the session appears under that project. Hosts the project has not been cloned to say so in the menu instead of failing at click time. The global + and File menus gain the host-scoped variant beside New Terminal, since that is the same verb aimed at another machine. Refs: #164, #170, #177
…ted to unblock main merge)
# Conflicts: # CLAUDE.md # docs/README.md # docs/design/20260730-termiod-session-protocol.md
The pull side of the file plane (spec C.12): batched fs_list with 2000-entry pages, per-path failure, unloaded_dir stubs for VCS dirs, and replies stamped with the fs: resource cursor so cached listings carry a freshness proof. fs_read answers with an fs_file header plus F frames (64 KiB fair-write chunks, 1 MiB soft cap, range for the editor later). Confinement canonicalises every requested path and rejects dotdot and symlink escapes from the workspace root.
The write side of the file plane (spec C.12), capability 'upload': upload_open resolves and confines the dest (project root: canonical parent, no dotdot, no symlink escape; temp:<name>: the session's scratch dir, 0600, reaped with the session), U frames carry <=64 KiB chunks acked credit-of-one so a keystroke on a shared pipe waits behind at most one chunk, and commit verifies size + sha256 before the dotfile is renamed into place. Re-open with the same dest, size, and hash is idempotent (restart from zero, no resume in v1). Memory stays O(chunk) via an incremental hasher.
The fuzzy-finder half of the file plane (spec C.12): a paths-only index per workspace, built breadth-first at idle priority when the first subscribe_resource starts the watch, kept incremental by the watcher's own batches (one changed dir = one re-list), and evicted with the watch. Replies carry coverage 0.0-1.0 so a client can say 'still indexing' instead of silently missing files; with no index running the answer is an honest empty 0.0, never an unrequested walk. Ranking: substring beats subsequence, basename beats path, shorter paths win ties.
The second consumer of the C.10 mechanism (spec C.13): id git:<canonical repo root>, same cursor/ring/gap/linger — the ring state is now generic over the batch kind instead of fs-specific. A git: subscription rides the workspace's existing fs: watch (one repo, one OS watch) through an internal subscriber; its loop turns watcher batches into debounced git status --porcelain=v2 -z runs (--no-optional-locks, or status's own index refresh would re-trigger the watcher forever) and publishes deltas in Zed's two-axis status vocabulary with the conflict set first-class. A gap subscriber is served the full state at the current cursor — only the host can rescan git status, so on gap it does the scan for the client. Read-only by design; the one verb is git_diff (unified diff, worktree or --cached, 1 MiB cap). Kind gated on capability 'git'. Trigger deviation from the spec text: worktree edits change status without touching .git, so any watcher batch triggers a run, not only git_meta; recorded in the spec changelog.
The last C.12 verb: the host runs git grep (-n -I --untracked,
fixed-strings) under the workspace root and streams search_results
events batched 50 matches at a time, closing with one fs_searched
reply carrying the count and why the stream ended (done, limit,
canceled). Events and the terminal reply share the connection's one
outbound queue, which is what guarantees the reply arrives last.
A generic cancel {request} verb kills the in-flight search by its
request id, idempotently; the connection going away cancels the
same way, so no grep outlives anyone who could read it. Match text
is capped at 512 bytes per line so minified files cannot bloat the
event stream.
Continuity rows for the five file-plane and git-plane steps, and the four implementation deviations (status trigger breadth, host-side gap rescan, upload.open root/session fields, generic cancel + git capability) in the protocol doc's changelog comment.
The resolution keeps the dated path, which is the file that exists after the docs/design rename.
…l brief The device doc asserted "one connection per device, multiplexed by channel id" as if it described the code, but the POC opens one connection per session, owned by that session's link and destroyed with it — so a dropped pipe has nowhere to be repaired and is reported as an exit that never happened. Add §5.1 stating the connection as a durable object with sessions as channels on it, split the work into a ControlMaster level (no protocol change) and a channel-id level (additive proto bump), and make reconnect normative: a transport failure is not an exit. Also in the device doc: §2 separates discovery from the "no host database" rule and specifies a provider interface (ssh config, tailnet, Bonjour) that proposes routes without becoming an identity; §3 records zmx as a second independent witness to the parallel-tee invariant, on the same libghostty-vt; §7 gains rows for connection ownership, discovery, and the phone client, and notes there is no Superlogical protocol published to adopt; §8 schedules the connection work, the WebSocket binding, and discovery; §9.6 opens the plain-terminal client question. The superlogical brief's "revisit the raw-PTY-first protocol decision" is marked superseded and must not be implemented: it was inferred on launch day, before the architecture was described, and the primary sources invert it — they tee raw bytes ahead of the server's own parse and reject screen diffs because a diffed client loses native scrollback and selection. Acting on that bullet would break the anti-100x invariant. Its companion "reconcile the two docs" item is resolved, and the launch-day "unpublished" list is split into what has since been answered and what is still unknown.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
Updated 2026-07-31 — the branch has grown past the v0 POC described below. Shipped since the original description:
hello+ capability negotiation,seq/rerequest ids, typed error model,Eevent frames,send/wait,workstreamin CreateSpec. Live-verified on a real VPS (ukvps, aarch64 static-musl): detach ≠ kill across hard disconnect, 12.1 ms median keystroke echo.bytes::Byteszero-copy fan-out + bounded 4 MiB per-client backlog (slow clients dropped + logged, RSS bounded).attach --observe#179 — non-interactiveattach --observe(pure copy-to-stdout, never claims the writer); fixes 0-bytes-over-SSH scripting.termiod/bench/): termiod's tee holds 4–6× tmux throughput on the Mac, 16–24× on the VPS; tmux loses ~50% plain→ANSI while termiod barely moves.termiod/vtcrate — same VT as every termio client, fidelity parity):Sframe (16-byte engine-neutral wire cells) +readyevent, capability-gated (snapshot), exact in-band byte boundary (unit-proven), ring-replay fallback.fan_outnever blocks on VT parse.S→ resume;TIOCSWINSZfailure surfaced as a typed error instead of swallowed; stale in-flight snapshots superseded byrequest_id; promoted writers reclaim size viaResizeClaim.Remote UI landed (
55b2734+8e86866): per-project+▸ New Remote Terminal picks an~/.ssh/confighost and opens a shell running there (auto-deploys termiod if missing); project right-click Clone on Remote… clones the repo onto the host viassh … git cloneand opens a terminal in it. Per-session remote host/cwd, all gated behind the opt-in flag — local path byte-identical. This is the remote feature made usable from the app, not just the CLI.Remote sessions in the app now work over SSH (
b91d180):TermiodSessionLinkgained a transport seam — local Unix socket or anssh <host> termiod stdiopipe. WithTERMIO_TERMIOD_REMOTE=<host>, an agent runs on a Linux box and the Mac attaches over SSH, reusing the whole snapshot-repaint client unchanged. Verified end-to-end against a real VPS (hello → attached → S snapshot → ready overssh). This is the differentiated feature — your agent lives on the server, the window is just a view.termiod stdiobridge landed (cbe9e23): the framed protocol now crosses SSH byte-identical (ssh <host> termiod stdio), proven by a deterministic Rust integration test. This is the enabler for remote sessions in the app — the differentiated feature — reusing the Mac client below over an SSH pipe instead of the local socket.Mac app is now an attach client (#170, in progress).
TERMIO_TERMIOD=1runs terminal sessions inside the local termiod daemon; quitting termio detaches instead of killing, and relaunch reattaches by session UUID (same pid) with a cleanS-snapshot repaint of the current screen — the "quit the app, your agent keeps working" demo. New Swift client inSources/termio/Terminal/Termiod/. Verified at the code layer (build + decode/render against a real captured daemon S frame); the live GUI recording is pending.Continuity doc for orchestration:
termiod/HANDOFF.md. Landed since: Phase 1c (attachedcarries authoritative rows/cols) and Phase 1d (staged scrollback —Hframes, newest-first, capture-at-boundary via libghostty-vt history grid refs, 1 MiB cap,snapshot+scrollbackcaps). Phase 1e landed too: capability-gatedGdirty-row diffs (v1.1 plane) —grid_diffclients get S keyframes + dirty-rowGframes instead of rawD(zero D backlog), single ordered sidecar channel prevents S/G reordering, periodic keyframes, raw clients byte-for-byte unaffected. #181's protocol ladder (S → H → G) is complete; QUIC stays gated on measured need.Rust POC of termiod, the durable PTY session host from the epic #164. One binary is both daemon and client; sessions live in the daemon and outlive every viewer. Detach ≠ kill. Everything lands under
termiod/at the repo root so it doesn't touch the SwiftPM app.Scope follows the design docs' v0: raw PTY bytes over a socket, no VT/grid snapshot, no custom crypto (remote = system SSH stdio). The zmx lesson — session persistence only, no in-daemon window manager.
What's here
#170 — local durable host ✅ (acceptance fully met, 16/16 smoke checks)
[kind][len][payload]— control JSON + raw PTY data + resize$XDG_RUNTIME_DIR/termiod/(0600), auto-starts on first client opserve/create/list/kill/send(inject w/o attach) /attach(create-on-missing, Ctrl-\ detaches)smoke_test.py: attach→type→detach→survives→reattach same pid; fan-out; single-writer; resize; inject; kill — all green#171 — SSH deploy + remote attach ✅ (transport verified; cross-arch install documented)
rust-lld— no musl-gcc / zig / Docker (.cargo/config.toml)remote deploy(uname-detect → build/--bin→ scp → verify),remote list,remote attachoverssh -tstdioDEPLOY.md: cross-compile, optional systemd--userunit, security model (Unix socket only, SSH is the ACL)#172 — open remote host (CLI MVP) ✅
remote open <host> [--cwd --agent --name]: ensure deployed → create durable remote session → attach, one command,~/.ssh/configonlyremote_smoke_test.py: 8 checks through a fake-ssh transport shim (list, open, survives disconnect, reattach same pid) — greenNot in this POC (by design)
DEPLOY.mdsteps (no Linux VPS available in this session; loopback sshd needs a key I didn't add unprompted)Test
Part of #164. Draft — remote paths want a real Linux VPS pass before merge.