-
Notifications
You must be signed in to change notification settings - Fork 0
tmux Session Naming & Reattach
Every terminal node in termsprawl runs inside a persistent tmux session on a dedicated, app-owned socket. The node id is the session key, so attaching after a restart is a pure function of the id: no registry, no lookup table, no random names. Two small core modules own this contract — src/core/tmux.ts (naming, discovery, generated config, has-session) and src/core/pty-manager.ts (spawn/attach, ownership, destroy) — with src/core/terminal-close.ts providing the durable teardown ordering and src/core/scrollback-store.ts consuming the same naming helper.
| File | Responsibility |
|---|---|
src/core/tmux.ts |
Electron-free helpers: absolute tmux discovery, sessionNameFor(nodeId), ensureTmuxConfig(userDataPath) → TmuxConfig, hasSession() warm-reattach probe. |
src/core/pty-manager.ts |
Owns live PTY clients. create() resolves local vs remote vs fallback, computes the fresh flag, spawns, and broadcasts data/exit. destroy() permanently kills the tmux session; killAll() only detaches. |
src/core/terminal-close.ts |
Orders node removal → destroyTerminal → completion, with staging as the durable commit point. |
src/core/scrollback-store.ts |
Snapshot side of the cold/warm split; snapshots target sessionNameFor(nodeId). |
src/core/session-name.ts |
Unrelated to tmux naming — tracks the agent transcript session_name for node titles (see “Two kinds of session name” below). |
sessionNameFor(nodeId) is the single choke point: `${SESSION_PREFIX}${nodeId}`, i.e. ts-<nodeId>. It is reused by hasSession, new-session, kill-session, tmux capture-pane, and ScrollbackStore.snapshot, so a session can never be addressed under two different names.
Consequences documented in the code:
- Node ids must stay stable. The comment in both modules is explicit: “The node id is the tmux session key — keep it stable.” Renaming the node’s display title does not rename the tmux session.
-
Ids are validated.
TERMINAL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/guards every PTY-facing entry (create,destroy,capturePane,readScrollback, bulk scrollback import/read). Invalid ids are rejected or skipped rather than reaching argv or the filesystem. -
Isolation from the user’s tmux. The app runs on its own socket,
TMUX_SOCKET = 'termsprawl', at<userData>/tmux-sockets/termsprawl.-S <absolute path>is used deliberately instead of-L <name>, because-Lresolves under/tmp/tmux-<uid>/which may not exist and fails silently. -
GUI PATH problem. GUI apps don’t inherit the shell PATH, so
findTmux()walksprocess.env.PATHand then absolute candidates (/usr/bin,/usr/local/bin,/opt/homebrew/bin), returningnullwhen tmux is genuinely absent.
ensureTmuxConfig(userDataPath) creates the socket directory, writes <userData>/tmux.conf, and returns the base argument vector applied to every tmux call:
baseArgs: ['-S', socketPath, '-f', configPath]The generated config disables the status bar (status off), enables mouse and OSC 52 clipboard (set-clipboard on, terminal-features ",*:clipboard"), raises history-limit to 50 000, and sets escape-time 10. The copy-mode binding must be wrapped as send-keys -X copy-pipe-and-cancel: a bare binding puts a tmux 3.4 pane into copy-mode at startup (pane_in_mode=1) and swallows all keyboard input.
sequenceDiagram
autonumber
participant Node as Renderer terminal node
participant PM as PtyManager
participant TM as tmux on termsprawl socket
Node->>PM: create({ id, cwd, shell, env, remote? })
PM->>PM: assertTerminalId(id)
PM->>TM: has-session -t ts-NODEID (2s timeout)
alt session exists
TM-->>PM: exit 0
PM->>PM: fresh = false (warm reattach)
else missing session / no tmux
TM-->>PM: error
PM->>PM: fresh = true (cold start)
end
PM->>PM: kill previous PTY client for the same id
PM->>TM: new-session -A -D -e KEY=VALUE ... -s ts-NODEID -- shell -lc 'exec CMD'
TM-->>PM: pane bytes
PM-->>Node: ptyDataChannel(ts-NODEID)
PM-->>Node: result { id, pid, fresh }
Key nodes in the flow:
-
has-sessionis the oracle forfresh.create()probes before spawning, so the result carriesfresh: falsefor a warm reattach (tmux redraws the pane) orfresh: truefor a cold start. That boolean is the signal the renderer uses to decide whether stored scrollback needs replaying. -
One argv serves both cases.
new-session -A -Dattaches when the session exists and creates it otherwise;-Ddetaches other clients (a second app instance, a bot attach) so the calling terminal owns the pane. -
Launch commands are the pane’s process, not keystrokes. A resolved command is wrapped as
exec <command>and passed to the shell (shell -lc launch), avoiding the race where sent keys are dropped before tmux/ssh has attached and leave a bare shell.preparedCommand(used by agent launch presets) bypasses local resolution entirely; for remote requests the command must resolve on the remote host. -
The previous client is killed first. Remounts reuse stable node ids, so
create()kills any existing local PTY for that id before replacing it — with tmux this only detaches; with the fallback shell it exits. -
Late exits from superseded PTYs are ignored.
onExitreturns early whenthis.sessions.get(id) !== session, so a stale PTY cannot tear down the replacement’s ownership or listeners.
Environment handling at spawn is part of the reattach contract: TMUX and TMUX_PANE are deleted so a reattach launched from inside tmux cannot refuse to nest, and stripAuthEnv removes inherited ANTHROPIC_* / CLAUDE_API_KEY so a managed account’s config dir is the only credential source. req.terminalProfile becomes TERMSPRAWL_TERMINAL_PROFILE; req.httpProxy fans out to the four proxy variables.
stateDiagram-v2
[*] --> Live: create() spawns tmux new-session -A -D
Live --> Detached: PTY client killed
Detached --> Live: create() reattaches with fresh=false
Live --> Gone: destroy() kills tmux session
Detached --> Gone: destroy() kills tmux session
Gone --> [*]
The distinction between “detach” and “destroy” is the whole point of the design:
-
Detach paths — node unmount, app quit (
killAll()), or a supersedingcreate()— kill only the local PTY client.killAll()explicitly “does NOT kill tmux sessions, so terminals keep running and reattach on next launch.” The tmux server, the child process, and the scrollback all survive. -
destroy(id)is permanent and kills the tmux session too. For a remote terminal it kills the local ssh client and callsremoteTmuxKillSessionSync— the remote tmux session remains authoritative. For local tmux it runskill-session -t ts-<id>with a 2 s timeout;isMissingTmuxSessionError(matchingcan't find session:/error connecting to ... (No such file or directory)) is treated as success for idempotent destroys, while real failures are rethrown so project deletion can retry instead of losing ownership.
| State | Meaning |
|---|---|
sessions: Map<string, pty.IPty> |
Live local PTY clients keyed by node id; liveSessionIds() and has() read it. |
projectBySession: Map<string, string> |
Node id → project id, powers sessionIdsForProject() for project-scoped cleanup. |
remoteBySession: Map<string, RemoteHost> |
Remote targets resolved at create time, consumed by destroy/capture. |
destroying: Set<string> |
Suppresses the normal exit-path teardown during an intentional destroy (the exit handler returns early), so destroy() alone owns cleanup. |
tmux: TmuxConfig | null |
Injected or auto-detected; null means fallback-shell mode with no cross-restart continuity. |
ScrollbackStore.timers |
Per-node snapshot intervals; started only for local (non-remote) tmux sessions. |
-
tmux unavailable —
ensureTmuxConfigreturnsnullandcreate()falls back to spawning a plain shell (-lc launch), always reportingfresh: true. Terminals work, but nothing survives a restart. -
Remote terminals differ deliberately.
create()usesssh -tthosting the remote tmux,freshcomes fromremoteTmuxHasSessionSync, and the local cwd is used for the ssh client (asprocess.cwd()) becausereq.cwdis a remote path that may not exist locally.tmux -c <dir>underssh -ttis intentionally omitted: it fails on first tmux-server start due to a chdir race. Local scrollback snapshots are not started for remote sessions — their scrollback lives in the remote tmux, and destroy skips the localscrollback.destroy. -
A command that can’t be resolved produces a notice executed as the pane command (
missingCommandExec(notice)) instead of a silent bare shell; the notice path is skipped whenpreparedCommandis supplied or when the request is remote. -
Command resolution asymmetry — local
req.commandgoes throughresolveCommandLine; remote commands are passed through untouched so they resolve on the host. -
Durable close ordering —
closeTerminalNodestages the removal first (the durable commit point), then removes the node, destroys the terminal, and completes the close. Any failure after staging returns{ committed: true, cleanupPendingIds: [terminalId] }rather than throwing, because the staged state inworkspace.jsonmakes the cleanup retryable. -
Tmux
-epassthrough vs. process env —req.enventries are forwarded verbatim as-e KEY=VALUEto tmux, while the spawned client process receivesstripAuthEnv({ ...process.env, ...req.env }).
Do not confuse the deterministic tmux name with the agent title tracker. sessionNameFor is stable, mechanical, and derived only from the node id. SessionNameTracker (src/core/session-name.ts) reads the agent transcript’s session_name to decide when a node title should change: hook events arrive on every tool call, so reads are throttled per session (default throttleMs: 5_000) and note() returns a name only when it differs from the last reported one — null for unchanged, throttled, or unreadable transcripts. The tracker is fail-open and has no effect on tmux addressing; the test file pins first-sight reporting and the throttled/change-detected contract.
-
Inject a
TmuxConfigvianew PtyManager(platform, tmux?)—undefinedmeans auto-detect,nullmeans force fallback mode. Tests and embedded scenarios can pin a socket/config. -
preparedCommandoncreate()— pre-resolved launch command that skips resolution and the unresolved-command notice; used by launch presets. -
TmuxConfig.baseArgs— every helper and the scrollback snapshot prefix it, so new flags (-f, socket changes) are added in one place. -
Constants
TMUX_SOCKET/SESSION_PREFIX— the naming namespace is centralized intmux.ts. -
Scrollback sync hooks —
readScrollbacks(ids)/importScrollback(map)onPtyManagerexpose the cold-replay store to bundle/snapshot flows while reusingTERMINAL_ID_PATTERNvalidation. -
SessionNameTrackerOptions.throttleMs— tunes transcript polling cadence independently of tmux behavior.
Sources: src/core/tmux.ts, src/core/pty-manager.ts, src/core/terminal-close.ts, src/core/scrollback-store.ts, src/core/session-name.ts, src/core/session-name.test.ts
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