v0.3.0 99dc266

client.go RawHistoryFolder

1// Package client implements vev's thin attach client: it speaks the framed
2// IPC protocol to a daemon, pumping terminal input, resize events, and PTY
3// output between the controlling terminal and the session.
4//
5// The package depends only on ports (transport, terminal, wire codecs) and
6// domain, never on a concrete transport or terminal implementation; the app
7// layer wires those in.
8package client
9
10import (
11	"context"
12	"crypto/rand"
13	"errors"
14	"fmt"
15	"io"
16	"log/slog"
17	"os"
18	"sync"
19	"sync/atomic"
20	"time"
21
22	renderer "github.com/bnema/vev-vt"
23	"github.com/bnema/vev/internal/domain"
24	"github.com/bnema/vev/internal/ports"
25	"github.com/bnema/vev/internal/usecase/theme"
26)
27
28// ProtocolError is a session- or protocol-level failure reported by the
29// daemon before attach could proceed (a Hello rejected with an ErrorMsg).
30// The app layer prints it to the user.
31type ProtocolError struct {
32	Code uint16
33	Text string
34}
35
36func (e *ProtocolError) Error() string {
37	if e.Text == "" {
38		return fmt.Sprintf("vev: daemon rejected attach (code %d)", e.Code)
39	}
40	return fmt.Sprintf("vev: %s", e.Text)
41}
42
43// DetachedError reports that the daemon detached this client for a reason
44// other than an ordinary user-initiated detach.
45type DetachedError struct {
46	Reason uint8
47	Text   string
48}
49
50func (e *DetachedError) Error() string { return "vev: " + e.Text }
51
52// terminalThemeState retains the latest terminal-reported colors across
53// transport attempts so a replacement attachment can restore the daemon theme
54// without relying on another OSC response.
55type terminalThemeState struct {
56	mu    sync.Mutex
57	theme ports.Theme
58}
59
60func (s *terminalThemeState) setTrueColor(enabled bool) {
61	s.mu.Lock()
62	s.theme.TrueColor = enabled
63	s.mu.Unlock()
64}
65
66func (s *terminalThemeState) update(update func(*ports.Theme)) ports.Theme {
67	s.mu.Lock()
68	defer s.mu.Unlock()
69	update(&s.theme)
70	return s.theme
71}
72
73func (s *terminalThemeState) snapshot() ports.Theme {
74	s.mu.Lock()
75	defer s.mu.Unlock()
76	return s.theme
77}
78
79// milestones tracks lifecycle progress so a failure can report which stages
80// were never reached — the key diagnostic when an attach dies early.
81type milestones struct {
82	dialed      bool
83	helloSent   bool
84	welcomed    bool
85	rawEntered  bool
86	firstOutput bool
87	detached    bool
88}
89
90func (m milestones) missing() []string {
91	var out []string
92	if !m.dialed {
93		out = append(out, "dialed")
94	}
95	if !m.helloSent {
96		out = append(out, "hello_sent")
97	}
98	if !m.welcomed {
99		out = append(out, "welcomed")
100	}
101	if !m.rawEntered {
102		out = append(out, "raw_entered")
103	}
104	if !m.firstOutput {
105		out = append(out, "first_output")
106	}
107	if !m.detached {
108		out = append(out, "detached")
109	}
110	return out
111}
112
113// processClientID identifies this client process across links. It is kept in
114// memory only; a fresh process gets a fresh ID.
115var processClientID = newClientID()
116
117func newClientID() [16]byte {
118	var id [16]byte
119	if _, err := rand.Read(id[:]); err != nil {
120		panic("crypto/rand failed generating client ID: " + err.Error())
121	}
122	return id
123}
124
125// stdinBufSize is the read buffer for the terminal input pump.
126const stdinBufSize = 4096
127
128// sendQueueDepth bounds the buffered frames awaiting the single sender
129// goroutine, decoupling the input/resize pumps from transport back-pressure.
130const sendQueueDepth = 64
131
132// preWelcomeTimeout remains the focused-test name for the shared handshake
133// budget, which now also covers connect and initial publication.
134const preWelcomeTimeout = ports.HandshakeTimeout
135
136// remoteHostLearnerShutdownTimeout bounds how long Run waits for async remote
137// host learning after terminal restoration. Learning is best-effort; a stalled
138// learner must not block shell prompt return indefinitely.
139const remoteHostLearnerShutdownTimeout = time.Second
140
141var reconnectSleep = sleepReconnect
142var reconnectSleepWithResize = sleepReconnectWithResizeEvents
143
144const (
145	statusReconnect = "\r\x1b[2Kreconnecting…"
146	statusClear     = "\r\x1b[2K"
147)
148
149// Dependencies supplies the collaborators required by a Runner.
150type AttachHandoffFunc func(ports.AttachTarget) (ports.Dialer, AttachRequest, error)
151
152type Dependencies struct {
153	Dialer            ports.Dialer
154	Terminal          ports.Terminal
155	Clock             ports.Clock
156	Clipboard         ports.ClipboardReader
157	Logger            *slog.Logger
158	RuntimeObserver   ports.SerializedRuntimeObserver
159	RemoteHostLearner ports.RemoteHostLearner
160	// AttachHandoff keeps one Runner and one terminal/input ownership across a
161	// local daemon's structured remote handoff. It is nil for direct CLI attach.
162	AttachHandoff AttachHandoffFunc
163	// Remote selects client-side carriage presentation only; it never enters
164	// the daemon-facing session request.
165	Remote bool
166	// Origin is explicit composition metadata for the initial route. A zero
167	// value is normalized to local for direct package callers and old tests.
168	Origin    ports.RouteOrigin
169	OriginKey string
170}
171
172// AttachRequest identifies the session for one client run. Remote is
173// client-only presentation metadata. RemoteTarget is an optional exact picker
174// handoff and is serialized into Hello only when present.
175type AttachRequest struct {
176	Intent                 uint8
177	SessionName            string
178	Remote                 bool
179	Origin                 ports.RouteOrigin
180	OriginKey              string
181	RemoteTarget           *domain.RemoteSessionTarget
182	ExactTarget            *ports.ExactSessionTarget
183	PreferredTabID         domain.TabStableID
184	RemoteOrigin           string
185	EnvironmentPolicy      ports.EnvironmentPolicy
186	NavigationCapabilities ports.NavigationCapabilities
187	StartupOverlay         ports.StartupOverlay
188}
189
190// attachRoute captures the dialer, request, and resume token needed to
191// restore a previous attachment route after a navigation handoff.
192type attachRoute struct {
193	dialer      ports.Dialer
194	request     AttachRequest
195	resumeToken uint64
196}
197
198type Runner struct {
199	dialer            ports.Dialer
200	term              ports.Terminal
201	clock             ports.Clock
202	clipboard         ports.ClipboardReader
203	logger            *slog.Logger
204	runtimeObserver   ports.SerializedRuntimeObserver
205	remoteHostLearner ports.RemoteHostLearner
206	attachHandoff     AttachHandoffFunc
207	remote            bool
208	origin            ports.RouteOrigin
209	ledger            *routeLedger
210	routeFailure      *ports.RouteNavigationFailure
211
212	inputMu sync.Mutex
213	input   *terminalInputPump
214}
215
216// NewRunner constructs a client runner. A nil logger uses the process default.
217func NewRunner(deps Dependencies) *Runner {
218	log := deps.Logger
219	if log == nil {
220		log = slog.Default()
221	}
222	return &Runner{
223		dialer:            deps.Dialer,
224		term:              deps.Terminal,
225		clock:             deps.Clock,
226		clipboard:         deps.Clipboard,
227		logger:            log,
228		runtimeObserver:   deps.RuntimeObserver,
229		remoteHostLearner: deps.RemoteHostLearner,
230		attachHandoff:     deps.AttachHandoff,
231		remote:            deps.Remote,
232		origin:            normalizeRouteOrigin(deps.Origin, deps.Remote),
233		ledger:            newRouteLedger(),
234	}
235}
236
237// terminalInput returns the Runner-owned sole reader. It survives sequential
238// Run calls because a caller-owned reader may remain blocked after an attach
239// exits and therefore cannot safely be replaced by another reader.
240func (r *Runner) terminalInput() *terminalInputPump {
241	r.inputMu.Lock()
242	defer r.inputMu.Unlock()
243
244	if r.input == nil || r.input.hasExited() || r.input.isClosed() {
245		r.input = newTerminalInputPump(r.term.In())
246		r.input.start()
247	}
248	r.input.resume()
249	return r.input
250}
251
252func validateAttachRequest(request AttachRequest) error {
253	if request.Origin != 0 {
254		if err := request.Origin.Validate(); err != nil {
255			return fmt.Errorf("vev: invalid route origin: %w", err)
256		}
257	}
258	if request.OriginKey != "" {
259		if err := ports.ValidateRouteLabel(request.OriginKey, false); err != nil {
260			return fmt.Errorf("vev: invalid route origin key: %w", err)
261		}
262	}
263	if request.ExactTarget != nil {
264		if err := request.ExactTarget.Validate(); err != nil {
265			return fmt.Errorf("vev: invalid exact target: %w", err)
266		}
267		if request.SessionName != request.ExactTarget.SessionName {
268			return errors.New("vev: exact target session mismatch")
269		}
270		if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != request.ExactTarget.LifecycleID || request.RemoteTarget.SessionName != request.ExactTarget.SessionName) {
271			return errors.New("vev: exact target does not match remote target")
272		}
273	}
274	if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil {
275		return fmt.Errorf("vev: invalid session target: %w", err)
276	}
277	if err := ports.ValidateNavigation(request.NavigationCapabilities, request.StartupOverlay, request.RemoteTarget != nil || request.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned); err != nil {
278		return fmt.Errorf("vev: invalid navigation route: %w", err)
279	}
280	if request.RemoteTarget == nil {
281		if request.EnvironmentPolicy == ports.EnvironmentPolicyClientOwned {
282			return nil
283		}
284		if request.Remote && request.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned {
285			return nil
286		}
287		return errors.New("vev: attach without remote target requires a matching environment policy")
288	}
289	if err := request.RemoteTarget.Validate(); err != nil {
290		return fmt.Errorf("vev: invalid remote attach target: %w", err)
291	}
292	if request.SessionName != request.RemoteTarget.SessionName {
293		return errors.New("vev: remote attach target session mismatch")
294	}
295	if request.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
296		return errors.New("vev: remote attach target requires daemon-owned environment")
297	}
298	if request.Intent != ports.IntentAttach && request.Intent != ports.IntentResume {
299		return errors.New("vev: remote attach target requires attach or resume")
300	}
301	return nil
302}
303
304// Run connects and runs the attach client. It owns the terminal lifecycle
305// above attach attempts so raw mode remains active while a live client process
306// redials a lost link.
307func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {
308	request = cloneAttachRequest(request)
309	if request.Origin == 0 {
310		request.Origin = r.origin
311	}
312	if err := validateAttachRequest(request); err != nil {
313		return err
314	}
315	ms := milestones{}
316
317	defer func() {
318		if retErr != nil {
319			r.logger.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing())
320		}
321	}()
322
323	var restore func() error
324	rawEntered := false
325	enterRaw := func() error {
326		if rawEntered {
327			return nil
328		}
329		var err error
330		restore, err = r.term.EnterRaw()
331		if err != nil {
332			return fmt.Errorf("vev: entering raw mode: %w", err)
333		}
334		rawEntered = true
335		ms.rawEntered = true
336		return nil
337	}
338
339	resumeToken := uint64(0)
340	attemptRequest := request
341	dialer := r.dialer
342	var homeRoute *attachRoute
343	var returnRoute *attachRoute
344	returnResumeFallback := false
345	homeNavigationPending := false
346	returnNavigationPending := false
347	routeNavigationPending := false
348	routeNavigationResumeFallback := false
349	var routeNavigationSelection *routeNavigationSelection
350	var routeNavigationFallback *attachRoute
351	var routeNavigationAction *ports.RouteNavigationAction
352	backoff := defaultReconnectBackoff.initial
353	themeState := &terminalThemeState{}
354	var rememberOnce sync.Once
355	var rememberWG sync.WaitGroup
356	var learnerStarted bool
357	rememberRemoteHost := func() {
358		if r.remoteHostLearner == nil {
359			return
360		}
361		rememberOnce.Do(func() {
362			learnerStarted = true
363			rememberWG.Go(func() {
364				if err := r.remoteHostLearner.RememberRemoteHost(); err != nil {
365					r.logger.Warn("remembering remote host failed", "err", err)
366				}
367			})
368		})
369	}
370	defer func() {
371		if !learnerStarted {
372			return
373		}
374		done := make(chan struct{})
375		go func() {
376			rememberWG.Wait()
377			close(done)
378		}()
379		timer := r.clock.NewTimer(remoteHostLearnerShutdownTimeout)
380		defer timer.Stop()
381		select {
382		case <-done:
383		case <-timer.C():
384			r.logger.Warn("remote host learner stalled past shutdown timeout")
385		}
386	}()
387	defer func() {
388		if rawEntered {
389			if rerr := restore(); rerr != nil && retErr == nil {
390				retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
391			}
392		}
393	}()
394	remote := request.Remote || r.remote
395	reconnect := &reconnectUI{
396		term:       r.term,
397		remote:     remote,
398		rawEntered: &rawEntered,
399		stage:      reconnectStageOfflineRetrying,
400	}
401	restoreReturnRoute := func(route attachRoute) {
402		returnNavigationPending = true
403		dialer = route.dialer
404		attemptRequest = route.request
405		if route.resumeToken != 0 {
406			attemptRequest.Intent = ports.IntentResume
407		} else {
408			attemptRequest.Intent = ports.IntentAttach
409		}
410		attemptRequest.StartupOverlay = ports.StartupOverlayNone
411		attemptRequest.NavigationCapabilities &^= ports.NavigationCapabilityBack | ports.NavigationCapabilityHomePicker
412		resumeToken = route.resumeToken
413		returnResumeFallback = route.resumeToken != 0
414		remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
415		backoff = defaultReconnectBackoff.initial
416	}
417	enterHomePicker := func() {
418		dialer = homeRoute.dialer
419		attemptRequest = homeRoute.request
420		attemptRequest.Intent = ports.IntentAttach
421		attemptRequest.NavigationCapabilities = ports.NavigationCapabilityBack
422		attemptRequest.StartupOverlay = ports.StartupOverlaySessionPicker
423		attemptRequest.RemoteTarget = nil
424		attemptRequest.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
425		attemptRequest.Remote = homeRoute.request.Remote || r.remote
426		resumeToken = 0
427		remote = syncReconnectRemote(reconnect, homeRoute.request.Remote || r.remote)
428		backoff = defaultReconnectBackoff.initial
429	}
430	var input *terminalInputPump
431	defer func() {
432		if input != nil {
433			input.suspend()
434		}
435	}()
436	originalEnterRaw := enterRaw
437	enterRaw = func() error {
438		if err := originalEnterRaw(); err != nil {
439			return err
440		}
441		if input == nil {
442			input = r.terminalInput()
443		}
444		return nil
445	}
446
447	for {
448		if err := validateAttachRequest(attemptRequest); err != nil {
449			return err
450		}
451		handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)
452		transport, err := boundedDial(handshakeCtx, dialer)
453		if err != nil {
454			err = handshakeContextError(ctx, timedOut, err)
455			finishHandshake()
456			if resumeToken == 0 || ctx.Err() != nil {
457				if clearErr := reconnect.clear(); clearErr != nil {
458					return errors.Join(err, clearErr)
459				}
460				return err
461			}
462			r.logger.Warn("reconnect dial failed", "err", err, "backoff", backoff)
463			if drawErr := reconnect.draw(); drawErr != nil {
464				return errors.Join(err, drawErr)
465			}
466			slept, sleepErr := reconnect.sleep(ctx, r.clock, backoff)
467			if sleepErr != nil {
468				return errors.Join(err, sleepErr)
469			}
470			if !slept {
471				if clearErr := reconnect.clear(); clearErr != nil {
472					return errors.Join(ctx.Err(), clearErr)
473				}
474				return ctx.Err()
475			}
476			backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
477			attemptRequest.Intent = ports.IntentResume
478			continue
479		}
480		ms.dialed = true
481		if transport == nil {
482			finishHandshake()
483			return errors.New("vev: dialer returned nil transport")
484		}
485		stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
486		connection, err := NewSessionConnection(transport, SessionTarget{
487			Intent:      attemptRequest.Intent,
488			SessionName: attemptRequest.SessionName,
489		})
490		if err != nil {
491			stopHandshakeTransport()
492			finishHandshake()
493			_ = transport.Close()
494			return err
495		}
496
497		var linkEvents <-chan ports.LinkEvent
498		if reporter, ok := connection.Transport().(ports.LinkStateReporter); ok {
499			linkEvents = reporter.LinkEvents()
500		}
501		result := (&attachAttempt{
502			runner:                   r,
503			dialer:                   dialer,
504			connection:               connection,
505			remote:                   remote,
506			handshakeCtx:             handshakeCtx,
507			handshakeTimedOut:        timedOut,
508			finishHandshake:          finishHandshake,
509			stopHandshakeTransport:   stopHandshakeTransport,
510			request:                  attemptRequest,
511			resumeToken:              resumeToken,
512			routeNavigationSelection: routeNavigationSelection,
513			clientID:                 processClientID,
514			milestones:               &ms,
515			themeState:               themeState,
516			enterRaw:                 enterRaw,
517			reconnect:                reconnect,
518			linkEvents:               linkEvents,
519			rememberRemoteHost:       rememberRemoteHost,
520			terminalInput: func() *terminalInputPump {
521				return input
522			},
523		}).run(ctx)
524		stopHandshakeTransport()
525		finishHandshake()
526		if result.welcomed {
527			backoff = defaultReconnectBackoff.initial
528		}
529		if !result.transportClosed {
530			_ = connection.Close()
531		}
532		// Apply metadata from every processed frame before dispatching its
533		// navigation action. A committed identity can arrive immediately before
534		// a home/back action and must become the route request's authority first.
535		if result.resumeToken != 0 {
536			resumeToken = result.resumeToken
537		}
538		if result.sessionName != "" {
539			attemptRequest.SessionName = result.sessionName
540		}
541		if result.committedIdentity != nil {
542			identity := *result.committedIdentity
543			if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != identity.Target {
544				attemptRequest.PreferredTabID = ""
545			}
546			if remoteTarget := attemptRequest.RemoteTarget; remoteTarget != nil &&
547				(remoteTarget.LifecycleID != identity.Target.LifecycleID || remoteTarget.SessionName != identity.Target.SessionName) {
548				attemptRequest.RemoteTarget = nil
549			}
550			attemptRequest.ExactTarget = &identity.Target
551		}
552		if result.routePosition != nil && attemptRequest.ExactTarget != nil &&
553			*attemptRequest.ExactTarget == result.routePosition.Target {
554			attemptRequest.PreferredTabID = result.routePosition.ActiveTabID
555			attemptRequest.RemoteTarget = nil
556		}
557		if result.err == nil && result.welcomed && routeNavigationPending {
558			routeNavigationPending = false
559			routeNavigationSelection = nil
560			routeNavigationResumeFallback = false
561			routeNavigationFallback = nil
562			routeNavigationAction = nil
563		}
564		if result.routeAction != nil {
565			if r.ledger == nil {
566				return errors.New("vev: route ledger unavailable")
567			}
568			selection, valid := r.ledger.navigationSelection(*result.routeAction)
569			if !valid {
570				return errors.New("vev: stale route navigation action")
571			}
572			if selection.noOp {
573				continue
574			}
575			action := *result.routeAction
576			routeNavigationAction = &action
577			routeNavigationFallback = &attachRoute{dialer: selection.prior.dialer, request: selection.prior.request, resumeToken: selection.prior.resumeToken}
578			dialer = selection.selected.dialer
579			attemptRequest = cloneAttachRequest(selection.selected.request)
580			if selection.selected.resumeToken != 0 {
581				attemptRequest.Intent = ports.IntentResume
582			} else {
583				attemptRequest.Intent = ports.IntentAttach
584			}
585			// Back is transient to the picker overlay. Re-derive the home-picker
586			// capability from the selected route instead of trusting request
587			// metadata, which may have been cleared after a daemon-side switch.
588			attemptRequest.StartupOverlay = ports.StartupOverlayNone
589			attemptRequest.NavigationCapabilities = 0
590			if homeRoute != nil && selection.selected.presentation.kind == ports.RouteKindRemote {
591				attemptRequest.NavigationCapabilities = ports.NavigationCapabilityHomePicker
592			}
593			resumeToken = selection.selected.resumeToken
594			routeNavigationPending = true
595			routeNavigationSelection = &selection
596			routeNavigationResumeFallback = selection.selected.resumeToken != 0
597			remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
598			backoff = defaultReconnectBackoff.initial
599			continue
600		}
601		if result.action != 0 {
602			switch result.action {
603			case ports.NavigationOpenHomePicker:
604				// The daemon only sends this action after accepting a Hello that
605				// advertised Home support. The durable client-side prerequisite is
606				// the captured route itself; request metadata may have been rebased by
607				// committed identity or cursor updates since that Hello.
608				if homeRoute == nil {
609					return errors.New("vev: stale home navigation action")
610				}
611				returnRequest := attemptRequest
612				if result.sessionName != "" {
613					returnRequest.SessionName = result.sessionName
614				}
615				returnRoute = &attachRoute{dialer: dialer, request: returnRequest, resumeToken: result.resumeToken}
616				homeNavigationPending = true
617				enterHomePicker()
618				continue
619			case ports.NavigationBack:
620				// As with Home, the daemon accepted Back in Hello before sending
621				// this action. The retained concrete route is the durable client
622				// prerequisite; request metadata may have been rebased meanwhile.
623				if returnRoute == nil {
624					return errors.New("vev: stale return navigation action")
625				}
626				route := *returnRoute
627				returnRoute = nil
628				homeNavigationPending = false
629				returnNavigationPending = true
630				restoreReturnRoute(route)
631				continue
632			default:
633				return errors.New("vev: unsupported navigation action")
634			}
635		}
636		if result.target != nil {
637			if result.target.RemoteTarget != nil || result.target.Endpoint != "" {
638				if homeRoute == nil && attemptRequest.RemoteTarget == nil {
639					routeRequest := attemptRequest
640					if result.sessionName != "" {
641						routeRequest.SessionName = result.sessionName
642					}
643					routeRequest.Intent = ports.IntentAttach
644					routeRequest.NavigationCapabilities = 0
645					routeRequest.StartupOverlay = ports.StartupOverlayNone
646					homeRoute = &attachRoute{dialer: dialer, request: routeRequest}
647				}
648			}
649			var (
650				nextDialer  ports.Dialer
651				nextRequest AttachRequest
652				handoffErr  error
653			)
654			if result.target.Endpoint == "" {
655				if r.ledger == nil {
656					return errors.New("vev: route ledger unavailable")
657				}
658				nextDialer = dialer
659				nextRequest = r.ledger.samePeerHandoff(attemptRequest, *result.target)
660			} else {
661				if r.attachHandoff == nil {
662					return &AttachTargetError{Target: *result.target}
663				}
664				nextDialer, nextRequest, handoffErr = r.attachHandoff(*result.target)
665				if handoffErr != nil {
666					return handoffErr
667				}
668			}
669			if result.target.ExactTarget != nil {
670				nextRequest.ExactTarget = result.target.ExactTarget
671			}
672			if homeRoute != nil && (nextRequest.RemoteTarget != nil || nextRequest.EnvironmentPolicy == ports.EnvironmentPolicyDaemonOwned) {
673				nextRequest.NavigationCapabilities |= ports.NavigationCapabilityHomePicker
674			}
675			if attemptRequest.StartupOverlay == ports.StartupOverlaySessionPicker {
676				returnRoute = nil
677				returnResumeFallback = false
678			}
679			nextRequest = cloneAttachRequest(nextRequest)
680			if err := validateAttachRequest(nextRequest); err != nil {
681				return fmt.Errorf("vev: invalid route handoff request: %w", err)
682			}
683			if nextDialer == nil {
684				return errors.New("vev: route handoff returned nil dialer")
685			}
686			dialer = nextDialer
687			attemptRequest = nextRequest
688			remote = syncReconnectRemote(reconnect, attemptRequest.Remote || r.remote)
689			resumeToken = 0
690			backoff = defaultReconnectBackoff.initial
691			continue
692		}
693		if result.err == nil {
694			if result.welcomed && returnNavigationPending {
695				returnNavigationPending = false
696				returnResumeFallback = false
697			}
698			if clearErr := reconnect.clear(); clearErr != nil {
699				return clearErr
700			}
701			return nil
702		}
703		if result.welcomed {
704			homeNavigationPending = false
705		}
706		if homeNavigationPending && returnRoute != nil {
707			route := *returnRoute
708			returnRoute = nil
709			homeNavigationPending = false
710			returnNavigationPending = true
711			restoreReturnRoute(route)
712			continue
713		}
714		if routeNavigationPending && routeNavigationResumeFallback && resumeNeedsExactAttach(result.err) {
715			attemptRequest.Intent = ports.IntentAttach
716			resumeToken = 0
717			routeNavigationResumeFallback = false
718			continue
719		}
720		if routeNavigationPending && routeNavigationFallback != nil {
721			if routeNavigationAction != nil {
722				action := *routeNavigationAction
723				r.routeFailure = &ports.RouteNavigationFailure{Key: action.Key, Generation: action.Generation, Code: routeFailureCode(result.err)}
724				routeNavigationAction = nil
725			}
726			route := *routeNavigationFallback
727			routeNavigationFallback = nil
728			routeNavigationPending = false
729			routeNavigationSelection = nil
730			routeNavigationResumeFallback = false
731			returnNavigationPending = true
732			restoreReturnRoute(route)
733			continue
734		}
735		if returnResumeFallback && resumeNeedsExactAttach(result.err) {
736			attemptRequest.Intent = ports.IntentAttach
737			resumeToken = 0
738			returnResumeFallback = false
739			continue
740		}
741		if returnNavigationPending && !returnResumeFallback && homeRoute != nil {
742			returnRoute = nil
743			returnNavigationPending = false
744			enterHomePicker()
745			continue
746		}
747		if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {
748			if clearErr := reconnect.clear(); clearErr != nil {
749				return errors.Join(result.err, clearErr)
750			}
751			return result.err
752		}
753		r.logger.Warn("reconnecting after attach error", "err", result.err, "backoff", backoff)
754		if drawErr := reconnect.drawStage(reconnectStageSSH); drawErr != nil {
755			return errors.Join(result.err, drawErr)
756		}
757		slept, sleepErr := reconnect.sleep(ctx, r.clock, backoff)
758		if sleepErr != nil {
759			return errors.Join(result.err, sleepErr)
760		}
761		if !slept {
762			if clearErr := reconnect.clear(); clearErr != nil {
763				return errors.Join(ctx.Err(), clearErr)
764			}
765			return ctx.Err()
766		}
767		backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
768		attemptRequest.Intent = ports.IntentResume
769	}
770}
771
772// reconnectUI owns reconnect presentation while Runner owns the lifecycle.
773// Its methods run only on Runner's goroutine, preserving the terminal's
774// single-writer rule.
775type reconnectUI struct {
776	term       ports.Terminal
777	remote     bool
778	rawEntered *bool
779	showing    bool
780	stage      reconnectStage
781}
782
783func syncReconnectRemote(reconnect *reconnectUI, remote bool) bool {
784	if reconnect != nil {
785		reconnect.remote = remote
786	}
787	return remote
788}
789
790func (u *reconnectUI) redraw(size domain.Size) error {
791	if !*u.rawEntered || !u.remote {
792		return nil
793	}
794	_, err := drawReconnectToastStage(u.term.Out(), size, u.stage)
795	if err != nil {
796		return fmt.Errorf("drawing reconnect toast: %w", err)
797	}
798	if err := u.term.Flush(); err != nil {
799		return fmt.Errorf("flushing reconnect toast: %w", err)
800	}
801	u.showing = true
802	return nil
803}
804
805func (u *reconnectUI) drawStage(stage reconnectStage) error {
806	u.stage = stage
807	if u.showing && u.remote {
808		size, err := u.term.Size()
809		if err != nil {
810			return fmt.Errorf("reading terminal size for reconnect toast: %w", err)
811		}
812		return u.redraw(size)
813	}
814	return u.draw()
815}
816
817func (u *reconnectUI) draw() error {
818	if !*u.rawEntered || u.showing {
819		return nil
820	}
821	if u.remote {
822		size, err := u.term.Size()
823		if err != nil {
824			return fmt.Errorf("reading terminal size for reconnect toast: %w", err)
825		}
826		return u.redraw(size)
827	}
828	if _, err := u.term.Out().Write([]byte(statusReconnect)); err != nil {
829		return fmt.Errorf("writing reconnect status: %w", err)
830	}
831	if err := u.term.Flush(); err != nil {
832		return fmt.Errorf("flushing reconnect status: %w", err)
833	}
834	u.showing = true
835	return nil
836}
837
838func (u *reconnectUI) clear() error {
839	if !u.showing {
840		return nil
841	}
842	if !u.remote {
843		if _, err := u.term.Out().Write([]byte(statusClear)); err != nil {
844			return fmt.Errorf("clearing reconnect status: %w", err)
845		}
846		if err := u.term.Flush(); err != nil {
847			return fmt.Errorf("flushing reconnect status clear: %w", err)
848		}
849	}
850	u.showing = false
851	return nil
852}
853
854func (u *reconnectUI) sleep(ctx context.Context, clk ports.Clock, d time.Duration) (bool, error) {
855	if u.remote && u.showing {
856		return reconnectSleepWithResize(ctx, clk, d, u.term.ResizeEvents(), u.redraw)
857	}
858	return reconnectSleep(ctx, clk, d), nil
859}
860
861func sleepReconnect(ctx context.Context, clk ports.Clock, d time.Duration) bool {
862	t := clk.NewTimer(d)
863	defer t.Stop()
864	select {
865	case <-t.C():
866		return true
867	case <-ctx.Done():
868		return false
869	}
870}
871
872func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time.Duration, resizeEvents <-chan domain.Size, onResize func(domain.Size) error) (bool, error) {
873	t := clk.NewTimer(d)
874	defer t.Stop()
875	for {
876		select {
877		case <-t.C():
878			return true, nil
879		case <-ctx.Done():
880			return false, nil
881		case size, ok := <-resizeEvents:
882			if !ok {
883				resizeEvents = nil
884				continue
885			}
886			if onResize != nil {
887				if err := onResize(size); err != nil {
888					return false, err
889				}
890			}
891		}
892	}
893}
894
895type attachAttempt struct {
896	runner                   *Runner
897	dialer                   ports.Dialer
898	connection               *SessionConnection
899	remote                   bool
900	transport                ports.Transport
901	handshakeCtx             context.Context
902	handshakeTimedOut        <-chan struct{}
903	finishHandshake          func()
904	stopHandshakeTransport   func()
905	request                  AttachRequest
906	resumeToken              uint64
907	routeNavigationSelection *routeNavigationSelection
908	clientID                 [16]byte
909	milestones               *milestones
910	themeState               *terminalThemeState
911	enterRaw                 func() error
912	reconnect                *reconnectUI
913	linkEvents               <-chan ports.LinkEvent
914	rememberRemoteHost       func()
915	terminalInput            func() *terminalInputPump
916}
917
918type attachResult struct {
919	resumeToken       uint64
920	sessionName       string
921	committedIdentity *ports.CommittedRouteIdentity
922	routePosition     *ports.RoutePosition
923	welcomed          bool
924	transportClosed   bool
925	target            *ports.AttachTarget
926	action            ports.NavigationAction
927	routeAction       *ports.RouteNavigationAction
928	err               error
929}
930
931// AttachTargetError requests that the composition root replace the current
932// transport with the selected endpoint. The daemon never opens that endpoint.
933type AttachTargetError struct {
934	Target ports.AttachTarget
935}
936
937func (e *AttachTargetError) Error() string {
938	if e == nil {
939		return "client: attach target handoff"
940	}
941	return fmt.Sprintf("client: attach target %q/%q", e.Target.Endpoint, e.Target.Session)
942}
943
944// paletteSlot validates the scanner's signed slot before narrowing it for the
945// wire accumulator. Keep this boundary even though the in-tree Scanner also
946// validates slots: callbacks are an interface boundary.
947func paletteSlot(slot int) (uint8, bool) {
948	if slot < 0 || slot > 15 {
949		return 0, false
950	}
951	return uint8(slot), true
952}
953
954// DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
955func DetectTrueColor(termEnv, colorTerm string) bool {
956	return ports.DetectTrueColor(termEnv, colorTerm)
957}
958
959func requestedOutputWindow(transport ports.Transport) uint8 {
960	if _, ok := transport.(ports.DatagramTransport); ok {
961		return 1
962	}
963	return 8
964}
965
966func (a *attachAttempt) run(ctx context.Context) attachResult {
967	transport := a.transport
968	if a.connection != nil {
969		transport = a.connection.Transport()
970	}
971	request := a.request
972	term := a.runner.term
973	clk := a.runner.clock
974	intent := request.Intent
975	name := request.SessionName
976	resumeToken := a.resumeToken
977	clientID := a.clientID
978	ms := a.milestones
979	themeState := a.themeState
980	enterRaw := a.enterRaw
981	reconnect := a.reconnect
982	linkEvents := a.linkEvents
983	remote := a.remote || a.request.Remote
984	clipboard := a.runner.clipboard
985	log := a.runner.logger
986	observer := a.runner.runtimeObserver
987	handshakeCtx := a.handshakeCtx
988	handshakeTimedOut := a.handshakeTimedOut
989	finishHandshake := a.finishHandshake
990	stopHandshakeTransport := a.stopHandshakeTransport
991	if handshakeCtx == nil {
992		handshakeCtx, handshakeTimedOut, finishHandshake = newHandshakeContext(ctx, clk)
993		stopHandshakeTransport = watchHandshakeTransport(handshakeCtx, transport)
994	}
995	handshakeFinished := false
996	endHandshake := func() {
997		if handshakeFinished {
998			return
999		}
1000		handshakeFinished = true
1001		if stopHandshakeTransport != nil {
1002			stopHandshakeTransport()
1003		}
1004		if finishHandshake != nil {
1005			finishHandshake()
1006		}
1007	}
1008	defer endHandshake()
1009	handshakeFailure := func(stage string, err error) attachResult {
1010		if handshakeTimedOut != nil {
1011			err = handshakeContextError(ctx, handshakeTimedOut, err)
1012		}
1013		return attachResult{transportClosed: handshakeCtx.Err() != nil, err: fmt.Errorf("vev: %s: %w", stage, err)}
1014	}
1015	sendHandshake := func(operation func() error) error {
1016		err := boundedHandshakeOperation(handshakeCtx, transport, operation)
1017		if err != nil {
1018			return handshakeContextError(ctx, handshakeTimedOut, err)
1019		}
1020		return nil
1021	}
1022	// 1. Handshake: send Hello with our size and TERM.
1023	size, err := term.Size()
1024	if err != nil {
1025		return attachResult{err: fmt.Errorf("vev: reading terminal size: %w", err)}
1026	}
1027	cwd, err := os.Getwd()
1028	if err != nil {
1029		cwd = ""
1030	}
1031	termEnv := os.Getenv("TERM")
1032	colorTerm := os.Getenv("COLORTERM")
1033	trueColor := DetectTrueColor(termEnv, colorTerm)
1034	themeState.setTrueColor(trueColor)
1035	exactTarget := request.ExactTarget
1036	if exactTarget == nil && request.RemoteTarget != nil {
1037		exactTarget = &ports.ExactSessionTarget{
1038			LifecycleID: request.RemoteTarget.LifecycleID,
1039			SessionName: request.RemoteTarget.SessionName,
1040		}
1041	}
1042	hello := ports.Hello{
1043		Version:                ports.ProtocolVersion,
1044		Intent:                 intent,
1045		ClientID:               clientID,
1046		ResumeToken:            resumeToken,
1047		Name:                   name,
1048		Size:                   size,
1049		TermEnv:                termEnv,
1050		Cwd:                    cwd,
1051		TrueColor:              trueColor,
1052		MaxOutputInFlight:      requestedOutputWindow(transport),
1053		Env:                    os.Environ(),
1054		RemoteTarget:           request.RemoteTarget,
1055		ExactTarget:            exactTarget,
1056		PreferredTabID:         request.PreferredTabID,
1057		RemoteOrigin:           request.RemoteOrigin,
1058		EnvironmentPolicy:      request.EnvironmentPolicy,
1059		NavigationCapabilities: request.NavigationCapabilities,
1060		StartupOverlay:         request.StartupOverlay,
1061	}
1062	if err := sendHandshake(func() error {
1063		return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
1064	}); err != nil {
1065		return handshakeFailure("sending hello", err)
1066	}
1067	ms.helloSent = true
1068
1069	// 2. Await Welcome or a typed rejection.
1070	var reply ports.Frame
1071	if err := boundedHandshakeOperation(handshakeCtx, transport, func() error {
1072		var recvErr error
1073		reply, recvErr = transport.Recv()
1074		return recvErr
1075	}); err != nil {
1076		return handshakeFailure("awaiting welcome", err)
1077	}
1078	var committedIdentity *ports.CommittedRouteIdentity
1079	var routePosition *ports.RoutePosition
1080	result := func(welcomed bool, err error) attachResult {
1081		return attachResult{
1082			resumeToken:       resumeToken,
1083			sessionName:       name,
1084			committedIdentity: cloneCommittedIdentity(committedIdentity),
1085			routePosition:     cloneRoutePosition(routePosition),
1086			welcomed:          welcomed,
1087			err:               err,
1088		}
1089	}
1090	switch reply.Type {
1091	case ports.MsgWelcome:
1092		welcome, derr := ports.UnmarshalWelcome(reply.Payload)
1093		if derr != nil {
1094			return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))
1095		}
1096		resumeToken = welcome.ResumeToken
1097		name = welcome.SessionName
1098		committedIdentity = cloneCommittedIdentity(welcome.CommittedIdentity)
1099		ms.welcomed = true
1100		log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
1101		if remote {
1102			if remember := a.rememberRemoteHost; remember != nil {
1103				remember()
1104			}
1105		}
1106	case ports.MsgError:
1107		em, derr := ports.UnmarshalErrorMsg(reply.Payload)
1108		if derr != nil {
1109			return result(false, fmt.Errorf("vev: decoding error reply: %w", derr))
1110		}
1111		return result(false, &ProtocolError{Code: em.Code, Text: em.Text})
1112	default:
1113		return result(false, fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type))
1114	}
1115	if err := handshakeCtx.Err(); err != nil {
1116		return handshakeFailure("validating welcome", err)
1117	}
1118	welcomedResult := func(err error) attachResult { return result(true, err) }
1119	if committedIdentity != nil && a.runner.ledger != nil {
1120		if request.ExactTarget != nil && *request.ExactTarget != committedIdentity.Target {
1121			return welcomedResult(errRouteTargetChanged)
1122		}
1123		if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != committedIdentity.Target.LifecycleID || request.RemoteTarget.SessionName != committedIdentity.Target.SessionName) {
1124			return welcomedResult(errRouteTargetChanged)
1125		}
1126		candidate := routeCandidateForAttach(request, *committedIdentity, a.dialer, resumeToken)
1127		var commitErr error
1128		if a.routeNavigationSelection != nil {
1129			selection := *a.routeNavigationSelection
1130			commitErr = a.runner.ledger.commitTransition(selection.selected.identity, selection.snapshotGeneration, selection.active, candidate)
1131		} else {
1132			_, commitErr = a.runner.ledger.commitAttach(candidate)
1133		}
1134		if commitErr != nil {
1135			return welcomedResult(fmt.Errorf("vev: committing route identity: %w", commitErr))
1136		}
1137		snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
1138		if err != nil {
1139			return welcomedResult(fmt.Errorf("vev: encoding route snapshot: %w", err))
1140		}
1141		attentionPayload, err := ports.MarshalRouteAttentionSubscription(a.runner.ledger.attentionSubscription())
1142		if err != nil {
1143			return welcomedResult(fmt.Errorf("vev: encoding route attention subscription: %w", err))
1144		}
1145		if err := sendHandshake(func() error {
1146			if err := transport.Send(ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: snapshotPayload}); err != nil {
1147				return err
1148			}
1149			return transport.Send(ports.Frame{Type: ports.MsgRouteAttentionSubscription, Payload: attentionPayload})
1150		}); err != nil {
1151			return welcomedResult(fmt.Errorf("vev: publishing route snapshot: %w", err))
1152		}
1153	}
1154	if a.runner.routeFailure != nil {
1155		payload, err := ports.MarshalRouteNavigationFailure(*a.runner.routeFailure)
1156		if err != nil {
1157			return welcomedResult(fmt.Errorf("vev: encoding route navigation failure: %w", err))
1158		}
1159		if err := sendHandshake(func() error {
1160			return transport.Send(ports.Frame{Type: ports.MsgRouteNavigationFailure, Payload: payload})
1161		}); err != nil {
1162			return welcomedResult(fmt.Errorf("vev: publishing route navigation failure: %w", err))
1163		}
1164		a.runner.routeFailure = nil
1165	}
1166
1167	// 3. Enter raw mode after Welcome; Run owns restoration.
1168	if err := enterRaw(); err != nil {
1169		return welcomedResult(err)
1170	}
1171
1172	// 4. Derive a cancellable context so the pumps always unwind when the
1173	// loop returns. The attach loop is the sole terminal writer and palette
1174	// coordinator owner.
1175	loopCtx, cancel := context.WithCancel(ctx)
1176	defer cancel()
1177	input := (*terminalInputPump)(nil)
1178	if a.terminalInput != nil {
1179		input = a.terminalInput()
1180	}
1181	ownedInput := input == nil
1182	if ownedInput {
1183		input = newTerminalInputPump(term.In())
1184		input.start()
1185		defer input.stop()
1186	}
1187	inputConsumer := input.claim()
1188	claimActive := true
1189	revokeInputClaim := func() {
1190		if claimActive {
1191			input.revoke(inputConsumer)
1192			claimActive = false
1193		}
1194	}
1195	// Register revocation before any palette publication or terminal I/O: both
1196	// can fail before the stdin scanner is started, and a reconnect must still
1197	// be able to claim this lifecycle-owned reader.
1198	defer revokeInputClaim()
1199
1200	sendCh := make(chan ports.Frame, sendQueueDepth)
1201	ackQueue := newCumulativeAckQueue()
1202	sendErrCh := make(chan error, 1)
1203	senderStarted := false
1204	paletteEvents := make(chan paletteGenerationEvent, 32)
1205	var activeGeneration atomic.Uint64
1206	coordinator := newPaletteGenerationCoordinator()
1207	type paletteTimer struct {
1208		timer  ports.Timer
1209		cancel chan struct{}
1210	}
1211	drainTimers := map[paletteGenerationID]paletteTimer{}
1212	completionTimers := map[paletteGenerationID]paletteTimer{}
1213	cancelTimer := func(timers map[paletteGenerationID]paletteTimer, id paletteGenerationID) {
1214		if timer, ok := timers[id]; ok {
1215			timer.timer.Stop()
1216			close(timer.cancel)
1217			delete(timers, id)
1218		}
1219	}
1220	publish := func(snapshot ports.Theme) error {
1221		frame := ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snapshot)}
1222		// The initial cleared snapshot is sent before the sender exists. This
1223		// keeps the generation publication ordered before the first query and
1224		// avoids leaving a queued Theme behind an immediate detach.
1225		if !senderStarted {
1226			if err := sendHandshake(func() error { return transport.Send(frame) }); err != nil {
1227				return fmt.Errorf("sending initial theme: %w", err)
1228			}
1229			return nil
1230		}
1231		select {
1232		case sendCh <- frame:
1233			return nil
1234		case <-loopCtx.Done():
1235			return context.Canceled
1236		}
1237	}
1238	processPaletteActions := func(actions []paletteGenerationAction) error {
1239		for _, action := range actions {
1240			switch action.kind {
1241			case paletteActionPublishCleared:
1242				if err := publish(action.theme); err != nil {
1243					return fmt.Errorf("publishing theme: %w", err)
1244				}
1245			case paletteActionPublishFinal:
1246				themeState.update(func(current *ports.Theme) { *current = action.theme })
1247				if err := publish(action.theme); err != nil {
1248					return fmt.Errorf("publishing theme: %w", err)
1249				}
1250			case paletteActionWriteDrain, paletteActionWriteBatch:
1251				if _, err := term.Out().Write([]byte(action.bytes)); err != nil {
1252					return fmt.Errorf("writing palette query: %w", err)
1253				}
1254				if err := term.Flush(); err != nil {
1255					return fmt.Errorf("flushing palette query: %w", err)
1256				}
1257			case paletteActionArmDrainDeadline, paletteActionArmCompletionDeadline:
1258				timer := clk.NewTimer(action.deadline)
1259				entry := paletteTimer{timer: timer, cancel: make(chan struct{})}
1260				timers := drainTimers
1261				kind := paletteEventDrainDeadline
1262				if action.kind == paletteActionArmCompletionDeadline {
1263					timers = completionTimers
1264					kind = paletteEventCompletionDeadline
1265				}
1266				timers[action.id] = entry
1267				go func(id paletteGenerationID, eventKind paletteGenerationEventKind, timer paletteTimer) {
1268					select {
1269					case <-timer.timer.C():
1270						select {
1271						case paletteEvents <- paletteGenerationEvent{id: id, kind: eventKind}:
1272						case <-loopCtx.Done():
1273						}
1274					case <-timer.cancel:
1275					case <-loopCtx.Done():
1276					}
1277				}(action.id, kind, entry)
1278			case paletteActionCancelDrainDeadline:
1279				cancelTimer(drainTimers, action.id)
1280			case paletteActionCancelCompletionDeadline:
1281				cancelTimer(completionTimers, action.id)
1282			}
1283		}
1284		return nil
1285	}
1286	defer func() {
1287		for id := range drainTimers {
1288			cancelTimer(drainTimers, id)
1289		}
1290		for id := range completionTimers {
1291			cancelTimer(completionTimers, id)
1292		}
1293	}()
1294
1295	// Initial acquisition is a generation too. A reconnect publishes its one
1296	// cleared snapshot from retained definitive colors, never a restore frame.
1297	retained := themeState.snapshot()
1298	initialActions := coordinator.start(retained, false)
1299	activeGeneration.Store(uint64(coordinator.current.id))
1300	if err := processPaletteActions(initialActions); err != nil {
1301		return welcomedResult(err)
1302	}
1303
1304	var clip ports.ClipboardReader
1305	if remote {
1306		clip = clipboard
1307	}
1308	awaitingReconnectReset := false
1309	outputState := outputApplyState{}
1310	outputResetRequested := false
1311	if reconnect.showing {
1312		if reconnect.remote {
1313			// Before the asynchronous sender starts, preserve transport ordering by
1314			// requesting the reset synchronously after the initial Theme publication.
1315			resetErr := sendHandshake(func() error {
1316				payload, err := ports.MarshalResize(ports.Resize{Size: size})
1317				if err != nil {
1318					return err
1319				}
1320				return transport.Send(ports.Frame{Type: ports.MsgResize, Payload: payload})
1321			})
1322			if resetErr != nil {
1323				resetErr = handshakeContextError(ctx, handshakeTimedOut, resetErr)
1324				resetResult := welcomedResult(fmt.Errorf("vev: requesting reconnect reconciliation: %w", resetErr))
1325				resetResult.transportClosed = handshakeCtx.Err() != nil
1326				return resetResult
1327			}
1328			awaitingReconnectReset = true
1329		}
1330		if err := reconnect.clear(); err != nil {
1331			return welcomedResult(err)
1332		}
1333	}
1334	// Welcome and every synchronous initial publication have committed. The
1335	// handshake deadline must not own the long-lived runtime transport: a quiet
1336	// or unchanged screen may legitimately produce no initial Output frame.
1337	endHandshake()
1338	senderStarted = true
1339	go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)
1340	stdinDone := make(chan struct{})
1341	go func() {
1342		defer close(stdinDone)
1343		(&stdinPump{ctx: loopCtx, cancel: cancel, input: input, consumer: inputConsumer, out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run()
1344	}()
1345	// Scanner cancellation can leave an undecided marker suffix (including a
1346	// standalone Escape) that it must hand back to the lifecycle-owned reader.
1347	// Wait for that handoff before revoking the claim: otherwise a replacement
1348	// scanner could claim the reader between revocation and preservation and
1349	// lose the suffix.
1350	defer func() {
1351		cancel()
1352		<-stdinDone
1353		revokeInputClaim()
1354	}()
1355	go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
1356
1357	// 5. Output/main loop: the only goroutine that touches the terminal.
1358	recvCh := make(chan recvResult, 1)
1359	go runRecv(loopCtx, transport, recvCh, log)
1360
1361	requestReconnectReset := func() error {
1362		if awaitingReconnectReset {
1363			return nil
1364		}
1365		size, serr := term.Size()
1366		if serr != nil {
1367			return fmt.Errorf("reading terminal size for reconnect reconciliation: %w", serr)
1368		}
1369		payload, err := ports.MarshalResize(ports.Resize{Size: size})
1370		if err != nil {
1371			return fmt.Errorf("encoding terminal size for reconnect reconciliation: %w", err)
1372		}
1373		select {
1374		case sendCh <- ports.Frame{Type: ports.MsgResize, Payload: payload}:
1375			awaitingReconnectReset = true
1376			return nil
1377		case <-loopCtx.Done():
1378			return context.Canceled
1379		}
1380	}
1381	publishRouteSnapshot := func() error {
1382		if a.runner.ledger == nil {
1383			return errors.New("vev: route ledger unavailable")
1384		}
1385		snapshotPayload, err := ports.MarshalRecentRouteSnapshot(a.runner.ledger.snapshot())
1386		if err != nil {
1387			return err
1388		}
1389		attentionPayload, err := ports.MarshalRouteAttentionSubscription(a.runner.ledger.attentionSubscription())
1390		if err != nil {
1391			return err
1392		}
1393		for _, frame := range []ports.Frame{
1394			{Type: ports.MsgRecentRouteSnapshot, Payload: snapshotPayload},
1395			{Type: ports.MsgRouteAttentionSubscription, Payload: attentionPayload},
1396		} {
1397			select {
1398			case sendCh <- frame:
1399			case <-loopCtx.Done():
1400				return context.Canceled
1401			}
1402		}
1403		return nil
1404	}
1405	sendRouteFailure := func(action ports.RouteNavigationAction, code ports.RouteFailureCode) error {
1406		payload, err := ports.MarshalRouteNavigationFailure(ports.RouteNavigationFailure{
1407			Key:        action.Key,
1408			Generation: action.Generation,
1409			Code:       code,
1410		})
1411		if err != nil {
1412			return err
1413		}
1414		select {
1415		case sendCh <- ports.Frame{Type: ports.MsgRouteNavigationFailure, Payload: payload}:
1416			return nil
1417		case <-loopCtx.Done():
1418			return context.Canceled
1419		}
1420	}
1421	dismissReconnect := func() error {
1422		if !reconnect.showing {
1423			return nil
1424		}
1425		if err := reconnect.clear(); err != nil {
1426			return err
1427		}
1428		if !reconnect.remote {
1429			return nil
1430		}
1431		return requestReconnectReset()
1432	}
1433	loopCanceledResult := func() attachResult {
1434		// A pump initiated shutdown (stdin EOF/detach) or the parent context
1435		// was cancelled. A queued sender error takes priority.
1436		select {
1437		case serr := <-sendErrCh:
1438			return welcomedResult(fmt.Errorf("vev: sending to daemon: %w", serr))
1439		default:
1440			return welcomedResult(nil)
1441		}
1442	}
1443	for {
1444		select {
1445		case <-loopCtx.Done():
1446			return loopCanceledResult()
1447		case ev, ok := <-linkEvents:
1448			if !ok {
1449				linkEvents = nil
1450				continue
1451			}
1452			if ev.State == ports.LinkStateConnected {
1453				if err := dismissReconnect(); err != nil {
1454					if errors.Is(err, context.Canceled) {
1455						return loopCanceledResult()
1456					}
1457					return welcomedResult(fmt.Errorf("dismissing reconnect toast: %w", err))
1458				}
1459				select {
1460				case sendCh <- ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkConnected})}:
1461				case <-loopCtx.Done():
1462					return welcomedResult(nil)
1463				}
1464				continue
1465			}
1466			if ev.State == ports.LinkStateDegraded {
1467				log.Warn("UDP link degraded")
1468				select {
1469				case sendCh <- ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded})}:
1470				case <-loopCtx.Done():
1471					return welcomedResult(nil)
1472				}
1473				continue
1474			}
1475			stage := stageForLinkState(ev.State)
1476			if reconnect.remote && reconnect.showing && reconnect.stage != stage {
1477				reconnect.stage = stage
1478				if err := requestReconnectReset(); err != nil {
1479					if errors.Is(err, context.Canceled) {
1480						return loopCanceledResult()
1481					}
1482					return welcomedResult(fmt.Errorf("requesting reconnect reconciliation: %w", err))
1483				}
1484			} else if err := reconnect.drawStage(stage); err != nil {
1485				return welcomedResult(err)
1486			}
1487			if ev.State == ports.LinkStateOffline {
1488				// Stop waiting for the transport to reach Dead at 60s: exit with
1489				// a retryable error so Run re-dials over ssh with the resume
1490				// token. Run closes this transport on the way out.
1491				return welcomedResult(errLinkOffline)
1492			}
1493		case event := <-paletteEvents:
1494			if event.kind == paletteEventScheme {
1495				retained := themeState.update(func(current *ports.Theme) {
1496					current.SchemeKnown = true
1497					current.Light = event.light
1498				})
1499				actions := coordinator.start(retained, true)
1500				activeGeneration.Store(uint64(coordinator.current.id))
1501				if err := processPaletteActions(actions); err != nil {
1502					return welcomedResult(err)
1503				}
1504				continue
1505			}
1506			if err := processPaletteActions(coordinator.handle(event)); err != nil {
1507				return welcomedResult(err)
1508			}
1509		case r := <-recvCh:
1510			if r.err != nil {
1511				if errors.Is(r.err, io.EOF) {
1512					return welcomedResult(fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err))
1513				}
1514				return welcomedResult(fmt.Errorf("vev: receiving from daemon: %w", r.err))
1515			}
1516			switch r.frame.Type {
1517			case ports.MsgOutput:
1518				o, derr := ports.UnmarshalOutput(r.frame.Payload)
1519				if derr != nil {
1520					return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))
1521				}
1522				nextState, accepted := outputState.next(o)
1523				if !accepted {
1524					// A discarded frame was never written and must never be ACKed.
1525					// Ask the daemon for one authoritative full reset, coalescing
1526					// repeated gaps while that reset is in flight.
1527					if !outputResetRequested {
1528						select {
1529						case sendCh <- ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}:
1530							outputResetRequested = true
1531						case <-loopCtx.Done():
1532							return loopCanceledResult()
1533						}
1534					}
1535					continue
1536				}
1537				if _, werr := term.Out().Write(o.Data); werr != nil {
1538					return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
1539				}
1540				if awaitingReconnectReset && o.Full && o.Base == 0 && o.New != 0 {
1541					awaitingReconnectReset = false
1542				}
1543				if reconnect.showing && reconnect.remote {
1544					size, serr := term.Size()
1545					if serr != nil {
1546						return welcomedResult(fmt.Errorf("vev: reading terminal size for reconnect redraw: %w", serr))
1547					}
1548					if rerr := reconnect.redraw(size); rerr != nil {
1549						return welcomedResult(fmt.Errorf("vev: redrawing reconnect toast: %w", rerr))
1550					}
1551				} else if ferr := term.Flush(); ferr != nil {
1552					return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
1553				}
1554				outputState = nextState
1555				if o.New != 0 {
1556					ackQueue.offer(o.Epoch, o.New)
1557					outputResetRequested = false
1558				}
1559				// The terminal boundary is after a successful flush and before ACK.
1560				if observer != nil {
1561					observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true))
1562				}
1563				if !ms.firstOutput {
1564					ms.firstOutput = true
1565					log.Debug("received first output")
1566					endHandshake()
1567				}
1568			case ports.MsgAttachTarget:
1569				target, derr := ports.UnmarshalAttachTarget(r.frame.Payload)
1570				if derr != nil {
1571					return welcomedResult(fmt.Errorf("vev: decoding attach target: %w", derr))
1572				}
1573				handoff := welcomedResult(nil)
1574				handoff.target = &target
1575				return handoff
1576			case ports.MsgNavigationAction:
1577				action, derr := ports.UnmarshalNavigationAction(r.frame.Payload)
1578				if derr != nil {
1579					return welcomedResult(fmt.Errorf("vev: decoding navigation action: %w", derr))
1580				}
1581				navigation := welcomedResult(nil)
1582				navigation.action = action
1583				return navigation
1584			case ports.MsgNavigateRecentRoute:
1585				action, derr := ports.UnmarshalRouteNavigationAction(r.frame.Payload)
1586				if derr != nil {
1587					return welcomedResult(fmt.Errorf("vev: decoding route navigation action: %w", derr))
1588				}
1589				if a.runner.ledger == nil {
1590					if ferr := sendRouteFailure(action, ports.RouteFailureUnavailable); ferr != nil {
1591						return welcomedResult(ferr)
1592					}
1593					continue
1594				}
1595				_, valid, noOp := a.runner.ledger.navigationRecord(action)
1596				if !valid {
1597					if ferr := sendRouteFailure(action, ports.RouteFailureStaleSelection); ferr != nil {
1598						return welcomedResult(ferr)
1599					}
1600					continue
1601				}
1602				if noOp {
1603					continue
1604				}
1605				navigation := welcomedResult(nil)
1606				navigation.routeAction = &action
1607				return navigation
1608			case ports.MsgCommittedRouteIdentity:
1609				identity, derr := ports.UnmarshalCommittedRouteIdentity(r.frame.Payload)
1610				if derr != nil {
1611					return welcomedResult(fmt.Errorf("vev: decoding committed route identity: %w", derr))
1612				}
1613				name = identity.Target.SessionName
1614				committedIdentity = cloneCommittedIdentity(&identity)
1615				if a.runner.ledger == nil {
1616					return welcomedResult(errors.New("vev: route ledger unavailable"))
1617				}
1618				if _, derr := a.runner.ledger.commitCommittedIdentity(identity); derr != nil {
1619					return welcomedResult(fmt.Errorf("vev: committing daemon-local route identity: %w", derr))
1620				}
1621				if derr := publishRouteSnapshot(); derr != nil {
1622					return welcomedResult(fmt.Errorf("vev: publishing daemon-local route snapshot: %w", derr))
1623				}
1624			case ports.MsgRoutePosition:
1625				position, derr := ports.UnmarshalRoutePosition(r.frame.Payload)
1626				if derr != nil {
1627					return welcomedResult(fmt.Errorf("vev: decoding route position: %w", derr))
1628				}
1629				if a.runner.ledger == nil {
1630					return welcomedResult(errors.New("vev: route ledger unavailable"))
1631				}
1632				if derr := a.runner.ledger.updateRoutePosition(position); derr != nil {
1633					return welcomedResult(fmt.Errorf("vev: remembering route position: %w", derr))
1634				}
1635				routePosition = cloneRoutePosition(&position)
1636			case ports.MsgRouteNavigationFailure:
1637				failure, derr := ports.UnmarshalRouteNavigationFailure(r.frame.Payload)
1638				if derr != nil {
1639					return welcomedResult(fmt.Errorf("vev: decoding route navigation failure: %w", derr))
1640				}
1641				log.Warn("route navigation rejected", "key", failure.Key, "generation", failure.Generation, "code", failure.Code)
1642			case ports.MsgDetached:
1643				d, derr := ports.UnmarshalDetached(r.frame.Payload)
1644				if derr != nil {
1645					return welcomedResult(fmt.Errorf("vev: decoding detached: %w", derr))
1646				}
1647				ms.detached = true
1648				if d.Reason == ports.ReasonDetach {
1649					log.Info("detached cleanly", "reason", d.Reason)
1650				} else {
1651					log.Warn("detached by daemon", "reason", d.Reason)
1652				}
1653				return welcomedResult(detachedResult(d.Reason))
1654			case ports.MsgPong:
1655				// Liveness reply; nothing to do.
1656			default:
1657				// Unknown/out-of-band message types are ignored so a newer
1658				// daemon can add server->client messages without breaking us.
1659			}
1660		}
1661	}
1662}
1663
1664// recvResult carries one framed message (or a read error) from the receive
1665// pump to the main loop.
1666type recvResult struct {
1667	frame ports.Frame
1668	err   error
1669}
1670
1671// runRecv reads frames until an error, forwarding each to the main loop.
1672// It exits on the first error or when the loop context is cancelled.
1673func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResult, log *slog.Logger) {
1674	defer log.Debug("receive pump exited")
1675	for {
1676		f, err := transport.Recv()
1677		select {
1678		case out <- recvResult{frame: f, err: err}:
1679		case <-ctx.Done():
1680			return
1681		}
1682		if err != nil {
1683			return
1684		}
1685	}
1686}
1687
1688// runSender is the sole caller of transport.Send: serialising the input and
1689// resize pumps through one goroutine keeps the transport's single-writer
1690// contract intact. A send failure cancels the loop and is surfaced once.
1691type cumulativeAckQueue struct {
1692	mu          sync.Mutex
1693	latestEpoch uint64
1694	latestState uint64
1695	wake        chan struct{}
1696}
1697
1698func newCumulativeAckQueue() *cumulativeAckQueue {
1699	return &cumulativeAckQueue{wake: make(chan struct{}, 1)}
1700}
1701
1702func (q *cumulativeAckQueue) offer(epoch, state uint64) {
1703	if epoch == 0 || state == 0 {
1704		return
1705	}
1706	q.mu.Lock()
1707	if epoch > q.latestEpoch || (epoch == q.latestEpoch && state > q.latestState) {
1708		q.latestEpoch, q.latestState = epoch, state
1709	}
1710	q.mu.Unlock()
1711	select {
1712	case q.wake <- struct{}{}:
1713	default:
1714	}
1715}
1716
1717func (q *cumulativeAckQueue) take() (uint64, uint64) {
1718	q.mu.Lock()
1719	defer q.mu.Unlock()
1720	epoch, state := q.latestEpoch, q.latestState
1721	q.latestEpoch, q.latestState = 0, 0
1722	return epoch, state
1723}
1724
1725func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {
1726	defer log.Debug("sender pump exited")
1727	send := func(f ports.Frame) bool {
1728		if err := transport.Send(f); err != nil {
1729			select {
1730			case errCh <- err:
1731			default:
1732			}
1733			cancel()
1734			return false
1735		}
1736		return true
1737	}
1738	sendAck := func() bool {
1739		epoch, state := acks.take()
1740		if state == 0 {
1741			return true
1742		}
1743		payload, err := ports.MarshalAck(ports.Ack{Epoch: epoch, State: state})
1744		if err != nil {
1745			select {
1746			case errCh <- fmt.Errorf("encoding output ACK: %w", err):
1747			default:
1748			}
1749			cancel()
1750			return false
1751		}
1752		return send(ports.Frame{Type: ports.MsgAck, Payload: payload})
1753	}
1754	for {
1755		select {
1756		case <-acks.wake:
1757			if !sendAck() {
1758				return
1759			}
1760			continue
1761		default:
1762		}
1763		select {
1764		case <-acks.wake:
1765			if !sendAck() {
1766				return
1767			}
1768		case f := <-in:
1769			select {
1770			case <-ctx.Done():
1771				return
1772			default:
1773			}
1774			if !send(f) {
1775				return
1776			}
1777		case <-ctx.Done():
1778			return
1779		}
1780	}
1781}
1782
1783// terminalReadResult is copied by terminalInputPump before the next Read so
1784// terminal input remains owned by one lifecycle reader across reconnects.
1785type terminalReadResult struct {
1786	data []byte
1787	err  error
1788}
1789
1790// terminalInputPump is the sole consumer of the caller-owned terminal reader.
1791// It is started once after raw mode is entered and is reused by each attach
1792// scanner. A bare io.Reader cannot be interrupted, so a Read may stay blocked
1793// after Run exits; suspend drops any result received while inactive and never
1794// closes caller-owned stdin. Reconnects and later runs never add another reader.
1795type terminalInputPump struct {
1796	in   io.Reader
1797	done chan struct{}
1798
1799	mu sync.Mutex
1800	// residual holds scanner bytes that were read but not delivered when an
1801	// attempt is cancelled. It is consumed before the next terminal Read so a
1802	// replacement scanner neither loses a partial marker nor replays bytes
1803	// whose callbacks already ran.
1804	residual           []byte
1805	pending            *terminalReadResult
1806	consumer           uint64
1807	delivering         uint64
1808	deliveringResidual bool
1809	nextID             uint64
1810	activation         uint64
1811	closed             bool
1812	active             bool
1813	ready              chan struct{}
1814	space              chan struct{}
1815	state              chan struct{}
1816	exited             chan struct{}
1817	afterRevoke        func() // test synchronization hook
1818	startMu            sync.Once
1819	stopMu             sync.Once
1820}
1821
1822func newTerminalInputPump(in io.Reader) *terminalInputPump {
1823	space := make(chan struct{}, 1)
1824	space <- struct{}{}
1825	return &terminalInputPump{
1826		in:         in,
1827		done:       make(chan struct{}),
1828		activation: 1,
1829		active:     true,
1830		ready:      make(chan struct{}, 1),
1831		space:      space,
1832		state:      make(chan struct{}, 1),
1833		exited:     make(chan struct{}),
1834	}
1835}
1836
1837// claim grants one attach scanner exclusive permission to dequeue terminal
1838// input. The reader itself remains lifecycle-owned across reconnects.
1839func (p *terminalInputPump) claim() uint64 {
1840	p.mu.Lock()
1841	defer p.mu.Unlock()
1842	if p.consumer != 0 {
1843		panic("terminal input consumer already claimed")
1844	}
1845	p.nextID++
1846	p.consumer = p.nextID
1847	return p.consumer
1848}
1849
1850// revoke invalidates an attempt before its replacement is allowed to claim
1851// input. Pending bytes are deliberately retained for that replacement.
1852func (p *terminalInputPump) revoke(consumer uint64) {
1853	p.mu.Lock()
1854	if p.consumer == consumer {
1855		p.consumer = 0
1856	}
1857	// An unacknowledged read was never delivered by this scanner. Leave it
1858	// pending and make it available to its replacement.
1859	if p.delivering == consumer {
1860		p.delivering = 0
1861		p.deliveringResidual = false
1862	}
1863	p.mu.Unlock()
1864	p.signalReady()
1865	if p.afterRevoke != nil {
1866		p.afterRevoke()
1867	}
1868}
1869
1870func (p *terminalInputPump) signalReady() {
1871	select {
1872	case p.ready <- struct{}{}:
1873	default:
1874	}
1875}
1876
1877// enqueue retains at most one completed terminal read. The reader blocks
1878// before publishing another result, keeping handoff buffering bounded.
1879func (p *terminalInputPump) enqueue(result terminalReadResult, activation uint64) bool {
1880	select {
1881	case <-p.done:
1882		return false
1883	case <-p.space:
1884	}
1885	p.mu.Lock()
1886	if p.closed || !p.active || p.activation != activation {
1887		p.mu.Unlock()
1888		p.space <- struct{}{}
1889		return false
1890	}
1891	p.pending = &result
1892	p.mu.Unlock()
1893	p.signalReady()
1894	return true
1895}
1896
1897// take leases a ready result only to the current, non-cancelled consumer.
1898// The raw read remains pending until ack, so revocation or cancellation before
1899// scanner delivery leaves the exact bytes available to the next attempt.
1900func (p *terminalInputPump) take(ctx context.Context, consumer uint64) (terminalReadResult, bool) {
1901	p.mu.Lock()
1902	if ctx.Err() != nil || p.consumer != consumer || p.delivering != 0 {
1903		ready := (len(p.residual) != 0 || p.pending != nil) && p.delivering == 0
1904		p.mu.Unlock()
1905		if ready {
1906			p.signalReady()
1907		}
1908		return terminalReadResult{}, false
1909	}
1910	if len(p.residual) != 0 {
1911		result := terminalReadResult{data: append([]byte(nil), p.residual...)}
1912		p.delivering = consumer
1913		p.deliveringResidual = true
1914		p.mu.Unlock()
1915		return result, true
1916	}
1917	if p.pending == nil {
1918		p.mu.Unlock()
1919		return terminalReadResult{}, false
1920	}
1921	result := *p.pending
1922	p.delivering = consumer
1923	p.deliveringResidual = false
1924	p.mu.Unlock()
1925	return result, true
1926}
1927
1928// preserveResidual records the undecided marker prefix after callbacks for the
1929// rest of its read have completed. It is intentionally bounded by the marker
1930// scanner's fixed response prefixes and is replayed before later terminal
1931// reads on a replacement attempt.
1932func (p *terminalInputPump) preserveResidual(consumer uint64, data []byte) {
1933	if len(data) == 0 {
1934		return
1935	}
1936	p.mu.Lock()
1937	defer p.mu.Unlock()
1938	if p.consumer != consumer {
1939		return
1940	}
1941	p.residual = append(p.residual[:0], data...)
1942}
1943
1944// ack commits scanner delivery of a leased read and lets the lifecycle reader
1945// accept the next result. Only the consumer that holds the lease can ack it.
1946func (p *terminalInputPump) ack(consumer uint64) {
1947	p.mu.Lock()
1948	if p.consumer != consumer || p.delivering != consumer {
1949		p.mu.Unlock()
1950		return
1951	}
1952	wasResidual := p.deliveringResidual
1953	if wasResidual {
1954		p.residual = nil
1955	} else {
1956		p.pending = nil
1957	}
1958	p.delivering = 0
1959	p.deliveringResidual = false
1960	more := len(p.residual) != 0 || p.pending != nil
1961	p.mu.Unlock()
1962	if wasResidual {
1963		if more {
1964			p.signalReady()
1965		}
1966		return
1967	}
1968	p.space <- struct{}{}
1969}
1970
1971func (p *terminalInputPump) finish() {
1972	p.mu.Lock()
1973	p.closed = true
1974	p.mu.Unlock()
1975	p.signalReady()
1976	p.signalState()
1977}
1978
1979func (p *terminalInputPump) signalState() {
1980	select {
1981	case p.state <- struct{}{}:
1982	default:
1983	}
1984}
1985
1986func (p *terminalInputPump) waitForActive() (uint64, bool) {
1987	for {
1988		p.mu.Lock()
1989		active, closed, activation := p.active, p.closed, p.activation
1990		p.mu.Unlock()
1991		if closed {
1992			return 0, false
1993		}
1994		if active {
1995			return activation, true
1996		}
1997		select {
1998		case <-p.done:
1999			return 0, false
2000		case <-p.state:
2001		}
2002	}
2003}
2004
2005func (p *terminalInputPump) start() {
2006	p.startMu.Do(func() {
2007		go func() {
2008			defer close(p.exited)
2009			buf := make([]byte, stdinBufSize)
2010			for {
2011				activation, ok := p.waitForActive()
2012				if !ok {
2013					return
2014				}
2015				n, err := p.in.Read(buf)
2016				result := terminalReadResult{err: err}
2017				if n > 0 {
2018					result.data = append([]byte(nil), buf[:n]...)
2019				}
2020				if !p.enqueue(result, activation) {
2021					if p.isClosed() {
2022						return
2023					}
2024					continue
2025				}
2026				if err != nil {
2027					p.finish()
2028					return
2029				}
2030			}
2031		}()
2032	})
2033}
2034
2035func (p *terminalInputPump) hasExited() bool {
2036	select {
2037	case <-p.exited:
2038		return true
2039	default:
2040		return false
2041	}
2042}
2043
2044func (p *terminalInputPump) isClosed() bool {
2045	p.mu.Lock()
2046	defer p.mu.Unlock()
2047	return p.closed
2048}
2049
2050// suspend drops input completed after an attach ends and parks the lifecycle
2051// reader before its next Read. A later Run resumes this same reader, avoiding
2052// both input replay between runs and a competing read on caller-owned stdin.
2053func (p *terminalInputPump) suspend() {
2054	p.mu.Lock()
2055	p.active = false
2056	discarded := p.pending != nil
2057	p.residual = nil
2058	p.pending = nil
2059	p.delivering = 0
2060	p.deliveringResidual = false
2061	p.mu.Unlock()
2062	if discarded {
2063		select {
2064		case p.space <- struct{}{}:
2065		default:
2066		}
2067	}
2068	p.signalState()
2069	p.signalReady()
2070}
2071
2072func (p *terminalInputPump) resume() {
2073	p.mu.Lock()
2074	if !p.closed && !p.active {
2075		p.activation++
2076		p.active = true
2077	}
2078	p.mu.Unlock()
2079	p.signalState()
2080}
2081
2082func (p *terminalInputPump) stop() {
2083	p.stopMu.Do(func() {
2084		// Mark closure while holding the same mutex that publishes a completed
2085		// Read. A Read which returns after stop therefore cannot publish even
2086		// when both done and space are selectable in enqueue.
2087		p.mu.Lock()
2088		p.closed = true
2089		p.mu.Unlock()
2090		close(p.done)
2091		p.signalReady()
2092		p.signalState()
2093	})
2094}
2095
2096// stdinPump parses terminal color reports into generation-tagged events while
2097// preserving all ordinary terminal input byte-for-byte. It never reads the
2098// terminal directly, publishes a Theme, or writes terminal output;
2099// attachAttempt owns those operations.
2100type stdinPump struct {
2101	ctx                 context.Context
2102	cancel              context.CancelFunc
2103	in                  io.Reader // compatibility for isolated scanner tests
2104	input               *terminalInputPump
2105	consumer            uint64
2106	out                 chan<- ports.Frame
2107	clock               ports.Clock
2108	clipboard           ports.ClipboardReader
2109	logger              *slog.Logger
2110	paletteEvents       chan<- paletteGenerationEvent
2111	activeGeneration    *atomic.Uint64
2112	afterPaletteEvent   func(paletteGenerationEvent) // test synchronization hook
2113	afterInputTake      func()                       // test synchronization hook
2114	afterInputDelivered func()                       // test synchronization hook
2115}
2116
2117func (p *stdinPump) run() {
2118	defer p.logger.Debug("stdin pump exited")
2119	var scanner theme.Scanner
2120	var markers paletteMarkerScanner
2121	var inputSeq uint64
2122	var sendOK atomic.Bool
2123	var undeliveredMu sync.Mutex
2124	var undelivered []byte
2125	sendOK.Store(true)
2126	send := func(frame ports.Frame) bool {
2127		if !sendOK.Load() {
2128			return false
2129		}
2130		select {
2131		case p.out <- frame:
2132			if p.afterInputDelivered != nil {
2133				p.afterInputDelivered()
2134			}
2135			return true
2136		case <-p.ctx.Done():
2137			sendOK.Store(false)
2138			return false
2139		}
2140	}
2141	sendEvent := func(event paletteGenerationEvent) bool {
2142		if !sendOK.Load() {
2143			return false
2144		}
2145		select {
2146		case p.paletteEvents <- event:
2147			if p.afterPaletteEvent != nil {
2148				p.afterPaletteEvent(event)
2149			}
2150			return true
2151		case <-p.ctx.Done():
2152			sendOK.Store(false)
2153			return false
2154		}
2155	}
2156	coalescer := newPasteCoalescer(p.clock, func(data []byte) {
2157		if len(data) == 0 {
2158			return
2159		}
2160		inputSeq++
2161		if !send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: append([]byte(nil), data...)})}) {
2162			undeliveredMu.Lock()
2163			undelivered = append(undelivered, data...)
2164			undeliveredMu.Unlock()
2165		}
2166	})
2167	defer coalescer.Close()
2168	sink := coalescer.Scan
2169	if p.clipboard != nil {
2170		ci := &clipboardIntercept{
2171			coalescer: coalescer,
2172			reader:    p.clipboard,
2173			log:       p.logger,
2174			sendNotice: func(action uint8) {
2175				send(ports.Frame{Type: ports.MsgClientNotice, Payload: ports.MarshalClientNotice(ports.ClientNotice{Action: action})})
2176			},
2177			sendImage: func(mime string, data []byte) {
2178				inputSeq++
2179				send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})})
2180			},
2181			next: coalescer.Scan,
2182		}
2183		sink = ci.Scan
2184	}
2185	input := p.input
2186	ownedInput := input == nil
2187	if ownedInput {
2188		input = newTerminalInputPump(p.in)
2189		input.start()
2190		defer input.stop()
2191	}
2192	consumer := p.consumer
2193	if consumer == 0 {
2194		consumer = input.claim()
2195		defer input.revoke(consumer)
2196	}
2197	// A marker prefix is ordinary terminal input until it becomes a complete
2198	// DECRQM reply. If cancellation wins while it is withheld, preserve just
2199	// that undecided suffix; the rest of its source read has already been
2200	// delivered and must not be replayed by the next attempt.
2201	defer func() {
2202		if p.ctx.Err() != nil {
2203			// Closing flushes any bracketed-paste prefix or buffer through the
2204			// coalescer's emit callback. Failed ordinary-input deliveries are
2205			// retained ahead of a trailing undecided marker prefix, matching their
2206			// original order within the raw read.
2207			held := coalescer.CloseAndTakeHeld()
2208			undeliveredMu.Lock()
2209			residual := append([]byte(nil), undelivered...)
2210			undeliveredMu.Unlock()
2211			residual = append(residual, held...)
2212			residual = append(residual, markers.takePending()...)
2213			input.preserveResidual(consumer, residual)
2214		}
2215	}()
2216
2217	var markerTimer ports.Timer
2218	var markerTimerC <-chan time.Time
2219	flushMarkerPrefix := func() {
2220		markers.flush(sink)
2221		markerTimer = nil
2222		markerTimerC = nil
2223	}
2224	// Disarm before processing a subsequent read. A deadline already ready at
2225	// that boundary wins, so bytes cannot be retroactively consumed as a
2226	// marker after they were due to be forwarded as ordinary input.
2227	disarmMarkerDeadline := func() {
2228		if markerTimer == nil {
2229			return
2230		}
2231		select {
2232		case <-markerTimerC:
2233			flushMarkerPrefix()
2234			return
2235		default:
2236		}
2237		if !markerTimer.Stop() {
2238			// Stop reports false only after expiry or a prior stop. This timer is
2239			// owned only here, so expiry wins even if delivery to C races this
2240			// select; do not let a late value consume a newly arrived suffix.
2241			flushMarkerPrefix()
2242			return
2243		}
2244		markerTimer = nil
2245		markerTimerC = nil
2246	}
2247	armMarkerDeadline := func() {
2248		disarmMarkerDeadline()
2249		markerTimer = p.clock.NewTimer(paletteMarkerAmbiguityDeadline)
2250		markerTimerC = markerTimer.C()
2251	}
2252	defer disarmMarkerDeadline()
2253
2254	for {
2255		select {
2256		case <-p.ctx.Done():
2257			return
2258		case <-markerTimerC:
2259			flushMarkerPrefix()
2260		case <-input.ready:
2261			result, ok := input.take(p.ctx, consumer)
2262			if !ok {
2263				if p.ctx.Err() != nil {
2264					return
2265				}
2266				continue
2267			}
2268			if p.afterInputTake != nil {
2269				p.afterInputTake()
2270			}
2271			// The lease is not acknowledged until scanner delivery completes.
2272			// This second check closes the cancellation window after take.
2273			if p.ctx.Err() != nil {
2274				return
2275			}
2276			disarmMarkerDeadline()
2277			// All scanner callbacks from this Read must retain this one generation.
2278			// A scheme notification can start its replacement before a later byte in
2279			// the same read is scanned; reloading there would misclassify an old
2280			// completion marker as the replacement's drain response.
2281			readGeneration := paletteGenerationID(p.activeGeneration.Load())
2282			if len(result.data) > 0 {
2283				scanner.Scan(result.data, func(kind int, rgb renderer.RGB) {
2284					kindEvent := paletteEventForeground
2285					if kind == 11 {
2286						kindEvent = paletteEventBackground
2287					}
2288					sendEvent(paletteGenerationEvent{id: readGeneration, kind: kindEvent, rgb: rgb})
2289				}, func(slot int, rgb renderer.RGB) {
2290					// Scanner implementations are independently testable; retain the
2291					// protocol boundary here too so a malformed implementation cannot
2292					// wrap a negative or oversized int into an ANSI palette slot.
2293					slot8, ok := paletteSlot(slot)
2294					if !ok {
2295						return
2296					}
2297					sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventPalette, slot: slot8, rgb: rgb})
2298				}, func(light bool) {
2299					sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventScheme, light: light})
2300				}, func(data []byte) {
2301					markers.scan(data, sink, func() {
2302						sendEvent(paletteGenerationEvent{id: readGeneration, kind: paletteEventMarker})
2303					})
2304				})
2305				if !sendOK.Load() {
2306					// Commit the source read so callbacks accepted before cancellation
2307					// are never replayed. The deferred handoff retains only ordinary
2308					// bytes whose callback could not be delivered, plus any undecided
2309					// trailing marker prefix.
2310					input.ack(consumer)
2311					return
2312				}
2313			}
2314			if markers.hasPendingPrefix() {
2315				armMarkerDeadline()
2316			}
2317			if result.err != nil {
2318				disarmMarkerDeadline()
2319				markers.flush(sink)
2320				select {
2321				case p.out <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}:
2322				case <-p.ctx.Done():
2323				}
2324				p.cancel()
2325				return
2326			}
2327			// Commit every callback from this raw read before observing a
2328			// concurrent cancellation. Any remaining marker prefix is retained
2329			// by the deferred handoff above, so reconnects never duplicate the
2330			// delivered bytes or lose a standalone Escape.
2331			input.ack(consumer)
2332			if p.ctx.Err() != nil {
2333				return
2334			}
2335		}
2336	}
2337}
2338
2339// runResize forwards coalesced terminal resize events to the daemon. It
2340// tolerates an already-closed resize channel (which the terminal adapter
2341// hands back when restore ran before ResizeEvents was first called).
2342func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.Frame, log *slog.Logger) {
2343	defer log.Debug("resize pump exited")
2344	for {
2345		select {
2346		case sz, ok := <-events:
2347			if !ok {
2348				return
2349			}
2350			payload, err := ports.MarshalResize(ports.Resize{Size: sz})
2351			if err != nil {
2352				log.Error("encoding terminal resize", "error", err)
2353				continue
2354			}
2355			frame := ports.Frame{Type: ports.MsgResize, Payload: payload}
2356			select {
2357			case out <- frame:
2358			case <-ctx.Done():
2359				return
2360			}
2361		case <-ctx.Done():
2362			return
2363		}
2364	}
2365}
2366
2367// detachedResult maps a Detached reason to a return value: an ordinary
2368// user-initiated detach is a clean (nil) exit; anything else is surfaced so
2369// the user learns why the session went away.
2370func detachedResult(reason uint8) error {
2371	switch reason {
2372	case ports.ReasonDetach:
2373		return nil
2374	case ports.ReasonSessionKilled:
2375		return &DetachedError{Reason: reason, Text: "session was killed"}
2376	case ports.ReasonServerShutdown:
2377		return &DetachedError{Reason: reason, Text: "daemon shut down"}
2378	case ports.ReasonReplaced:
2379		return &DetachedError{Reason: reason, Text: "session taken over by another client"}
2380	default:
2381		return &DetachedError{Reason: reason, Text: "detached by daemon"}
2382	}
2383}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close