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
232type Output struct {
233	Epoch        uint64
234	Base         uint64
235	New          uint64
236	Echo         uint64
237	ViewRevision uint64
238	Size         domain.Size
239	Full         bool
240	Context      *ViewContext
241	Data         []byte
242}
243
244type UIFence struct{ ActionID uint64 }
245
246type UIReceiptOutcome uint8
247
248const (
249	UIReceiptProcessed UIReceiptOutcome = iota + 1
250	UIReceiptUnavailable
251)
252
253type UIReceipt struct {
254	ActionID        uint64
255	Epoch           uint64
256	State           uint64
257	ViewPublication uint64
258	Outcome         UIReceiptOutcome
259}
260
261// Validate requires a committed publication for success. Unavailable receipts
262// carry no boundary: expiry cannot claim that any captured view was processed.
263func (r UIReceipt) Validate() error {
264	if r.ActionID == 0 {
265		return errors.New("UI receipt requires an action ID")
266	}
267	switch r.Outcome {
268	case UIReceiptProcessed:
269		if r.Epoch != 0 && r.State != 0 && r.ViewPublication != 0 {
270			return nil
271		}
272	case UIReceiptUnavailable:
273		if r.Epoch == 0 && r.State == 0 && r.ViewPublication == 0 {
274			return nil
275		}
276	}
277	return errors.New("invalid UI receipt boundary or outcome")
278}
279
280type UIViewUpdate struct {
281	Epoch   uint64
282	State   uint64
283	Context ViewContext
284}
285
286type AttachTarget struct {
287	CauseActionID     uint64
288	RequestID         uint64
289	Endpoint          string
290	Session           string
291	Intent            uint8
292	SessionTarget     *SessionAttachTarget
293	ExactTarget       *ExactSessionTarget
294	EnvironmentPolicy EnvironmentPolicy
295	SamePeer          bool
296	// PreferredTabID carries an explicit local tab selection across a handoff.
297	// Empty retains the client's route memory; a disappeared tab uses Hello's
298	// existing default-tab fallback.
299	PreferredTabID domain.TabStableID
300}
301
302type Detached struct{ Reason uint8 }
303type List struct{}
304
305type KillScope uint8
306
307const (
308	KillSession KillScope = 0
309	// KillDaemon retains the legacy All=true wire value so an older client can
310	// never turn daemon shutdown into destructive session deletion.
311	KillDaemon KillScope = 1
312	KillAll    KillScope = 2
313)
314
315type Kill struct {
316	RequestID uint64
317	Name      string
318	Scope     KillScope
319}
320
321type KillOutcome uint8
322
323const (
324	KillSucceeded KillOutcome = iota + 1
325	KillFailed
326	KillOutcomeUnknown
327)
328
329type KillFailure struct {
330	Class string
331	Name  string
332	Text  string
333}
334
335type KillResult struct {
336	RequestID uint64
337	Outcome   KillOutcome
338	Code      uint16
339	Text      string
340	Failures  []KillFailure
341}
342
343type SessionState uint8
344
345const (
346	SessionUp SessionState = iota
347	SessionDown
348	SessionBroken
349)
350
351type SessionInfo struct {
352	SessionID string
353	Name      string
354	State     SessionState
355	Ephemeral bool
356	Tabs      uint16
357	Attached  bool
358}
359
360type Sessions struct{ Sessions []SessionInfo }
361type OutputResetRequest struct{}
362
363// SelectTab asks the daemon to show one exact tab of the attached session on
364// this attachment's own view. It is the client picker's in-place tab switch:
365// the attachment and its stream stay live, and a tab that no longer exists is
366// refused without changing the view.
367type SelectTab struct {
368	TabID domain.TabStableID
369}
370
371// Validate requires a well-formed stable tab identity.
372func (m SelectTab) Validate() error {
373	return domain.ValidateTabStableID(m.TabID)
374}
375
376// TerminalFocus reports that the client's terminal window gained or lost
377// focus. A client sends it only after its terminal reports focus, so an
378// attachment that never sends it keeps unknown focus.
379type TerminalFocus struct {
380	Focus domain.TerminalFocus
381}
382
383// Validate accepts only a reported state: unknown is the absence of a report.
384func (m TerminalFocus) Validate() error {
385	if m.Focus != domain.TerminalFocusFocused && m.Focus != domain.TerminalFocusUnfocused {
386		return ErrInvalidTerminalFocus
387	}
388	return nil
389}
390
391func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
392	return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
393}
394
395func ValidateEnvironmentPolicy(policy EnvironmentPolicy) error {
396	if !validEnvironmentPolicy(policy) {
397		return ErrInvalidEnvironmentPolicy
398	}
399	return nil
400}
401
402func validClientNoticeAction(action uint8) bool {
403	switch action {
404	case ClientNoticeClipboardFallback, ClientNoticeClipboardTooLarge, ClientNoticeLinkDegraded, ClientNoticeLinkConnected:
405		return true
406	default:
407		return false
408	}
409}
410
411func validDetachedReason(reason uint8) bool {
412	switch reason {
413	case ReasonDetach, ReasonSessionKilled, ReasonServerShutdown, ReasonReplaced, ReasonDetachToPicker:
414		return true
415	default:
416		return false
417	}
418}
419
420func ValidateClientNotice(m ClientNotice) error {
421	if !validClientNoticeAction(m.Action) {
422		return ErrInvalidClientNotice
423	}
424	return nil
425}
426
427func ValidateDetached(m Detached) error {
428	if !validDetachedReason(m.Reason) {
429		return ErrInvalidDetached
430	}
431	return nil
432}
433
434func ValidateHello(h Hello) error {
435	if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
436		return ErrInvalidHello
437	}
438	if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {
439		return fmt.Errorf("%w: geometry", ErrInvalidHello)
440	}
441	if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || !h.Color.Valid() || (h.Remote && h.ClientPID != 0) {
442		return ErrInvalidHello
443	}
444	if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {
445		return fmt.Errorf("%w: navigation: %w", ErrInvalidHello, err)
446	}
447	if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || uint64(len(h.Env)) > math.MaxUint32 {
448		return ErrInvalidHello
449	}
450	for _, entry := range h.Env {
451		if uint64(len(entry)) > math.MaxUint32 {
452			return ErrInvalidHello
453		}
454	}
455	if h.PreferredTabID != "" {
456		if h.Intent != IntentAttach && h.Intent != IntentResume {
457			return ErrInvalidHello
458		}
459		if err := domain.ValidateTabStableID(h.PreferredTabID); err != nil {
460			return fmt.Errorf("%w: preferred tab: %v", ErrInvalidHello, err)
461		}
462	}
463	if h.ExactTarget != nil {
464		if h.Intent != IntentAttach && h.Intent != IntentResume {
465			return ErrInvalidHello
466		}
467		if err := h.ExactTarget.Validate(); err != nil {
468			return fmt.Errorf("%w: exact target: %v", ErrInvalidHello, err)
469		}
470		if h.Name != h.ExactTarget.SessionName {
471			return ErrInvalidHello
472		}
473		if h.SessionTarget != nil && (h.SessionTarget.LifecycleID != h.ExactTarget.LifecycleID || h.SessionTarget.SessionName != h.ExactTarget.SessionName) {
474			return ErrInvalidHello
475		}
476	}
477	if h.SessionTarget == nil {
478		return nil
479	}
480	if h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned || (h.Intent != IntentAttach && h.Intent != IntentResume) {
481		return ErrInvalidHello
482	}
483	if err := h.SessionTarget.Validate(); err != nil {
484		return fmt.Errorf("%w: session target: %v", ErrInvalidHello, err)
485	}
486	if h.Name != h.SessionTarget.SessionName {
487		return ErrInvalidHello
488	}
489	return nil
490}
491
492func ValidateOutput(m Output) error {
493	if m.Epoch == 0 {
494		return ErrInvalidOutput
495	}
496	if m.New == 0 {
497		if m.Base != 0 || m.Full || m.Context != nil {
498			return ErrInvalidOutput
499		}
500	} else if m.Context == nil || (m.Base == 0 && !m.Full) || (m.Base != 0 && (m.Full || m.New != m.Base+1)) {
501		return ErrInvalidOutput
502	}
503	if err := ValidateSize(m.Size); err != nil {
504		return fmt.Errorf("%w: size", ErrInvalidOutput)
505	}
506	if m.Context != nil {
507		if err := m.Context.Validate(); err != nil {
508			return fmt.Errorf("%w: context: %w", ErrInvalidOutput, err)
509		}
510	}
511	if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxOutputDataLen {
512		return ErrInvalidOutput
513	}
514	return nil
515}
516
517func ValidateAck(m Ack) error {
518	if m.Epoch == 0 {
519		return ErrInvalidAck
520	}
521	return nil
522}
523
524func ValidateAttachTarget(m AttachTarget) error {
525	if m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 {
526		return ErrInvalidAttachTarget
527	}
528	if err := domain.ValidateSessionName(m.Session); err != nil {
529		return ErrInvalidAttachTarget
530	}
531	if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {
532		return ErrInvalidAttachTarget
533	}
534	if m.Intent == IntentResume || !validEnvironmentPolicy(m.EnvironmentPolicy) {
535		return ErrInvalidAttachTarget
536	}
537	if m.Intent == IntentNew && m.Endpoint != "" && m.RequestID == 0 || m.RequestID != 0 && m.Intent != IntentNew {
538		return ErrInvalidAttachTarget
539	}
540	if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) {
541		return ErrInvalidAttachTarget
542	}
543	if m.SamePeer && (m.Endpoint != "" || m.SessionTarget != nil || m.ExactTarget == nil) {
544		return ErrInvalidAttachTarget
545	}
546	if m.PreferredTabID != "" && (m.Endpoint != "" || m.SessionTarget != nil || m.ExactTarget == nil || m.Intent != IntentAttach || domain.ValidateTabStableID(m.PreferredTabID) != nil) {
547		return ErrInvalidAttachTarget
548	}
549	if m.SessionTarget == nil {
550		return nil
551	}
552	if m.Endpoint != "" || m.EnvironmentPolicy != EnvironmentPolicyDaemonOwned || m.Intent != IntentAttach {
553		return ErrInvalidAttachTarget
554	}
555	if err := m.SessionTarget.Validate(); err != nil {
556		return fmt.Errorf("%w: session target: %v", ErrInvalidAttachTarget, err)
557	}
558	if m.Session != m.SessionTarget.SessionName {
559		return ErrInvalidAttachTarget
560	}
561	return nil
562}

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