PTY session host
ptyhost spawns a command under a real pseudo-terminal and is the single authority for late-joiner screen snapshots.
ptyhost is the OSS PTY session host for interactive-attach-v1. A Session
spawns a command under a pseudo-terminal, owns the master file descriptor,
feeds every read chunk through a headless terminal emulator, and publishes
sequence-bearing frames (Output, applied-Resize echo, Marker, Snapshot,
Exit) to a bounded ring buffer and any number of live subscriptions.
The package is transport-free by construction: it opens no inbound
listener of any kind - no sockets, no HTTP or TLS servers. The only attach
surface it exposes directly is AttachLocal, an in-process viewer/driver used
for the OSS-standalone case. Everything else - carrying frames to a relay - is
attachclient's job.
Spawning a session
sess, err := ptyhost.Spawn(ptyhost.Spec{
Command: []string{"bash"},
Cols: 120,
Rows: 40,
})
if err != nil {
// ...
}
defer sess.Stop(ctx)Spec fields all have documented defaults - Cols/Rows fall back to 80x24,
RingBytes to an 8 MiB output-frame ring, Scrollback to a 200-line VT tail.
Epoch stamps the host stream epoch (the value itself is minted externally,
by whatever control plane issues attach tokens) into every serialized
snapshot; RecordPath, if set, writes a parallel asciinema v2 cast sharing the
wire's rel_time epoch anchor.
A Session is safe for concurrent use. It exposes:
| Method | Purpose |
|---|---|
WriteInput(p []byte) (int, error) | write bytes to the PTY stdin sink |
Resize(cols, rows, pxWidth, pxHeight uint32) error | apply caller-validated geometry to the PTY and headless VT |
Snapshot() (attachwire.Screen, attachwire.HostSeq, error) | serialize the current screen without emitting a frame |
EmitSnapshot() (attachwire.Frame, bool, error) | answer a snapshot_request, pre- or post-Exit |
EmitMarker(label string) error | append a seq-bearing annotation frame |
Subscribe(fromSeq attachwire.HostSeq) (agent.InteractiveSubscription, error) | a live feed of host-produced frames from fromSeq+1 |
Done() <-chan struct{} | closed once the child has exited and drained |
Exit() (attachwire.ExitPayload, bool) | the terminal exit payload, once Done |
Stop(ctx context.Context) error | terminate the process group (SIGTERM, then SIGKILL after a grace period) |
Validate geometry before Resize
The Unix winsize fields are 16-bit. Keep cols and rows in 1...65535, and
pixel dimensions in 0...65535, before calling Resize. v0.52.1 checks only
that columns and rows are nonzero; larger values are narrowed for
TIOCSWINSZ while the headless VT receives the original dimensions. For
example, cols = 65536 becomes zero at the kernel PTY but remains 65536 in
the VT and applied-Resize echo. Do not pass untrusted wire values through
without this bound.
Snapshot authority and terminal queries
The host-side headless VT (built on charmbracelet/x/vt, behind a small
internal interface so it stays swappable) is the single authority for
"what does the screen look like right now" - neither a relay nor a viewer
computes it independently. It also answers, locally and directly to the PTY
master, any query a real terminal would answer on its own input: cursor
position reports (CSI 6n), device attributes, and other query sequences.
In shipped v0.52.1, that local reply does not remove the query from the
host stream. Session.onOutput first feeds the read chunk to the VT, then
publishes the same original bytes as raw Output; reply-triggering query and
control sequences therefore can traverse the host→relay leg. The security
boundary is viewer-bound output: before display, a stateful
attachwire/sanitize pass strips those triggers, and the viewer remains a
display-only mirror that never replies. Without the host's local answer, a TUI
that probes its own terminal (which most full-screen TUIs do at startup) would
hang waiting for a reply that never comes.
Concurrency
The VT is fed from a single goroutine (the PTY read loop), and every snapshot is taken under the same mutex that guards feeding - so a snapshot is always consistent with the frames already published. Host frame sequence allocation, ring/subscription fan-out, and the parallel recorder are all serialized under that one mutex, which makes frame ordering correct by construction rather than something callers have to coordinate.
Standalone local attach (no relay)
la, err := sess.AttachLocal(ptyhost.LocalAttachOptions{})
if err != nil {
// returns an error only on a ring miss (agent.ErrRingMiss);
// recover by taking a fresh Snapshot and re-attaching from its atSeq.
}
defer la.Close()
for f := range la.Frames() {
// f is an attachwire.Frame; Output bytes are already sanitized
}AttachLocal is not a network endpoint - this package opens no listener, full
stop. The first live local attach is assigned the driver role
(la.CanDrive()), while concurrent attaches start read-only and are never
promoted. After that driver calls Close, the next new AttachLocal call is
also assigned the driver role.
v0.52.1 closed-handle limitation
Close releases the session's driver slot but does not revoke the closed
handle's cached driver capability. On v0.52.1, a caller that retains that
handle will still see CanDrive() == true, and its WriteInput and Resize
methods can still reach the session after a replacement driver attaches.
Treat Close as ownership transfer: discard the handle immediately and never
call its methods again. This API is not an authorization boundary and does not
strictly enforce one writable handle across a lifecycle handoff.
Richer multi-writer arbitration is a relay policy, not something this package
implements. All viewer-bound Output bytes on the local surface pass a fresh
sanitizer before reaching Frames(). On the networked path, ptyhost and
attachclient send raw host→relay Output; the proposed protocol requires
sanitization on the relay→viewer/display boundary, and every viewer must apply
its own pass rather than assume an upstream relay enforced it. The OSS host leg
contains no relay logic and makes no claim about a particular relay's
enforcement.
Exit and teardown ordering
The host drains the PTY master to EOF and emits every pending Output frame
before emitting Exit - flush-before-Exit. Exit is the final sequence-bearing
host frame; a normal exit carries the process's own exit code, a signal death
carries the signal name and exitCode = 128 + signum (the standard shell
convention). After Exit, ptyhost.Session retains the final screen and
EmitSnapshot can continue producing out-of-namespace (seq = 0) snapshots for
as long as that session object remains available. ptyhost itself sets no
post-Exit deadline. The bounded network-serving window belongs to
attachclient.HostConfig.FinalScreenWindow (60 seconds by default), not to the
PTY host.
Dependencies
ptyhost depends only on the framing library
(attachwire and its sanitize
sub-package), the VT (github.com/charmbracelet/x/vt), creack/pty, and
golang.org/x/sys.
Interactive sessions
The PTY session host, wire protocol library, and generic attach client behind donmai's live, attachable terminal sessions.
Wire protocol
attachwire implements interactive-attach-v1 - the binary frame format, control messages, sanitization allowlist, and backpressure shared by every leg of the wire.