session.go RawHistoryFolder

1// Package protocol defines vev's typed, transport-neutral client/daemon
2// contract. Binary framing and codecs live outside this package.
3package protocol
4
5import (
6	"errors"
7	"fmt"
8	"math"
9	"time"
10
11	renderer "github.com/bnema/vev-vt"
12	"github.com/bnema/vev/internal/domain"
13	"github.com/bnema/vev/internal/domain/terminalcap"
14)
15
16// Version is the negotiated vev session protocol version. It carries the
17// daemonmux physical preamble conversation (multiplex.proto), whose
18// negotiated ceilings and accepted daemon binding ride the same wire
19// version as the session and broker conversations.
20const Version uint16 = 65
21
22// HandshakeTimeout bounds every transport handshake from connect through the
23// first committed publication. It excludes the preceding client-local
24// warm-reuse probe, which has its own short budget: a cold dial that follows
25// a warm miss still receives the full handshake budget.
26const HandshakeTimeout = 15 * time.Second
27
28// MaxOutputDataLen reserves the type byte, fixed Output fields, and the largest
29// valid ViewContext (publication, lifecycle, session name, ephemeral flag, tab
30// and pane IDs, including string lengths) within the 16 MiB frame limit.
31const MaxOutputDataLen = (16 << 20) - 1 - 55 - (8 + 16 + 2 + 64 + 1 + 2 + 128 + 2 + 128)
32
33// MaxOutputWindow caps output states sent before client acknowledgement.
34const MaxOutputWindow = 8
35
36// ConnectionCapabilities describes transport behavior relevant to session
37// flow without naming a concrete carriage.
38type ConnectionCapabilities struct {
39	OutputDataLimit       int
40	PreferredOutputWindow uint8
41	AsyncSend             bool
42	OwnedSynchronousSend  bool
43	LinkState             bool
44}
45
46var (
47	ErrInvalidSize              = errors.New("ports: invalid terminal size")
48	ErrInvalidHello             = errors.New("ports: invalid hello")
49	ErrInvalidOutput            = errors.New("ports: invalid output")
50	ErrInvalidAck               = errors.New("ports: invalid ack")
51	ErrInvalidAttachTarget      = errors.New("ports: invalid attach target")
52	ErrInvalidNavigation        = errors.New("ports: invalid navigation")
53	ErrInvalidEnvironmentPolicy = errors.New("ports: invalid environment policy")
54	ErrInvalidClientNotice      = errors.New("ports: invalid client notice")
55	ErrInvalidTerminalFocus     = errors.New("ports: invalid terminal focus")
56	ErrInvalidDetached          = errors.New("ports: invalid detached reason")
57)
58
59const (
60	IntentEphemeral uint8 = 0
61	IntentNew       uint8 = 1
62	IntentAttach    uint8 = 2
63	IntentResume    uint8 = 3
64)
65
66type EnvironmentPolicy uint8
67
68const (
69	EnvironmentPolicyClientOwned EnvironmentPolicy = iota
70	EnvironmentPolicyDaemonOwned
71)
72
73const (
74	CapabilityResume  uint32 = 1 << 0
75	CapabilityUDP     uint32 = 1 << 1
76	CapabilityPredict uint32 = 1 << 2
77)
78
79const (
80	ErrVersionMismatch    uint16 = 1
81	ErrNoSuchSession      uint16 = 2
82	ErrNameTaken          uint16 = 3
83	ErrServerShutdown     uint16 = 4
84	ErrInvalidSessionName uint16 = 5
85	ErrUnknownCommand     uint16 = 6
86	ErrNotScriptable      uint16 = 7
87	ErrInvalidCommandArgs uint16 = 8
88	ErrNoSuchTarget       uint16 = 9
89	ErrAmbiguousTarget    uint16 = 10
90	ErrInternal           uint16 = 255
91)
92
93const (
94	ReasonDetach         uint8 = 0
95	ReasonSessionKilled  uint8 = 1
96	ReasonServerShutdown uint8 = 2
97	ReasonReplaced       uint8 = 3
98	// ReasonDetachToPicker transfers terminal ownership to the client picker.
99	ReasonDetachToPicker uint8 = 4
100)
101
102// Hello is sent by the client immediately after connecting.
103type Hello struct {
104	Version     uint16
105	Intent      uint8
106	ClientID    [16]byte
107	ResumeToken uint64
108	Name        string
109	Size        domain.Size
110	PixelWidth  int
111	PixelHeight int
112	TermEnv     string
113	Cwd         string
114	// Color is the color output capability the client claims for this
115	// attachment. It travels only in Hello; Theme carries no capability.
116	Color terminalcap.ColorCapabilities
117	// KittyDirectGraphics is an explicit declaration that the client has
118	// probed its direct outer terminal and it accepts Kitty graphics output.
119	KittyDirectGraphics bool
120	// KittyKeyboard declares that the client enabled the kitty keyboard
121	// protocol on its outer terminal, so input carries kitty key sequences.
122	KittyKeyboard          bool
123	MaxOutputInFlight      uint8
124	Env                    []string
125	SessionTarget          *SessionAttachTarget
126	ExactTarget            *ExactSessionTarget
127	PreferredTabID         domain.TabStableID
128	EnvironmentPolicy      EnvironmentPolicy
129	NavigationCapabilities NavigationCapabilities
130	Remote                 bool
131	// TerminalFocus is the client's terminal window focus when it attaches,
132	// so the attachment's first paint already knows whether it may be seen.
133	TerminalFocus domain.TerminalFocus
134	// ClientPID is the local client process, exposed to bar scripts. It is 0
135	// for remote attaches, which run on another machine.
136	ClientPID uint32
137}
138
139func (h Hello) Geometry() domain.Geometry {
140	return domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}.NormalizePixels()
141}
142
143type Input struct {
144	InputSeq uint64
145	ActionID uint64
146	Data     []byte
147}
148
149type Resize struct {
150	Size        domain.Size
151	PixelWidth  int
152	PixelHeight int
153}
154
155func (m Resize) Geometry() domain.Geometry {
156	return domain.Geometry{Size: m.Size, PixelWidth: m.PixelWidth, PixelHeight: m.PixelHeight}.NormalizePixels()
157}
158
159type Theme struct {
160	HasForeground bool
161	Foreground    renderer.RGB
162	HasBackground bool
163	Background    renderer.RGB
164	SchemeKnown   bool
165	Light         bool
166	PaletteKnown  uint16
167	Palette       [16]renderer.RGB
168}
169
170type ImagePush struct {
171	InputSeq uint64
172	Mime     string
173	Data     []byte
174}
175
176type ClientNotice struct{ Action uint8 }
177
178const (
179	ClientNoticeClipboardFallback uint8 = 1
180	ClientNoticeClipboardTooLarge uint8 = 2
181	ClientNoticeLinkDegraded      uint8 = 3
182	ClientNoticeLinkConnected     uint8 = 4
183)
184
185// Detach releases the attachment. Closed reports that the client process is
186// ending, so the daemon applies the ephemeral close-on-exit policy.
187type Detach struct {
188	Closed bool
189}
190type Ping struct{}
191type Pong struct{}
192
193type Ack struct {
194	Epoch uint64
195	State uint64
196}
197
198type Welcome struct {
199	SessionID         string
200	SessionName       string
201	Ephemeral         bool
202	ResumeToken       uint64
203	Capabilities      uint32
204	CommittedIdentity *CommittedRouteIdentity
205}
206
207type ErrorMsg struct {
208	Code uint16
209	Text string
210}
211
212type ViewContext struct {
213	Publication   uint64
214	Route         CommittedRouteIdentity
215	TabID         domain.TabStableID
216	FocusedPaneID domain.PaneStableID
217}
218
219func (c ViewContext) Validate() error {
220	if c.Publication == 0 {
221		return errors.New("invalid view publication")
222	}
223	if err := c.Route.Validate(); err != nil {
224		return err
225	}
226	if err := domain.ValidateTabStableID(c.TabID); err != nil {
227		return err
228	}
229	return domain.ValidatePaneStableID(c.FocusedPaneID)
230}
231
232// EchoAckDelay is how long sequenced input must have been applied before the
233// daemon acknowledges it in Output.Echo. Like mosh's ECHO_TIMEOUT, it gives the
234// PTY time to echo the input, so the screen published with the acknowledgement
235// already contains that echo. Clients remove it from round-trip samples.
236const EchoAckDelay = 50 * time.Millisecond
237
238type Output struct {
239	Epoch        uint64
240	Base         uint64
241	New          uint64
242	Echo         uint64
243	ViewRevision uint64
244	Size         domain.Size
245	Full         bool
246	Context      *ViewContext
247	Data         []byte
248}
249
250type UIFence struct{ ActionID uint64 }
251
252type UIReceiptOutcome uint8
253
254const (
255	UIReceiptProcessed UIReceiptOutcome = iota + 1
256	UIReceiptUnavailable
257)
258
259type UIReceipt struct {
260	ActionID        uint64
261	Epoch           uint64
262	State           uint64
263	ViewPublication uint64
264	Outcome         UIReceiptOutcome
265}
266
267// Validate requires a committed publication for success. Unavailable receipts
268// carry no boundary: expiry cannot claim that any captured view was processed.
269func (r UIReceipt) Validate() error {
270	if r.ActionID == 0 {
271		return errors.New("UI receipt requires an action ID")
272	}
273	switch r.Outcome {
274	case UIReceiptProcessed:
275		if r.Epoch != 0 && r.State != 0 && r.ViewPublication != 0 {
276			return nil
277		}
278	case UIReceiptUnavailable:
279		if r.Epoch == 0 && r.State == 0 && r.ViewPublication == 0 {
280			return nil
281		}
282	}
283	return errors.New("invalid UI receipt boundary or outcome")
284}
285
286type UIViewUpdate struct {
287	Epoch   uint64
288	State   uint64
289	Context ViewContext
290}
291
292type AttachTarget struct {
293	CauseActionID     uint64
294	RequestID         uint64
295	Endpoint          string
296	Session           string
297	Intent            uint8
298	SessionTarget     *SessionAttachTarget
299	ExactTarget       *ExactSessionTarget
300	EnvironmentPolicy EnvironmentPolicy
301	SamePeer          bool
302	// PreferredTabID carries an explicit local tab selection across a handoff.
303	// Empty retains the client's route memory; a disappeared tab uses Hello's
304	// existing default-tab fallback.
305	PreferredTabID domain.TabStableID
306}
307
308type Detached struct{ Reason uint8 }
309type List struct{}
310
311type KillScope uint8
312
313const (
314	KillSession KillScope = 0
315	// KillDaemon retains the legacy All=true wire value so an older client can
316	// never turn daemon shutdown into destructive session deletion.
317	KillDaemon KillScope = 1
318	KillAll    KillScope = 2
319)
320
321type Kill struct {
322	RequestID uint64
323	Name      string
324	Scope     KillScope
325}
326
327type KillOutcome uint8
328
329const (
330	KillSucceeded KillOutcome = iota + 1
331	KillFailed
332	KillOutcomeUnknown
333)
334
335type KillFailure struct {
336	Class string
337	Name  string
338	Text  string
339}
340
341type KillResult struct {
342	RequestID uint64
343	Outcome   KillOutcome
344	Code      uint16
345	Text      string
346	Failures  []KillFailure
347}
348
349type SessionState uint8
350
351const (
352	SessionUp SessionState = iota
353	SessionDown
354	SessionBroken
355)
356
357type SessionInfo struct {
358	SessionID string
359	Name      string
360	State     SessionState
361	Ephemeral bool
362	Tabs      uint16
363	Attached  bool
364}
365
366type Sessions struct{ Sessions []SessionInfo }
367type OutputResetRequest struct{}
368
369// SelectTab asks the daemon to show one exact tab of the attached session on
370// this attachment's own view. It is the client picker's in-place tab switch:
371// the attachment and its stream stay live, and a tab that no longer exists is
372// refused without changing the view.
373type SelectTab struct {
374	TabID domain.TabStableID
375}
376
377// Validate requires a well-formed stable tab identity.
378func (m SelectTab) Validate() error {
379	return domain.ValidateTabStableID(m.TabID)
380}
381
382// TerminalFocus reports that the client's terminal window gained or lost
383// focus. A client sends it only after its terminal reports focus, so an
384// attachment that never sends it keeps unknown focus.
385type TerminalFocus struct {
386	Focus domain.TerminalFocus
387}
388
389// Validate accepts only a reported state: unknown is the absence of a report.
390func (m TerminalFocus) Validate() error {
391	if m.Focus != domain.TerminalFocusFocused && m.Focus != domain.TerminalFocusUnfocused {
392		return ErrInvalidTerminalFocus
393	}
394	return nil
395}
396
397func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
398	return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
399}
400
401func ValidateEnvironmentPolicy(policy EnvironmentPolicy) error {
402	if !validEnvironmentPolicy(policy) {
403		return ErrInvalidEnvironmentPolicy
404	}
405	return nil
406}
407
408func validClientNoticeAction(action uint8) bool {
409	switch action {
410	case ClientNoticeClipboardFallback, ClientNoticeClipboardTooLarge, ClientNoticeLinkDegraded, ClientNoticeLinkConnected:
411		return true
412	default:
413		return false
414	}
415}
416
417func validDetachedReason(reason uint8) bool {
418	switch reason {
419	case ReasonDetach, ReasonSessionKilled, ReasonServerShutdown, ReasonReplaced, ReasonDetachToPicker:
420		return true
421	default:
422		return false
423	}
424}
425
426func ValidateClientNotice(m ClientNotice) error {
427	if !validClientNoticeAction(m.Action) {
428		return ErrInvalidClientNotice
429	}
430	return nil
431}
432
433func ValidateDetached(m Detached) error {
434	if !validDetachedReason(m.Reason) {
435		return ErrInvalidDetached
436	}
437	return nil
438}
439
440func ValidateHello(h Hello) error {
441	if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
442		return ErrInvalidHello
443	}
444	if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {
445		return fmt.Errorf("%w: geometry", ErrInvalidHello)
446	}
447	if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || !h.Color.Valid() || (h.Remote && h.ClientPID != 0) {
448		return ErrInvalidHello
449	}
450	if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {
451		return fmt.Errorf("%w: navigation: %w", ErrInvalidHello, err)
452	}
453	if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || uint64(len(h.Env)) > math.MaxUint32 {
454		return ErrInvalidHello
455	}
456	for _, entry := range h.Env {
457		if uint64(len(entry)) > math.MaxUint32 {
458			return ErrInvalidHello
459		}
460	}
461	if h.PreferredTabID != "" {
462		if h.Intent != IntentAttach && h.Intent != IntentResume {
463			return ErrInvalidHello
464		}
465		if err := domain.ValidateTabStableID(h.PreferredTabID); err != nil {
466			return fmt.Errorf("%w: preferred tab: %v", ErrInvalidHello, err)
467		}
468	}
469	if h.ExactTarget != nil {
470		if h.Intent != IntentAttach && h.Intent != IntentResume {
471			return ErrInvalidHello
472		}
473		if err := h.ExactTarget.Validate(); err != nil {
474			return fmt.Errorf("%w: exact target: %v", ErrInvalidHello, err)
475		}
476		if h.Name != h.ExactTarget.SessionName {
477			return ErrInvalidHello
478		}
479		if h.SessionTarget != nil && (h.SessionTarget.LifecycleID != h.ExactTarget.LifecycleID || h.SessionTarget.SessionName != h.ExactTarget.SessionName) {
480			return ErrInvalidHello
481		}
482	}
483	if h.SessionTarget == nil {
484		return nil
485	}
486	if h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned || (h.Intent != IntentAttach && h.Intent != IntentResume) {
487		return ErrInvalidHello
488	}
489	if err := h.SessionTarget.Validate(); err != nil {
490		return fmt.Errorf("%w: session target: %v", ErrInvalidHello, err)
491	}
492	if h.Name != h.SessionTarget.SessionName {
493		return ErrInvalidHello
494	}
495	return nil
496}
497
498func ValidateOutput(m Output) error {
499	if m.Epoch == 0 {
500		return ErrInvalidOutput
501	}
502	if m.New == 0 {
503		if m.Base != 0 || m.Full || m.Context != nil {
504			return ErrInvalidOutput
505		}
506	} else if m.Context == nil || (m.Base == 0 && !m.Full) || (m.Base != 0 && (m.Full || m.New != m.Base+1)) {
507		return ErrInvalidOutput
508	}
509	if err := ValidateSize(m.Size); err != nil {
510		return fmt.Errorf("%w: size", ErrInvalidOutput)
511	}
512	if m.Context != nil {
513		if err := m.Context.Validate(); err != nil {
514			return fmt.Errorf("%w: context: %w", ErrInvalidOutput, err)
515		}
516	}
517	if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxOutputDataLen {
518		return ErrInvalidOutput
519	}
520	return nil
521}
522
523func ValidateAck(m Ack) error {
524	if m.Epoch == 0 {
525		return ErrInvalidAck
526	}
527	return nil
528}
529
530func ValidateAttachTarget(m AttachTarget) error {
531	if m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 {
532		return ErrInvalidAttachTarget
533	}
534	if err := domain.ValidateSessionName(m.Session); err != nil {
535		return ErrInvalidAttachTarget
536	}
537	if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {
538		return ErrInvalidAttachTarget
539	}
540	if m.Intent == IntentResume || !validEnvironmentPolicy(m.EnvironmentPolicy) {
541		return ErrInvalidAttachTarget
542	}
543	if m.Intent == IntentNew && m.Endpoint != "" && m.RequestID == 0 || m.RequestID != 0 && m.Intent != IntentNew {
544		return ErrInvalidAttachTarget
545	}
546	if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) {
547		return ErrInvalidAttachTarget
548	}
549	if m.SamePeer && (m.Endpoint != "" || m.SessionTarget != nil || m.ExactTarget == nil) {
550		return ErrInvalidAttachTarget
551	}
552	if m.PreferredTabID != "" && (m.Endpoint != "" || m.SessionTarget != nil || m.ExactTarget == nil || m.Intent != IntentAttach || domain.ValidateTabStableID(m.PreferredTabID) != nil) {
553		return ErrInvalidAttachTarget
554	}
555	if m.SessionTarget == nil {
556		return nil
557	}
558	if m.Endpoint != "" || m.EnvironmentPolicy != EnvironmentPolicyDaemonOwned || m.Intent != IntentAttach {
559		return ErrInvalidAttachTarget
560	}
561	if err := m.SessionTarget.Validate(); err != nil {
562		return fmt.Errorf("%w: session target: %v", ErrInvalidAttachTarget, err)
563	}
564	if m.Session != m.SessionTarget.SessionName {
565		return ErrInvalidAttachTarget
566	}
567	return nil
568}

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