Skip to content

PTY Lifecycle & Terminal Sessions

dazeb edited this page Sep 17, 2026 · 2 revisions

PTY Lifecycle & Terminal Sessions

This page covers the main-process side of terminal nodes: how a node id becomes a real OS process, how that process is kept alive across app restarts by tmux, how data and exit events reach the renderer, and how sessions are torn down (or deliberately not torn down) when a node closes, a project is deleted, or the app quits.

The subsystem is deliberately Electron-free. PtyManager talks to the outside world only through a CorePlatform seam, so the same manager can be hosted by the Electron main process or the Server Edition.


1. Key files

File Responsibility
src/core/pty-manager.ts PtyManager: spawn/attach, per-session bookkeeping, event routing, write/resize/destroy, capturePane, scrollback surface, killAll.
src/core/tmux.ts Pure tmux helpers: tmux binary discovery, session naming, socket + generated config, has-session probe. No Electron.
src/core/terminal-close.ts closeTerminalNode(): the durable, retryable node-close sequence that couples workspace persistence with PTY destruction.
src/shared/ipc.ts Channel names (ptyCreate, ptyWrite, ptyResize, ptyDestroy, terminalClose, ptyReadScrollback) and the per-session push-channel builders (ptyDataChannel).
src/main/index.ts Concrete CorePlatform implementation (broadcast + userDataPath); per src/main/AGENTS.md, main must never be imported by src/core/ or src/renderer/.
src/renderer/src/nodes/TerminalNode.tsx Renderer consumer of the streams; xterm.js binding is covered on the terminal-node rendering page.

Collaborating modules whose interfaces are visible through PtyManager's imports: command-resolver (resolveCommandLine, missingCommandExec, unresolvedNotice), agent-accounts (stripAuthEnv), remote-pty (remoteTmuxSpawnArgv, remoteTmuxHasSessionSync, remoteTmuxKillSessionSync, remoteTmuxCaptureSync), ssh (RemoteHost), and scrollback-store (ScrollbackStore).


2. Session identity: one node id, three layers

A terminal is addressed everywhere by a single terminal id, which is also its node id. That id is validated on every entry point that can reach the OS:

const TERMINAL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/

Validation runs in create(), destroy(), capturePane(), readScrollback(), and (per-entry, skipping rather than failing) in importScrollback() / readScrollbacks().

The id is mapped onto tmux deterministically so reattach is automatic:

export function sessionNameFor(nodeId: string): string {
  return `${SESSION_PREFIX}${nodeId}`   // SESSION_PREFIX = 'ts-'
}

Because the node id is the tmux session key, it must stay stable across app runs. The layers are:

  1. Node id — renderer/canvas identity, stable in workspace.json.
  2. tmux session — ts-<nodeId> on a dedicated socket (termsprawl), owned by the tmux server, not by the app.
  3. pty.IPty client — a short-lived node-pty child process that either hosts local tmux (tmux -S <socket> ... new-session -A -D) or hosts ssh -tt for remote projects.

3. Spawning: PtyManager.create()

3.1 Command construction

const shell = req.shell ?? process.env.SHELL ?? '/bin/bash'
const cwd   = req.remote ? process.cwd() : req.cwd ?? process.cwd()
const sessionName = sessionNameFor(req.id)

const command = preparedCommand ?? (req.command
  ? req.remote ? req.command : resolveCommandLine(req.command)
  : undefined)
const notice = !preparedCommand && req.command && !req.remote ? unresolvedNotice(req.command) : null
const launch = command ? notice ? missingCommandExec(notice) : `exec ${command}` : undefined
const sessionCommand = launch ? [shell, '-lc', launch] : [shell]

Design points:

  • Launch presets are passed as the pane's process, not as keystrokes typed before tmux/ssh is attached — keystrokes can be dropped, leaving a bare shell.
  • Remote commands are never resolved locally: they must resolve on the remote host.
  • If the local command cannot be resolved, missingCommandExec(notice) becomes the pane command so the user sees an explanation instead of a bare shell.

