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 theInputwire payload itself carries no such nonce.penGenerationincrements 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 owninputSeqat 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.