Skip to content

termiod: durable PTY session host (Rust POC) — #170/#171/#172 - #177

Draft
jiweiyuan wants to merge 68 commits into
mainfrom
termiod/rust-poc
Draft

termiod: durable PTY session host (Rust POC) — #170/#171/#172#177
jiweiyuan wants to merge 68 commits into
mainfrom
termiod/rust-poc

Conversation

@jiweiyuan

@jiweiyuan jiweiyuan commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Note

Updated 2026-07-31 — the branch has grown past the v0 POC described below. Shipped since the original description:

  • Protocol v0.1: hello + capability negotiation, seq/re request ids, typed error model, E event frames, send/wait, workstream in CreateSpec. Live-verified on a real VPS (ukvps, aarch64 static-musl): detach ≠ kill across hard disconnect, 12.1 ms median keystroke echo.
  • termiod: bytes::Bytes zero-copy fan-out + bounded per-client backlog #180bytes::Bytes zero-copy fan-out + bounded 4 MiB per-client backlog (slow clients dropped + logged, RSS bounded).
  • termiod: add pipe-mode (non-interactive) attach — attach --observe #179 — non-interactive attach --observe (pure copy-to-stdout, never claims the writer); fixes 0-bytes-over-SSH scripting.
  • Anti-100× benchmark (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 v1: authoritative VT snapshot + ready frame + resize barrier + staged scrollback #181 v1 VT sidecar (engine decision: libghostty-vt, FFI'd via the new termiod/vt crate — same VT as every termio client, fidelity parity):
    • Phase 1a: snapshot-on-attach — per-session VT sidecar thread fed off the hot path; one versioned binary S frame (16-byte engine-neutral wire cells) + ready event, capability-gated (snapshot), exact in-band byte boundary (unit-proven), ring-replay fallback. fan_out never blocks on VT parse.
    • Phase 1b: resize is a barrier — quiesce → resize → fresh S → resume; TIOCSWINSZ failure surfaced as a typed error instead of swallowed; stale in-flight snapshots superseded by request_id; promoted writers reclaim size via ResizeClaim.
  • Suites: 48 local + 8 remote smoke checks, 10+4 unit tests — green on macOS-arm64 and native aarch64-musl (ukvps).

Remote UI landed (55b2734+8e86866): per-project +New Remote Terminal picks an ~/.ssh/config host and opens a shell running there (auto-deploys termiod if missing); project right-click Clone on Remote… clones the repo onto the host via ssh … git clone and 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): TermiodSessionLink gained a transport seam — local Unix socket or an ssh <host> termiod stdio pipe. With TERMIO_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 over ssh). This is the differentiated feature — your agent lives on the server, the window is just a view.

termiod stdio bridge 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=1 runs terminal sessions inside the local termiod daemon; quitting termio detaches instead of killing, and relaunch reattaches by session UUID (same pid) with a clean S-snapshot repaint of the current screen — the "quit the app, your agent keeps working" demo. New Swift client in Sources/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 (attached carries authoritative rows/cols) and Phase 1d (staged scrollback — H frames, newest-first, capture-at-boundary via libghostty-vt history grid refs, 1 MiB cap, snapshot+scrollback caps). Phase 1e landed too: capability-gated G dirty-row diffs (v1.1 plane) — grid_diff clients get S keyframes + dirty-row G frames instead of raw D (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)

  • protocol v0: framed [kind][len][payload] — control JSON + raw PTY data + resize
  • PTY spawned with the login_tty shape (setsid + TIOCSCTTY) so agents reflow on resize; async read/write with a dedicated writer task that 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 under $XDG_RUNTIME_DIR/termiod/ (0600), auto-starts on first client op
  • CLI: serve / 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)

  • cross-compiles to static musl Linux (x86_64 + aarch64) with the bundled rust-lld — no musl-gcc / zig / Docker (.cargo/config.toml)
  • remote deploy (uname-detect → build/--bin → scp → verify), remote list, remote attach over ssh -t stdio
  • daemon auto-starts detached (setsid) on the remote, so a session survives SSH disconnect for free
  • DEPLOY.md: cross-compile, optional systemd --user unit, 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/config only
  • remote_smoke_test.py: 8 checks through a fake-ssh transport shim (list, open, survives disconnect, reattach same pid) — green

Not in this POC (by design)

  • libghostty-vt grid snapshot / scrollback restore (later phase)
  • Mac app / iOS wiring, host-side agent status
  • Real cross-arch scp/install is verified only by the static-ELF build + manual DEPLOY.md steps (no Linux VPS available in this session; loopback sshd needs a key I didn't add unprompted)

Test

cd termiod && cargo build
python3 smoke_test.py          # #170, 16 checks
python3 remote_smoke_test.py   # #171/#172 orchestration, 8 checks

Part of #164. Draft — remote paths want a real Linux VPS pass before merge.

jiweiyuan and others added 3 commits July 30, 2026 17:44
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>
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
landing Ready Ready Preview Aug 8, 2026 11:07am

Request Review

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.
@jiweiyuan

Copy link
Copy Markdown
Collaborator Author

Architecture framing (Superlogical-clean)

Pushed docs(termiod): Superlogical-clean host · protocol · clients architecture so the POC reads as a session host, not a CLI product.

Three parts only (see termiod/ARCHITECTURE.md + docs/design/termiod-session-mux.md §0 / §4):

Part Role
Host (termiod serve) Owns PTYs / sessions
Protocol Transport-agnostic attach contract
Clients Mac / iOS / CLI viewers

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.
jiweiyuan and others added 3 commits July 30, 2026 18:51
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
# 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant