-
Notifications
You must be signed in to change notification settings - Fork 0
Scrollback Snapshots & Cold Replay
The tmux server holds scrollback in memory. When a machine reboots (or the tmux server otherwise dies), every session's history is gone with it — reattaching to a session of the same name yields an empty pane. ScrollbackStore closes that gap: while a session is attached it periodically dumps the pane's text (with escape codes) to a per-node file under userData/terminal-scrollback/<nodeId>.txt, and on a cold start the terminal spawn path can read that file back and replay it into the fresh PTY before tmux output begins. Warm reattaches are expected to skip replay entirely, because tmux redraws the pane itself.
Capture loop. start(nodeId, tmux) registers a 5-second interval and does nothing else — the absence of an immediate snapshot is deliberate (L28-L31): on a cold start the freshly created pane is empty, and writing immediately would overwrite the previous run's file, which is exactly the data the replay needs. First capture therefore always happens one interval after attach. start first calls stop(nodeId) without a tmux config, so re-arming a node discards the old timer without a final capture.
Capture command. Each tick runs tmux capture-pane -e -p -S -4000 -t <sessionNameFor(nodeId)> with the caller's tmuxPath and baseArgs, so the capture targets the same server/socket the attach flow uses. -e keeps SGR escapes (colors replay), -p writes to stdout, -S -4000 pulls history plus the visible screen. The exec is asynchronous with a 2 s timeout and a 512 KiB maxBuffer; it never blocks the caller, which matters because the first capture-pane after a tmux server spin-up can be slow.
Persist. On success the directory is re-created defensively, the payload is capped, and it is written as UTF-8. On any error the callback returns silently, leaving the last good snapshot in place. stop(nodeId, tmux) clears the timer and optionally takes one final snapshot; stopAll(tmux) snapshots every live timer key before clearing (iterating a copy, since stop mutates the map); destroy(nodeId) clears the timer without a final snapshot and deletes the file.
Cold vs. warm replay. The decision to replay lives at the call site, not in this store. The store exposes read(nodeId): string | null; a caller that knows it is on the cold path (tmux server absent) feeds that text into the terminal at local-PTY spawn, producing the history followed by the app's usual "session restored" separator. Callers that are reattaching warm simply never call read.
sequenceDiagram
participant Spawn as Terminal spawn path
participant SS as ScrollbackStore
participant FS as userData/terminal-scrollback
participant TM as tmux
Note over TM: machine rebooted — server gone
Spawn->>SS: read(nodeId)
SS->>FS: readFileSync(nodeId.txt)
FS-->>SS: prior run's tail (or null)
SS-->>Spawn: text | null
Spawn->>Spawn: replay into fresh xterm (cold path only)
Spawn->>TM: create/attach sessionNameFor(nodeId)
Spawn->>SS: start(nodeId, tmux)
Note over SS: no snapshot until first tick — pane was empty at attach
loop every 5s
SS->>TM: capture-pane -e -p -S -4000 -t <name>
TM-->>SS: pane text + escapes
SS->>FS: cap to tail, write
end
Spawn->>SS: stop(nodeId, tmux)
SS->>TM: final capture-pane
SS->>FS: write
The diagram's critical asymmetry: read happens before the session is attached, and start happens after. That ordering is what makes the "no immediate snapshot" rule safe and what keeps a cold start from destroying its own replay input.
stateDiagram-v2
[*] --> Absent
Absent --> Seeded: importSnapshot() (space pull)
Absent --> Live: start() then +5s
Seeded --> Replayed: cold start read()
Seeded --> Live: warm attach, start()
Replayed --> Live: start()
Live --> Live: every 5s, tail-capped write
Live --> Stopped: stop() → final snapshot
Stopped --> Live: start()
Live --> Absent: destroy()
Stopped --> Absent: destroy()
src/core/scrollback-store.ts is the whole feature. Its state is two things: dir (resolved once in the constructor from userDataPath, created synchronously) and timers, a Map<nodeId, Interval> that is also the set of "sessions currently being snapshotted". There is no in-memory copy of file contents — read always goes to disk, and any failure (missing file, permissions, directory removed) collapses to null.
The file name is ${nodeId}.txt, and the tmux target is sessionNameFor(nodeId) imported from ./tmux. That shared derivation is the load-bearing invariant: because both the file path and the tmux session name are functions of the same stable node id, a restart lands the replay on the terminal that produced the snapshot. Rename or regenerate a node id and the replay is orphaned.
importSnapshot(record) and readMany(ids) are the sync seams, used when scrollback travels with a cloud-space snapshot. importSnapshot accepts a nodeId → text record, skips non-string values, applies the same cap, and returns a count of entries written — a write failure is swallowed so an import never fails because of scrollback. readMany is the mirror: it returns only ids that actually have a file, so a terminal that never ran carries nothing rather than an empty string.
-
The cap is in UTF-16 code units, not bytes.
out.lengthandString.sliceare used for the 256 KiB cap, so multi-byte content can produce a file several times larger than 256 KiB, and a surrogate pair can be split. Head-truncation can also cut an ANSI escape sequence in half, leaving the replayed stream starting with a partial SGR. Any consumer should treat the snapshot as a best-effort tail, not a well-formed escape stream from byte zero. -
maxBufferandtimeoutare silent drop conditions. A capture exceeding 512 KiB or taking more than 2 s produceserr, and the callback keeps the previous file. Dense escape output or a first-time tmux server start can therefore skip a tick — or, at quit time, the final one. -
stop/stopAlldo not await the final snapshot. They fire an asyncexecFileand return. A quit that exits the process promptly can lose the last few seconds of output; the 5 s interval bounds how much is at risk on a crash. -
Late callbacks can resurrect a destroyed file.
destroyclears the timer andrmSyncs, but a capture already in flight will still run its success callback, which re-creates the directory and writes the file. If the node id is reused, a stale replay is possible. Gating the write on a per-node generation counter would close this. -
No orphan cleanup. Files are only removed by
destroy, so nodes deleted through a path that does not call it leave<nodeId>.txtbehind.readManynever surfaces them, so the cost is disk only. -
readManyis N synchronous reads. Restoring a large workspace pays one blockingreadFileSyncper node on the main process. - Directory creation can throw in the constructor, unlike the callback paths which explicitly tolerate a missing directory.
The tunables — SNAPSHOT_INTERVAL_MS, MAX_BYTES, CAPTURE_BACK_LINES (L15-L17) — are module constants, not settings; changing them changes replay depth, file size, and quit-time loss window together. The policy of when to replay (cold only vs. always) is intentionally outside this module; if you need to change it, change the spawn caller. importSnapshot is the ingestion seam for any other producer of snapshot text, and file layout is flat nodeId.txt, so any sibling metadata must avoid colliding with node ids. This page's excerpt does not include the terminal spawn path, the tmux reattach code, or the space-sync callers, so exact call sites and the replay injection implementation are not verifiable from these lines alone.
Sources: src/core/scrollback-store.ts, constants, lifecycle, snapshot, read/destroy, import/readMany, stopAll/fileFor
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance