l0ng-aiandl0ng-ai 88bf9a5da5 feat(daemon): upgrade in place, keep pane screens across a crash, and give panes their own history (#449)
* feat(daemon): keep a pane's screen across a death nobody chose

A daemon that crashes, is `kill -9`'d, or goes down with the machine takes
every pane's replay ring with it, and the window comes back to a row of
blank shells. The processes cannot be saved that way — nothing written to
a file brings a process back — but the picture can.

The daemon now keeps a capped tail of each pane's ring under the config
directory, and a client whose `Attach` found nothing can ask, on the
`Spawn` that replaces it, for the dead pane's screen. The new pane opens
showing it, under a rule that says the shell below is new.

- periodic and dirty-only: a ring that has not moved is not rewritten, so
  an idle machine does no IO at all. Write-through would be an enormous
  amount of write amplification for a few seconds of freshness.
- capped at 256 KiB per pane, far below the ring's 8 MiB: the value of
  scrollback decays with distance from the bottom, and every byte here is
  a byte of someone's terminal on disk.
- off by default. The ring holds whatever the pane printed, including
  echoed tokens, `env` output and agent transcripts; in memory that dies
  with the daemon, and writing it down is the whole feature and the whole
  cost. Files are 0600, and turning the setting off deletes what was kept.
- dropped by relevance, not by calendar: a pane the user closed, or one no
  workspace names any more, has its file removed on the next sweep.

Restored bytes are replayed at the geometry they were written at, and are
preceded by resets — leave the alternate screen, show the cursor, restore
autowrap, clear SGR — because a snapshot is cut at the front and can begin
in the middle of any of them.

* feat(daemon): upgrade the daemon in place instead of killing every shell

Picking up a new build meant stopping the daemon, and stopping the daemon
means every pane dies: the pty master is a descriptor this process holds,
so when the process goes the slave side raises SIGHUP and takes the shell,
the agent and the half-finished command with it. That is why the update
path leaves the old daemon serving and Settings has to offer the restart
as a thing you schedule for a quiet moment.

`execve` does not have that problem. It replaces the image and keeps the
process: same pid, same children, same descriptors, same file locks. The
daemon now rewrites itself that way on `ClientMsg::Handoff` — it writes
what it knows about each pane into a blob, clears FD_CLOEXEC on the pty
masters, the blob and the singleton lock, and execs the new binary, which
picks the panes back up on the other side.

- **the seat travels on the command line, not in the blob.** The lock is
  still held by this process, so the new image must adopt the descriptor
  rather than ask for the lock again — asking would be refused by its own
  lock and it would stand down in favour of itself. A daemon that loses
  its panes is a bad afternoon; a daemon that exits leaves the machine
  with nothing serving, so that one fact has to survive an unreadable blob.
- **the blob is unlinked before it is written.** It holds every pane's
  ring, which is the output `scrollback` makes people opt into storing;
  a handoff must not be a back door for writing it to disk.
- **the exec is the last step.** Everything is staged first, so any
  failure before it costs a log line and the daemon carries on serving —
  which is what lets callers treat a failed handoff as "fall back to a
  restart" without having lost anything on the way.

Native SSH panes cannot cross — their session is cipher state in memory,
not a descriptor — so they are hung up first and the far end sees a clean
close. Windows has neither execve nor a transferable ConPTY handle, so it
keeps the stop/start path; the dialogs there still promise what they
always did, and the new copy is shown only where it is true.

Also retries flock on EINTR: a signal landing mid-call said nothing about
the lock, but was reported as "could not be evaluated", which starts a
second daemon beside the first — the split machine singleton exists to
prevent.

The end-to-end test sets a variable in the shell, hands over, and reads it
back. Nothing but the original process can answer that, and the daemon's
instance id changing while its pid does not is what says an exec really
happened.

* feat(shell): give each pane its own history when asked

Two panes running zsh with `share_history` are appending to one file and
reading each other's lines back, which is either the feature or the
problem depending on what the panes are for. Someone with a pane per task
wants Up to walk that task's commands, not an interleaving of four.

Each pane can now have its own history file instead. It is seeded from
the shell's real history, so a new pane is not blank, and what the pane
added is appended back when it closes, so nothing typed is lost — a
per-pane history that evaporated would be a way of losing commands, not
of organising them.

The seeding is done by the shell, not the daemon, and that is the only
reason it works: `HISTFILE` belongs to the user's rc file and can point
anywhere, long after the pane's environment was decided. tty7's snippet
is appended to the rc it wraps, so it runs after that decision and is the
one place the real path is known — it copies the tail, records how much it
copied, and repoints. Both shells load history after their startup files,
so the switch lands before the first line is read.

The daemon's half is a filename, a rename when a restored pane inherits
its predecessor's file, a merge on close, and a sweep for the panes a
killed daemon never got to retire.

Off by default: shared history is what a terminal has always done, and
someone who did not ask for the change would experience it as their
history mysteriously forgetting the other window. bash and zsh only —
fish and PowerShell do not keep a HISTFILE, and a shell launched with the
user's own arguments gets no snippet to repoint anything in.

* fix(daemon): store pane screens on the shutdown a restart actually uses

The periodic writer covers a death nobody prepares for and the SIGTERM
path covers a signal, but the restart the app itself performs goes through
ClientMsg::Shutdown — which killed every pty without taking a copy first.
That is the one shutdown where the panes are expected back.

* fix(daemon): leave nothing dangerous behind when a handoff fails or lands

Review findings on the in-place upgrade and per-pane history:

- A failed exec now puts back everything it had staged: FD_CLOEXEC on the
  seat and every pty master (a child inheriting the seat keeps the flock
  held past the daemon's death, so no future daemon could seat itself),
  and the SIGPIPE disposition plus this thread's signal mask, both of
  which Command::exec resets on its way to the attempt — without this,
  the still-serving daemon dies on the first client that hangs up
  mid-write.
- The adopting image restores close-on-exec on the seat and on every
  adopted master, so children it spawns later cannot hold a pty open
  past its pane, or the seat past the daemon.
- The target binary is checked before the handoff gives anything up:
  native-SSH panes are hung up on the promise that this process is about
  to be replaced, and an exec that was never going to work must not
  collect on it.
- The integration snippets raise HISTSIZE/HISTFILESIZE (bash) and
  SAVEHIST/HISTSIZE (zsh) for the pane's private history file. At their
  defaults the exit rewrite truncates the file below its own seed mark,
  which the merge-back rightly reads as "replaced under us" — silently
  losing the pane's commands for anyone with more history than the caps.
- The restart dialog's promise now binds the action: where the copy said
  "nothing is interrupted", a failed handoff is reported instead of
  silently traded for the restart that kills every pane.
- The scrollback writer checks the ring's mark before cloning it, so an
  idle pane no longer costs a full ring copy under the state lock every
  tick.

Each behavioural fix carries a test that fails without it; the history
truncation one was verified to fail with the snippet change removed.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-10 10:59:02 +08:00

tty7

tty7

A terminal workbench: persistent sessions, remote work, agents.

Pure Rust · GPU rendering on Zed's gpui · VT core from Alacritty


CI Version License Discord

English · 简体中文

Why

  • Performance — ~2× the throughput of Alacritty, Ghostty, or Kitty (benchmarks)
  • Persistent sessions — quit or reboot; your shells and supported agent sessions keep running, no tmux
  • Editor-grade input — suggestions, completion, highlighting, history search
  • Remote development — files, repos, panes, and git data stay on the remote machine
  • Native SSH — profiles, SFTP, port forwarding, and jump hosts
  • Agent-aware — Claude Code, Codex & co.: status, notifications, git context
  • CLI + Skills — agents create panes, run commands, and inspect output

Install

Native builds for each platform on Releases:

macOS …-macos-arm64.dmg · …-x86_64.dmg drag into Applications
Windows …-setup.exe · portable ….zip
Linux …-x86_64.AppImage chmod +x and run — X11/Wayland libraries bundled

What's inside

Editor-grade input ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · ⌃ R fuzzy history
Window tabs & splits · ⌘ P palette · ⌘ F scrollback search · nine themes · IME
Agent-aware per-pane detection (18 CLIs): status dot · notifications · branch + diff · resume after reboot · tray icon when input is needed
Remote workspaces remote files, repos, changes, diffs, worktrees, tabs, and panes · reconnect from any client and continue where you left off
CLI + Skills bundled tty7 CLI · agent skill · pane/workspace control · real PTY commands · output, process, port, and agent status
SSH native russh stack: profiles with keychain secrets · SFTP panel · port forwarding · jump hosts · one-time, unprivileged tty7-server install

Terminal and keybinding reference: docs/features.md. The agent-facing CLI interface is documented in skills/tty7/SKILL.md.

Install the skill with:

npx skills add l0ng-ai/tty7

Benchmarks

Same machine, same day, same 155×40 grid — Apple M1 Pro, macOS 26.3.1, five-run averages (2026-07-04):

tty7 Alacritty Ghostty Kitty
Plaintext I/O — 11 MB cat (lower = better) 95 ms 239 ms 179 ms 185 ms
DOOM-fire frame rate (higher = better) 888 fps 485 fps 552 fps 617 fps
Cold-launch memory 116 MB¹ 105 MB 128 MB 130 MB

¹ GUI 105 MB + the persistent server 11 MB.

Methodology and one-command reproduction: scripts/bench/.


S
Description
A terminal workbench in pure Rust: shells, persistent sessions, SSH, coding agents. GPU-rendered on Zed's gpui, VT core from Alacritty.
Readme Apache-2.0
34 MiB
Languages
Rust 99.1%
Shell 0.5%
PowerShell 0.2%
Inno Setup 0.1%