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

Wire protocol

attachwire implements interactive-attach-v1 - the binary frame format, control messages, sanitization allowlist, and backpressure shared by every leg of the wire.

attachwire implements the framing and codec layer of interactive-attach-v1: the binary frame format, two independent sequence namespaces, typed event payloads, the JSON control-plane message set, a screen-snapshot serialization, degraded-lane batch envelopes, and a token-bucket backpressure helper. It is pure Go, standard-library only, and carries no transport (WebSocket, HTTP, SSE) and no relay logic of its own - it is the shared codec that a host, a viewer, and a relay all encode and decode against, so the two ends of a connection cannot drift apart from each other.

The protocol is derived from asciinema's ALiS live-stream protocol in shape only (binary, length-prefixed, varint-framed, with a server-side snapshot for late joiners and resume-from-sequence on reconnect) - it is not byte-compatible with ALiS. The event types, the Snapshot/Control/Input additions, the sanitization allowlist, and the auth model are this protocol's own.

Out of scope by design

Multi-writer arbitration policy - who may take the pen, grab eligibility, cooldowns, auto-release, presence derivation - is deliberately not implemented here. This library carries the grab/release/pen-state messages as an opaque, parse-only registry; arbitration policy belongs to whatever sits in front of the wire (a relay) and can evolve without touching this codec. attachwire implements only the wire encoding and the wire-visible invariants: framing errors, the role enum, and the error-code registry.

Binary frame format

Every message, in both directions and on both the host and viewer legs, is one frame:

+----------+--------------+------------------+-------------------+
| type:u8  | seq:varint   | rel_time:varint  | payload:bytes...  |
+----------+--------------+------------------+-------------------+

seq and rel_time are unsigned LEB128 varints. Out-of-namespace frames carry zeroed headers: every Control frame in either direction, every non-host-produced Resize, and a post-Exit Snapshot sets seq = 0 and rel_time = 0; receivers ignore both fields on those frames. Host-produced stream frames (Output, applied-geometry Resize, Marker, Exit, and pre-Exit Snapshot) carry the host output sequence. Input is the exception on the viewer side: its header seq is meaningful and anchors the highest host output sequence that viewer had applied, while the separate payload inputSeq orders the viewer's keystrokes.

f := attachwire.Frame{Type: attachwire.TypeOutput, Seq: 42, Payload: data}
wire := attachwire.EncodeFrame(f)

decoded, err := attachwire.DecodeFrame(wire)

Event types

typeNameProducerPayload
0x01Outputhostraw terminal output bytes
0x02Inputviewerkeystroke bytes + inputSeq + penGeneration + stamped userId
0x03Resizeviewer (advertise) / host (applied echo)terminal geometry
0x04Markerhostan annotation label (display-only, never executed)
0x05Exithostprocess exit code + optional signal name
0x06Snapshothostthe current screen at a given sequence
0x07Controlanyone JSON control-plane message

An unknown type byte or an overflowing or truncated header varint is an attachwire.FramingError from DecodeFrame, reported with Code() == CodeFraming; the structured payload decoders use the same error type for payload syntax violations. DecodeFrame does not know the authenticated role and therefore does not enforce the protocol section 6.3 producer-admission matrix. The relay or other caller must apply that matrix and close a disallowed producer with error.code = framing.

Two sequence namespaces

There are two independent monotonic counters, and conflating them is the defect the split exists to prevent:

  • Host output sequence - assigned by the host to every host-produced frame, starting at 1, strictly increasing within one host stream epoch (the lifetime of one PTY-owning process). This is the space a resume position addresses.
  • Viewer input sequence (inputSeq) - assigned per viewer connection to its own Input frames, starting at 1, and preserved across a carrier switch (WSS to/from the degraded lane).

An Input frame's frame-level seq is not an input sequence - it is the host output sequence the viewer had applied when it produced the keystroke (used for predictive-echo reconciliation). The viewer's own ordering lives in the inputSeq payload field.

Typed payloads

out := attachwire.EncodeOutput(data)

in, err := attachwire.DecodeInput(payload) // InputPayload{InputSeq, PenGeneration, UserID, Data}

resize := attachwire.ResizePayload{Cols: 120, Rows: 40}
if err := resize.Validate(); err != nil {
    // cols == 0 || rows == 0 is a framing error, not a valid 0xN geometry
}

v0.52.1 geometry bound

