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

Attach client

attachclient is the generic outbound client that dials a relay URL with a bearer token and runs one interactive PTY session's host leg.

attachclient is the OSS generic outbound attach client for interactive-attach-v1. It dials out to a relay, presents a per-session bearer JWT, and runs the host leg of one interactive PTY session behind a single call: RunHost forwards a live session's frames outbound and applies inbound control (input, resize, snapshot requests, kill) to that session.

The client is brand-neutral and points at no default endpoint - the composing binary supplies the attach URL and a token source. It never opens an inbound listener; it only dials out (WSS, or HTTPS for the degraded lane). Viewers are someone else's concern - this package is the host side only.

RunHost

err := attachclient.RunHost(ctx, attachclient.HostConfig{
    AttachURL:   attachURL,       // e.g. "wss://<relay-host>/v1/rooms/<roomId>"
    TokenSource: mintOrCacheJWT,  // func(ctx) (string, error); called once per dial attempt
    Session:     session,         // the live ptyhost.Session (via a thin adapter)
    Kill:        killProcessGroup,
})

At v0.52.1, RunHost's clean-completion signal is local: a carrier attempt's outbound session-frame pump finishes while Session.Exit() reports complete, and the bounded final-screen window elapses (returns nil). Otherwise it blocks until ctx is cancelled (returns ctx's error), or until a terminal condition occurs - an epoch-stale rejection, a non-retryable relay error, or an unrecoverable degraded-lane ring miss (returns ErrEpochStale or a *RelayStopError). Only AttachURL, TokenSource, and Session are required; every other HostConfig field (backoff bounds, the degraded-lane fallback threshold, the final-screen window, the read-size limit, the input dedup window) has a documented default.

v0.52.1 completion does not prove Exit delivery

After any prior carrier has streamed, a reconnect subscribes from the current Snapshot().atSeq. If the local session has since exited, that point can be the Exit head, so Subscribe(atSeq) returns an already-closed, empty feed. The WSS and degraded paths then observe the closed subscription, see that Session.Exit() is complete, start (or reuse) FinalScreenWindow, and can return nil after the window without transmitting Exit on that carrier - even when the earlier carrier dropped before Exit existed. The internal exitDelivered result name is therefore not a delivery receipt.

If no carrier reaches a finished outbound pump plus completed-Exit observation, retries continue until cancellation or another terminal condition. Always give the call a cancellation or deadline context, and treat a nil return as completion of the host client's bounded serving lifecycle, not proof that a relay or viewer received Exit. If remote delivery matters, require separate relay/viewer evidence or an explicit acknowledgement outside RunHost.

The Session interface

attachclient never imports the agent runtime package - it depends inward from the composing binary instead, which owns both sides. Its Session interface is structurally identical to ptyhost's live surface (WriteInput, Resize, Snapshot, EmitSnapshot, EmitMarker, Subscribe, Done, Exit), with one difference: Subscribe returns this package's own Subscription type rather than agent.InteractiveSubscription, because Go interface satisfaction is nominal on method return types. The composing binary supplies a roughly five-line adapter to bridge the two:

type sessAdapter struct{ *ptyhost.Session }

func (a sessAdapter) Subscribe(
    from attachwire.HostSeq,
) (attachclient.Subscription, error) {
    return a.Session.Subscribe(from)
}

TokenSource is a func(ctx context.Context) (string, error) called once per dial attempt - it re-resolves the bearer on every reconnect, which is what makes "identity-based reconnect" (re-presenting the same token within its lifetime, minting a fresh one after expiry) the client's only refresh path. KillFunc is the process-group termination hook invoked when a kill control message arrives; it must be idempotent, because the degraded lane's at-least-once delivery can redeliver a kill (the client also guards against a second invocation on its own).

Two carriers, one interface

RunHost speaks the primary WSS lane and, when that's unavailable, transparently falls back to the degraded SSE-down + POST-up host lane (see Wire protocol). Carrier fallback and upgrade-back are invisible to the session: the host output sequence space is never reset by a carrier switch, and the session sees one continuous consumer regardless of which carrier is currently live. Reconnect uses cancel-aware exponential backoff with equal jitter and re-resolves the bearer on every attempt. The backoff resets after carrier negotiation/subscription progress (or an authoritative inbound frame), not only after a session frame is received.

Two spec-level subtleties the client's reconnect logic has to get right:

  • No host-ack on WSS reconnect. The host does not retransmit on a dropped WSS connection - resuming from the last sequence handed to the dropped connection would re-send a gap the relay side has already truncated from its ring. On reconnect the client instead subscribes fresh from the current stream head and lets the far side request a resync snapshot. When that head is already Exit, the fresh subscription can be empty and closed; the reconnect does not retransmit the terminal frame.
  • Degraded-lane input de-duplication keys on (userId, penGeneration, inputSeq) rather than a connection nonce, because the Input wire payload itself carries no such nonce. penGeneration increments on every pen change and belongs to exactly one connection at a time, so the triple is collision-free across pen handoffs (including the same user's two devices, each restarting its own inputSeq at 1) using only fields the wire actually carries.

Testing without a real relay

The client's tests exercise the host leg against attachclient/attachtest, a deliberately minimal, single-room stub that speaks only the wire mechanism (WebSocket accept, the host-leg compare-and-swap, the sequence-keyed ring and resume, the degraded-lane batch contract) with none of a real relay's policy - no arbitration, no token verification beyond an aud check, no multi-tenancy, no backpressure, no sanitization. It exists so this repository's tests have something to talk to and is explicitly not a reference relay implementation; standing up a real relay for a given deployment - with auth, arbitration, and multi-tenancy - is out of scope for this package by design (see Interactive sessions for the boundary).

Standalone use

Standalone local use belongs to ptyhost, not this client: do not start attachclient when no relay is needed. Call ptyhost.Session.AttachLocal for the single-machine, in-process viewer. Use attachclient once a second device needs to attach - it runs the host leg against any endpoint that implements the current proposed protocol revision, not a specific vendor's relay.

doc docs/sessions/attach-clientrev 2026-07-18 · built 2026-07-18T20:45Z