Architecture
How the client, the host helper and the shadow terminal fit together, and how a session flows from start to reboot. The decisions behind it are in the decision records.
1. The picture
Laptop: macOS or Linux Host: any provider, Linux
┌──────────────────────────────┐ ┌───────────────────────────────────────────────┐
│ Ghostty tab │ │ simstim-host bridge (one per client channel)│
│ └ simstim (client) │ system │ │ │
│ ├ screens: hosts, │ OpenSSH │ │ Unix sockets, 0600 │
│ │ sessions, dialogs │ ─────────▶ │ ├─ holder "auth-refactor" ─ PTY ─ claude │
│ ├ attach pump (raw bytes) │ your │ │ ├ output ring, 8 MiB, byte offsets │
│ ├ input filter (2 keys) │ ssh │ │ ├ manifest.json, status engine │
│ └ private ControlMaster │ config: │ │ └ vt child (libghostty-vt) │
│ │ ProxyJump,│ ├─ holder "api-tests" ─ PTY ─ codex │
│ ~/.config/simstim/ │ IAP, SSM, │ └─ holder "notes" ─ PTY ─ zsh │
│ config.toml, hosts.toml │ Tailscale │ │
└──────────────────────────────┘ │ systemd --user: one service per session, │
│ in simstim.slice, with memory caps │
└───────────────────────────────────────────────┘There is no daemon on the laptop and no central daemon on the host. Each session is one holder process, with a small vt child. The bridge lives only while a client is connected.
2. Components
2.1 Client: simstim
One static binary (Go in the proposed design).
| Part | Job |
|---|---|
| Screens | The launcher: the hosts, then one host’s sessions, one list at a time, with search across hosts, a one-line quick start, and sheets (the palette, peek, forms, the add-host wizard) that rise over it. It remembers what it saw, to draw at once. It runs in the alternate screen and is built with Bubble Tea v2. While attached it draws nothing. See UX, 0009, 0011 and 0012. |
| CLI | simstim host …, new, ls, attach, kill, doctor, update, completion. --json on every list. |
| SSH layer | Runs the system ssh with a generated config. Owns a private ControlMaster per host. Opens the bridge channel. Uploads files. |
| Attach pump | Raw mode, an output pump with a passive mode tracker, the input filter, resize forwarding, the reset list on detach. |
| Reconnect | Heartbeat, wake detection, backoff, and resume by byte offset. |
| Clipboard | Reads images from the local clipboard. Rewrites dropped file paths. |
| Capability probe | One batch of terminal queries at start-up, cached per terminal. It drives simstim’s own screens and doctor. |
2.2 Host: simstim-host and simstim-vt
Installed by the client into ~/.local/share/simstim/bin/<version>/, with a current link. After each install, the new helper removes the versions nothing uses (simstim-host prune, and the same rule in host doctor --fix): it keeps the newest three, the current one, any that a session or a process of the user runs from or a unit file names, and any installed in the last hour.
| Subcommand | Role | Lives for |
|---|---|---|
bridge | Speaks framed RPC on stdin and stdout to one client. Connects to holder sockets. Runs ls, watch, new, kill and uploads for that client. | One client channel |
new | Resolves the environment, writes the manifest, starts a holder as a systemd user service | Seconds |
holder <id> (internal) | Owns one session’s PTY, ring, socket, manifest and status | The session |
simstim-vt (internal) | The shadow terminal: a static Zig program on libghostty-vt (source in vt/). A child of the holder, fed every output byte; it makes snapshots for new clients and answers terminal queries while no client is attached. Restarted on a crash from the ring. | The session |
attach <id> | Raw fallback: bridges a plain ssh -t to one holder, for any SSH client | One attach |
ls --json, watch --json | Reads manifests and probes holders in parallel. watch streams status events as NDJSON. | Seconds, or one client |
hook <event> | Called by agent hooks. Forwards the event to the session’s holder (its socket is in the agent’s environment). | Milliseconds |
keytest | Prints every key it receives; run inside a session, it proves keys cross the whole path unchanged | One test |
kill, rm | Stops the session’s whole cgroup. Removes the manifest, and in v2 the worktree when it is safe. | Seconds |
doctor | Checks linger, cgroups, terminfo, agents on PATH and the disk, and prints fixes | Seconds |
restore [--all] | Respawns sessions after a reboot with each agent’s resume command. A user unit, <name>-restore.service, runs it at boot. | Run at boot, then exit |
2.3 Session status
The holder decides each session’s state from four layers, strongest first:
| Layer | Source | Examples |
|---|---|---|
| L0 | The process | Exit code, signal, out-of-memory kill (from the cgroup’s memory.events) |
| L3 | Screen rules, kept as data in internal/agents/rules/*.toml and read from an x/vt copy of the screen | Claude Code’s “Do you want to proceed?”, an interrupt hint, a spinner title |
| L1 | The agent’s own hooks, added per launch from the agent table | Claude Code’s SessionStart, UserPromptSubmit, Stop, Notification; Codex’s notify |
| L2 | Terminal signals in the output | BEL, OSC 9/777/99 notifications, OSC 9;4 progress, recent output |
Each session row says which layer and rule set its state.
2.4 The agent table
All agent knowledge is data, shipped with the host helper and overridable by the user.
| Field | Example for Claude Code |
|---|---|
kind, binaries | claude, ["claude"] |
launch_argv | claude --session-id {uuid} -n {name} |
resume_argv, continue_argv (v2) | claude --resume {agent_session_id}, claude --continue |
id_capture | choose (we pick the ID), create, hook, scan or none |
hooks | SessionStart calls simstim-host hook session-start, because the ID changes on /clear |
quirks | For Codex: add --no-daemon, so its work stays inside the session |
hooks | Extra arguments that make the agent report status and its session ID |
| screen rules | A TOML file per agent in internal/agents/rules |
The full resume table for eight agents is in the host runtime note.
3. Flows
3.1 Add a host
- The user picks a host from
~/.ssh/config(resolved withssh -G), or typesuser@host, with an optional jump host. - simstim writes the host to its own
hosts.conf. It never edits~/.ssh/config. - It opens the private master and runs
uname -sm. It checksPATHfor an installed helper. - If there is no helper, or the wrong version, it asks first, then uploads the static helper over an exec channel. It checks the SHA-256 against a signed manifest and installs into a versioned folder.
- A
hellohandshake exchanges the protocol version and features. simstim-host doctorruns:- Turn on linger, or warn that sessions die at logout.
- Install Ghostty’s terminfo (
infocmp -x xterm-ghostty | tic -x -). - Find agents through the login shell.
- Check cgroup delegation.
- The host appears in the browser with its facts: provider label, latency, CPU, memory and sessions.
3.2 Start a session
In the host browser, the user presses Enter on a host. A dialog asks for the agent, the folder and the name.
The client sends
newover the bridge.simstim-host newresolves the environment from the login shell, once, with a timeout. It adds:TERM=xterm-ghostty(orxterm-256colorif the terminfo is missing);COLORTERM=truecolor;- the client’s
TERM_PROGRAMand its version; SIMSTIM_SESSION.
It writes the manifest and starts the holder:
systemd-run --user --unit=simstim-s-<id> --slice=simstim.slice \ -p MemoryHigh=… -p MemoryMax=… -p MemorySwapMax=0 -p OOMPolicy=continue \ simstim-host holder <id>The holder forks the PTY at the client’s size and runs the agent’s
launch_argv. It starts the vt child, and opens its socket.The client attaches at once (3.3).
3.3 Attach
- The client sends
attach{cols, rows, xpx, ypx, client_id, caps, last_offset}. - The holder decides how to catch the client up:
- Same client, offset still in the ring: it sends the raw bytes from
last_offset. Local scrollback continues with no duplicates, and missed notifications arrive. Repeated ones are collapsed. - New client, or offset too old: the vt child makes a snapshot at the client’s size. The client pushes what the terminal shows into its scrollback with blank lines, then the snapshot redraws the screen, including the kitty keyboard flags and other modes.
- Same client, offset still in the ring: it sends the raw bytes from
- The holder resizes the PTY and sends SIGWINCH to the foreground process group, even if the size is unchanged, so the agent repaints.
- Then it is pure pass-through, and the session owns every row and column (0009):
- Output frames go straight to stdout. The client draws nothing of its own: no frame, no status line and no window title. The one exception is the reconnect notice (3.5). Alerts about other sessions, and a failed upload, go out as OSC 777 notifications, which draw nothing on the screen.
- Input bytes go straight to the PTY, except the two filtered keys.
- Terminal queries from the agent go to the leader client’s terminal, and only the leader’s replies reach the PTY.
3.4 Detach
Triggers: the detach key, closing the tab, simstim kill from elsewhere, or SIGTERM.
The client closes its attach stream, but the holder keeps running.
The client writes the ordered reset list in one write, and never sends RIS (
ESC c), which wipes Ghostty’s scrollback. The list:- pop the kitty keyboard flags the session pushed;
- turn off mouse modes (1000, 1002, 1003, 1006), focus (1004) and bracketed paste (2004);
- end synchronized output;
- show the cursor and reset its shape;
- reset colours (OSC 110, 111, 112) and the pointer (OSC 22);
- leave the alternate screen if the session entered it;
- reset SGR, and pop the title stack.
The full 19-step list is in the client stack note.
While no client is attached, the vt child answers the agent’s terminal queries from a cached profile of the last client. If the agent turned on focus reporting, the holder sends focus-out.
3.5 Sleep and reconnect
- A heartbeat runs over the bridge: a ping every 5 s, with a 15 s deadline.
- On wake, the client probes at once. A timer gap much longer than the interval means the laptop slept; on macOS the client also listens for the wake event.
- While disconnected:
- the title shows “reconnecting”;
- an OSC 9;4 progress mark appears in Ghostty;
- after 3 s, one banner line is drawn;
- keystrokes are dropped, never replayed.
- Reconnect tries in the background with
BatchMode, backing off from 1 s to 30 s. If a person is needed, for example a password or FIDO touch, it says “press Enter”. - On success the client re-attaches with
last_offset(3.3). The replay or snapshot redraws over the banner.
3.6 Image paste and dropped files
- The input filter sees Ctrl+V in any encoding: legacy
0x16, kitty CSI u (with lock bits masked and event types), or modifyOtherKeys. - The client reads the local clipboard. On macOS this takes about 20 µs warm.
- If it holds no image, the key is passed through unchanged.
- If it holds an image (16 MiB at most), the client uploads it over the bridge. The holder stores it under the session’s
paste/folder, mode 0700, and deletes it after 24 hours. - The client sends the remote path as one bracketed paste. Claude Code, Codex, Gemini CLI, opencode and Amp all attach an image when you paste its path. So no agent needs special code.
- Dropped files: a bracketed paste is rewritten only when every token in it is an existing local file. Each file is uploaded, and its path replaced with the remote one.
3.7 A session ends
- The agent exits: the holder records the exit code and signal, keeps the final screen, and marks the session
exited. The session stays in the list until the user removes it, or respawns it in v2. - Out of memory: the cgroup’s
memory.eventsshows the kill, and the session is markederrored: oomwith its last screen. The host and the other sessions are unharmed.
3.8 Upgrade
- The client and the helper upgrade independently. New sessions use the new helper, and old holders keep running their own version.
hellofeature flags let a new client drive an old holder. If a feature is missing, the UI says “restart this session to get X”.- The vt child can be upgraded live: start the new one and replay the ring.
- In v1.x, a holder can re-exec into the new version in place and keep the PTY.
3.9 Host reboot and respawn (v2)
- A user unit
simstimd.service(WantedBy=default.target, with linger on) runssimstim-host restoreat boot. - For each manifest whose
boot_iddiffers from the current boot and whose holder is gone:- start a new holder in its
cwd; - run
resume_argvwith the storedagent_session_id, orcontinue_argvif the ID is unknown; - count retries, with backoff.
- start a new holder in its
- The old screen snapshot is printed above the resumed agent. It is only used if the same pinned vt build wrote it.
- The session is marked
restored. A turn that was running when the host went down is not resumed.
3.10 Worktrees, hooks, ports and providers
- Worktree:
new --worktreerunsgit worktree add -b <name>/<slug>-<id>from the base (defaultHEAD) into~/.local/share/<name>/worktrees/<repo>/, locks it, and copies the files listed in.worktreeinclude. Delete removes it and its branch only when it has no changes and no new commits; otherwise it keeps it and says so. - Setup and resume hooks: an executable
.<name>/setupin the repository (or~/.config/<name>/hooks/setupon the host) runs in the session’s terminal before the agent;resumeruns before a respawn. A repository’s hooks run only after the user trusts that repository on that host: the client asks once and keeps the answer. A failing hook is reported, not fatal (setup hooks). - Ports: the holder lists TCP listening sockets owned by the session’s processes (its cgroup, or the agent’s descendants).
forwardrunsssh -N -L 127.0.0.1:…for them. - Providers: a host may name a script that starts, stops and reports on it (JSON on stdin). With
--wake, a host that does not answer is started first. Seeexamples/providers.
4. Protocol
- Framing:
u32little-endian length,u8type, payload. Control payloads are JSON;inputandoutputpayloads are raw bytes. Every frame has a size limit. - Hello first, both ways:
{magic, protocol: "1.0", features: […], version}. - Compatibility: message types are append-only. A peer ignores unknown types. A missing feature degrades with a clear message.
- Bridge to client:
hello,ping/pong;hosts.info,ls,watch/event,new,kill,rename;upload.begin/upload.chunk/upload.end;attach,detach,take_size,input,resize,output{offset},snapshot(VT bytes that redraw the screen; output fromAttached.Fromfollows).
- Holder socket: the same
attachfamily, plusinfo,subscribe,hook,set_metaandkill. - Back-pressure:
- Each client’s buffer is capped at 4 MiB.
- A client that falls behind is dropped to a fresh snapshot.
- The holder never blocks reading the PTY.
- Leader rule: the last client to type real input owns the size. Replies and mouse events do not count. A device can claim the size without typing by sending
take_size.
5. Terminal fidelity contract
The contract has its own page: the fidelity contract.
6. Files and paths
Laptop:
| Path | Contents |
|---|---|
~/.config/simstim/config.toml | Settings and keymap overrides, with a JSON Schema |
~/.config/simstim/hosts.toml | Saved hosts: name, SSH target, labels, optional provider block (v2) |
~/.config/simstim/ssh/hosts.conf | Generated SSH blocks for hosts added in simstim. Included by the wrapper. |
~/.cache/simstim/cm/%C | ControlMaster sockets. The path is kept under 86 bytes because of macOS. |
~/.cache/simstim/helpers/<version>/ | Host helpers fetched from the release, for a client built with go install |
~/.local/state/simstim/ | Logs, and each session’s last byte offset |
Host:
| Path | Contents |
|---|---|
~/.local/share/simstim/bin/<version>/ and current | simstim-host and simstim-vt |
~/.local/state/simstim/sessions/<id>/ | manifest.json, paste/ (0700), screen snapshot, logs |
$XDG_RUNTIME_DIR/simstim/s/<id>.sock | Holder sockets, 0600. Falls back to /tmp/simstim-<uid> (0700, owner checked) without linger. |
~/.local/share/simstim/worktrees/<repo>/<slug>-<id> (v2) | Worktrees per task |
~/.config/simstim/alerts.toml | Where alerts go: commands, ntfy topics, webhooks (0600; alerts) |
~/.local/state/simstim/alerts.log | Each alert’s delivery, as JSON lines; turned over at 1 MiB |
7. Security model
The security model has its own page: security model.
8. Providers
A host is anything simstim can run one command on and talk to over its stdin and stdout. Most hosts are SSH hosts you add yourself. host create can also make a host with a provider (0013; how to).
8.1 The transport seam
The helper’s protocol needs only a two-way, binary-safe byte stream to one process. The client gets one from a Transport (internal/client/transport.go): start a remote command, then a stdin writer, a stdout reader and a wait. Bootstrap (uname -sm, then cat > f; sha256sum -c; chmod; mv) and the hello run over any transport, unchanged.
| Transport | Used by | How |
|---|---|---|
| SSH | Hosts you add, and Lima VMs | The system ssh -T, with the generated config (Lima’s ssh.config is included) |
| Docker | host create docker | docker exec -i <container> sh -c …. The daemon kills the command when the client goes. |
| E2B | host create e2b | envd’s process API over Connect: Start streams stdout; ordered SendInput calls, or one StreamInput, carry stdin. No Connect-Timeout-Ms header, so envd sets no deadline. |
Each command started over envd has a tag of its own. A dropped request does not end it there, so the client pings the bridge every 10 s and the bridge exits when the pings stop for 30 s (bridge --idle-exit 30). Closing a connection closes stdin and then kills the old bridge. forward needs an SSH master, so it stays SSH-only.
8.2 Lifecycle
Each provider is a package under internal/provider (lima, docker, e2b). It creates a machine, and for a host it made, gives a lifecycle: status, start or resume, stop or pause, destroy, and a transport. hosts.toml records the provider and the machine’s ID in the host’s [host.machine] table.
- Create: make the machine, save the host, then the normal add: probe, install the helper (showing what and where), set up linger and terminfo, and connect.
- Wake on attach: before it dials, the client asks the provider for the machine’s state. If it is stopped or paused, the client starts or resumes it (
limactl start,docker start,POST /v2/sandboxes/{id}/connect), waits until a command runs, reads the address again, then dials. The launcher does not wake hosts by itself; attaching does. Provider scripts keep working as before. - Idle stop, VMs: a systemd user timer runs
<helper> idle checkevery few minutes. With no client connected (no bridge running) and no session working for the idle time (30 minutes by default), it runssudo -n systemctl poweroff. No credential is kept on the host. A new boot starts the count again. - Idle stop, sandboxes: a lease. While any client is connected, it renews the sandbox’s timeout every minute: 60 minutes while a session works, 15 when none does. When nobody renews it, the sandbox pauses with its memory.
- Destroy: asks first, refuses while a session works unless
--force, deletes the machine, then forgets the host and its generated SSH config. - Credentials: read when needed (an environment variable, the provider CLI’s own login, or a command you name), kept in memory, and never written, logged or sent to a host.
8.3 SSH recipes
For a host you add yourself, if OpenSSH can reach it, simstim can. The connectivity note has a ready ssh_config block for each recipe.
| Recipe | Notes |
|---|---|
| Tailscale + plain OpenSSH | The recommended default for a VPS or a homelab. It roams best. |
| Homelab behind a jump host | ProxyJump through a host on Tailscale. The guests need no Tailscale of their own. |
| Plain SSH (Hetzner, DigitalOcean, Linode, Vultr) | Simple. TCP dies when the laptop’s IP changes, so reconnect handles it. |
| GCP IAP | A gcloud ProxyCommand. It drops after 1 hour idle, and the heartbeat keeps traffic flowing. |
| AWS SSM, EC2 Instance Connect Endpoint | SSM is slow (about 0.6 MB/s). EICE ends every connection at 1 hour, and simstim reconnects. |
| Azure Bastion, Cloudflare Access | Weak fits. Bastion has no ProxyCommand mode, and Cloudflare does not reconnect. |
Host size: at least 2 vCPU and 4 GB plus swap. Comfortable: 4 vCPU, 8 GB and 80 GB of disk. One VM with many sessions beats one VM per session: about 5–6 busy Claude sessions fit in 6.5 GiB, against about 4 with a VM each.
9. What we borrow, and from where
| Source | Licence | What |
|---|---|---|
| zmx | MIT | One process per session. Restore rules. The leader rule. Its bug list as a checklist. Restore code adapted for simstim-vt. |
| Herdr | Apache-2.0 | The managed SSH config and banner skipping. Remote install with consent. The reconnect ladder and wake detection. The image paste bridge. The resume table and hooks. The agent detection manifests. Safe file writes. The pane environment clean-up. Exact files are listed in the Herdr note. |
| Amp | Proprietary | Patterns only, clean-room. No code, prompts or assets. See UX. |
| VS Code Remote, Zed | — | The login-shell environment resolution. Uploading a versioned server over SSH. |