The current codec rejects zero columns or rows but does not cap the four unsigned geometry fields. The host ultimately applies Unix 16-bit winsize fields, so compatible callers and relays must reject cols or rows above 65535 and pixel dimensions above 65535 before forwarding. 65536 currently decodes successfully, then narrows to zero at the kernel PTY while the headless VT and applied-Resize echo retain the larger value. Treat this explicit 16-bit bound as a v0.52.1 safety requirement while the proposed wire specification is corrected.

InputPayload.UserID is relay-stamped, never client-supplied: a viewer sends Input with an empty user ID; whatever sits in front of the host verifies the sender's token and stamps the verified identity before forwarding. The host rejects any Input that arrives unstamped - a presence check, not an independent verification, because the host trusts that only its own authenticated outbound connection can deliver frames to it.

Control messages

Control frames (type 0x07) carry one JSON object with a type discriminator. attachwire exposes each as a typed Go struct implementing the ControlMessage interface (Subscribe, ResumeFrom, SnapshotRequest, Kill, Grab, Release, Presence, InputAck, PenGranted, PenRevoked, PenState, RoomState, ControlError):

frame, err := attachwire.BuildControlFrame(attachwire.SnapshotRequest{
    Reason: attachwire.ReasonResync,
})

msg, err := attachwire.DecodeControl(jsonPayload) // dispatches on "type"

Unknown JSON fields on a known message are ignored for forward compatibility; an unknown message type is rejected. error.code values are framing, auth, room-mismatch, pen-denied, ring-miss, backpressure, rate-limited, epoch-stale, and internal (attachwire.ErrorCode constants) - the code registry is draft and extendable, the error message shape itself is frozen.

Sanitization allowlist

Terminal output originates in an untrusted process - an agent's PTY running arbitrary commands over content it doesn't control. Direction matters: host→relay Output carries raw PTY master bytes in v0.52.1; the sanitization boundary is relay→viewer/display-bound output. The proposed protocol requires a relay to filter before forwarding and requires every viewer to filter again, but attachwire is only a codec plus sanitizer implementation and does not enforce relay behavior. A viewer must never assume an upstream relay sanitized.

san := sanitize.New()
clean := san.Write(rawPtyBytes) // stateful across calls - a split escape
                                 // sequence is classified the same as a
                                 // contiguous one

The governing rule: a viewer is a display-only mirror and never emits input in response to output bytes. Every escape sequence whose real-terminal effect is "reply on stdin" - cursor-position reports, device-attribute queries, color/title queries - is stripped at the viewer-bound boundary. The host-side VT answers those queries locally, but v0.52.1 still publishes the original query bytes in raw host→relay Output; see PTY session host. A representative sample of the allowlist:

Escape classDispositionWhy
Printable text, SGR color, cursor addressingpassnormal, cosmetic, VT-bounded content
OSC 52 (clipboard write)strippaste-jacking / clipboard theft
OSC 8 (hyperlink)display-onlyrender link text; no auto-navigation
Cursor-position / device-attribute query repliesstrip at viewerthe host VT answers these; a viewer that replied would inject input
Window manipulation / geometry reportsstripcan move/resize the viewer; geometry is owned by the Resize frame only
Sixel graphicspass, size-cappeddisplay-only image, bounded by backpressure

A dangling, incomplete escape introducer at a frame boundary is held pending

  • never passed through - until its terminator arrives or a named cap is hit, at which point it is dropped rather than flushed through unsanitized.

Backpressure

Buffering toward a slow consumer is never unbounded. attachwire.TokenBucket is the shared rate-limiting primitive a relay uses per viewer: when a slow viewer's bucket is exhausted, intermediate Output frames for that viewer are coalesced or dropped and it is brought current with a fresh Snapshot rather than queued history; a viewer whose queue overflows entirely is disconnected with error.code = backpressure rather than left to grow memory without bound.

Degraded lane

When the primary transport is unavailable (a WebSocket upgrade blocked by a proxy, or repeated failed reconnects), the wire falls back to a first-class HTTP transport built on the same framing: Server-Sent Events downstream and batched POST upstream, with the same auth, the same two sequence namespaces, and the same idempotency guarantees (batches are deduplicated by a stable batch id, and de-duplicated content is never re-applied). Carrier fallback and upgrade-back are meant to be invisible above this library - see the attach client for how the host leg implements both carriers behind one interface.

doc docs/sessions/wire-protocolrev 2026-07-18 · built 2026-07-18T20:45Z