skip to content
donmai
donmai is in 0.x preview. APIs may change between minor releases. See the changelog.
docs · donmai documentation · rev 2026-07-18github.com/RenseiAI/donmai
interactive sessions

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:

MethodPurpose
WriteInput(p []byte) (int, error)write bytes to the PTY stdin sink
Resize(cols, rows, pxWidth, pxHeight uint32) errorapply 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) errorappend 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) errorterminate 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.

doc docs/sessions/pty-hostrev 2026-07-18 · built 2026-07-18T20:45Z