Terminal
The half of a persistent worker terminal that is a string — the dtach scripts, the session layout, and the parsing of what they print back.
Why it exists
A worker's terminal is a docker exec attached to a session that outlives it, so that closing a
browser tab does not kill a build. The attaching is I/O and stays with whoever owns the Docker
socket. Everything else — the shell that gets attached, the layout it writes into, and the parsing
of what it prints back — is a string, and lives here.
dtach, not tmux. tmux's only knob for "the wheel scrolls history" is mouse on, which also makes
it swallow every drag — the frontend then has to forge Shift-drags to get a native selection back.
dtach does only persistence: it owns a pty, lets clients attach and detach, and forwards every byte,
mouse included. The terminal emulator keeps the mouse, its own scrollback and its own search.
What dtach does not do is remember the screen. So the session is recorded inside the container
with script(1), and the tail of that recording is replayed just before attaching:
docker exec ──> sh ──> [replay: tail of the log] ──> exec dtach -A ──> script -f log ──> $SHELL
│
persistent masterRecording inside the container rather than in a control-plane buffer is what makes the history survive a control-plane restart with nothing permanently attached.
import { sanitizeSessionName, buildDtachCommand, parseSessionList } from "@spunto/build/terminal"
const name = sanitizeSessionName(fromQuery) // every name MUST come through this
await exec(container, ["/bin/sh", "-c", buildDtachCommand()], { env: { MP_TERM_SESSION: name } })sanitizeSessionName is a security boundary
Session names are interpolated into the shell scripts this module builds. A name that has not
been through sanitizeSessionName is a command injection into someone's container. This is
not tidiness — sanitize at the boundary, every time, before calling any builder below.
Layout and limits
TERMINAL_DIR
const TERMINAL_DIR = "/tmp/spunto-term"Where every session's socket (<name>.sock) and recording (<name>.log) live — one directory, two
files per session, created with mode 700. The name deliberately avoids the substring dtach: the
listing and kill scripts match processes by socket path, and a path containing dtach would make
every helper's own shell look like a dtach process to its own grep.
Replay constants
const REPLAY_BYTES = 262144 // 256 KB of recording replayed on attach
const REPLAY_MIN_BYTES = 512 // below this, nothing is replayed
const MAX_LOG_BYTES = 4194304 // above this (4 MB), the recording is trimmed on the next attach…
const TRIM_TO = 1048576 // …down to its last 1 MBREPLAY_MIN_BYTES exists because a session created from a "+" button has already recorded its
prompt, and replaying that would greet the user with a replay banner on a terminal they just
opened; the attach redraw paints the prompt anyway.
Building the scripts
sanitizeSessionName(name)
function sanitizeSessionName(name: string | undefined): stringRestricts a name to [A-Za-z0-9_-] — both what a socket filename can hold without quoting and what
the listing script's awk can match on. Every other character becomes -, leading and trailing
dashes are trimmed, the result is capped at 40 characters, and an empty result (or undefined)
becomes main.
sanitizeSessionName("my-build_2") // → "my-build_2"
sanitizeSessionName(undefined) // → "main"
sanitizeSessionName("x; rm -rf /") // → "x--rm--rf"buildDtachCommand()
function buildDtachCommand(): stringThe shell to run in the attaching docker exec (with a TTY). It reads the session name from the
MP_TERM_SESSION environment variable — not from an argument — and then:
- picks the user's login shell (
getent passwd, falling back to/bin/bash); - if
dtachis missing, prints a yellow notice and degrades to a plain, non-persistent login shell; - treats a socket no process holds any more as dead, and drops it and its recording, so an old log is never replayed into a brand-new shell;
- for a live session, trims the recording above
MAX_LOG_BYTES, and when it is overREPLAY_MIN_BYTES, resets the terminal modes and prints a dim── replay ──banner followed by the lastREPLAY_BYTES; exec dtach -Aon the socket with-r winch -E -z, wrapping the shell inscript -q -a -f <log>whenscriptis available.
const session = sanitizeSessionName(req.query.session)
const execInstance = await container.exec({
Cmd: ["/bin/sh", "-c", buildDtachCommand()],
Env: [`MP_TERM_SESSION=${session}`],
AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true,
User: "vscode",
})makeAttachClearStripper(windowMs?, now?)
function makeAttachClearStripper(windowMs?: number, now?: () => number): (chunk: Uint8Array) => Uint8Arraydtach clears the screen as soon as a client attaches — a bare ESC[H ESC[J. That write lands
after the replay the attach script just printed, and wipes exactly what the replay exists to show.
The returned filter drops that sequence once, and only within windowMs (default 5000) of
creation — long enough to cover dtach's write, short enough that a program legitimately clearing its
screen later is never touched. (clear emits different bytes, so it would not match anyway.)
Create one per attach and pass every output chunk through it. Uint8Array in and out, so Node
Buffer chunks pass straight through.
const strip = makeAttachClearStripper()
stream.on("data", (chunk: Buffer) => ws.send(strip(chunk)))buildCreateSessionCommand(session)
function buildCreateSessionCommand(session: string): stringShell that creates a session without attaching (dtach -n, daemonized, stdio redirected so the
master does not hold the exec's stdout open). No-op if dtach is missing or the socket already
exists. session is interpolated: it must already have been through sanitizeSessionName.
buildKillSessionCommand(session)
function buildKillSessionCommand(session: string): stringShell that kills a session: SIGTERM to every dtach process holding its socket (master and any
client), then removes the socket and the recording. Always exits 0. session must already have
been through sanitizeSessionName.
const session = sanitizeSessionName(params.name)
await execCapture(container, ["/bin/sh", "-c", buildKillSessionCommand(session)])Listing sessions
LIST_SESSIONS_SCRIPT
const LIST_SESSIONS_SCRIPT: stringA shell script that prints one line per session socket in TERMINAL_DIR:
<name>|1|0|<created, epoch seconds>|<foreground command>|<title>A dtach session is a socket file, nothing more — there is no server to ask. The name and creation
date come from the socket; the foreground command is recovered from the process table by walking
down from the dtach master to the deepest descendant in its pty's foreground process group (what tmux
calls pane_current_command). The title is the last OSC 0/2 sequence found in the tail of the
recording — best effort: a container whose grep lacks -a simply yields no title.
TerminalSession
Prop
Type
parseSessionList(output, isAttached)
function parseSessionList(output: string, isAttached: (session: string) => boolean): TerminalSession[]Parses what LIST_SESSIONS_SCRIPT printed. Kept separate from running it — executing a command in a
container is the caller's business — which is what lets the parse be tested without a container. The
title is the last field and keeps any | it contains; lines with an empty name are dropped.
const output = await execCapture(container, ["/bin/sh", "-c", LIST_SESSIONS_SCRIPT])
const sessions = parseSessionList(output, (name) => openSockets.has(`${workerId}/${name}`))
parseSessionList("build|1|0|1700000000|npm|Building the thing", () => false)
// → [{ name: "build", windows: 1, attached: false, createdAt: 1700000000000,
// command: "npm", title: "Building the thing" }]sanitizeReportedTitle(raw, placeholders?)
function sanitizeReportedTitle(raw: string | undefined, placeholders?: string[]): stringNormalises a title a program reported: control characters replaced (a stray BEL or ESC would
otherwise land in a JSON payload and then a React tree), whitespace collapsed, trimmed, capped at 120
characters. Returns "" when nothing is left, or when the title equals one of placeholders —
values that mean "nobody reported anything", such as the container hostname tmux seeds its pane
title with. parseSessionList already applies it (with no placeholders).
sanitizeReportedTitle("vim\u0007 main.ts") // → "vim main.ts"
sanitizeReportedTitle("a1b2c3d4e5f6", [hostname]) // → "" when hostname === "a1b2c3d4e5f6"