3.2 Three spawn modes

create() chooses one of three shapes and reports fresh accordingly:

Mode Condition Spawn target fresh
Remote req.remote set ssh + remoteTmuxSpawnArgv(...) !remoteTmuxHasSessionSync(host, sessionName)
Local tmux this.tmux non-null tmuxPath + [...baseArgs, 'new-session', '-A', '-D', ...] !hasSession(this.tmux, req.id)
Fallback shell tmux unavailable shell with ['-lc', launch] or [] always true

fresh: false means warm reattach — tmux already had the session and will redraw it. fresh: true means cold start (the caller uses the stored scrollback to replay prior output).

The local tmux argv carries per-request environment into the pane itself:

spawnArgs = [
  ...this.tmux.baseArgs,
  'new-session', '-A', '-D',
  ...Object.entries(req.env ?? {}).flatMap(([key, value]) => ['-e', `${key}=${value}`]),
  '-s', sessionName,
  '--', ...sessionCommand
]

-A attaches when the session exists, -D detaches any other attached client, so remounting a node always ends with exactly one live pane.

For remote projects cwd is deliberately not forwarded: tmux -c <dir> under ssh -tt hits a chdir race on first tmux-server start, so the remote shell starts in the remote user's home and the local ssh client runs in process.cwd().

3.3 Environment hygiene

const env = stripAuthEnv({ ...process.env, ...req.env }) as Record<string, string>
if (req.terminalProfile) env.TERMSPRAWL_TERMINAL_PROFILE = req.terminalProfile
if (req.httpProxy) { HTTP_PROXY / HTTPS_PROXY / http_proxy / https_proxy = req.httpProxy }
delete env['TMUX']
delete env['TMUX_PANE']
  • stripAuthEnv drops inherited ANTHROPIC_* / CLAUDE_API_KEY credentials so a managed account's config dir is the only credential source.
  • TMUX / TMUX_PANE are removed so a reattach inside tmux cannot refuse.
  • The PTY is always spawned as xterm-256color with the requested cols/rows.

3.4 Replacement of an existing session

Remounts reuse stable node ids, so create() may be called for an id that already has a live client:

const existing = this.sessions.get(req.id)
if (existing) existing.kill()

With tmux this only detaches the old client; the tmux session (and pane) survives. With fallback shells the old process actually exits.

3.5 Flow

sequenceDiagram
    autonumber
    participant R as Renderer (TerminalNode)
    participant P as PtyManager.create()
    participant T as tmux helpers
    participant N as node-pty

    R->>P: pty:create(req)
    P->>P: assertTerminalId(req.id)
    P->>P: build shell / cwd / command / env
    alt req.remote
        P->>T: remoteTmuxHasSessionSync → fresh
        P->>N: spawn('ssh', remoteTmuxSpawnArgv(...))
    else local tmux available
        P->>T: hasSession() → fresh
        P->>N: spawn(tmux, [baseArgs, new-session -A -D -e ... -s ts-<id> -- cmd])
    else no tmux
        P->>N: spawn(shell, ['-lc', launch] or [])
    end
    N-->>P: IPty (pid)
    P->>P: sessions.set / projectBySession / remoteBySession
    P->>T: scrollback.start() (local tmux only)
    P-->>R: { id, pid, fresh }
    N-->>R: onData → broadcast pty:data:<id>
    N-->>R: onExit → broadcast pty:exit:<id> + bookkeeping
Loading

Key nodes: the has-session probe happens before spawn, which is what makes fresh meaningful; the -A flag is what turns "spawn" into "attach or create"; and the sessions.get(id) === session ownership check (next section) is what makes the subsequent event wiring safe under id reuse.


4. Event routing and the ownership rule

Both listeners guard against a stale PTY from a previous create() for the same id:

session.onData((data) => {
  if (this.sessions.get(req.id) === session) {
    this.platform.broadcast(ptyDataChannel(req.id), data)
  }
})

session.onExit(({ exitCode, signal }) => {
  const current = this.sessions.get(req.id)
  if (current && current !== session) return          // superseded — stay silent
  const info: PtyExitInfo = { id: req.id, exitCode, signal }
  this.platform.broadcast(ptyExitChannel(req.id), info)
  if (this.destroying.has(req.id)) return             // destroy() owns cleanup
  this.sessions.delete(req.id)
  this.projectBySession.delete(req.id)
  this.remoteBySession.delete(req.id)
  if (!req.remote) this.scrollback.stop(req.id, this.tmux ?? undefined)
})

Channels are per-session pushes built from the shared constants:

export function ptyDataChannel(sessionId: string): string {
  return `${IPC.ptyData}:${sessionId}`
}

IPC.ptyData is 'pty:data' and IPC.ptyExit is 'pty:exit'; commands travel on the plain channels pty:create, pty:write, pty:resize, pty:destroy, terminal:close, pty:read-scrollback.

Two independent guards matter:

  1. Supersession guard (current !== session → return). A late exit from an old client must not tear down the replacement's ownership or listeners, and must not emit a spurious exit event to the renderer.
  2. Destroy guard (destroying.has(id) → return). destroy() kills the PTY itself and then does its own bookkeeping; the exit callback must not race it.

5. Teardown: three distinct paths

Terminal sessions have three different "endings", and conflating them would break either continuity or file consistency.

5.1 destroy(id) — permanent

destroy(id: string): void {
  assertTerminalId(id)
  const session = this.sessions.get(id)
  this.destroying.add(id)
  const remote = this.remoteBySession.get(id)
  if (remote) {
    try { session?.kill() } catch { /* The remote tmux session remains authoritative. */ }
    remoteTmuxKillSessionSync(remote, sessionNameFor(id))
  } else if (this.tmux) {
    try { session?.kill() } catch { /* The tmux session remains authoritative. */ }
    try {
      execFileSync(this.tmux.tmuxPath, [...this.tmux.baseArgs, 'kill-session', '-t', sessionNameFor(id)], {
        stdio: ['ignore', 'ignore', 'pipe'], timeout: 2000
      })
    } catch (error) {
      if (!isMissingTmuxSessionError(error)) throw error
    }
  } else if (session) {
    session.kill()
  }
  this.sessions.delete(id)
  this.projectBySession.delete(id)
  this.remoteBySession.delete(id)
  if (!remote) this.scrollback.destroy(id)
  this.destroying.delete(id)
}
  • Idempotency is explicit: isMissingTmuxSessionError treats tmux's "can't find session:" and "error connecting to … (No such file or directory)" as success.
  • A real tmux failure is rethrown before the map cleanup, so ownership is preserved and project deletion can be retried from the durable state.
  • Local scrollback snapshots are destroyed; remote scrollback lives in the remote tmux and is left alone.

5.2 killAll() — detach on quit

killAll(): void {
  for (const session of this.sessions.values()) session.kill()
  this.sessions.clear()
  this.projectBySession.clear()
  this.remoteBySession.clear()
  this.destroying.clear()
  if (this.tmux) this.scrollback.stopAll(this.tmux)
}

Deliberately does not kill tmux sessions. Terminals keep running and reattach on next launch (fresh: false). This is the app-quit path.

5.3 closeTerminalNode() — durable node close

export function closeTerminalNode(
  store: TerminalCloseStore,
  destroyTerminal: (id: string) => void,
  projectId: string,
  terminalId: string
): DurableCleanupResult {
  store.stageTerminalNodeClose(projectId, terminalId)
  try {
    store.removeTerminalNode(projectId, terminalId)
    destroyTerminal(terminalId)
    store.completeTerminalNodeClose(projectId, terminalId)
    return { committed: true, cleanupPendingIds: [] }
  } catch {
    return { committed: true, cleanupPendingIds: [terminalId] }
  }
}

stageTerminalNodeClose is the durable commit point. Once staging succeeds the result is always committed: true; a failure in any later step (node removal, PTY destruction, or completion) is reported through cleanupPendingIds instead of throwing, because the intent is already recoverable from workspace.json. This is the path behind the terminal:close IPC channel, and it is what keeps node bookkeeping and live PTYs from diverging.

5.4 Lifecycle state diagram

stateDiagram-v2
    [*] --> NoTmuxSession
    NoTmuxSession --> ColdStart: hasSession()=false → fresh:true
    TmuxAlive --> WarmAttach: hasSession()=true → fresh:false
    ColdStart --> TmuxAlive: tmux new-session -A -D
    WarmAttach --> TmuxAlive: tmux new-session -A -D (attach)
    TmuxAlive --> Detached: killAll() / app quit (client killed, tmux kept)
    Detached --> WarmAttach: next create() for same id
    TmuxAlive --> Gone: destroy() → kill-session
    Detached --> Gone: destroy() → kill-session
    TmuxAlive --> Gone: natural exit (onExit clears maps)
    Gone --> [*]
Loading

The TmuxAlive → Detached → WarmAttach loop is the continuity guarantee: killAll() only kills the node-pty clients, and the next create() for the same node id finds the tmux session and reports fresh: false, so the renderer replays scrollback only on a cold start.


6. Key state and invariants

PtyManager holds five pieces of state:

Field Key → Value Meaning
sessions id → pty.IPty The live local client. Presence = has(id) / liveSessionIds().
projectBySession id → projectId Project ownership; drives sessionIdsForProject() for project-scoped teardown.
remoteBySession id → RemoteHost Selects remote vs local kill/capture/scrollback behaviour.
destroying Set of ids Suppresses exit-callback cleanup while destroy() owns the session.
scrollback ScrollbackStore Local-tmux-scoped snapshots (cold-start replay, D1 import, D2 export).

Invariants that hold across the module:

  • Ownership gate: a PTY may only broadcast or clean up when sessions.get(id) === session.
  • Scrollback scope: scrollback.start() only runs when this.tmux && !req.remote; remote scrollback is the remote tmux's business.
  • Remote authority: for remote sessions the remote tmux session is authoritative — local kill failures are swallowed, and remote scrollback is never destroyed locally.
  • fresh is probed, not guessed: it is computed by hasSession / remoteTmuxHasSessionSync immediately before spawn.
  • Durable close is always committed: closeTerminalNode never throws after staging.

7. Boundary conditions and failure handling

  • Invalid ids fail fast with Invalid terminal id: …; ids must start alphanumeric and be at most 128 characters. The same pattern gates importScrollback() / readScrollbacks(), where invalid entries are skipped rather than aborting the whole batch.
  • tmux missing (ensureTmuxConfig returns null after findTmux() scans PATH then /usr/bin, /usr/local/bin, /opt/homebrew/bin): terminals still work as plain shells, but fresh is always true, killAll() grants no continuity, and there is no local scrollback.
  • tmux discovery in a GUI app: findTmux() resolves an absolute path because GUI processes do not inherit the user's shell PATH. The socket is passed as an absolute path (-S) rather than a name (-L, which is relative to /tmp/tmux-<uid>/ and can fail silently).
  • Generated tmux config: the dedicated socket gets status off, mouse on, history-limit 50000, set-clipboard on, terminal-features ",*:clipboard", escape-time 10, and copy-mode drag bindings written as send-keys -X copy-pipe-and-cancel — the send-keys -X wrapper is required because on tmux 3.4 a bare copy-pipe-and-cancel binding puts the pane into copy mode at startup (pane_in_mode=1) and swallows all keyboard input.
  • has-session cost: hasSession() runs execFileSync with stdio: 'ignore' and a 2000 ms timeout, returning false on any failure.
  • destroy() timeouts: kill-session uses a 2000 ms timeout; capturePane uses 3000 ms and returns null on any failure (missing session, no tmux), never throwing.
  • Remote cwd race: documented inline — remote project cwd is intentionally not forwarded to tmux -c under ssh -tt; the remote shell starts in the remote home directory.
  • Auth env inheritance: stripAuthEnv is applied even though req.env is merged on top, so managed-account isolation cannot be bypassed by ambient environment.
  • tmux nesting: TMUX and TMUX_PANE are always deleted from the child env so a reattach cannot be refused.

8. Auxiliary read surface (other consumers of the same sessions)

PtyManager exposes read-only helpers used by non-canvas consumers (notably the Telegram bot):

  • liveSessionIds() — all live session ids, i.e. terminal node ids.
  • capturePane(id) — recent pane output via tmux capture-pane -p -S -200 -t ts-<id> on the termsprawl socket, or remoteTmuxCaptureSync over ssh; null when the session is gone or tmux is unavailable. Backs /peek and /attach.
  • sessionIdsForProject(projectId) — reverse lookup over projectBySession.
  • readScrollback(id) / readScrollbacks(ids) / importScrollback(record) — the scrollback surface used by cold replay and by the D1/D2 cloud-sync paths.
  • isWarm(id) and localTmux() — pre-flight checks that answer "would create() be a warm reattach?" without spawning.
  • hasLiveSession(id) / has(id) — local client presence, distinct from tmux session presence.

9. Extension points

  • preparedCommand on create() — lets a caller pre-resolve or fully override the launch command, bypassing resolveCommandLine / unresolvedNotice. This is the seam for agent launch and CLI probing paths.
  • tmux?: TmuxConfig | null constructor parameter — undefined triggers ensureTmuxConfig(userDataPath); null disables tmux entirely (fallback-shell mode, useful for tests).
  • CorePlatform — the only external dependency (broadcast, userDataPath), so the manager is host-agnostic. The Electron-side implementation lives in src/main/index.ts; src/core/ must never import src/main/ or src/renderer/.
  • Push-channel helpers — ptyDataChannel(sessionId) (and ptyExitChannel) centralize the '<channel>:<sessionId>' convention; a new per-session stream should follow it rather than hardcoding channel strings.
  • PtyExitInfo — the exit payload ({ id, exitCode, signal }) broadcast at pty:exit:<id>.
  • closeTerminalNode's store interface — typed as Pick<WorkspaceStore, 'stageTerminalNodeClose' | 'removeTerminalNode' | 'completeTerminalNodeClose'>, so any store implementing those three methods can drive the durable close path.
  • RemoteHost / remote-pty — the remote transport seam; swapping or extending remote behaviour is localized to the req.remote branches in create(), destroy(), and capturePane().
  • ScrollbackStore — the byte-capped persistence seam behind cold-start replay and the D1/D2 sync hooks.

10. Summary of the call chain

renderer (TerminalNode)
  └─ IPC pty:create ─────► PtyManager.create()
                            ├─ assertTerminalId
                            ├─ resolve command + build env
                            ├─ hasSession / remoteTmuxHasSessionSync  → fresh
                            ├─ pty.spawn(tmux | ssh | shell)
                            ├─ sessions / projectBySession / remoteBySession
                            └─ scrollback.start (local tmux only)
  ◄─ pty:data:<id> ─────── session.onData  (ownership-gated)
  ◄─ pty:exit:<id> ─────── session.onExit  (supersede + destroying guards)

renderer (close node)
  └─ IPC terminal:close ──► closeTerminalNode(store, destroyTerminal, projectId, id)
                            ├─ stageTerminalNodeClose     ← durable commit point
                            ├─ removeTerminalNode
                            ├─ PtyManager.destroy(id)     → kill-session / remoteTmuxKillSessionSync
                            └─ completeTerminalNodeClose

app quit
  └─ PtyManager.killAll()  → clients killed, tmux sessions preserved

Sources: src/core/pty-manager.ts, src/core/tmux.ts, src/core/terminal-close.ts, src/shared/ipc.ts, src/main/AGENTS.md

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