Skip to content

tmux Session Naming & Reattach

dazeb edited this page Sep 17, 2026 · 2 revisions

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.

Module responsibilities

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).

Deterministic naming

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 -L resolves under /tmp/tmux-<uid>/ which may not exist and fails silently.
  • GUI PATH problem. GUI apps don’t inherit the shell PATH, so findTmux() walks process.env.PATH and then absolute candidates (/usr/bin, /usr/local/bin, /opt/homebrew/bin), returning null when 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.

Call chain: create → (re)attach

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 }
Loading

Key nodes in the flow:

  • has-session is the oracle for fresh. create() probes before spawning, so the result carries fresh: false for a warm reattach (tmux redraws the pane) or fresh: true for 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 -D attaches when the session exists and creates it otherwise; -D detaches 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. onExit returns early when this.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.

Session lifecycle

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 --> [*]
Loading

The distinction between “detach” and “destroy” is the whole point of the design:

  • Detach paths — node unmount, app quit (killAll()), or a superseding create() — 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 calls remoteTmuxKillSessionSync — the remote tmux session remains authoritative. For local tmux it runs kill-session -t ts-<id> with a 2 s timeout; isMissingTmuxSessionError (matching can'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.

Key state

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.

Boundary conditions

  • tmux unavailable — ensureTmuxConfig returns null and create() falls back to spawning a plain shell (-lc launch), always reporting fresh: true. Terminals work, but nothing survives a restart.
  • Remote terminals differ deliberately. create() uses ssh -tt hosting the remote tmux, fresh comes from remoteTmuxHasSessionSync, and the local cwd is used for the ssh client (as process.cwd()) because req.cwd is a remote path that may not exist locally. tmux -c <dir> under ssh -tt is 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 local scrollback.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 when preparedCommand is supplied or when the request is remote.
  • Command resolution asymmetry — local req.command goes through resolveCommandLine; remote commands are passed through untouched so they resolve on the host.
  • Durable close ordering — closeTerminalNode stages 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 in workspace.json makes the cleanup retryable.
  • Tmux -e passthrough vs. process env — req.env entries are forwarded verbatim as -e KEY=VALUE to tmux, while the spawned client process receives stripAuthEnv({ ...process.env, ...req.env }).

Two kinds of “session name”

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.

Extension points

  • Inject a TmuxConfig via new PtyManager(platform, tmux?) — undefined means auto-detect, null means force fallback mode. Tests and embedded scenarios can pin a socket/config.
  • preparedCommand on create() — 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 in tmux.ts.
  • Scrollback sync hooks — readScrollbacks(ids) / importScrollback(map) on PtyManager expose the cold-replay store to bundle/snapshot flows while reusing TERMINAL_ID_PATTERN validation.
  • 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

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally