-
Notifications
You must be signed in to change notification settings - Fork 0
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.
| 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).
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:
-
Node id — renderer/canvas identity, stable in
workspace.json. -
tmux session —
ts-<nodeId>on a dedicated socket (termsprawl), owned by the tmux server, not by the app. -
pty.IPtyclient — a short-livednode-ptychild process that either hosts local tmux (tmux -S <socket> ... new-session -A -D) or hostsssh -ttfor remote projects.
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.
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().
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']-
stripAuthEnvdrops inheritedANTHROPIC_*/CLAUDE_API_KEYcredentials so a managed account's config dir is the only credential source. -
TMUX/TMUX_PANEare removed so a reattach inside tmux cannot refuse. - The PTY is always spawned as
xterm-256colorwith the requestedcols/rows.
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.
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
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.
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:
-
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. -
Destroy guard (
destroying.has(id)→ return).destroy()kills the PTY itself and then does its own bookkeeping; the exit callback must not race it.
Terminal sessions have three different "endings", and conflating them would break either continuity or file consistency.
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:
isMissingTmuxSessionErrortreats 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.
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.
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.
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 --> [*]
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.
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 whenthis.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.
-
freshis probed, not guessed: it is computed byhasSession/remoteTmuxHasSessionSyncimmediately before spawn. -
Durable close is always
committed:closeTerminalNodenever throws after staging.
-
Invalid ids fail fast with
Invalid terminal id: …; ids must start alphanumeric and be at most 128 characters. The same pattern gatesimportScrollback()/readScrollbacks(), where invalid entries are skipped rather than aborting the whole batch. -
tmux missing (
ensureTmuxConfigreturnsnullafterfindTmux()scansPATHthen/usr/bin,/usr/local/bin,/opt/homebrew/bin): terminals still work as plain shells, butfreshis alwaystrue,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 shellPATH. 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 assend-keys -X copy-pipe-and-cancel— thesend-keys -Xwrapper is required because on tmux 3.4 a barecopy-pipe-and-cancelbinding puts the pane into copy mode at startup (pane_in_mode=1) and swallows all keyboard input. -
has-sessioncost:hasSession()runsexecFileSyncwithstdio: 'ignore'and a 2000 ms timeout, returningfalseon any failure. -
destroy()timeouts:kill-sessionuses a 2000 ms timeout;capturePaneuses 3000 ms and returnsnullon any failure (missing session, no tmux), never throwing. -
Remote cwd race: documented inline — remote project cwd is intentionally not forwarded to
tmux -cunderssh -tt; the remote shell starts in the remote home directory. -
Auth env inheritance:
stripAuthEnvis applied even thoughreq.envis merged on top, so managed-account isolation cannot be bypassed by ambient environment. -
tmux nesting:
TMUXandTMUX_PANEare always deleted from the child env so a reattach cannot be refused.
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 viatmux capture-pane -p -S -200 -t ts-<id>on the termsprawl socket, orremoteTmuxCaptureSyncover ssh;nullwhen the session is gone or tmux is unavailable. Backs/peekand/attach. -
sessionIdsForProject(projectId)— reverse lookup overprojectBySession. -
readScrollback(id)/readScrollbacks(ids)/importScrollback(record)— the scrollback surface used by cold replay and by the D1/D2 cloud-sync paths. -
isWarm(id)andlocalTmux()— pre-flight checks that answer "wouldcreate()be a warm reattach?" without spawning. -
hasLiveSession(id)/has(id)— local client presence, distinct from tmux session presence.
-
preparedCommandoncreate()— lets a caller pre-resolve or fully override the launch command, bypassingresolveCommandLine/unresolvedNotice. This is the seam for agent launch and CLI probing paths. -
tmux?: TmuxConfig | nullconstructor parameter —undefinedtriggersensureTmuxConfig(userDataPath);nulldisables 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 insrc/main/index.ts;src/core/must never importsrc/main/orsrc/renderer/. -
Push-channel helpers —
ptyDataChannel(sessionId)(andptyExitChannel) 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 atpty:exit:<id>. -
closeTerminalNode's store interface — typed asPick<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 thereq.remotebranches increate(),destroy(), andcapturePane(). -
ScrollbackStore— the byte-capped persistence seam behind cold-start replay and the D1/D2 sync hooks.
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
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