detached 36aa808

feat(ports,ipc): list/kill/sessions protocol surface

commit
36aa808b5649c50d6d7b263ada7f700adc09efb5
author
brice · · 2026-07-02 11:23 UTC
parent
58001c6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +219 -0
M internal/adapters/ipc/protocol.go +117 -0
@@ -90,6 +90,29 @@ type Detached struct {
9090 Reason uint8
9191 }
9292
93+// List asks the daemon to enumerate its live sessions. It carries no
94+// fields; the request is fully described by its message type.
95+type List struct{}
96+
97+// Kill asks the daemon to terminate the named session.
98+type Kill struct {
99+ Name string
100+}
101+
102+// SessionInfo describes one session in a Sessions listing.
103+type SessionInfo struct {
104+ SessionID string
105+ Name string
106+ Ephemeral bool
107+ Windows uint16
108+ Attached bool
109+}
110+
111+// Sessions is the daemon's reply to a List, enumerating live sessions.
112+type Sessions struct {
113+ Sessions []SessionInfo
114+}
115+
93116 // payloadWriter builds a message payload by appending fields in wire order.
94117 type payloadWriter struct {
95118 b []byte
@@ -378,3 +401,97 @@ func UnmarshalDetached(b []byte) (Detached, error) {
378401 }
379402 return Detached{Reason: reason}, nil
380403 }
404+
405+// MarshalList encodes a List message payload (always empty).
406+func MarshalList(List) []byte {
407+ return nil
408+}
409+
410+// UnmarshalList decodes a List message payload.
411+func UnmarshalList(b []byte) (List, error) {
412+ r := payloadReader{b: b}
413+ if err := r.done(); err != nil {
414+ return List{}, err
415+ }
416+ return List{}, nil
417+}
418+
419+// MarshalKill encodes m into a Kill message payload.
420+func MarshalKill(m Kill) []byte {
421+ w := payloadWriter{}
422+ w.putString(m.Name)
423+ return w.b
424+}
425+
426+// UnmarshalKill decodes a Kill message payload.
427+func UnmarshalKill(b []byte) (Kill, error) {
428+ r := payloadReader{b: b}
429+ name, err := r.getString()
430+ if err != nil {
431+ return Kill{}, err
432+ }
433+ if err := r.done(); err != nil {
434+ return Kill{}, err
435+ }
436+ return Kill{Name: name}, nil
437+}
438+
439+// MarshalSessions encodes m into a Sessions message payload: a uint16 count
440+// followed by that many session records.
441+func MarshalSessions(m Sessions) []byte {
442+ w := payloadWriter{}
443+ w.putUint16(uint16(len(m.Sessions)))
444+ for _, s := range m.Sessions {
445+ w.putString(s.SessionID)
446+ w.putString(s.Name)
447+ if s.Ephemeral {
448+ w.putUint8(1)
449+ } else {
450+ w.putUint8(0)
451+ }
452+ w.putUint16(s.Windows)
453+ if s.Attached {
454+ w.putUint8(1)
455+ } else {
456+ w.putUint8(0)
457+ }
458+ }
459+ return w.b
460+}
461+
462+// UnmarshalSessions decodes a Sessions message payload.
463+func UnmarshalSessions(b []byte) (Sessions, error) {
464+ r := payloadReader{b: b}
465+ count, err := r.getUint16()
466+ if err != nil {
467+ return Sessions{}, err
468+ }
469+ sessions := make([]SessionInfo, 0, count)
470+ for i := 0; i < int(count); i++ {
471+ var s SessionInfo
472+ if s.SessionID, err = r.getString(); err != nil {
473+ return Sessions{}, err
474+ }
475+ if s.Name, err = r.getString(); err != nil {
476+ return Sessions{}, err
477+ }
478+ eph, err := r.getUint8()
479+ if err != nil {
480+ return Sessions{}, err
481+ }
482+ s.Ephemeral = eph != 0
483+ if s.Windows, err = r.getUint16(); err != nil {
484+ return Sessions{}, err
485+ }
486+ att, err := r.getUint8()
487+ if err != nil {
488+ return Sessions{}, err
489+ }
490+ s.Attached = att != 0
491+ sessions = append(sessions, s)
492+ }
493+ if err := r.done(); err != nil {
494+ return Sessions{}, err
495+ }
496+ return Sessions{Sessions: sessions}, nil
497+}
@@ -90,6 +90,29 @@ type Detached struct {
90 Reason uint890 Reason uint8
91 }91 }
92 92
93+// List asks the daemon to enumerate its live sessions. It carries no
94+// fields; the request is fully described by its message type.
95+type List struct{}
96+
97+// Kill asks the daemon to terminate the named session.
98+type Kill struct {
99+ Name string
100+}
101+
102+// SessionInfo describes one session in a Sessions listing.
103+type SessionInfo struct {
104+ SessionID string
105+ Name string
106+ Ephemeral bool
107+ Windows uint16
108+ Attached bool
109+}
110+
111+// Sessions is the daemon's reply to a List, enumerating live sessions.
112+type Sessions struct {
113+ Sessions []SessionInfo
114+}
115+
93 // payloadWriter builds a message payload by appending fields in wire order.116 // payloadWriter builds a message payload by appending fields in wire order.
94 type payloadWriter struct {117 type payloadWriter struct {
95 b []byte118 b []byte
@@ -378,3 +401,97 @@ func UnmarshalDetached(b []byte) (Detached, error) {
378 }401 }
379 return Detached{Reason: reason}, nil402 return Detached{Reason: reason}, nil
380 }403 }
404+
405+// MarshalList encodes a List message payload (always empty).
406+func MarshalList(List) []byte {
407+ return nil
408+}
409+
410+// UnmarshalList decodes a List message payload.
411+func UnmarshalList(b []byte) (List, error) {
412+ r := payloadReader{b: b}
413+ if err := r.done(); err != nil {
414+ return List{}, err
415+ }
416+ return List{}, nil
417+}
418+
419+// MarshalKill encodes m into a Kill message payload.
420+func MarshalKill(m Kill) []byte {
421+ w := payloadWriter{}
422+ w.putString(m.Name)
423+ return w.b
424+}
425+
426+// UnmarshalKill decodes a Kill message payload.
427+func UnmarshalKill(b []byte) (Kill, error) {
428+ r := payloadReader{b: b}
429+ name, err := r.getString()
430+ if err != nil {
431+ return Kill{}, err
432+ }
433+ if err := r.done(); err != nil {
434+ return Kill{}, err
435+ }
436+ return Kill{Name: name}, nil
437+}
438+
439+// MarshalSessions encodes m into a Sessions message payload: a uint16 count
440+// followed by that many session records.
441+func MarshalSessions(m Sessions) []byte {
442+ w := payloadWriter{}
443+ w.putUint16(uint16(len(m.Sessions)))
444+ for _, s := range m.Sessions {
445+ w.putString(s.SessionID)
446+ w.putString(s.Name)
447+ if s.Ephemeral {
448+ w.putUint8(1)
449+ } else {
450+ w.putUint8(0)
451+ }
452+ w.putUint16(s.Windows)
453+ if s.Attached {
454+ w.putUint8(1)
455+ } else {
456+ w.putUint8(0)
457+ }
458+ }
459+ return w.b
460+}
461+
462+// UnmarshalSessions decodes a Sessions message payload.
463+func UnmarshalSessions(b []byte) (Sessions, error) {
464+ r := payloadReader{b: b}
465+ count, err := r.getUint16()
466+ if err != nil {
467+ return Sessions{}, err
468+ }
469+ sessions := make([]SessionInfo, 0, count)
470+ for i := 0; i < int(count); i++ {
471+ var s SessionInfo
472+ if s.SessionID, err = r.getString(); err != nil {
473+ return Sessions{}, err
474+ }
475+ if s.Name, err = r.getString(); err != nil {
476+ return Sessions{}, err
477+ }
478+ eph, err := r.getUint8()
479+ if err != nil {
480+ return Sessions{}, err
481+ }
482+ s.Ephemeral = eph != 0
483+ if s.Windows, err = r.getUint16(); err != nil {
484+ return Sessions{}, err
485+ }
486+ att, err := r.getUint8()
487+ if err != nil {
488+ return Sessions{}, err
489+ }
490+ s.Attached = att != 0
491+ sessions = append(sessions, s)
492+ }
493+ if err := r.done(); err != nil {
494+ return Sessions{}, err
495+ }
496+ return Sessions{Sessions: sessions}, nil
497+}
M internal/adapters/ipc/protocol_test.go +96 -0
@@ -299,6 +299,102 @@ func TestDetachedGoldenAndRoundTrip(t *testing.T) {
299299 assertTrailingGarbageFails(t, got, UnmarshalDetached)
300300 }
301301
302+func TestListGoldenAndRoundTrip(t *testing.T) {
303+ got := MarshalList(List{})
304+ if len(got) != 0 {
305+ t.Fatalf("MarshalList() = %#v, want empty", got)
306+ }
307+ back, err := UnmarshalList(got)
308+ if err != nil {
309+ t.Fatalf("UnmarshalList() error = %v", err)
310+ }
311+ if back != (List{}) {
312+ t.Fatalf("round trip = %#v, want %#v", back, List{})
313+ }
314+ assertTrailingGarbageFails(t, got, UnmarshalList)
315+}
316+
317+func TestKillGoldenAndRoundTrip(t *testing.T) {
318+ tests := []struct {
319+ name string
320+ msg Kill
321+ want []byte
322+ }{
323+ {name: "named", msg: Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e}},
324+ {name: "empty", msg: Kill{Name: ""}, want: []byte{0x00, 0x00}},
325+ }
326+
327+ for _, tt := range tests {
328+ t.Run(tt.name, func(t *testing.T) {
329+ got := MarshalKill(tt.msg)
330+ if !bytes.Equal(got, tt.want) {
331+ t.Fatalf("MarshalKill() = %#v, want %#v", got, tt.want)
332+ }
333+ back, err := UnmarshalKill(got)
334+ if err != nil {
335+ t.Fatalf("UnmarshalKill() error = %v", err)
336+ }
337+ if !reflect.DeepEqual(back, tt.msg) {
338+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
339+ }
340+ })
341+ }
342+
343+ full := MarshalKill(tests[0].msg)
344+ assertAllPrefixesFail(t, full, UnmarshalKill)
345+ assertTrailingGarbageFails(t, full, UnmarshalKill)
346+}
347+
348+func TestSessionsGoldenAndRoundTrip(t *testing.T) {
349+ tests := []struct {
350+ name string
351+ msg Sessions
352+ want []byte
353+ }{
354+ {
355+ name: "empty",
356+ msg: Sessions{},
357+ want: []byte{0x00, 0x00},
358+ },
359+ {
360+ name: "two",
361+ msg: Sessions{Sessions: []SessionInfo{
362+ {SessionID: "0", Name: "0", Ephemeral: true, Windows: 1, Attached: false},
363+ {SessionID: "work", Name: "proj", Ephemeral: false, Windows: 5, Attached: true},
364+ }},
365+ want: []byte{
366+ 0x00, 0x02,
367+ 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00,
368+ 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01,
369+ },
370+ },
371+ }
372+
373+ for _, tt := range tests {
374+ t.Run(tt.name, func(t *testing.T) {
375+ got := MarshalSessions(tt.msg)
376+ if !bytes.Equal(got, tt.want) {
377+ t.Fatalf("MarshalSessions() = %#v, want %#v", got, tt.want)
378+ }
379+ back, err := UnmarshalSessions(got)
380+ if err != nil {
381+ t.Fatalf("UnmarshalSessions() error = %v", err)
382+ }
383+ // Normalize nil vs empty slice for comparison.
384+ if len(back.Sessions) == 0 && len(tt.msg.Sessions) == 0 {
385+ return
386+ }
387+ if !reflect.DeepEqual(back, tt.msg) {
388+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
389+ }
390+ })
391+ }
392+
393+ full := MarshalSessions(tests[1].msg)
394+ assertAllPrefixesFail(t, full, UnmarshalSessions)
395+ assertTrailingGarbageFails(t, full, UnmarshalSessions)
396+}
397+
302398 // TestMsgTypeConstantsDistinct guards the enumerations defined in this
303399 // package against accidental collisions (Intent, ErrorMsg code, Detached
304400 // reason are each independent small spaces, but duplicates within one
@@ -299,6 +299,102 @@ func TestDetachedGoldenAndRoundTrip(t *testing.T) {
299 assertTrailingGarbageFails(t, got, UnmarshalDetached)299 assertTrailingGarbageFails(t, got, UnmarshalDetached)
300 }300 }
301 301
302+func TestListGoldenAndRoundTrip(t *testing.T) {
303+ got := MarshalList(List{})
304+ if len(got) != 0 {
305+ t.Fatalf("MarshalList() = %#v, want empty", got)
306+ }
307+ back, err := UnmarshalList(got)
308+ if err != nil {
309+ t.Fatalf("UnmarshalList() error = %v", err)
310+ }
311+ if back != (List{}) {
312+ t.Fatalf("round trip = %#v, want %#v", back, List{})
313+ }
314+ assertTrailingGarbageFails(t, got, UnmarshalList)
315+}
316+
317+func TestKillGoldenAndRoundTrip(t *testing.T) {
318+ tests := []struct {
319+ name string
320+ msg Kill
321+ want []byte
322+ }{
323+ {name: "named", msg: Kill{Name: "main"}, want: []byte{0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e}},
324+ {name: "empty", msg: Kill{Name: ""}, want: []byte{0x00, 0x00}},
325+ }
326+
327+ for _, tt := range tests {
328+ t.Run(tt.name, func(t *testing.T) {
329+ got := MarshalKill(tt.msg)
330+ if !bytes.Equal(got, tt.want) {
331+ t.Fatalf("MarshalKill() = %#v, want %#v", got, tt.want)
332+ }
333+ back, err := UnmarshalKill(got)
334+ if err != nil {
335+ t.Fatalf("UnmarshalKill() error = %v", err)
336+ }
337+ if !reflect.DeepEqual(back, tt.msg) {
338+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
339+ }
340+ })
341+ }
342+
343+ full := MarshalKill(tests[0].msg)
344+ assertAllPrefixesFail(t, full, UnmarshalKill)
345+ assertTrailingGarbageFails(t, full, UnmarshalKill)
346+}
347+
348+func TestSessionsGoldenAndRoundTrip(t *testing.T) {
349+ tests := []struct {
350+ name string
351+ msg Sessions
352+ want []byte
353+ }{
354+ {
355+ name: "empty",
356+ msg: Sessions{},
357+ want: []byte{0x00, 0x00},
358+ },
359+ {
360+ name: "two",
361+ msg: Sessions{Sessions: []SessionInfo{
362+ {SessionID: "0", Name: "0", Ephemeral: true, Windows: 1, Attached: false},
363+ {SessionID: "work", Name: "proj", Ephemeral: false, Windows: 5, Attached: true},
364+ }},
365+ want: []byte{
366+ 0x00, 0x02,
367+ 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00,
368+ 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01,
369+ },
370+ },
371+ }
372+
373+ for _, tt := range tests {
374+ t.Run(tt.name, func(t *testing.T) {
375+ got := MarshalSessions(tt.msg)
376+ if !bytes.Equal(got, tt.want) {
377+ t.Fatalf("MarshalSessions() = %#v, want %#v", got, tt.want)
378+ }
379+ back, err := UnmarshalSessions(got)
380+ if err != nil {
381+ t.Fatalf("UnmarshalSessions() error = %v", err)
382+ }
383+ // Normalize nil vs empty slice for comparison.
384+ if len(back.Sessions) == 0 && len(tt.msg.Sessions) == 0 {
385+ return
386+ }
387+ if !reflect.DeepEqual(back, tt.msg) {
388+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
389+ }
390+ })
391+ }
392+
393+ full := MarshalSessions(tests[1].msg)
394+ assertAllPrefixesFail(t, full, UnmarshalSessions)
395+ assertTrailingGarbageFails(t, full, UnmarshalSessions)
396+}
397+
302 // TestMsgTypeConstantsDistinct guards the enumerations defined in this398 // TestMsgTypeConstantsDistinct guards the enumerations defined in this
303 // package against accidental collisions (Intent, ErrorMsg code, Detached399 // package against accidental collisions (Intent, ErrorMsg code, Detached
304 // reason are each independent small spaces, but duplicates within one400 // reason are each independent small spaces, but duplicates within one
M internal/ports/frame.go +3 -0
@@ -14,12 +14,15 @@ const (
1414 MsgResize MsgType = 3
1515 MsgDetach MsgType = 4
1616 MsgPing MsgType = 5
17+ MsgList MsgType = 6
18+ MsgKill MsgType = 7
1719
1820 MsgWelcome MsgType = 16
1921 MsgError MsgType = 17
2022 MsgOutput MsgType = 18
2123 MsgDetached MsgType = 19
2224 MsgPong MsgType = 20
25+ MsgSessions MsgType = 21
2326 )
2427
2528 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -14,12 +14,15 @@ const (
14 MsgResize MsgType = 314 MsgResize MsgType = 3
15 MsgDetach MsgType = 415 MsgDetach MsgType = 4
16 MsgPing MsgType = 516 MsgPing MsgType = 5
17+ MsgList MsgType = 6
18+ MsgKill MsgType = 7
17 19
18 MsgWelcome MsgType = 1620 MsgWelcome MsgType = 16
19 MsgError MsgType = 1721 MsgError MsgType = 17
20 MsgOutput MsgType = 1822 MsgOutput MsgType = 18
21 MsgDetached MsgType = 1923 MsgDetached MsgType = 19
22 MsgPong MsgType = 2024 MsgPong MsgType = 20
25+ MsgSessions MsgType = 21
23 )26 )
24 27
25 // Frame is the unit of exchange over a Transport: a typed, length-delimited28 // Frame is the unit of exchange over a Transport: a typed, length-delimited
M internal/ports/frame_test.go +3 -0
@@ -12,11 +12,14 @@ func TestMsgTypeUnique(t *testing.T) {
1212 {"MsgResize", MsgResize},
1313 {"MsgDetach", MsgDetach},
1414 {"MsgPing", MsgPing},
15+ {"MsgList", MsgList},
16+ {"MsgKill", MsgKill},
1517 {"MsgWelcome", MsgWelcome},
1618 {"MsgError", MsgError},
1719 {"MsgOutput", MsgOutput},
1820 {"MsgDetached", MsgDetached},
1921 {"MsgPong", MsgPong},
22+ {"MsgSessions", MsgSessions},
2023 }
2124
2225 seen := make(map[MsgType]string, len(tests))
@@ -12,11 +12,14 @@ func TestMsgTypeUnique(t *testing.T) {
12 {"MsgResize", MsgResize},12 {"MsgResize", MsgResize},
13 {"MsgDetach", MsgDetach},13 {"MsgDetach", MsgDetach},
14 {"MsgPing", MsgPing},14 {"MsgPing", MsgPing},
15+ {"MsgList", MsgList},
16+ {"MsgKill", MsgKill},
15 {"MsgWelcome", MsgWelcome},17 {"MsgWelcome", MsgWelcome},
16 {"MsgError", MsgError},18 {"MsgError", MsgError},
17 {"MsgOutput", MsgOutput},19 {"MsgOutput", MsgOutput},
18 {"MsgDetached", MsgDetached},20 {"MsgDetached", MsgDetached},
19 {"MsgPong", MsgPong},21 {"MsgPong", MsgPong},
22+ {"MsgSessions", MsgSessions},
20 }23 }
21 24
22 seen := make(map[MsgType]string, len(tests))25 seen := make(map[MsgType]string, len(tests))

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