detached 217c05a

fix(routes): stabilize remote navigation state

commit
217c05acbf75956fee9fdde9f71f50389579ba85
author
brice · · 2026-08-10 04:20 UTC
parent
17d2014
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
39 files · +905 -129
M .agent-reviews/redteam.md +46 -0
@@ -46,3 +46,49 @@ history cap is 20, origin keys distinguish daemon endpoints, route labels reject
4646 invalid UTF-8/control text, navigation actions carry the snapshot generation,
4747 active selection is a no-op, and failed transitions invoke the prior-route
4848 restore hook before returning failure.
49+
50+# Client-owned route-position red-team
51+
52+## Builder decision
53+
54+Keep live tab/view authority on each daemon attachment, but remember the last
55+stable tab ID per exact client route in the process-local route ledger. Add an
56+optional preferred stable tab to Hello and a daemon-to-client route-position
57+publication bound to an exact lifecycle/name target. A missing preferred tab is
58+a hint miss and falls back to the daemon's normal attachment tab; it never
59+changes session identity or shared session tab state.
60+
61+## Critic pass
62+
63+- **Multiple clients:** daemon-global or session-global memory would couple
64+ clients. The remembered cursor stays in each Runner's private route ledger;
65+ the daemon applies it only to the requesting attachment.
66+- **Stale/out-of-order publications:** a tab ID without session identity could
67+ corrupt the route selected by a concurrent transition. RoutePosition carries
68+ the exact lifecycle/name target, and the ledger applies it only to that route.
69+- **Handshake ordering:** publishing another server frame before Welcome would
70+ recreate the resume-token failure found during manual testing. Initial route
71+ position is emitted only by post-Welcome rendering; Welcome stays first.
72+- **Deleted tabs:** remembered positions are hints, not exact picker authority.
73+ The daemon validates stable IDs under target-session ownership and falls back
74+ without rejecting the attachment when the tab no longer exists.
75+- **Discovery metadata:** RemoteTarget includes a point-in-time live tab and must
76+ not become route memory. Committed route records drop that catalog selector;
77+ PreferredTabID is the independent mutable cursor.
78+- **Resume and identity authority:** a preferred tab cannot select a session. It
79+ is accepted only for attach/resume and only after resume-token or exact-target
80+ session resolution succeeds.
81+- **Untrusted IDs:** both Hello and RoutePosition reject empty/oversized/control
82+ stable IDs and trailing or truncated payloads.
83+- **Publication completeness:** tab selection and repair always invalidate a
84+ render. RoutePosition is serialized with the attachment's post-commit output
85+ transaction and cached per transport incarnation; output rebase forces an
86+ initial publication after attach/resume.
87+
88+## Accepted risks
89+
90+- The state is intentionally process-memory only. Restarting the client loses
91+ remembered tabs; restarting a daemon does not make the daemon the memory
92+ owner, and missing/replaced stable IDs safely fall back.
93+- RoutePosition is an additional small control frame only when the exact target
94+ or active stable tab changes, not on every output diff.
@@ -46,3 +46,49 @@ history cap is 20, origin keys distinguish daemon endpoints, route labels reject
46 invalid UTF-8/control text, navigation actions carry the snapshot generation,46 invalid UTF-8/control text, navigation actions carry the snapshot generation,
47 active selection is a no-op, and failed transitions invoke the prior-route47 active selection is a no-op, and failed transitions invoke the prior-route
48 restore hook before returning failure.48 restore hook before returning failure.
49+
50+# Client-owned route-position red-team
51+
52+## Builder decision
53+
54+Keep live tab/view authority on each daemon attachment, but remember the last
55+stable tab ID per exact client route in the process-local route ledger. Add an
56+optional preferred stable tab to Hello and a daemon-to-client route-position
57+publication bound to an exact lifecycle/name target. A missing preferred tab is
58+a hint miss and falls back to the daemon's normal attachment tab; it never
59+changes session identity or shared session tab state.
60+
61+## Critic pass
62+
63+- **Multiple clients:** daemon-global or session-global memory would couple
64+ clients. The remembered cursor stays in each Runner's private route ledger;
65+ the daemon applies it only to the requesting attachment.
66+- **Stale/out-of-order publications:** a tab ID without session identity could
67+ corrupt the route selected by a concurrent transition. RoutePosition carries
68+ the exact lifecycle/name target, and the ledger applies it only to that route.
69+- **Handshake ordering:** publishing another server frame before Welcome would
70+ recreate the resume-token failure found during manual testing. Initial route
71+ position is emitted only by post-Welcome rendering; Welcome stays first.
72+- **Deleted tabs:** remembered positions are hints, not exact picker authority.
73+ The daemon validates stable IDs under target-session ownership and falls back
74+ without rejecting the attachment when the tab no longer exists.
75+- **Discovery metadata:** RemoteTarget includes a point-in-time live tab and must
76+ not become route memory. Committed route records drop that catalog selector;
77+ PreferredTabID is the independent mutable cursor.
78+- **Resume and identity authority:** a preferred tab cannot select a session. It
79+ is accepted only for attach/resume and only after resume-token or exact-target
80+ session resolution succeeds.
81+- **Untrusted IDs:** both Hello and RoutePosition reject empty/oversized/control
82+ stable IDs and trailing or truncated payloads.
83+- **Publication completeness:** tab selection and repair always invalidate a
84+ render. RoutePosition is serialized with the attachment's post-commit output
85+ transaction and cached per transport incarnation; output rebase forces an
86+ initial publication after attach/resume.
87+
88+## Accepted risks
89+
90+- The state is intentionally process-memory only. Restarting the client loses
91+ remembered tabs; restarting a daemon does not make the daemon the memory
92+ owner, and missing/replaced stable IDs safely fall back.
93+- RoutePosition is an additional small control frame only when the exact target
94+ or active stable tab changes, not on every output diff.
M internal/app/cmd.go +15 -2
@@ -128,11 +128,20 @@ type cmdDeps struct {
128128 stdout io.Writer
129129 getenv func(string) string
130130 dial func(context.Context, string) (ports.Transport, error)
131+ ensure func(context.Context, string) (ports.Transport, error)
131132 clock ports.Clock
132133 }
133134
134135 func runCmd(ctx context.Context, invocation cmdInvocation) error {
135- return runCmdWithDeps(ctx, invocation, cmdDeps{stdout: os.Stdout, getenv: os.Getenv, dial: realDial, clock: clock.New()})
136+ return runCmdWithDeps(ctx, invocation, cmdDeps{
137+ stdout: os.Stdout,
138+ getenv: os.Getenv,
139+ dial: realDial,
140+ ensure: func(ctx context.Context, dir string) (ports.Transport, error) {
141+ return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)
142+ },
143+ clock: clock.New(),
144+ })
136145 }
137146
138147 func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {
@@ -166,7 +175,11 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
166175 return fmt.Errorf("encoding command: %w", err)
167176 }
168177
169- transport, err := deps.dial(ctx, ipc.SocketDir())
178+ dial := deps.dial
179+ if invocation.slug == "remote-catalog" && deps.ensure != nil {
180+ dial = deps.ensure
181+ }
182+ transport, err := dial(ctx, ipc.SocketDir())
170183 if err != nil {
171184 return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}
172185 }
@@ -128,11 +128,20 @@ type cmdDeps struct {
128 stdout io.Writer128 stdout io.Writer
129 getenv func(string) string129 getenv func(string) string
130 dial func(context.Context, string) (ports.Transport, error)130 dial func(context.Context, string) (ports.Transport, error)
131+ ensure func(context.Context, string) (ports.Transport, error)
131 clock ports.Clock132 clock ports.Clock
132 }133 }
133 134
134 func runCmd(ctx context.Context, invocation cmdInvocation) error {135 func runCmd(ctx context.Context, invocation cmdInvocation) error {
135- return runCmdWithDeps(ctx, invocation, cmdDeps{stdout: os.Stdout, getenv: os.Getenv, dial: realDial, clock: clock.New()})136+ return runCmdWithDeps(ctx, invocation, cmdDeps{
137+ stdout: os.Stdout,
138+ getenv: os.Getenv,
139+ dial: realDial,
140+ ensure: func(ctx context.Context, dir string) (ports.Transport, error) {
141+ return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)
142+ },
143+ clock: clock.New(),
144+ })
136 }145 }
137 146
138 func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {147 func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {
@@ -166,7 +175,11 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
166 return fmt.Errorf("encoding command: %w", err)175 return fmt.Errorf("encoding command: %w", err)
167 }176 }
168 177
169- transport, err := deps.dial(ctx, ipc.SocketDir())178+ dial := deps.dial
179+ if invocation.slug == "remote-catalog" && deps.ensure != nil {
180+ dial = deps.ensure
181+ }
182+ transport, err := dial(ctx, ipc.SocketDir())
170 if err != nil {183 if err != nil {
171 return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}184 return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}
172 }185 }
M internal/app/cmd_test.go +19 -0
@@ -156,6 +156,25 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
156156 }
157157 }
158158
159+func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) {
160+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
161+ ensureCalls := 0
162+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{
163+ stdout: io.Discard,
164+ getenv: func(string) string { return "" },
165+ dial: func(context.Context, string) (ports.Transport, error) {
166+ t.Fatal("remote catalog used non-starting daemon dial")
167+ return nil, errors.New("unexpected dial")
168+ },
169+ ensure: func(context.Context, string) (ports.Transport, error) {
170+ ensureCalls++
171+ return transport, nil
172+ },
173+ })
174+ require.NoError(t, err)
175+ require.Equal(t, 1, ensureCalls)
176+}
177+
159178 func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
160179 for _, invocation := range []cmdInvocation{
161180 {slug: "move-pane"},
@@ -156,6 +156,25 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
156 }156 }
157 }157 }
158 158
159+func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) {
160+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
161+ ensureCalls := 0
162+ err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{
163+ stdout: io.Discard,
164+ getenv: func(string) string { return "" },
165+ dial: func(context.Context, string) (ports.Transport, error) {
166+ t.Fatal("remote catalog used non-starting daemon dial")
167+ return nil, errors.New("unexpected dial")
168+ },
169+ ensure: func(context.Context, string) (ports.Transport, error) {
170+ ensureCalls++
171+ return transport, nil
172+ },
173+ })
174+ require.NoError(t, err)
175+ require.Equal(t, 1, ensureCalls)
176+}
177+
159 func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {178 func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
160 for _, invocation := range []cmdInvocation{179 for _, invocation := range []cmdInvocation{
161 {slug: "move-pane"},180 {slug: "move-pane"},
M internal/app/run.go +0 -41
@@ -835,17 +835,6 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti
835835 }, nil
836836 }
837837
838-type attachHandoffKey struct {
839- endpoint string
840- session string
841- intent uint8
842- lifecycle domain.SessionLifecycleID
843- liveTab domain.TabStableID
844- stopped domain.TabSelector
845-}
846-
847-const maxAttachHandoffHops = 32
848-
849838 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
850839 if activeSession != "" {
851840 if remoteTarget == "" && intent == ports.IntentNew {
@@ -868,19 +857,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
868857 var remoteEnvironmentPolicy ports.EnvironmentPolicy
869858 routeOrigin := ports.RouteOriginLocal
870859 routeOriginKey := "local"
871- seenHandoffs := make(map[attachHandoffKey]struct{})
872- handoffHops := 0
873- admitHandoff := func(next attachHandoffKey) error {
874- if _, seen := seenHandoffs[next]; seen {
875- return fmt.Errorf("vev: attach handoff loop for %q/%q", next.endpoint, next.session)
876- }
877- if handoffHops >= maxAttachHandoffHops {
878- return fmt.Errorf("vev: attach handoff exceeded maximum of %d hops", maxAttachHandoffHops)
879- }
880- handoffHops++
881- seenHandoffs[next] = struct{}{}
882- return nil
883- }
884860 if remoteTarget != "" {
885861 routeOrigin = ports.RouteOriginRemote
886862 routeOriginKey = remoteTarget
@@ -890,7 +866,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
890866 if factory == nil {
891867 factory = defaultRemoteDialerFactory()
892868 }
893- seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{}
894869 }
895870 pickerHandoff := remoteTarget == ""
896871 pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, policy ports.EnvironmentPolicy) ports.EnvironmentPolicy {
@@ -909,17 +884,10 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
909884 if factory == nil {
910885 factory = defaultRemoteDialerFactory()
911886 }
912- next := attachHandoffKey{endpoint: target.Endpoint, session: target.Session, intent: target.Intent}
913887 var selection *domain.RemoteSessionTarget
914888 if target.RemoteTarget != nil {
915889 copyTarget := *target.RemoteTarget
916890 selection = &copyTarget
917- next.lifecycle = copyTarget.LifecycleID
918- next.liveTab = copyTarget.LiveTabID
919- next.stopped = copyTarget.StoppedTab
920- }
921- if err := admitHandoff(next); err != nil {
922- return nil, client.AttachRequest{}, err
923891 }
924892 dialer, err := factory.DialerForRemote(target.Endpoint, target.Session, mode, log)
925893 if err != nil {
@@ -995,15 +963,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
995963 if err := validateRemoteAttachHandoff(handoffErr.Target); err != nil {
996964 return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
997965 }
998- next := attachHandoffKey{endpoint: handoffErr.Target.Endpoint, session: handoffErr.Target.Session, intent: handoffErr.Target.Intent}
999- if handoffErr.Target.RemoteTarget != nil {
1000- next.lifecycle = handoffErr.Target.RemoteTarget.LifecycleID
1001- next.liveTab = handoffErr.Target.RemoteTarget.LiveTabID
1002- next.stopped = handoffErr.Target.RemoteTarget.StoppedTab
1003- }
1004- if err := admitHandoff(next); err != nil {
1005- return err
1006- }
1007966 remoteTarget = handoffErr.Target.Endpoint
1008967 routeOrigin = ports.RouteOriginDiscovery
1009968 routeOriginKey = remoteTarget
@@ -835,17 +835,6 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti
835 }, nil835 }, nil
836 }836 }
837 837
838-type attachHandoffKey struct {
839- endpoint string
840- session string
841- intent uint8
842- lifecycle domain.SessionLifecycleID
843- liveTab domain.TabStableID
844- stopped domain.TabSelector
845-}
846-
847-const maxAttachHandoffHops = 32
848-
849 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {838 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
850 if activeSession != "" {839 if activeSession != "" {
851 if remoteTarget == "" && intent == ports.IntentNew {840 if remoteTarget == "" && intent == ports.IntentNew {
@@ -868,19 +857,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
868 var remoteEnvironmentPolicy ports.EnvironmentPolicy857 var remoteEnvironmentPolicy ports.EnvironmentPolicy
869 routeOrigin := ports.RouteOriginLocal858 routeOrigin := ports.RouteOriginLocal
870 routeOriginKey := "local"859 routeOriginKey := "local"
871- seenHandoffs := make(map[attachHandoffKey]struct{})
872- handoffHops := 0
873- admitHandoff := func(next attachHandoffKey) error {
874- if _, seen := seenHandoffs[next]; seen {
875- return fmt.Errorf("vev: attach handoff loop for %q/%q", next.endpoint, next.session)
876- }
877- if handoffHops >= maxAttachHandoffHops {
878- return fmt.Errorf("vev: attach handoff exceeded maximum of %d hops", maxAttachHandoffHops)
879- }
880- handoffHops++
881- seenHandoffs[next] = struct{}{}
882- return nil
883- }
884 if remoteTarget != "" {860 if remoteTarget != "" {
885 routeOrigin = ports.RouteOriginRemote861 routeOrigin = ports.RouteOriginRemote
886 routeOriginKey = remoteTarget862 routeOriginKey = remoteTarget
@@ -890,7 +866,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
890 if factory == nil {866 if factory == nil {
891 factory = defaultRemoteDialerFactory()867 factory = defaultRemoteDialerFactory()
892 }868 }
893- seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{}
894 }869 }
895 pickerHandoff := remoteTarget == ""870 pickerHandoff := remoteTarget == ""
896 pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, policy ports.EnvironmentPolicy) ports.EnvironmentPolicy {871 pickerEnvironmentPolicy := func(target *domain.RemoteSessionTarget, policy ports.EnvironmentPolicy) ports.EnvironmentPolicy {
@@ -909,17 +884,10 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
909 if factory == nil {884 if factory == nil {
910 factory = defaultRemoteDialerFactory()885 factory = defaultRemoteDialerFactory()
911 }886 }
912- next := attachHandoffKey{endpoint: target.Endpoint, session: target.Session, intent: target.Intent}
913 var selection *domain.RemoteSessionTarget887 var selection *domain.RemoteSessionTarget
914 if target.RemoteTarget != nil {888 if target.RemoteTarget != nil {
915 copyTarget := *target.RemoteTarget889 copyTarget := *target.RemoteTarget
916 selection = &copyTarget890 selection = &copyTarget
917- next.lifecycle = copyTarget.LifecycleID
918- next.liveTab = copyTarget.LiveTabID
919- next.stopped = copyTarget.StoppedTab
920- }
921- if err := admitHandoff(next); err != nil {
922- return nil, client.AttachRequest{}, err
923 }891 }
924 dialer, err := factory.DialerForRemote(target.Endpoint, target.Session, mode, log)892 dialer, err := factory.DialerForRemote(target.Endpoint, target.Session, mode, log)
925 if err != nil {893 if err != nil {
@@ -995,15 +963,6 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
995 if err := validateRemoteAttachHandoff(handoffErr.Target); err != nil {963 if err := validateRemoteAttachHandoff(handoffErr.Target); err != nil {
996 return fmt.Errorf("vev: invalid remote attach handoff: %w", err)964 return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
997 }965 }
998- next := attachHandoffKey{endpoint: handoffErr.Target.Endpoint, session: handoffErr.Target.Session, intent: handoffErr.Target.Intent}
999- if handoffErr.Target.RemoteTarget != nil {
1000- next.lifecycle = handoffErr.Target.RemoteTarget.LifecycleID
1001- next.liveTab = handoffErr.Target.RemoteTarget.LiveTabID
1002- next.stopped = handoffErr.Target.RemoteTarget.StoppedTab
1003- }
1004- if err := admitHandoff(next); err != nil {
1005- return err
1006- }
1007 remoteTarget = handoffErr.Target.Endpoint966 remoteTarget = handoffErr.Target.Endpoint
1008 routeOrigin = ports.RouteOriginDiscovery967 routeOrigin = ports.RouteOriginDiscovery
1009 routeOriginKey = remoteTarget968 routeOriginKey = remoteTarget
M internal/app/run_test.go +11 -24
@@ -6,7 +6,6 @@ import (
66 "context"
77 "encoding/json"
88 "errors"
9- "fmt"
109 "io"
1110 "log/slog"
1211 "net"
@@ -916,36 +915,24 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.
916915 require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection")
917916 }
918917
919-func TestRunAttachWithDepsRejectsHandoffLoop(t *testing.T) {
918+func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) {
920919 factory := portsmocks.NewMockRemoteDialerFactory(t)
921920 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
921+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-revisit"}, nil).Once()
922922
923- calls := 0
924923 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
925924 remoteDialerFactory: factory,
926- runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
927- calls++
928- return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}}
929- },
930- })
931- require.ErrorContains(t, err, "attach handoff loop")
932- require.Equal(t, 1, calls)
933-}
934-
935-func TestRunAttachWithDepsCapsUniqueHandoffHops(t *testing.T) {
936- factory := portsmocks.NewMockRemoteDialerFactory(t)
937- factory.EXPECT().DialerForRemote(mock.Anything, mock.Anything, ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachHandoffHops + 1)
938-
939- calls := 0
940- err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
941- remoteDialerFactory: factory,
942- runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
943- calls++
944- return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: fmt.Sprintf("remote-%d.example", calls), Session: "work", Intent: ports.IntentAttach}}
925+ runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
926+ dialer, request, err := deps.AttachHandoff(ports.AttachTarget{
927+ Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach,
928+ })
929+ require.NoError(t, err)
930+ require.NotNil(t, dialer)
931+ require.Equal(t, "work", request.SessionName)
932+ return nil
945933 },
946934 })
947- require.ErrorContains(t, err, "exceeded maximum")
948- require.Equal(t, maxAttachHandoffHops+1, calls)
935+ require.NoError(t, err)
949936 }
950937
951938 func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {
@@ -6,7 +6,6 @@ import (
6 "context"6 "context"
7 "encoding/json"7 "encoding/json"
8 "errors"8 "errors"
9- "fmt"
10 "io"9 "io"
11 "log/slog"10 "log/slog"
12 "net"11 "net"
@@ -916,36 +915,24 @@ func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.
916 require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection")915 require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection")
917 }916 }
918 917
919-func TestRunAttachWithDepsRejectsHandoffLoop(t *testing.T) {918+func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) {
920 factory := portsmocks.NewMockRemoteDialerFactory(t)919 factory := portsmocks.NewMockRemoteDialerFactory(t)
921 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()920 factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
921+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-revisit"}, nil).Once()
922 922
923- calls := 0
924 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{923 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
925 remoteDialerFactory: factory,924 remoteDialerFactory: factory,
926- runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {925+ runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
927- calls++926+ dialer, request, err := deps.AttachHandoff(ports.AttachTarget{
928- return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}}927+ Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach,
929- },928+ })
930- })929+ require.NoError(t, err)
931- require.ErrorContains(t, err, "attach handoff loop")930+ require.NotNil(t, dialer)
932- require.Equal(t, 1, calls)931+ require.Equal(t, "work", request.SessionName)
933-}932+ return nil
934-
935-func TestRunAttachWithDepsCapsUniqueHandoffHops(t *testing.T) {
936- factory := portsmocks.NewMockRemoteDialerFactory(t)
937- factory.EXPECT().DialerForRemote(mock.Anything, mock.Anything, ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachHandoffHops + 1)
938-
939- calls := 0
940- err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
941- remoteDialerFactory: factory,
942- runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
943- calls++
944- return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: fmt.Sprintf("remote-%d.example", calls), Session: "work", Intent: ports.IntentAttach}}
945 },933 },
946 })934 })
947- require.ErrorContains(t, err, "exceeded maximum")935+ require.NoError(t, err)
948- require.Equal(t, maxAttachHandoffHops+1, calls)
949 }936 }
950 937
951 func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {938 func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {
M internal/ports/frame.go +5 -4
@@ -12,7 +12,7 @@ import (
1212 const HandshakeTimeout = 15 * time.Second
1313
1414 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 27
15+const ProtocolVersion uint16 = 28
1616
1717 // MaxFrameLen is the largest permitted frame length, including the type byte
1818 // and excluding the four-byte length prefix.
@@ -22,9 +22,9 @@ const MaxFrameLen = 16 << 20
2222 type MsgType uint8
2323
2424 // Frame message types. Client-originated messages occupy 1–13 and 15;
25-// server-originated messages occupy 16–23 and 25–27. Values 14 and 24 remain
26-// reserved for future extensions. Route snapshot/navigation messages use the
27-// next available values 28–30 and remain daemon-neutral until the cutover.
25+// server-originated messages occupy 16–23 and 25–31. Values 14 and 24 remain
26+// reserved for future extensions. Route metadata/navigation messages use the
27+// post-26 range.
2828 const (
2929 MsgHello MsgType = 1
3030 MsgInput MsgType = 2
@@ -57,6 +57,7 @@ const (
5757 MsgRecentRouteSnapshot MsgType = 28
5858 MsgNavigateRecentRoute MsgType = 29
5959 MsgRouteNavigationFailure MsgType = 30
60+ MsgRoutePosition MsgType = 31
6061 )
6162
6263 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -12,7 +12,7 @@ import (
12 const HandshakeTimeout = 15 * time.Second12 const HandshakeTimeout = 15 * time.Second
13 13
14 // ProtocolVersion is the current vev IPC wire protocol version.14 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 2715+const ProtocolVersion uint16 = 28
16 16
17 // MaxFrameLen is the largest permitted frame length, including the type byte17 // MaxFrameLen is the largest permitted frame length, including the type byte
18 // and excluding the four-byte length prefix.18 // and excluding the four-byte length prefix.
@@ -22,9 +22,9 @@ const MaxFrameLen = 16 << 20
22 type MsgType uint822 type MsgType uint8
23 23
24 // Frame message types. Client-originated messages occupy 1–13 and 15;24 // Frame message types. Client-originated messages occupy 1–13 and 15;
25-// server-originated messages occupy 16–23 and 25–27. Values 14 and 24 remain25+// server-originated messages occupy 16–23 and 25–31. Values 14 and 24 remain
26-// reserved for future extensions. Route snapshot/navigation messages use the26+// reserved for future extensions. Route metadata/navigation messages use the
27-// next available values 28–30 and remain daemon-neutral until the cutover.27+// post-26 range.
28 const (28 const (
29 MsgHello MsgType = 129 MsgHello MsgType = 1
30 MsgInput MsgType = 230 MsgInput MsgType = 2
@@ -57,6 +57,7 @@ const (
57 MsgRecentRouteSnapshot MsgType = 2857 MsgRecentRouteSnapshot MsgType = 28
58 MsgNavigateRecentRoute MsgType = 2958 MsgNavigateRecentRoute MsgType = 29
59 MsgRouteNavigationFailure MsgType = 3059 MsgRouteNavigationFailure MsgType = 30
60+ MsgRoutePosition MsgType = 31
60 )61 )
61 62
62 // Frame is the unit of exchange over a Transport: a typed, length-delimited63 // Frame is the unit of exchange over a Transport: a typed, length-delimited
M internal/ports/frame_test.go +3 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 27 {
7- t.Fatalf("ProtocolVersion = %d, want 27", ProtocolVersion)
6+ if ProtocolVersion != 28 {
7+ t.Fatalf("ProtocolVersion = %d, want 28", ProtocolVersion)
88 }
99 }
1010
@@ -39,6 +39,7 @@ func TestMsgTypeUnique(t *testing.T) {
3939 {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},
4040 {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},
4141 {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},
42+ {"MsgRoutePosition", MsgRoutePosition},
4243 }
4344
4445 seen := make(map[MsgType]string, len(tests))
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 27 {6+ if ProtocolVersion != 28 {
7- t.Fatalf("ProtocolVersion = %d, want 27", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 28", ProtocolVersion)
8 }8 }
9 }9 }
10 10
@@ -39,6 +39,7 @@ func TestMsgTypeUnique(t *testing.T) {
39 {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},39 {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},
40 {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},40 {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},
41 {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},41 {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},
42+ {"MsgRoutePosition", MsgRoutePosition},
42 }43 }
43 44
44 seen := make(map[MsgType]string, len(tests))45 seen := make(map[MsgType]string, len(tests))
M internal/ports/navigation_test.go +1 -1
@@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
6262 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
6363 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
6464 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
65- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001b02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0201"},
65+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001c02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},
6666 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
6767 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
6868 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
@@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
62 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},62 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
63 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},63 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
64 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},64 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
65- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001b02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0201"},65+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001c02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},
66 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},66 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
67 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},67 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
68 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},68 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
M internal/ports/remote_target_wire_test.go +1 -1
@@ -115,7 +115,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
115115 })
116116 },
117117 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },
118- policyOffset: 4,
118+ policyOffset: 6,
119119 wantErr: ErrInvalidHello,
120120 },
121121 {
@@ -115,7 +115,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
115 })115 })
116 },116 },
117 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },117 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },
118- policyOffset: 4,118+ policyOffset: 6,
119 wantErr: ErrInvalidHello,119 wantErr: ErrInvalidHello,
120 },120 },
121 {121 {
M internal/ports/routes.go +18 -0
@@ -142,6 +142,24 @@ func (i CommittedRouteIdentity) Validate() error {
142142 return nil
143143 }
144144
145+// RoutePosition is mutable per-client route state published by the daemon.
146+// Target binds the tab cursor to one exact session lifecycle so delayed frames
147+// cannot update another route.
148+type RoutePosition struct {
149+ Target ExactSessionTarget
150+ ActiveTabID domain.TabStableID
151+}
152+
153+func (p RoutePosition) Validate() error {
154+ if err := p.Target.Validate(); err != nil {
155+ return err
156+ }
157+ if err := domain.ValidateTabStableID(p.ActiveTabID); err != nil {
158+ return fmt.Errorf("invalid active tab ID: %w", err)
159+ }
160+ return nil
161+}
162+
145163 // RouteRef is an opaque client-ledger reference. It is intentionally not a
146164 // lifecycle identifier and is meaningful only with the matching generation.
147165 type RouteRef struct {
@@ -142,6 +142,24 @@ func (i CommittedRouteIdentity) Validate() error {
142 return nil142 return nil
143 }143 }
144 144
145+// RoutePosition is mutable per-client route state published by the daemon.
146+// Target binds the tab cursor to one exact session lifecycle so delayed frames
147+// cannot update another route.
148+type RoutePosition struct {
149+ Target ExactSessionTarget
150+ ActiveTabID domain.TabStableID
151+}
152+
153+func (p RoutePosition) Validate() error {
154+ if err := p.Target.Validate(); err != nil {
155+ return err
156+ }
157+ if err := domain.ValidateTabStableID(p.ActiveTabID); err != nil {
158+ return fmt.Errorf("invalid active tab ID: %w", err)
159+ }
160+ return nil
161+}
162+
145 // RouteRef is an opaque client-ledger reference. It is intentionally not a163 // RouteRef is an opaque client-ledger reference. It is intentionally not a
146 // lifecycle identifier and is meaningful only with the matching generation.164 // lifecycle identifier and is meaningful only with the matching generation.
147 type RouteRef struct {165 type RouteRef struct {
M internal/ports/routes_test.go +29 -5
@@ -32,11 +32,12 @@ func TestExactSessionTargetCodecIsStrict(t *testing.T) {
3232 func TestHelloExactTargetRoundTrip(t *testing.T) {
3333 target := testExactTarget()
3434 msg := Hello{
35- Version: ProtocolVersion,
36- Intent: IntentAttach,
37- Name: target.SessionName,
38- Size: domain.Size{Cols: 80, Rows: 24},
39- ExactTarget: &target,
35+ Version: ProtocolVersion,
36+ Intent: IntentAttach,
37+ Name: target.SessionName,
38+ Size: domain.Size{Cols: 80, Rows: 24},
39+ ExactTarget: &target,
40+ PreferredTabID: "tab-2",
4041 }
4142
4243 encoded := MarshalHello(msg)
@@ -48,6 +49,29 @@ func TestHelloExactTargetRoundTrip(t *testing.T) {
4849 invalid := msg
4950 invalid.Intent = IntentNew
5051 require.Error(t, ValidateHello(invalid))
52+
53+ invalid = msg
54+ invalid.PreferredTabID = "bad tab"
55+ require.Error(t, ValidateHello(invalid))
56+}
57+
58+func TestRoutePositionCodecIsStrict(t *testing.T) {
59+ want := RoutePosition{Target: testExactTarget(), ActiveTabID: "tab-2"}
60+ encoded, err := MarshalRoutePosition(want)
61+ require.NoError(t, err)
62+ require.Equal(t, "010203000000000000000000000000000004776f726b00057461622d32", hex.EncodeToString(encoded))
63+ assertAllPrefixesFail(t, encoded, UnmarshalRoutePosition)
64+ _, err = UnmarshalRoutePosition(append(append([]byte(nil), encoded...), 0))
65+ require.Error(t, err)
66+
67+ got, err := UnmarshalRoutePosition(encoded)
68+ require.NoError(t, err)
69+ require.Equal(t, want, got)
70+
71+ _, err = MarshalRoutePosition(RoutePosition{Target: testExactTarget()})
72+ require.ErrorIs(t, err, ErrInvalidRouteWire)
73+ _, err = MarshalRoutePosition(RoutePosition{Target: testExactTarget(), ActiveTabID: "bad tab"})
74+ require.ErrorIs(t, err, ErrInvalidRouteWire)
5175 }
5276
5377 func TestWelcomeCarriesCommittedRouteIdentity(t *testing.T) {
@@ -32,11 +32,12 @@ func TestExactSessionTargetCodecIsStrict(t *testing.T) {
32 func TestHelloExactTargetRoundTrip(t *testing.T) {32 func TestHelloExactTargetRoundTrip(t *testing.T) {
33 target := testExactTarget()33 target := testExactTarget()
34 msg := Hello{34 msg := Hello{
35- Version: ProtocolVersion,35+ Version: ProtocolVersion,
36- Intent: IntentAttach,36+ Intent: IntentAttach,
37- Name: target.SessionName,37+ Name: target.SessionName,
38- Size: domain.Size{Cols: 80, Rows: 24},38+ Size: domain.Size{Cols: 80, Rows: 24},
39- ExactTarget: &target,39+ ExactTarget: &target,
40+ PreferredTabID: "tab-2",
40 }41 }
41 42
42 encoded := MarshalHello(msg)43 encoded := MarshalHello(msg)
@@ -48,6 +49,29 @@ func TestHelloExactTargetRoundTrip(t *testing.T) {
48 invalid := msg49 invalid := msg
49 invalid.Intent = IntentNew50 invalid.Intent = IntentNew
50 require.Error(t, ValidateHello(invalid))51 require.Error(t, ValidateHello(invalid))
52+
53+ invalid = msg
54+ invalid.PreferredTabID = "bad tab"
55+ require.Error(t, ValidateHello(invalid))
56+}
57+
58+func TestRoutePositionCodecIsStrict(t *testing.T) {
59+ want := RoutePosition{Target: testExactTarget(), ActiveTabID: "tab-2"}
60+ encoded, err := MarshalRoutePosition(want)
61+ require.NoError(t, err)
62+ require.Equal(t, "010203000000000000000000000000000004776f726b00057461622d32", hex.EncodeToString(encoded))
63+ assertAllPrefixesFail(t, encoded, UnmarshalRoutePosition)
64+ _, err = UnmarshalRoutePosition(append(append([]byte(nil), encoded...), 0))
65+ require.Error(t, err)
66+
67+ got, err := UnmarshalRoutePosition(encoded)
68+ require.NoError(t, err)
69+ require.Equal(t, want, got)
70+
71+ _, err = MarshalRoutePosition(RoutePosition{Target: testExactTarget()})
72+ require.ErrorIs(t, err, ErrInvalidRouteWire)
73+ _, err = MarshalRoutePosition(RoutePosition{Target: testExactTarget(), ActiveTabID: "bad tab"})
74+ require.ErrorIs(t, err, ErrInvalidRouteWire)
51 }75 }
52 76
53 func TestWelcomeCarriesCommittedRouteIdentity(t *testing.T) {77 func TestWelcomeCarriesCommittedRouteIdentity(t *testing.T) {
M internal/ports/routes_wire.go +35 -0
@@ -3,6 +3,8 @@ package ports
33 import (
44 "errors"
55 "fmt"
6+
7+ "github.com/bnema/vev/internal/domain"
68 )
79
810 var ErrInvalidRouteWire = errors.New("invalid route wire message")
@@ -176,6 +178,39 @@ func UnmarshalCommittedRouteIdentity(b []byte) (CommittedRouteIdentity, error) {
176178 return identity, nil
177179 }
178180
181+// MarshalRoutePosition encodes one exact session and its attachment-local tab
182+// cursor. Route positions are mutable client memory, not attach authority.
183+func MarshalRoutePosition(position RoutePosition) ([]byte, error) {
184+ if err := position.Validate(); err != nil {
185+ return nil, fmt.Errorf("%w: route position: %v", ErrInvalidRouteWire, err)
186+ }
187+ w := payloadWriter{}
188+ marshalExactSessionTarget(&w, position.Target)
189+ w.putString(string(position.ActiveTabID))
190+ return w.b, nil
191+}
192+
193+// UnmarshalRoutePosition decodes one strict route-position payload.
194+func UnmarshalRoutePosition(b []byte) (RoutePosition, error) {
195+ r := payloadReader{b: b}
196+ target, err := unmarshalExactSessionTarget(&r)
197+ if err != nil {
198+ return RoutePosition{}, err
199+ }
200+ activeTabID, err := r.getString()
201+ if err != nil {
202+ return RoutePosition{}, err
203+ }
204+ if err := r.done(); err != nil {
205+ return RoutePosition{}, err
206+ }
207+ position := RoutePosition{Target: target, ActiveTabID: domain.TabStableID(activeTabID)}
208+ if err := position.Validate(); err != nil {
209+ return RoutePosition{}, fmt.Errorf("%w: route position: %v", ErrInvalidRouteWire, err)
210+ }
211+ return position, nil
212+}
213+
179214 func validateRouteRef(ref RouteRef) error {
180215 if err := ref.Validate(); err != nil {
181216 return fmt.Errorf("%w: route reference: %v", ErrInvalidRouteWire, err)
@@ -3,6 +3,8 @@ package ports
3 import (3 import (
4 "errors"4 "errors"
5 "fmt"5 "fmt"
6+
7+ "github.com/bnema/vev/internal/domain"
6 )8 )
7 9
8 var ErrInvalidRouteWire = errors.New("invalid route wire message")10 var ErrInvalidRouteWire = errors.New("invalid route wire message")
@@ -176,6 +178,39 @@ func UnmarshalCommittedRouteIdentity(b []byte) (CommittedRouteIdentity, error) {
176 return identity, nil178 return identity, nil
177 }179 }
178 180
181+// MarshalRoutePosition encodes one exact session and its attachment-local tab
182+// cursor. Route positions are mutable client memory, not attach authority.
183+func MarshalRoutePosition(position RoutePosition) ([]byte, error) {
184+ if err := position.Validate(); err != nil {
185+ return nil, fmt.Errorf("%w: route position: %v", ErrInvalidRouteWire, err)
186+ }
187+ w := payloadWriter{}
188+ marshalExactSessionTarget(&w, position.Target)
189+ w.putString(string(position.ActiveTabID))
190+ return w.b, nil
191+}
192+
193+// UnmarshalRoutePosition decodes one strict route-position payload.
194+func UnmarshalRoutePosition(b []byte) (RoutePosition, error) {
195+ r := payloadReader{b: b}
196+ target, err := unmarshalExactSessionTarget(&r)
197+ if err != nil {
198+ return RoutePosition{}, err
199+ }
200+ activeTabID, err := r.getString()
201+ if err != nil {
202+ return RoutePosition{}, err
203+ }
204+ if err := r.done(); err != nil {
205+ return RoutePosition{}, err
206+ }
207+ position := RoutePosition{Target: target, ActiveTabID: domain.TabStableID(activeTabID)}
208+ if err := position.Validate(); err != nil {
209+ return RoutePosition{}, fmt.Errorf("%w: route position: %v", ErrInvalidRouteWire, err)
210+ }
211+ return position, nil
212+}
213+
179 func validateRouteRef(ref RouteRef) error {214 func validateRouteRef(ref RouteRef) error {
180 if err := ref.Validate(); err != nil {215 if err := ref.Validate(); err != nil {
181 return fmt.Errorf("%w: route reference: %v", ErrInvalidRouteWire, err)216 return fmt.Errorf("%w: route reference: %v", ErrInvalidRouteWire, err)
M internal/ports/wire.go +21 -1
@@ -165,7 +165,10 @@ type Hello struct {
165165 // ExactTarget is an optional lifecycle/name identity selected by the client
166166 // ledger. It is transport-neutral and never replaces the daemon's authority
167167 // to validate or commit the target.
168- ExactTarget *ExactSessionTarget
168+ ExactTarget *ExactSessionTarget
169+ // PreferredTabID is a client-owned route cursor. The daemon treats it as an
170+ // attachment-local hint and falls back when the stable tab no longer exists.
171+ PreferredTabID domain.TabStableID
169172 EnvironmentPolicy EnvironmentPolicy
170173 NavigationCapabilities NavigationCapabilities
171174 StartupOverlay StartupOverlay
@@ -725,6 +728,14 @@ func ValidateHello(h Hello) error {
725728 return ErrInvalidHello
726729 }
727730 }
731+ if h.PreferredTabID != "" {
732+ if h.Intent != IntentAttach && h.Intent != IntentResume {
733+ return ErrInvalidHello
734+ }
735+ if err := domain.ValidateTabStableID(h.PreferredTabID); err != nil {
736+ return fmt.Errorf("%w: preferred tab: %v", ErrInvalidHello, err)
737+ }
738+ }
728739 if h.ExactTarget != nil {
729740 if h.Intent != IntentAttach && h.Intent != IntentResume {
730741 return ErrInvalidHello
@@ -852,6 +863,7 @@ func MarshalHello(h Hello) []byte {
852863 }
853864 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)
854865 marshalExactTargetSection(&w, h.ExactTarget)
866+ w.putString(string(h.PreferredTabID))
855867 w.putUint8(uint8(h.NavigationCapabilities))
856868 w.putUint8(uint8(h.StartupOverlay))
857869 return w.b
@@ -915,6 +927,9 @@ func preflightHello(b []byte) error {
915927 if err := skipExactTargetSection(&r); err != nil {
916928 return err
917929 }
930+ if err := r.skipString(); err != nil {
931+ return err
932+ }
918933 if _, err := r.getUint8(); err != nil {
919934 return err
920935 }
@@ -999,6 +1014,11 @@ func UnmarshalHello(b []byte) (Hello, error) {
9991014 if h.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil {
10001015 return Hello{}, err
10011016 }
1017+ preferredTabID, err := r.getString()
1018+ if err != nil {
1019+ return Hello{}, err
1020+ }
1021+ h.PreferredTabID = domain.TabStableID(preferredTabID)
10021022 capabilities, err := r.getUint8()
10031023 if err != nil {
10041024 return Hello{}, err
@@ -165,7 +165,10 @@ type Hello struct {
165 // ExactTarget is an optional lifecycle/name identity selected by the client165 // ExactTarget is an optional lifecycle/name identity selected by the client
166 // ledger. It is transport-neutral and never replaces the daemon's authority166 // ledger. It is transport-neutral and never replaces the daemon's authority
167 // to validate or commit the target.167 // to validate or commit the target.
168- ExactTarget *ExactSessionTarget168+ ExactTarget *ExactSessionTarget
169+ // PreferredTabID is a client-owned route cursor. The daemon treats it as an
170+ // attachment-local hint and falls back when the stable tab no longer exists.
171+ PreferredTabID domain.TabStableID
169 EnvironmentPolicy EnvironmentPolicy172 EnvironmentPolicy EnvironmentPolicy
170 NavigationCapabilities NavigationCapabilities173 NavigationCapabilities NavigationCapabilities
171 StartupOverlay StartupOverlay174 StartupOverlay StartupOverlay
@@ -725,6 +728,14 @@ func ValidateHello(h Hello) error {
725 return ErrInvalidHello728 return ErrInvalidHello
726 }729 }
727 }730 }
731+ if h.PreferredTabID != "" {
732+ if h.Intent != IntentAttach && h.Intent != IntentResume {
733+ return ErrInvalidHello
734+ }
735+ if err := domain.ValidateTabStableID(h.PreferredTabID); err != nil {
736+ return fmt.Errorf("%w: preferred tab: %v", ErrInvalidHello, err)
737+ }
738+ }
728 if h.ExactTarget != nil {739 if h.ExactTarget != nil {
729 if h.Intent != IntentAttach && h.Intent != IntentResume {740 if h.Intent != IntentAttach && h.Intent != IntentResume {
730 return ErrInvalidHello741 return ErrInvalidHello
@@ -852,6 +863,7 @@ func MarshalHello(h Hello) []byte {
852 }863 }
853 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)864 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)
854 marshalExactTargetSection(&w, h.ExactTarget)865 marshalExactTargetSection(&w, h.ExactTarget)
866+ w.putString(string(h.PreferredTabID))
855 w.putUint8(uint8(h.NavigationCapabilities))867 w.putUint8(uint8(h.NavigationCapabilities))
856 w.putUint8(uint8(h.StartupOverlay))868 w.putUint8(uint8(h.StartupOverlay))
857 return w.b869 return w.b
@@ -915,6 +927,9 @@ func preflightHello(b []byte) error {
915 if err := skipExactTargetSection(&r); err != nil {927 if err := skipExactTargetSection(&r); err != nil {
916 return err928 return err
917 }929 }
930+ if err := r.skipString(); err != nil {
931+ return err
932+ }
918 if _, err := r.getUint8(); err != nil {933 if _, err := r.getUint8(); err != nil {
919 return err934 return err
920 }935 }
@@ -999,6 +1014,11 @@ func UnmarshalHello(b []byte) (Hello, error) {
999 if h.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil {1014 if h.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil {
1000 return Hello{}, err1015 return Hello{}, err
1001 }1016 }
1017+ preferredTabID, err := r.getString()
1018+ if err != nil {
1019+ return Hello{}, err
1020+ }
1021+ h.PreferredTabID = domain.TabStableID(preferredTabID)
1002 capabilities, err := r.getUint8()1022 capabilities, err := r.getUint8()
1003 if err != nil {1023 if err != nil {
1004 return Hello{}, err1024 return Hello{}, err
M internal/ports/wire_final_test.go +4 -4
@@ -11,8 +11,8 @@ import (
1111 )
1212
1313 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 27 {
15- t.Fatalf("ProtocolVersion = %d, want 27", ProtocolVersion)
14+ if ProtocolVersion != 28 {
15+ t.Fatalf("ProtocolVersion = %d, want 28", ProtocolVersion)
1616 }
1717 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1818 if len(payload) < 2 {
@@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2626
2727 func TestFinalHelloGoldenStrict(t *testing.T) {
2828 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 27, 2}, make([]byte, 16+8)...)
30- want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
29+ want := append([]byte{0, 28, 2}, make([]byte, 16+8)...)
30+ want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
3131 got := MarshalHello(msg)
3232 if !bytes.Equal(got, want) {
3333 t.Fatalf("Hello bytes = %x, want %x", got, want)
@@ -11,8 +11,8 @@ import (
11 )11 )
12 12
13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 27 {14+ if ProtocolVersion != 28 {
15- t.Fatalf("ProtocolVersion = %d, want 27", ProtocolVersion)15+ t.Fatalf("ProtocolVersion = %d, want 28", ProtocolVersion)
16 }16 }
17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
18 if len(payload) < 2 {18 if len(payload) < 2 {
@@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
26 26
27 func TestFinalHelloGoldenStrict(t *testing.T) {27 func TestFinalHelloGoldenStrict(t *testing.T) {
28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 27, 2}, make([]byte, 16+8)...)29+ want := append([]byte{0, 28, 2}, make([]byte, 16+8)...)
30- want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)30+ want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
31 got := MarshalHello(msg)31 got := MarshalHello(msg)
32 if !bytes.Equal(got, want) {32 if !bytes.Equal(got, want) {
33 t.Fatalf("Hello bytes = %x, want %x", got, want)33 t.Fatalf("Hello bytes = %x, want %x", got, want)
M internal/ports/wire_test.go +9 -8
@@ -54,8 +54,9 @@ func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func
5454 }
5555
5656 func appendNoNavigationTail(payload []byte) []byte {
57- // exact-target absent, navigation capabilities none, startup overlay none
58- return append(payload, 0, 0, 0)
57+ // exact-target absent, preferred-tab absent, navigation capabilities none,
58+ // startup overlay none.
59+ return append(payload, 0, 0, 0, 0, 0)
5960 }
6061
6162 func TestHelloGoldenAndRoundTrip(t *testing.T) {
@@ -130,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
130131 }
131132 got := MarshalHello(msg)
132133 want := []byte{
133- 0x00, 0x1b, 0x00, // version, intent, first client ID byte
134+ 0x00, 0x1c, 0x00, // version, intent, first client ID byte
134135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
135136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
136137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -349,7 +350,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
349350 }
350351 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
351352 require.Equal(t, want, MarshalTheme(cleared))
352- require.Equal(t, uint16(27), ProtocolVersion)
353+ require.Equal(t, uint16(28), ProtocolVersion)
353354 }
354355
355356 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -510,7 +511,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
510511 name: "minimal",
511512 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
512513 want: []byte{
513- 0x00, 0x1b,
514+ 0x00, 0x1c,
514515 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
515516 0x00, // attached
516517 0x00, // self
@@ -533,7 +534,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
533534 JSON: true,
534535 },
535536 want: []byte{
536- 0x00, 0x1b,
537+ 0x00, 0x1c,
537538 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
538539 0x00, // attached
539540 0x01, // self
@@ -962,7 +963,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
962963 name: "attached",
963964 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
964965 want: []byte{
965- 0x00, 0x1b, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
966+ 0x00, 0x1c, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
966967 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
967968 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
968969 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -972,7 +973,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
972973 name: "control",
973974 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
974975 want: []byte{
975- 0x00, 0x1b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
976+ 0x00, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
976977 0x00, 0x02, 'l', 's', 0x00, 0x00,
977978 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
978979 },
@@ -54,8 +54,9 @@ func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func
54 }54 }
55 55
56 func appendNoNavigationTail(payload []byte) []byte {56 func appendNoNavigationTail(payload []byte) []byte {
57- // exact-target absent, navigation capabilities none, startup overlay none57+ // exact-target absent, preferred-tab absent, navigation capabilities none,
58- return append(payload, 0, 0, 0)58+ // startup overlay none.
59+ return append(payload, 0, 0, 0, 0, 0)
59 }60 }
60 61
61 func TestHelloGoldenAndRoundTrip(t *testing.T) {62 func TestHelloGoldenAndRoundTrip(t *testing.T) {
@@ -130,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
130 }131 }
131 got := MarshalHello(msg)132 got := MarshalHello(msg)
132 want := []byte{133 want := []byte{
133- 0x00, 0x1b, 0x00, // version, intent, first client ID byte134+ 0x00, 0x1c, 0x00, // version, intent, first client ID byte
134 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -349,7 +350,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
349 }350 }
350 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)351 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
351 require.Equal(t, want, MarshalTheme(cleared))352 require.Equal(t, want, MarshalTheme(cleared))
352- require.Equal(t, uint16(27), ProtocolVersion)353+ require.Equal(t, uint16(28), ProtocolVersion)
353 }354 }
354 355
355 func TestResizeGoldenAndRoundTrip(t *testing.T) {356 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -510,7 +511,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
510 name: "minimal",511 name: "minimal",
511 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},512 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
512 want: []byte{513 want: []byte{
513- 0x00, 0x1b,514+ 0x00, 0x1c,
514 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID515 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
515 0x00, // attached516 0x00, // attached
516 0x00, // self517 0x00, // self
@@ -533,7 +534,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
533 JSON: true,534 JSON: true,
534 },535 },
535 want: []byte{536 want: []byte{
536- 0x00, 0x1b,537+ 0x00, 0x1c,
537 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID538 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
538 0x00, // attached539 0x00, // attached
539 0x01, // self540 0x01, // self
@@ -962,7 +963,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
962 name: "attached",963 name: "attached",
963 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},964 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
964 want: []byte{965 want: []byte{
965- 0x00, 0x1b, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,966+ 0x00, 0x1c, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
966 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',967 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
967 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',968 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
968 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,969 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -972,7 +973,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
972 name: "control",973 name: "control",
973 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},974 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
974 want: []byte{975 want: []byte{
975- 0x00, 0x1b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,976+ 0x00, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
976 0x00, 0x02, 'l', 's', 0x00, 0x00,977 0x00, 0x02, 'l', 's', 0x00, 0x00,
977 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,978 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
978 },979 },
M internal/usecase/client/client.go +35 -1
@@ -180,6 +180,7 @@ type AttachRequest struct {
180180 OriginKey string
181181 RemoteTarget *domain.RemoteSessionTarget
182182 ExactTarget *ports.ExactSessionTarget
183+ PreferredTabID domain.TabStableID
183184 EnvironmentPolicy ports.EnvironmentPolicy
184185 NavigationCapabilities ports.NavigationCapabilities
185186 StartupOverlay ports.StartupOverlay
@@ -539,10 +540,19 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
539540 if result.committedIdentity != nil {
540541 identity := *result.committedIdentity
541542 if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != identity.Target {
543+ attemptRequest.PreferredTabID = ""
544+ }
545+ if remoteTarget := attemptRequest.RemoteTarget; remoteTarget != nil &&
546+ (remoteTarget.LifecycleID != identity.Target.LifecycleID || remoteTarget.SessionName != identity.Target.SessionName) {
542547 attemptRequest.RemoteTarget = nil
543548 }
544549 attemptRequest.ExactTarget = &identity.Target
545550 }
551+ if result.routePosition != nil && attemptRequest.ExactTarget != nil &&
552+ *attemptRequest.ExactTarget == result.routePosition.Target {
553+ attemptRequest.PreferredTabID = result.routePosition.ActiveTabID
554+ attemptRequest.RemoteTarget = nil
555+ }
546556 if result.err == nil && result.welcomed && routeNavigationPending {
547557 routeNavigationPending = false
548558 routeNavigationSelection = nil
@@ -590,7 +600,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
590600 if result.action != 0 {
591601 switch result.action {
592602 case ports.NavigationOpenHomePicker:
593- if attemptRequest.NavigationCapabilities&ports.NavigationCapabilityHomePicker == 0 || homeRoute == nil {
603+ // The daemon only sends this action after accepting a Hello that
604+ // advertised Home support. The durable client-side prerequisite is
605+ // the captured route itself; request metadata may have been rebased by
606+ // committed identity or cursor updates since that Hello.
607+ if homeRoute == nil {
594608 return errors.New("vev: stale home navigation action")
595609 }
596610 returnRequest := attemptRequest
@@ -891,6 +905,7 @@ type attachResult struct {
891905 resumeToken uint64
892906 sessionName string
893907 committedIdentity *ports.CommittedRouteIdentity
908+ routePosition *ports.RoutePosition
894909 welcomed bool
895910 transportClosed bool
896911 target *ports.AttachTarget
@@ -1024,6 +1039,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10241039 Env: os.Environ(),
10251040 RemoteTarget: request.RemoteTarget,
10261041 ExactTarget: exactTarget,
1042+ PreferredTabID: request.PreferredTabID,
10271043 EnvironmentPolicy: request.EnvironmentPolicy,
10281044 NavigationCapabilities: request.NavigationCapabilities,
10291045 StartupOverlay: request.StartupOverlay,
@@ -1045,11 +1061,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10451061 return handshakeFailure("awaiting welcome", err)
10461062 }
10471063 var committedIdentity *ports.CommittedRouteIdentity
1064+ var routePosition *ports.RoutePosition
10481065 result := func(welcomed bool, err error) attachResult {
10491066 return attachResult{
10501067 resumeToken: resumeToken,
10511068 sessionName: name,
10521069 committedIdentity: cloneCommittedIdentity(committedIdentity),
1070+ routePosition: cloneRoutePosition(routePosition),
10531071 welcomed: welcomed,
10541072 err: err,
10551073 }
@@ -1291,6 +1309,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
12911309 return welcomedResult(err)
12921310 }
12931311 }
1312+ // Welcome and every synchronous initial publication have committed. The
1313+ // handshake deadline must not own the long-lived runtime transport: a quiet
1314+ // or unchanged screen may legitimately produce no initial Output frame.
1315+ endHandshake()
12941316 senderStarted = true
12951317 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)
12961318 stdinDone := make(chan struct{})
@@ -1568,6 +1590,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
15681590 if derr := publishRouteSnapshot(); derr != nil {
15691591 return welcomedResult(fmt.Errorf("vev: publishing daemon-local route snapshot: %w", derr))
15701592 }
1593+ case ports.MsgRoutePosition:
1594+ position, derr := ports.UnmarshalRoutePosition(r.frame.Payload)
1595+ if derr != nil {
1596+ return welcomedResult(fmt.Errorf("vev: decoding route position: %w", derr))
1597+ }
1598+ if a.runner.ledger == nil {
1599+ return welcomedResult(errors.New("vev: route ledger unavailable"))
1600+ }
1601+ if derr := a.runner.ledger.updateRoutePosition(position); derr != nil {
1602+ return welcomedResult(fmt.Errorf("vev: remembering route position: %w", derr))
1603+ }
1604+ routePosition = cloneRoutePosition(&position)
15711605 case ports.MsgRouteNavigationFailure:
15721606 failure, derr := ports.UnmarshalRouteNavigationFailure(r.frame.Payload)
15731607 if derr != nil {
@@ -180,6 +180,7 @@ type AttachRequest struct {
180 OriginKey string180 OriginKey string
181 RemoteTarget *domain.RemoteSessionTarget181 RemoteTarget *domain.RemoteSessionTarget
182 ExactTarget *ports.ExactSessionTarget182 ExactTarget *ports.ExactSessionTarget
183+ PreferredTabID domain.TabStableID
183 EnvironmentPolicy ports.EnvironmentPolicy184 EnvironmentPolicy ports.EnvironmentPolicy
184 NavigationCapabilities ports.NavigationCapabilities185 NavigationCapabilities ports.NavigationCapabilities
185 StartupOverlay ports.StartupOverlay186 StartupOverlay ports.StartupOverlay
@@ -539,10 +540,19 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
539 if result.committedIdentity != nil {540 if result.committedIdentity != nil {
540 identity := *result.committedIdentity541 identity := *result.committedIdentity
541 if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != identity.Target {542 if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != identity.Target {
543+ attemptRequest.PreferredTabID = ""
544+ }
545+ if remoteTarget := attemptRequest.RemoteTarget; remoteTarget != nil &&
546+ (remoteTarget.LifecycleID != identity.Target.LifecycleID || remoteTarget.SessionName != identity.Target.SessionName) {
542 attemptRequest.RemoteTarget = nil547 attemptRequest.RemoteTarget = nil
543 }548 }
544 attemptRequest.ExactTarget = &identity.Target549 attemptRequest.ExactTarget = &identity.Target
545 }550 }
551+ if result.routePosition != nil && attemptRequest.ExactTarget != nil &&
552+ *attemptRequest.ExactTarget == result.routePosition.Target {
553+ attemptRequest.PreferredTabID = result.routePosition.ActiveTabID
554+ attemptRequest.RemoteTarget = nil
555+ }
546 if result.err == nil && result.welcomed && routeNavigationPending {556 if result.err == nil && result.welcomed && routeNavigationPending {
547 routeNavigationPending = false557 routeNavigationPending = false
548 routeNavigationSelection = nil558 routeNavigationSelection = nil
@@ -590,7 +600,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
590 if result.action != 0 {600 if result.action != 0 {
591 switch result.action {601 switch result.action {
592 case ports.NavigationOpenHomePicker:602 case ports.NavigationOpenHomePicker:
593- if attemptRequest.NavigationCapabilities&ports.NavigationCapabilityHomePicker == 0 || homeRoute == nil {603+ // The daemon only sends this action after accepting a Hello that
604+ // advertised Home support. The durable client-side prerequisite is
605+ // the captured route itself; request metadata may have been rebased by
606+ // committed identity or cursor updates since that Hello.
607+ if homeRoute == nil {
594 return errors.New("vev: stale home navigation action")608 return errors.New("vev: stale home navigation action")
595 }609 }
596 returnRequest := attemptRequest610 returnRequest := attemptRequest
@@ -891,6 +905,7 @@ type attachResult struct {
891 resumeToken uint64905 resumeToken uint64
892 sessionName string906 sessionName string
893 committedIdentity *ports.CommittedRouteIdentity907 committedIdentity *ports.CommittedRouteIdentity
908+ routePosition *ports.RoutePosition
894 welcomed bool909 welcomed bool
895 transportClosed bool910 transportClosed bool
896 target *ports.AttachTarget911 target *ports.AttachTarget
@@ -1024,6 +1039,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1024 Env: os.Environ(),1039 Env: os.Environ(),
1025 RemoteTarget: request.RemoteTarget,1040 RemoteTarget: request.RemoteTarget,
1026 ExactTarget: exactTarget,1041 ExactTarget: exactTarget,
1042+ PreferredTabID: request.PreferredTabID,
1027 EnvironmentPolicy: request.EnvironmentPolicy,1043 EnvironmentPolicy: request.EnvironmentPolicy,
1028 NavigationCapabilities: request.NavigationCapabilities,1044 NavigationCapabilities: request.NavigationCapabilities,
1029 StartupOverlay: request.StartupOverlay,1045 StartupOverlay: request.StartupOverlay,
@@ -1045,11 +1061,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1045 return handshakeFailure("awaiting welcome", err)1061 return handshakeFailure("awaiting welcome", err)
1046 }1062 }
1047 var committedIdentity *ports.CommittedRouteIdentity1063 var committedIdentity *ports.CommittedRouteIdentity
1064+ var routePosition *ports.RoutePosition
1048 result := func(welcomed bool, err error) attachResult {1065 result := func(welcomed bool, err error) attachResult {
1049 return attachResult{1066 return attachResult{
1050 resumeToken: resumeToken,1067 resumeToken: resumeToken,
1051 sessionName: name,1068 sessionName: name,
1052 committedIdentity: cloneCommittedIdentity(committedIdentity),1069 committedIdentity: cloneCommittedIdentity(committedIdentity),
1070+ routePosition: cloneRoutePosition(routePosition),
1053 welcomed: welcomed,1071 welcomed: welcomed,
1054 err: err,1072 err: err,
1055 }1073 }
@@ -1291,6 +1309,10 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1291 return welcomedResult(err)1309 return welcomedResult(err)
1292 }1310 }
1293 }1311 }
1312+ // Welcome and every synchronous initial publication have committed. The
1313+ // handshake deadline must not own the long-lived runtime transport: a quiet
1314+ // or unchanged screen may legitimately produce no initial Output frame.
1315+ endHandshake()
1294 senderStarted = true1316 senderStarted = true
1295 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)1317 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)
1296 stdinDone := make(chan struct{})1318 stdinDone := make(chan struct{})
@@ -1568,6 +1590,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1568 if derr := publishRouteSnapshot(); derr != nil {1590 if derr := publishRouteSnapshot(); derr != nil {
1569 return welcomedResult(fmt.Errorf("vev: publishing daemon-local route snapshot: %w", derr))1591 return welcomedResult(fmt.Errorf("vev: publishing daemon-local route snapshot: %w", derr))
1570 }1592 }
1593+ case ports.MsgRoutePosition:
1594+ position, derr := ports.UnmarshalRoutePosition(r.frame.Payload)
1595+ if derr != nil {
1596+ return welcomedResult(fmt.Errorf("vev: decoding route position: %w", derr))
1597+ }
1598+ if a.runner.ledger == nil {
1599+ return welcomedResult(errors.New("vev: route ledger unavailable"))
1600+ }
1601+ if derr := a.runner.ledger.updateRoutePosition(position); derr != nil {
1602+ return welcomedResult(fmt.Errorf("vev: remembering route position: %w", derr))
1603+ }
1604+ routePosition = cloneRoutePosition(&position)
1571 case ports.MsgRouteNavigationFailure:1605 case ports.MsgRouteNavigationFailure:
1572 failure, derr := ports.UnmarshalRouteNavigationFailure(r.frame.Payload)1606 failure, derr := ports.UnmarshalRouteNavigationFailure(r.frame.Payload)
1573 if derr != nil {1607 if derr != nil {
M internal/usecase/client/client_test.go +158 -4
@@ -439,7 +439,7 @@ func TestAttachPublishesCommittedRouteSnapshot(t *testing.T) {
439439
440440 var snapshot ports.RecentRouteSnapshot
441441 found := false
442- for _, frame := range tr.sends {
442+ for _, frame := range tr.Sends() {
443443 if frame.Type != ports.MsgRecentRouteSnapshot {
444444 continue
445445 }
@@ -483,6 +483,56 @@ func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {
483483 require.Equal(t, request.StartupOverlay, hello.StartupOverlay)
484484 }
485485
486+func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testing.T) {
487+ term := newRunTerminal()
488+ defer term.in.unblock()
489+ localLifecycle := domain.SessionLifecycleID{1}
490+ remoteLifecycle := domain.SessionLifecycleID{2}
491+ welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
492+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
493+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
494+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
495+ }))
496+ }
497+ remoteTarget := domain.RemoteSessionTarget{
498+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle,
499+ SessionName: "work", LiveTabID: "tab-1",
500+ }
501+ local1 := &recordingTransport{recvs: []recvItem{
502+ {f: welcome("local", localLifecycle)},
503+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
504+ Endpoint: "remote", Session: "work", Intent: ports.IntentAttach,
505+ RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
506+ }))},
507+ }}
508+ remote := &recordingTransport{recvs: []recvItem{
509+ {f: welcome("work", remoteLifecycle)},
510+ {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},
511+ }}
512+ local2 := &recordingTransport{recvs: []recvItem{
513+ {f: welcome("local", localLifecycle)},
514+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
515+ }}
516+ localDialer := &sequenceDialer{trs: []ports.Transport{local1, local2}}
517+ remoteDialer := &sequenceDialer{trs: []ports.Transport{remote}}
518+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
519+ deps.AttachHandoff = func(ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
520+ target := ports.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "work"}
521+ return remoteDialer, client.AttachRequest{
522+ Intent: ports.IntentAttach, SessionName: "work", Remote: true,
523+ Origin: ports.RouteOriginRemote, OriginKey: "remote", ExactTarget: &target,
524+ EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
525+ // Deliberately omit the copied Home capability. The accepted server
526+ // action remains valid because Runner retained the concrete home route.
527+ }, nil
528+ }
529+
530+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
531+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
532+ }))
533+ require.Equal(t, ports.StartupOverlaySessionPicker, helloFromSend(t, local2).StartupOverlay)
534+}
535+
486536 func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {
487537 term := newRunTerminal()
488538 defer term.in.unblock()
@@ -538,6 +588,9 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
538588 {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{
539589 Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"},
540590 }))},
591+ {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{
592+ Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, ActiveTabID: "tab-2",
593+ }))},
541594 {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},
542595 }}
543596 remote3 := &recordingTransport{recvs: []recvItem{
@@ -585,6 +638,14 @@ func mustMarshalRouteAction(action ports.RouteNavigationAction) []byte {
585638 return payload
586639 }
587640
641+func mustMarshalRoutePosition(position ports.RoutePosition) []byte {
642+ payload, err := ports.MarshalRoutePosition(position)
643+ if err != nil {
644+ panic(err)
645+ }
646+ return payload
647+}
648+
588649 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {
589650 var out bytes.Buffer
590651 var restoreCount atomic.Int32
@@ -1026,16 +1087,25 @@ func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
10261087 }
10271088
10281089 type recordingTransport struct {
1090+ mu sync.Mutex
10291091 recvs []recvItem
10301092 sends []ports.Frame
10311093 closed atomic.Int32
10321094 }
10331095
10341096 func (t *recordingTransport) Send(f ports.Frame) error {
1097+ t.mu.Lock()
10351098 t.sends = append(t.sends, f)
1099+ t.mu.Unlock()
10361100 return nil
10371101 }
10381102
1103+func (t *recordingTransport) Sends() []ports.Frame {
1104+ t.mu.Lock()
1105+ defer t.mu.Unlock()
1106+ return append([]ports.Frame(nil), t.sends...)
1107+}
1108+
10391109 func (t *recordingTransport) Recv() (ports.Frame, error) {
10401110 if len(t.recvs) == 0 {
10411111 return ports.Frame{}, io.EOF
@@ -1074,9 +1144,10 @@ func (t *runTerminal) Flush() error { return nil }
10741144
10751145 func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello {
10761146 t.Helper()
1077- require.NotEmpty(t, tr.sends)
1078- require.Equal(t, ports.MsgHello, tr.sends[0].Type)
1079- h, err := ports.UnmarshalHello(tr.sends[0].Payload)
1147+ sends := tr.Sends()
1148+ require.NotEmpty(t, sends)
1149+ require.Equal(t, ports.MsgHello, sends[0].Type)
1150+ h, err := ports.UnmarshalHello(sends[0].Payload)
10801151 require.NoError(t, err)
10811152 return h
10821153 }
@@ -1119,6 +1190,89 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
11191190 require.Contains(t, term.out.String(), "\x1b[2K")
11201191 }
11211192
1193+func TestReconnectRestoresLastPublishedRouteTab(t *testing.T) {
1194+ term := newRunTerminal()
1195+ defer term.in.unblock()
1196+ lifecycle := domain.SessionLifecycleID{7, 8, 9}
1197+ target := ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}
1198+ welcome := func(token uint64) ports.Frame {
1199+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
1200+ SessionID: "work", SessionName: "work", ResumeToken: token, Capabilities: ports.CapabilityResume,
1201+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: target},
1202+ }))
1203+ }
1204+ tr1 := &recordingTransport{recvs: []recvItem{
1205+ {f: welcome(11)},
1206+ {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{Target: target, ActiveTabID: "tab-2"}))},
1207+ {err: io.EOF},
1208+ }}
1209+ tr2 := &recordingTransport{recvs: []recvItem{
1210+ {f: welcome(22)},
1211+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
1212+ }}
1213+ dialer := &sequenceDialer{trs: []ports.Transport{tr1, tr2}}
1214+ clock := &reconnectTestClock{}
1215+ result := make(chan error, 1)
1216+ go func() {
1217+ result <- runTestClient(context.Background(), testDependencies(dialer, term, clock, nil, nil), client.AttachRequest{
1218+ Intent: ports.IntentAttach, SessionName: "work", Origin: ports.RouteOriginLocal, OriginKey: "local",
1219+ })
1220+ }()
1221+ clock.fireReconnect(t)
1222+ require.NoError(t, <-result)
1223+
1224+ reconnectHello := helloFromSend(t, tr2)
1225+ require.Equal(t, domain.TabStableID("tab-2"), reconnectHello.PreferredTabID)
1226+}
1227+
1228+func TestRemoteReconnectDropsStalePickerTargetAfterDaemonSessionSwitch(t *testing.T) {
1229+ term := newRunTerminal()
1230+ defer term.in.unblock()
1231+ oldLifecycle := domain.SessionLifecycleID{1, 2, 3}
1232+ newLifecycle := domain.SessionLifecycleID{4, 5, 6}
1233+ remoteTarget := &domain.RemoteSessionTarget{
1234+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: oldLifecycle,
1235+ SessionName: "old", LiveTabID: "tab-1",
1236+ }
1237+ welcome := func(name string, lifecycle domain.SessionLifecycleID, token uint64) ports.Frame {
1238+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
1239+ SessionID: name, SessionName: name, ResumeToken: token, Capabilities: ports.CapabilityResume,
1240+ CommittedIdentity: &ports.CommittedRouteIdentity{
1241+ Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name},
1242+ },
1243+ }))
1244+ }
1245+ tr1 := &recordingTransport{recvs: []recvItem{
1246+ {f: welcome("old", oldLifecycle, 11)},
1247+ {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{
1248+ Target: ports.ExactSessionTarget{LifecycleID: newLifecycle, SessionName: "new"},
1249+ }))},
1250+ {err: io.EOF},
1251+ }}
1252+ tr2 := &recordingTransport{recvs: []recvItem{
1253+ {f: welcome("new", newLifecycle, 22)},
1254+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
1255+ }}
1256+ dialer := &sequenceDialer{trs: []ports.Transport{tr1, tr2}}
1257+ clock := &reconnectTestClock{}
1258+ result := make(chan error, 1)
1259+ go func() {
1260+ result <- runTestClient(context.Background(), testDependencies(dialer, term, clock, nil, nil), client.AttachRequest{
1261+ Intent: ports.IntentAttach, SessionName: "old", Remote: true,
1262+ Origin: ports.RouteOriginDiscovery, OriginKey: "remote",
1263+ RemoteTarget: remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1264+ })
1265+ }()
1266+ clock.fireReconnect(t)
1267+ require.NoError(t, <-result)
1268+
1269+ reconnectHello := helloFromSend(t, tr2)
1270+ require.Equal(t, ports.IntentResume, reconnectHello.Intent)
1271+ require.Equal(t, "new", reconnectHello.Name)
1272+ require.Equal(t, &ports.ExactSessionTarget{LifecycleID: newLifecycle, SessionName: "new"}, reconnectHello.ExactTarget)
1273+ require.Nil(t, reconnectHello.RemoteTarget)
1274+}
1275+
11221276 func TestAttachRememberRemoteHost(t *testing.T) {
11231277 tests := []struct {
11241278 name string
@@ -439,7 +439,7 @@ func TestAttachPublishesCommittedRouteSnapshot(t *testing.T) {
439 439
440 var snapshot ports.RecentRouteSnapshot440 var snapshot ports.RecentRouteSnapshot
441 found := false441 found := false
442- for _, frame := range tr.sends {442+ for _, frame := range tr.Sends() {
443 if frame.Type != ports.MsgRecentRouteSnapshot {443 if frame.Type != ports.MsgRecentRouteSnapshot {
444 continue444 continue
445 }445 }
@@ -483,6 +483,56 @@ func TestAttachHelloPreservesCompleteAttachRequest(t *testing.T) {
483 require.Equal(t, request.StartupOverlay, hello.StartupOverlay)483 require.Equal(t, request.StartupOverlay, hello.StartupOverlay)
484 }484 }
485 485
486+func TestAcceptedHomeActionUsesCapturedRouteAfterRequestMetadataRebase(t *testing.T) {
487+ term := newRunTerminal()
488+ defer term.in.unblock()
489+ localLifecycle := domain.SessionLifecycleID{1}
490+ remoteLifecycle := domain.SessionLifecycleID{2}
491+ welcome := func(name string, lifecycle domain.SessionLifecycleID) ports.Frame {
492+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
493+ SessionID: name, SessionName: name, ResumeToken: 1, Capabilities: ports.CapabilityResume,
494+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}},
495+ }))
496+ }
497+ remoteTarget := domain.RemoteSessionTarget{
498+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: remoteLifecycle,
499+ SessionName: "work", LiveTabID: "tab-1",
500+ }
501+ local1 := &recordingTransport{recvs: []recvItem{
502+ {f: welcome("local", localLifecycle)},
503+ {f: frameOf(ports.MsgAttachTarget, ports.MarshalAttachTarget(ports.AttachTarget{
504+ Endpoint: "remote", Session: "work", Intent: ports.IntentAttach,
505+ RemoteTarget: &remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
506+ }))},
507+ }}
508+ remote := &recordingTransport{recvs: []recvItem{
509+ {f: welcome("work", remoteLifecycle)},
510+ {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},
511+ }}
512+ local2 := &recordingTransport{recvs: []recvItem{
513+ {f: welcome("local", localLifecycle)},
514+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
515+ }}
516+ localDialer := &sequenceDialer{trs: []ports.Transport{local1, local2}}
517+ remoteDialer := &sequenceDialer{trs: []ports.Transport{remote}}
518+ deps := testDependencies(localDialer, term, realClock{}, nil, nil)
519+ deps.AttachHandoff = func(ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
520+ target := ports.ExactSessionTarget{LifecycleID: remoteLifecycle, SessionName: "work"}
521+ return remoteDialer, client.AttachRequest{
522+ Intent: ports.IntentAttach, SessionName: "work", Remote: true,
523+ Origin: ports.RouteOriginRemote, OriginKey: "remote", ExactTarget: &target,
524+ EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
525+ // Deliberately omit the copied Home capability. The accepted server
526+ // action remains valid because Runner retained the concrete home route.
527+ }, nil
528+ }
529+
530+ require.NoError(t, runTestClient(context.Background(), deps, client.AttachRequest{
531+ Intent: ports.IntentAttach, SessionName: "local", Origin: ports.RouteOriginLocal, OriginKey: "local",
532+ }))
533+ require.Equal(t, ports.StartupOverlaySessionPicker, helloFromSend(t, local2).StartupOverlay)
534+}
535+
486 func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {536 func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T) {
487 term := newRunTerminal()537 term := newRunTerminal()
488 defer term.in.unblock()538 defer term.in.unblock()
@@ -538,6 +588,9 @@ func TestRouteNavigationPreservesRemoteHomePickerAcrossLocalReturn(t *testing.T)
538 {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{588 {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{
539 Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"},589 Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"},
540 }))},590 }))},
591+ {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{
592+ Target: ports.ExactSessionTarget{LifecycleID: remoteNewLifecycle, SessionName: "remote-new"}, ActiveTabID: "tab-2",
593+ }))},
541 {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},594 {f: frameOf(ports.MsgNavigationAction, ports.MarshalNavigationAction(ports.NavigationOpenHomePicker))},
542 }}595 }}
543 remote3 := &recordingTransport{recvs: []recvItem{596 remote3 := &recordingTransport{recvs: []recvItem{
@@ -585,6 +638,14 @@ func mustMarshalRouteAction(action ports.RouteNavigationAction) []byte {
585 return payload638 return payload
586 }639 }
587 640
641+func mustMarshalRoutePosition(position ports.RoutePosition) []byte {
642+ payload, err := ports.MarshalRoutePosition(position)
643+ if err != nil {
644+ panic(err)
645+ }
646+ return payload
647+}
648+
588 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {649 func TestAttachTargetHandoffReturnsValidatedTargetAndClosesTransport(t *testing.T) {
589 var out bytes.Buffer650 var out bytes.Buffer
590 var restoreCount atomic.Int32651 var restoreCount atomic.Int32
@@ -1026,16 +1087,25 @@ func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
1026 }1087 }
1027 1088
1028 type recordingTransport struct {1089 type recordingTransport struct {
1090+ mu sync.Mutex
1029 recvs []recvItem1091 recvs []recvItem
1030 sends []ports.Frame1092 sends []ports.Frame
1031 closed atomic.Int321093 closed atomic.Int32
1032 }1094 }
1033 1095
1034 func (t *recordingTransport) Send(f ports.Frame) error {1096 func (t *recordingTransport) Send(f ports.Frame) error {
1097+ t.mu.Lock()
1035 t.sends = append(t.sends, f)1098 t.sends = append(t.sends, f)
1099+ t.mu.Unlock()
1036 return nil1100 return nil
1037 }1101 }
1038 1102
1103+func (t *recordingTransport) Sends() []ports.Frame {
1104+ t.mu.Lock()
1105+ defer t.mu.Unlock()
1106+ return append([]ports.Frame(nil), t.sends...)
1107+}
1108+
1039 func (t *recordingTransport) Recv() (ports.Frame, error) {1109 func (t *recordingTransport) Recv() (ports.Frame, error) {
1040 if len(t.recvs) == 0 {1110 if len(t.recvs) == 0 {
1041 return ports.Frame{}, io.EOF1111 return ports.Frame{}, io.EOF
@@ -1074,9 +1144,10 @@ func (t *runTerminal) Flush() error { return nil }
1074 1144
1075 func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello {1145 func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello {
1076 t.Helper()1146 t.Helper()
1077- require.NotEmpty(t, tr.sends)1147+ sends := tr.Sends()
1078- require.Equal(t, ports.MsgHello, tr.sends[0].Type)1148+ require.NotEmpty(t, sends)
1079- h, err := ports.UnmarshalHello(tr.sends[0].Payload)1149+ require.Equal(t, ports.MsgHello, sends[0].Type)
1150+ h, err := ports.UnmarshalHello(sends[0].Payload)
1080 require.NoError(t, err)1151 require.NoError(t, err)
1081 return h1152 return h
1082 }1153 }
@@ -1119,6 +1190,89 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
1119 require.Contains(t, term.out.String(), "\x1b[2K")1190 require.Contains(t, term.out.String(), "\x1b[2K")
1120 }1191 }
1121 1192
1193+func TestReconnectRestoresLastPublishedRouteTab(t *testing.T) {
1194+ term := newRunTerminal()
1195+ defer term.in.unblock()
1196+ lifecycle := domain.SessionLifecycleID{7, 8, 9}
1197+ target := ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}
1198+ welcome := func(token uint64) ports.Frame {
1199+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
1200+ SessionID: "work", SessionName: "work", ResumeToken: token, Capabilities: ports.CapabilityResume,
1201+ CommittedIdentity: &ports.CommittedRouteIdentity{Target: target},
1202+ }))
1203+ }
1204+ tr1 := &recordingTransport{recvs: []recvItem{
1205+ {f: welcome(11)},
1206+ {f: frameOf(ports.MsgRoutePosition, mustMarshalRoutePosition(ports.RoutePosition{Target: target, ActiveTabID: "tab-2"}))},
1207+ {err: io.EOF},
1208+ }}
1209+ tr2 := &recordingTransport{recvs: []recvItem{
1210+ {f: welcome(22)},
1211+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
1212+ }}
1213+ dialer := &sequenceDialer{trs: []ports.Transport{tr1, tr2}}
1214+ clock := &reconnectTestClock{}
1215+ result := make(chan error, 1)
1216+ go func() {
1217+ result <- runTestClient(context.Background(), testDependencies(dialer, term, clock, nil, nil), client.AttachRequest{
1218+ Intent: ports.IntentAttach, SessionName: "work", Origin: ports.RouteOriginLocal, OriginKey: "local",
1219+ })
1220+ }()
1221+ clock.fireReconnect(t)
1222+ require.NoError(t, <-result)
1223+
1224+ reconnectHello := helloFromSend(t, tr2)
1225+ require.Equal(t, domain.TabStableID("tab-2"), reconnectHello.PreferredTabID)
1226+}
1227+
1228+func TestRemoteReconnectDropsStalePickerTargetAfterDaemonSessionSwitch(t *testing.T) {
1229+ term := newRunTerminal()
1230+ defer term.in.unblock()
1231+ oldLifecycle := domain.SessionLifecycleID{1, 2, 3}
1232+ newLifecycle := domain.SessionLifecycleID{4, 5, 6}
1233+ remoteTarget := &domain.RemoteSessionTarget{
1234+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: oldLifecycle,
1235+ SessionName: "old", LiveTabID: "tab-1",
1236+ }
1237+ welcome := func(name string, lifecycle domain.SessionLifecycleID, token uint64) ports.Frame {
1238+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
1239+ SessionID: name, SessionName: name, ResumeToken: token, Capabilities: ports.CapabilityResume,
1240+ CommittedIdentity: &ports.CommittedRouteIdentity{
1241+ Target: ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name},
1242+ },
1243+ }))
1244+ }
1245+ tr1 := &recordingTransport{recvs: []recvItem{
1246+ {f: welcome("old", oldLifecycle, 11)},
1247+ {f: frameOf(ports.MsgCommittedRouteIdentity, mustMarshalCommittedIdentity(ports.CommittedRouteIdentity{
1248+ Target: ports.ExactSessionTarget{LifecycleID: newLifecycle, SessionName: "new"},
1249+ }))},
1250+ {err: io.EOF},
1251+ }}
1252+ tr2 := &recordingTransport{recvs: []recvItem{
1253+ {f: welcome("new", newLifecycle, 22)},
1254+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
1255+ }}
1256+ dialer := &sequenceDialer{trs: []ports.Transport{tr1, tr2}}
1257+ clock := &reconnectTestClock{}
1258+ result := make(chan error, 1)
1259+ go func() {
1260+ result <- runTestClient(context.Background(), testDependencies(dialer, term, clock, nil, nil), client.AttachRequest{
1261+ Intent: ports.IntentAttach, SessionName: "old", Remote: true,
1262+ Origin: ports.RouteOriginDiscovery, OriginKey: "remote",
1263+ RemoteTarget: remoteTarget, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
1264+ })
1265+ }()
1266+ clock.fireReconnect(t)
1267+ require.NoError(t, <-result)
1268+
1269+ reconnectHello := helloFromSend(t, tr2)
1270+ require.Equal(t, ports.IntentResume, reconnectHello.Intent)
1271+ require.Equal(t, "new", reconnectHello.Name)
1272+ require.Equal(t, &ports.ExactSessionTarget{LifecycleID: newLifecycle, SessionName: "new"}, reconnectHello.ExactTarget)
1273+ require.Nil(t, reconnectHello.RemoteTarget)
1274+}
1275+
1122 func TestAttachRememberRemoteHost(t *testing.T) {1276 func TestAttachRememberRemoteHost(t *testing.T) {
1123 tests := []struct {1277 tests := []struct {
1124 name string1278 name string
M internal/usecase/client/palette_attach_generation_test.go +12 -0
@@ -252,6 +252,18 @@ func paletteReply(foreground, background string, slot uint8, color string) strin
252252 return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"
253253 }
254254
255+func TestAttachHandshakeDeadlineStopsBeforeRuntimeWithoutInitialOutput(t *testing.T) {
256+ h := startAttachPaletteHarness(&terminalThemeState{})
257+ select {
258+ case <-h.handshakeTimer.stopped:
259+ // Welcome and synchronous client publications commit the handshake. A
260+ // quiet runtime is allowed to produce no Output frame.
261+ case <-time.After(2 * time.Second):
262+ t.Fatal("handshake deadline still owned the long-lived runtime transport")
263+ }
264+ h.detach(t)
265+}
266+
255267 func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) {
256268 t.Setenv("COLORTERM", "truecolor")
257269 state := &terminalThemeState{}
@@ -252,6 +252,18 @@ func paletteReply(foreground, background string, slot uint8, color string) strin
252 return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"252 return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"
253 }253 }
254 254
255+func TestAttachHandshakeDeadlineStopsBeforeRuntimeWithoutInitialOutput(t *testing.T) {
256+ h := startAttachPaletteHarness(&terminalThemeState{})
257+ select {
258+ case <-h.handshakeTimer.stopped:
259+ // Welcome and synchronous client publications commit the handshake. A
260+ // quiet runtime is allowed to produce no Output frame.
261+ case <-time.After(2 * time.Second):
262+ t.Fatal("handshake deadline still owned the long-lived runtime transport")
263+ }
264+ h.detach(t)
265+}
266+
255 func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) {267 func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) {
256 t.Setenv("COLORTERM", "truecolor")268 t.Setenv("COLORTERM", "truecolor")
257 state := &terminalThemeState{}269 state := &terminalThemeState{}
M internal/usecase/client/routes.go +40 -0
@@ -105,6 +105,14 @@ func cloneCommittedIdentity(identity *ports.CommittedRouteIdentity) *ports.Commi
105105 return &clone
106106 }
107107
108+func cloneRoutePosition(position *ports.RoutePosition) *ports.RoutePosition {
109+ if position == nil {
110+ return nil
111+ }
112+ clone := *position
113+ return &clone
114+}
115+
108116 func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRouteIdentity, dialer ports.Dialer, resumeToken uint64) routeCandidate {
109117 origin := normalizeRouteOrigin(request.Origin, request.Remote)
110118 originKey := normalizeRouteOriginKey(request.OriginKey, origin)
@@ -124,6 +132,10 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout
124132 request.OriginKey = originKey
125133 request.SessionName = identity.Target.SessionName
126134 request.ExactTarget = &identity.Target
135+ // Discovery targets are point-in-time attach authority, including a live
136+ // tab selector. Once Welcome commits an exact route, mutable tab memory is
137+ // carried independently by PreferredTabID.
138+ request.RemoteTarget = nil
127139 return routeCandidate{
128140 origin: origin,
129141 originKey: originKey,
@@ -146,6 +158,9 @@ func routeCandidateForCommittedIdentity(active routeRecord, identity ports.Commi
146158 request.SessionName = identity.Target.SessionName
147159 request.ExactTarget = &identity.Target
148160 request.RemoteTarget = nil
161+ if active.target != identity.Target {
162+ request.PreferredTabID = ""
163+ }
149164 return routeCandidate{
150165 origin: active.origin,
151166 originKey: active.originKey,
@@ -338,6 +353,31 @@ func (l *routeLedger) commitCommittedIdentity(identity ports.CommittedRouteIdent
338353 return l.commitLocked(candidate)
339354 }
340355
356+// updateRoutePosition remembers a cursor only for the currently active exact
357+// route. Delayed frames from a prior transition are valid wire messages but do
358+// not mutate another route.
359+func (l *routeLedger) updateRoutePosition(position ports.RoutePosition) error {
360+ if err := position.Validate(); err != nil {
361+ return err
362+ }
363+ l.mu.Lock()
364+ defer l.mu.Unlock()
365+ activeIndex := l.indexByIdentityLocked(l.active)
366+ if activeIndex < 0 || l.entries[activeIndex].target != position.Target {
367+ return nil
368+ }
369+ entry := &l.entries[activeIndex]
370+ if entry.request.PreferredTabID == position.ActiveTabID {
371+ return nil
372+ }
373+ // The tab cursor is mutable payload on an otherwise unchanged route.
374+ // Rotating the route generation here would invalidate navigation actions
375+ // captured immediately before a paint published the new cursor.
376+ entry.request.PreferredTabID = position.ActiveTabID
377+ entry.request.RemoteTarget = nil
378+ return nil
379+}
380+
341381 func (l *routeLedger) commitLocked(candidate routeCandidate) (routeIdentity, error) {
342382 identity, err := l.nextIdentityLocked()
343383 if err != nil {
@@ -105,6 +105,14 @@ func cloneCommittedIdentity(identity *ports.CommittedRouteIdentity) *ports.Commi
105 return &clone105 return &clone
106 }106 }
107 107
108+func cloneRoutePosition(position *ports.RoutePosition) *ports.RoutePosition {
109+ if position == nil {
110+ return nil
111+ }
112+ clone := *position
113+ return &clone
114+}
115+
108 func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRouteIdentity, dialer ports.Dialer, resumeToken uint64) routeCandidate {116 func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRouteIdentity, dialer ports.Dialer, resumeToken uint64) routeCandidate {
109 origin := normalizeRouteOrigin(request.Origin, request.Remote)117 origin := normalizeRouteOrigin(request.Origin, request.Remote)
110 originKey := normalizeRouteOriginKey(request.OriginKey, origin)118 originKey := normalizeRouteOriginKey(request.OriginKey, origin)
@@ -124,6 +132,10 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout
124 request.OriginKey = originKey132 request.OriginKey = originKey
125 request.SessionName = identity.Target.SessionName133 request.SessionName = identity.Target.SessionName
126 request.ExactTarget = &identity.Target134 request.ExactTarget = &identity.Target
135+ // Discovery targets are point-in-time attach authority, including a live
136+ // tab selector. Once Welcome commits an exact route, mutable tab memory is
137+ // carried independently by PreferredTabID.
138+ request.RemoteTarget = nil
127 return routeCandidate{139 return routeCandidate{
128 origin: origin,140 origin: origin,
129 originKey: originKey,141 originKey: originKey,
@@ -146,6 +158,9 @@ func routeCandidateForCommittedIdentity(active routeRecord, identity ports.Commi
146 request.SessionName = identity.Target.SessionName158 request.SessionName = identity.Target.SessionName
147 request.ExactTarget = &identity.Target159 request.ExactTarget = &identity.Target
148 request.RemoteTarget = nil160 request.RemoteTarget = nil
161+ if active.target != identity.Target {
162+ request.PreferredTabID = ""
163+ }
149 return routeCandidate{164 return routeCandidate{
150 origin: active.origin,165 origin: active.origin,
151 originKey: active.originKey,166 originKey: active.originKey,
@@ -338,6 +353,31 @@ func (l *routeLedger) commitCommittedIdentity(identity ports.CommittedRouteIdent
338 return l.commitLocked(candidate)353 return l.commitLocked(candidate)
339 }354 }
340 355
356+// updateRoutePosition remembers a cursor only for the currently active exact
357+// route. Delayed frames from a prior transition are valid wire messages but do
358+// not mutate another route.
359+func (l *routeLedger) updateRoutePosition(position ports.RoutePosition) error {
360+ if err := position.Validate(); err != nil {
361+ return err
362+ }
363+ l.mu.Lock()
364+ defer l.mu.Unlock()
365+ activeIndex := l.indexByIdentityLocked(l.active)
366+ if activeIndex < 0 || l.entries[activeIndex].target != position.Target {
367+ return nil
368+ }
369+ entry := &l.entries[activeIndex]
370+ if entry.request.PreferredTabID == position.ActiveTabID {
371+ return nil
372+ }
373+ // The tab cursor is mutable payload on an otherwise unchanged route.
374+ // Rotating the route generation here would invalidate navigation actions
375+ // captured immediately before a paint published the new cursor.
376+ entry.request.PreferredTabID = position.ActiveTabID
377+ entry.request.RemoteTarget = nil
378+ return nil
379+}
380+
341 func (l *routeLedger) commitLocked(candidate routeCandidate) (routeIdentity, error) {381 func (l *routeLedger) commitLocked(candidate routeCandidate) (routeIdentity, error) {
342 identity, err := l.nextIdentityLocked()382 identity, err := l.nextIdentityLocked()
343 if err != nil {383 if err != nil {
M internal/usecase/client/routes_test.go +28 -0
@@ -69,6 +69,34 @@ func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) {
6969 require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route")
7070 }
7171
72+func TestRouteLedgerRemembersTabsIndependentlyPerExactRoute(t *testing.T) {
73+ ledger := newRouteLedger()
74+ first := routeTestCandidate(1, ports.RouteOriginLocal)
75+ firstIdentity, err := ledger.commit(first)
76+ require.NoError(t, err)
77+ firstRef := ledger.activeRef()
78+ firstSnapshotGeneration := ledger.snapshot().Generation
79+ require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: first.target, ActiveTabID: "tab-first"}))
80+ require.Equal(t, firstRef, ledger.activeRef(), "tab memory must not rotate route identity")
81+ require.Equal(t, firstSnapshotGeneration, ledger.snapshot().Generation, "tab memory must not stale navigation snapshots")
82+
83+ second := routeTestCandidate(2, ports.RouteOriginRemote)
84+ secondIdentity, err := ledger.commit(second)
85+ require.NoError(t, err)
86+ secondRef := ledger.activeRef()
87+ secondSnapshotGeneration := ledger.snapshot().Generation
88+ require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: second.target, ActiveTabID: "tab-second"}))
89+ require.Equal(t, secondRef, ledger.activeRef(), "tab memory must not rotate route identity")
90+ require.Equal(t, secondSnapshotGeneration, ledger.snapshot().Generation, "tab memory must not stale navigation snapshots")
91+
92+ firstRecord, ok := ledger.lookup(firstIdentity.wire())
93+ require.True(t, ok)
94+ require.Equal(t, domain.TabStableID("tab-first"), firstRecord.request.PreferredTabID)
95+ secondRecord, ok := ledger.lookup(secondIdentity.wire())
96+ require.True(t, ok)
97+ require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID)
98+}
99+
72100 func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) {
73101 ledger := newRouteLedger()
74102 home := routeTestCandidate(1, ports.RouteOriginLocal)
@@ -69,6 +69,34 @@ func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) {
69 require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route")69 require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route")
70 }70 }
71 71
72+func TestRouteLedgerRemembersTabsIndependentlyPerExactRoute(t *testing.T) {
73+ ledger := newRouteLedger()
74+ first := routeTestCandidate(1, ports.RouteOriginLocal)
75+ firstIdentity, err := ledger.commit(first)
76+ require.NoError(t, err)
77+ firstRef := ledger.activeRef()
78+ firstSnapshotGeneration := ledger.snapshot().Generation
79+ require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: first.target, ActiveTabID: "tab-first"}))
80+ require.Equal(t, firstRef, ledger.activeRef(), "tab memory must not rotate route identity")
81+ require.Equal(t, firstSnapshotGeneration, ledger.snapshot().Generation, "tab memory must not stale navigation snapshots")
82+
83+ second := routeTestCandidate(2, ports.RouteOriginRemote)
84+ secondIdentity, err := ledger.commit(second)
85+ require.NoError(t, err)
86+ secondRef := ledger.activeRef()
87+ secondSnapshotGeneration := ledger.snapshot().Generation
88+ require.NoError(t, ledger.updateRoutePosition(ports.RoutePosition{Target: second.target, ActiveTabID: "tab-second"}))
89+ require.Equal(t, secondRef, ledger.activeRef(), "tab memory must not rotate route identity")
90+ require.Equal(t, secondSnapshotGeneration, ledger.snapshot().Generation, "tab memory must not stale navigation snapshots")
91+
92+ firstRecord, ok := ledger.lookup(firstIdentity.wire())
93+ require.True(t, ok)
94+ require.Equal(t, domain.TabStableID("tab-first"), firstRecord.request.PreferredTabID)
95+ secondRecord, ok := ledger.lookup(secondIdentity.wire())
96+ require.True(t, ok)
97+ require.Equal(t, domain.TabStableID("tab-second"), secondRecord.request.PreferredTabID)
98+}
99+
72 func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) {100 func TestCommittedIdentityDoesNotReassignHomeRoute(t *testing.T) {
73 ledger := newRouteLedger()101 ledger := newRouteLedger()
74 home := routeTestCandidate(1, ports.RouteOriginLocal)102 home := routeTestCandidate(1, ports.RouteOriginLocal)
M internal/usecase/daemon/attachment_parking_test.go +2 -0
@@ -137,6 +137,7 @@ func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) {
137137 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
138138 require.NoError(t, err)
139139 oldToken := ac.resumeToken
140+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
140141 d.clientGone(sess, ac, oldTransport, false)
141142
142143 resumedTransport := &closeTrackingTransport{}
@@ -150,6 +151,7 @@ func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) {
150151 require.False(t, oldRetained)
151152 require.NotNil(t, newParked)
152153 require.NotEmpty(t, resumedTransport.Sends())
154+ require.Equal(t, ports.MsgWelcome, resumedTransport.Sends()[0].Type, "Welcome must remain the first server frame after Hello")
153155 }
154156
155157 func TestFailedResumeHandshakeKeepsParkedCredential(t *testing.T) {
@@ -137,6 +137,7 @@ func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) {
137 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)137 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
138 require.NoError(t, err)138 require.NoError(t, err)
139 oldToken := ac.resumeToken139 oldToken := ac.resumeToken
140+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
140 d.clientGone(sess, ac, oldTransport, false)141 d.clientGone(sess, ac, oldTransport, false)
141 142
142 resumedTransport := &closeTrackingTransport{}143 resumedTransport := &closeTrackingTransport{}
@@ -150,6 +151,7 @@ func TestSuccessfulResumeRejectsOldCredentialAfterWelcome(t *testing.T) {
150 require.False(t, oldRetained)151 require.False(t, oldRetained)
151 require.NotNil(t, newParked)152 require.NotNil(t, newParked)
152 require.NotEmpty(t, resumedTransport.Sends())153 require.NotEmpty(t, resumedTransport.Sends())
154+ require.Equal(t, ports.MsgWelcome, resumedTransport.Sends()[0].Type, "Welcome must remain the first server frame after Hello")
153 }155 }
154 156
155 func TestFailedResumeHandshakeKeepsParkedCredential(t *testing.T) {157 func TestFailedResumeHandshakeKeepsParkedCredential(t *testing.T) {
M internal/usecase/daemon/attachment_transition.go +4 -1
@@ -177,7 +177,10 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
177177 if err != nil {
178178 return result, err
179179 }
180- if result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {
180+ // A not-ready publication is still inside the Hello handshake. Welcome
181+ // carries the committed identity for those attachments and must remain the
182+ // first server frame. Ready transitions notify an already-running client.
183+ if req.ready && result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {
181184 identityErr := errAttachmentTransition
182185 if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted {
183186 identityErr = d.sendCommittedRouteIdentityForAttachment(effect.connectionToken())
@@ -177,7 +177,10 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
177 if err != nil {177 if err != nil {
178 return result, err178 return result, err
179 }179 }
180- if result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {180+ // A not-ready publication is still inside the Hello handshake. Welcome
181+ // carries the committed identity for those attachments and must remain the
182+ // first server frame. Ready transitions notify an already-running client.
183+ if req.ready && result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {
181 identityErr := errAttachmentTransition184 identityErr := errAttachmentTransition
182 if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted {185 if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted {
183 identityErr = d.sendCommittedRouteIdentityForAttachment(effect.connectionToken())186 identityErr = d.sendCommittedRouteIdentityForAttachment(effect.connectionToken())
M internal/usecase/daemon/client.go +15 -13
@@ -85,19 +85,20 @@ type attachedClient struct {
8585 // view is attachment-local navigation state. It is never inferred from a
8686 // session-wide active tab, so multiple attachments can observe different
8787 // tabs and panes without changing shared session ownership.
88- viewMu sync.Mutex
89- view attachmentView
90- sess Guarded[*session]
91- mouseScan mouse.Scanner
92- themeMu sync.Mutex
93- clientTheme themeui.Theme
94- appliedTheme appliedTheme
95- lastCursor cursorOut
96- renderStages renderStageHooks // optional render and handoff observability hooks
97- linkMu sync.Mutex
98- sendMu sync.Mutex
99- routeMu sync.RWMutex
100- routeSnapshot ports.RecentRouteSnapshot
88+ viewMu sync.Mutex
89+ view attachmentView
90+ sess Guarded[*session]
91+ mouseScan mouse.Scanner
92+ themeMu sync.Mutex
93+ clientTheme themeui.Theme
94+ appliedTheme appliedTheme
95+ lastCursor cursorOut
96+ lastRoutePosition ports.RoutePosition
97+ renderStages renderStageHooks // optional render and handoff observability hooks
98+ linkMu sync.Mutex
99+ sendMu sync.Mutex
100+ routeMu sync.RWMutex
101+ routeSnapshot ports.RecentRouteSnapshot
101102 // routeCreatedSession marks a session created by this attachment's route.
102103 // A handshake that never commits Welcome must tear down that exact empty
103104 // session, while an attachment routed to an existing session must not.
@@ -382,6 +383,7 @@ func (ac *attachedClient) rebaseOutput() {
382383 if ac.output != nil {
383384 ac.output.rebase()
384385 }
386+ ac.lastRoutePosition = ports.RoutePosition{}
385387 }
386388
387389 var errTransportReplaced = errors.New("client transport was replaced")
@@ -85,19 +85,20 @@ type attachedClient struct {
85 // view is attachment-local navigation state. It is never inferred from a85 // view is attachment-local navigation state. It is never inferred from a
86 // session-wide active tab, so multiple attachments can observe different86 // session-wide active tab, so multiple attachments can observe different
87 // tabs and panes without changing shared session ownership.87 // tabs and panes without changing shared session ownership.
88- viewMu sync.Mutex88+ viewMu sync.Mutex
89- view attachmentView89+ view attachmentView
90- sess Guarded[*session]90+ sess Guarded[*session]
91- mouseScan mouse.Scanner91+ mouseScan mouse.Scanner
92- themeMu sync.Mutex92+ themeMu sync.Mutex
93- clientTheme themeui.Theme93+ clientTheme themeui.Theme
94- appliedTheme appliedTheme94+ appliedTheme appliedTheme
95- lastCursor cursorOut95+ lastCursor cursorOut
96- renderStages renderStageHooks // optional render and handoff observability hooks96+ lastRoutePosition ports.RoutePosition
97- linkMu sync.Mutex97+ renderStages renderStageHooks // optional render and handoff observability hooks
98- sendMu sync.Mutex98+ linkMu sync.Mutex
99- routeMu sync.RWMutex99+ sendMu sync.Mutex
100- routeSnapshot ports.RecentRouteSnapshot100+ routeMu sync.RWMutex
101+ routeSnapshot ports.RecentRouteSnapshot
101 // routeCreatedSession marks a session created by this attachment's route.102 // routeCreatedSession marks a session created by this attachment's route.
102 // A handshake that never commits Welcome must tear down that exact empty103 // A handshake that never commits Welcome must tear down that exact empty
103 // session, while an attachment routed to an existing session must not.104 // session, while an attachment routed to an existing session must not.
@@ -382,6 +383,7 @@ func (ac *attachedClient) rebaseOutput() {
382 if ac.output != nil {383 if ac.output != nil {
383 ac.output.rebase()384 ac.output.rebase()
384 }385 }
386+ ac.lastRoutePosition = ports.RoutePosition{}
385 }387 }
386 388
387 var errTransportReplaced = errors.New("client transport was replaced")389 var errTransportReplaced = errors.New("client transport was replaced")
M internal/usecase/daemon/daemon.go +20 -0
@@ -1266,6 +1266,12 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
12661266 return nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"}
12671267 }
12681268 }
1269+ if h.PreferredTabID != "" {
1270+ // Route memory is a best-effort attachment cursor, not attach authority.
1271+ // It overrides point-in-time picker tab metadata when still present and
1272+ // otherwise falls back to the session's normal first-tab repair.
1273+ initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)
1274+ }
12691275 // Session state is the sole source for future PTY children. Update it before
12701276 // publishing the attachment; existing PTYs keep their original environment.
12711277 // A picker handoff deliberately leaves the daemon-owned environment and CWD
@@ -1307,6 +1313,20 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
13071313 return ac, nil
13081314 }
13091315
1316+func preferredTabIndex(sess *session, preferred domain.TabStableID) int {
1317+ if sess == nil || preferred == "" {
1318+ return -1
1319+ }
1320+ sess.mu.Lock()
1321+ defer sess.mu.Unlock()
1322+ for i, candidate := range sess.tabs {
1323+ if candidate != nil && domain.TabStableID(candidate.stableID) == preferred {
1324+ return i
1325+ }
1326+ }
1327+ return -1
1328+}
1329+
13101330 func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
13111331 d.mu.Lock()
13121332 var (
@@ -1266,6 +1266,12 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1266 return nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"}1266 return nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"}
1267 }1267 }
1268 }1268 }
1269+ if h.PreferredTabID != "" {
1270+ // Route memory is a best-effort attachment cursor, not attach authority.
1271+ // It overrides point-in-time picker tab metadata when still present and
1272+ // otherwise falls back to the session's normal first-tab repair.
1273+ initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)
1274+ }
1269 // Session state is the sole source for future PTY children. Update it before1275 // Session state is the sole source for future PTY children. Update it before
1270 // publishing the attachment; existing PTYs keep their original environment.1276 // publishing the attachment; existing PTYs keep their original environment.
1271 // A picker handoff deliberately leaves the daemon-owned environment and CWD1277 // A picker handoff deliberately leaves the daemon-owned environment and CWD
@@ -1307,6 +1313,20 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1307 return ac, nil1313 return ac, nil
1308 }1314 }
1309 1315
1316+func preferredTabIndex(sess *session, preferred domain.TabStableID) int {
1317+ if sess == nil || preferred == "" {
1318+ return -1
1319+ }
1320+ sess.mu.Lock()
1321+ defer sess.mu.Unlock()
1322+ for i, candidate := range sess.tabs {
1323+ if candidate != nil && domain.TabStableID(candidate.stableID) == preferred {
1324+ return i
1325+ }
1326+ }
1327+ return -1
1328+}
1329+
1310 func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {1330 func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
1311 d.mu.Lock()1331 d.mu.Lock()
1312 var (1332 var (
M internal/usecase/daemon/palette.go +57 -5
@@ -42,7 +42,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
4242 // consults its own session history for recent-route commands.
4343 routeSnapshot := ac.routeSnapshotCopy()
4444 commands := d.paletteCommands()
45- results := d.paletteResults(sess, commands)
45+ results := d.paletteResults(sess, commands, routeSnapshot, ac.remoteOrigin)
4646 ac.overlays.paletteMu.Lock()
4747 ac.overlays.paletteGeneration++
4848 ac.overlays.palette = palette.New(results)
@@ -56,7 +56,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
5656
5757 // paletteResults captures eligible named sessions before paletteMu so the
5858 // palette has an immutable lifecycle target without violating lock ordering.
59-func (d *Daemon) paletteResults(current *session, commands []command.Command) []palette.Result {
59+func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot, currentRemoteOrigin string) []palette.Result {
6060 d.mu.Lock()
6161 sessions := d.sessionsSnapshotLocked()
6262 stopped := make([]stoppedSession, 0, len(d.stopped))
@@ -67,7 +67,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) []
6767 }
6868 d.mu.Unlock()
6969
70- results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped))
70+ results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)+len(routeSnapshot.Entries))
7171 for _, cmd := range commands {
7272 results = append(results, palette.NewCommandResult(cmd))
7373 }
@@ -88,8 +88,36 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) []
8888 })
8989 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
9090 results = append(results, active...)
91+ localNames := make(map[string]struct{}, len(active)+len(stopped))
92+ for _, candidate := range active {
93+ name, _ := candidate.SessionName()
94+ localNames[name] = struct{}{}
95+ }
9196 for _, candidate := range stopped {
9297 results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt)))
98+ localNames[candidate.name] = struct{}{}
99+ }
100+ formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)
101+ for i, entry := range routeSnapshot.Entries {
102+ if i >= len(formattedRoutes) {
103+ break
104+ }
105+ label := formattedRoutes[i].name
106+ if label == "" {
107+ continue
108+ }
109+ if _, represented := localNames[entry.Name]; represented {
110+ localRoute := entry.Kind == ports.RouteKindLocal
111+ currentRemoteRoute := entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin
112+ if localRoute || currentRemoteRoute {
113+ continue
114+ }
115+ }
116+ results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{
117+ SnapshotGeneration: routeSnapshot.Generation,
118+ Key: entry.Key,
119+ Generation: entry.Generation,
120+ }))
93121 }
94122 return results
95123 }
@@ -166,6 +194,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
166194 var cmd command.Command
167195 var sessionTarget palette.Result
168196 var hasSessionTarget bool
197+ var routeTarget ports.RouteNavigationAction
198+ var hasRouteTarget bool
169199 var args []string
170200 var routeSnapshot ports.RecentRouteSnapshot
171201 var generation uint64
@@ -241,6 +271,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
241271 routeSnapshot = ac.overlays.paletteRouteSnapshot
242272 routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)
243273 }
274+ } else if action, ok := selected.RouteNavigationAction(); ok {
275+ routeTarget = action
276+ hasRouteTarget = true
244277 } else if _, ok := selected.SessionName(); ok {
245278 sessionTarget = selected
246279 hasSessionTarget = true
@@ -281,6 +314,22 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
281314 return
282315 }
283316
317+ if hasRouteTarget {
318+ exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
319+ if err := exec.NavigateRecentRoute(routeTarget); err != nil {
320+ if errors.Is(err, errAttachmentTransition) {
321+ return
322+ }
323+ ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
324+ d.invalidateRender(entry, ac, true, "palette.go")
325+ return
326+ }
327+ if ac.closeExecutedPalette(generation, rawQuery) {
328+ d.invalidateRender(entry, ac, true, "palette.go")
329+ }
330+ return
331+ }
332+
284333 if hasSessionTarget {
285334 createdAt, _ := sessionTarget.SessionCreatedAt()
286335 expectedCreatedAt := createdAt.UnixNano()
@@ -654,11 +703,14 @@ func (e paletteExec) JumpRecentSession(rank int) error {
654703 return command.ErrInvalidArguments
655704 }
656705 entry := e.routeSnapshot.Entries[rank-1]
657- action := ports.RouteNavigationAction{
706+ return e.NavigateRecentRoute(ports.RouteNavigationAction{
658707 SnapshotGeneration: e.routeSnapshot.Generation,
659708 Key: entry.Key,
660709 Generation: entry.Generation,
661- }
710+ })
711+}
712+
713+func (e paletteExec) NavigateRecentRoute(action ports.RouteNavigationAction) error {
662714 if e.d.beforeRecentSessionHandoff != nil {
663715 e.d.beforeRecentSessionHandoff()
664716 }
@@ -42,7 +42,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
42 // consults its own session history for recent-route commands.42 // consults its own session history for recent-route commands.
43 routeSnapshot := ac.routeSnapshotCopy()43 routeSnapshot := ac.routeSnapshotCopy()
44 commands := d.paletteCommands()44 commands := d.paletteCommands()
45- results := d.paletteResults(sess, commands)45+ results := d.paletteResults(sess, commands, routeSnapshot, ac.remoteOrigin)
46 ac.overlays.paletteMu.Lock()46 ac.overlays.paletteMu.Lock()
47 ac.overlays.paletteGeneration++47 ac.overlays.paletteGeneration++
48 ac.overlays.palette = palette.New(results)48 ac.overlays.palette = palette.New(results)
@@ -56,7 +56,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
56 56
57 // paletteResults captures eligible named sessions before paletteMu so the57 // paletteResults captures eligible named sessions before paletteMu so the
58 // palette has an immutable lifecycle target without violating lock ordering.58 // palette has an immutable lifecycle target without violating lock ordering.
59-func (d *Daemon) paletteResults(current *session, commands []command.Command) []palette.Result {59+func (d *Daemon) paletteResults(current *session, commands []command.Command, routeSnapshot ports.RecentRouteSnapshot, currentRemoteOrigin string) []palette.Result {
60 d.mu.Lock()60 d.mu.Lock()
61 sessions := d.sessionsSnapshotLocked()61 sessions := d.sessionsSnapshotLocked()
62 stopped := make([]stoppedSession, 0, len(d.stopped))62 stopped := make([]stoppedSession, 0, len(d.stopped))
@@ -67,7 +67,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) []
67 }67 }
68 d.mu.Unlock()68 d.mu.Unlock()
69 69
70- results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped))70+ results := make([]palette.Result, 0, len(commands)+len(sessions)+len(stopped)+len(routeSnapshot.Entries))
71 for _, cmd := range commands {71 for _, cmd := range commands {
72 results = append(results, palette.NewCommandResult(cmd))72 results = append(results, palette.NewCommandResult(cmd))
73 }73 }
@@ -88,8 +88,36 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command) []
88 })88 })
89 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })89 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
90 results = append(results, active...)90 results = append(results, active...)
91+ localNames := make(map[string]struct{}, len(active)+len(stopped))
92+ for _, candidate := range active {
93+ name, _ := candidate.SessionName()
94+ localNames[name] = struct{}{}
95+ }
91 for _, candidate := range stopped {96 for _, candidate := range stopped {
92 results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt)))97 results = append(results, palette.NewStoppedSessionResult(candidate.name, time.Unix(0, candidate.createdAt)))
98+ localNames[candidate.name] = struct{}{}
99+ }
100+ formattedRoutes := formatRecentRouteSnapshot(routeSnapshot)
101+ for i, entry := range routeSnapshot.Entries {
102+ if i >= len(formattedRoutes) {
103+ break
104+ }
105+ label := formattedRoutes[i].name
106+ if label == "" {
107+ continue
108+ }
109+ if _, represented := localNames[entry.Name]; represented {
110+ localRoute := entry.Kind == ports.RouteKindLocal
111+ currentRemoteRoute := entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin
112+ if localRoute || currentRemoteRoute {
113+ continue
114+ }
115+ }
116+ results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{
117+ SnapshotGeneration: routeSnapshot.Generation,
118+ Key: entry.Key,
119+ Generation: entry.Generation,
120+ }))
93 }121 }
94 return results122 return results
95 }123 }
@@ -166,6 +194,8 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
166 var cmd command.Command194 var cmd command.Command
167 var sessionTarget palette.Result195 var sessionTarget palette.Result
168 var hasSessionTarget bool196 var hasSessionTarget bool
197+ var routeTarget ports.RouteNavigationAction
198+ var hasRouteTarget bool
169 var args []string199 var args []string
170 var routeSnapshot ports.RecentRouteSnapshot200 var routeSnapshot ports.RecentRouteSnapshot
171 var generation uint64201 var generation uint64
@@ -241,6 +271,9 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
241 routeSnapshot = ac.overlays.paletteRouteSnapshot271 routeSnapshot = ac.overlays.paletteRouteSnapshot
242 routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)272 routeSnapshot.Entries = append([]ports.RecentRouteEntry(nil), routeSnapshot.Entries...)
243 }273 }
274+ } else if action, ok := selected.RouteNavigationAction(); ok {
275+ routeTarget = action
276+ hasRouteTarget = true
244 } else if _, ok := selected.SessionName(); ok {277 } else if _, ok := selected.SessionName(); ok {
245 sessionTarget = selected278 sessionTarget = selected
246 hasSessionTarget = true279 hasSessionTarget = true
@@ -281,6 +314,22 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
281 return314 return
282 }315 }
283 316
317+ if hasRouteTarget {
318+ exec := paletteExec{d: d, sess: sess, attachment: entry, ac: ac, effect: effect}
319+ if err := exec.NavigateRecentRoute(routeTarget); err != nil {
320+ if errors.Is(err, errAttachmentTransition) {
321+ return
322+ }
323+ ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
324+ d.invalidateRender(entry, ac, true, "palette.go")
325+ return
326+ }
327+ if ac.closeExecutedPalette(generation, rawQuery) {
328+ d.invalidateRender(entry, ac, true, "palette.go")
329+ }
330+ return
331+ }
332+
284 if hasSessionTarget {333 if hasSessionTarget {
285 createdAt, _ := sessionTarget.SessionCreatedAt()334 createdAt, _ := sessionTarget.SessionCreatedAt()
286 expectedCreatedAt := createdAt.UnixNano()335 expectedCreatedAt := createdAt.UnixNano()
@@ -654,11 +703,14 @@ func (e paletteExec) JumpRecentSession(rank int) error {
654 return command.ErrInvalidArguments703 return command.ErrInvalidArguments
655 }704 }
656 entry := e.routeSnapshot.Entries[rank-1]705 entry := e.routeSnapshot.Entries[rank-1]
657- action := ports.RouteNavigationAction{706+ return e.NavigateRecentRoute(ports.RouteNavigationAction{
658 SnapshotGeneration: e.routeSnapshot.Generation,707 SnapshotGeneration: e.routeSnapshot.Generation,
659 Key: entry.Key,708 Key: entry.Key,
660 Generation: entry.Generation,709 Generation: entry.Generation,
661- }710+ })
711+}
712+
713+func (e paletteExec) NavigateRecentRoute(action ports.RouteNavigationAction) error {
662 if e.d.beforeRecentSessionHandoff != nil {714 if e.d.beforeRecentSessionHandoff != nil {
663 e.d.beforeRecentSessionHandoff()715 e.d.beforeRecentSessionHandoff()
664 }716 }
M internal/usecase/daemon/palette_test.go +57 -0
@@ -419,6 +419,63 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
419419 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")
420420 }
421421
422+func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T) {
423+ d := newTestDaemon(t, nil, stubClock{})
424+ current := addControlSession(d, "hi", "tab-1", "pane-1")
425+ represented := addControlSession(d, "remote-manual", "tab-2", "pane-2")
426+ represented.ephemeral = false
427+ snapshot := ports.RecentRouteSnapshot{
428+ Generation: 2,
429+ Active: ports.RouteRef{Key: 1, Generation: 1},
430+ Entries: []ports.RecentRouteEntry{{
431+ Key: 2, Generation: 1, Name: "remote-manual", HostLabel: "remote", Kind: ports.RouteKindRemote,
432+ }},
433+ }
434+
435+ for _, test := range []struct {
436+ name string
437+ currentOrigin string
438+ want []string
439+ }{
440+ {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}},
441+ {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}},
442+ } {
443+ t.Run(test.name, func(t *testing.T) {
444+ results := d.paletteResults(current, nil, snapshot, test.currentOrigin)
445+ var matching []string
446+ for _, result := range results {
447+ if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@remote" {
448+ matching = append(matching, result.DisplayText())
449+ }
450+ }
451+ require.Equal(t, test.want, matching)
452+ })
453+ }
454+}
455+
456+func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) {
457+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
458+ defer releaseAll(releases)
459+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
460+ Generation: 9,
461+ Active: ports.RouteRef{Key: 1, Generation: 1},
462+ Entries: []ports.RecentRouteEntry{{
463+ Key: 8, Generation: 4, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
464+ }},
465+ })
466+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
467+
468+ d.handleInputForAttachment(token, []byte("\x1b "))
469+ awaitFrame(t, sends, ports.MsgOutput)
470+ d.handleInputForAttachment(token, []byte("logs@edge\r"))
471+
472+ actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
473+ action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload)
474+ require.NoError(t, err)
475+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 9, Key: 8, Generation: 4}, action)
476+ require.False(t, ac.overlays.paletteActive())
477+}
478+
422479 func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {
423480 d, sess, ac, sends, releases := newManualTabSession(t, 2)
424481 defer releases[0]()
@@ -419,6 +419,63 @@ func TestPaletteJRSUsesEffectiveOverrideOnly(t *testing.T) {
419 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")419 require.True(t, ac.overlays.paletteActive(), "literal JRS must not execute an overridden jump command")
420 }420 }
421 421
422+func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T) {
423+ d := newTestDaemon(t, nil, stubClock{})
424+ current := addControlSession(d, "hi", "tab-1", "pane-1")
425+ represented := addControlSession(d, "remote-manual", "tab-2", "pane-2")
426+ represented.ephemeral = false
427+ snapshot := ports.RecentRouteSnapshot{
428+ Generation: 2,
429+ Active: ports.RouteRef{Key: 1, Generation: 1},
430+ Entries: []ports.RecentRouteEntry{{
431+ Key: 2, Generation: 1, Name: "remote-manual", HostLabel: "remote", Kind: ports.RouteKindRemote,
432+ }},
433+ }
434+
435+ for _, test := range []struct {
436+ name string
437+ currentOrigin string
438+ want []string
439+ }{
440+ {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}},
441+ {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}},
442+ } {
443+ t.Run(test.name, func(t *testing.T) {
444+ results := d.paletteResults(current, nil, snapshot, test.currentOrigin)
445+ var matching []string
446+ for _, result := range results {
447+ if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@remote" {
448+ matching = append(matching, result.DisplayText())
449+ }
450+ }
451+ require.Equal(t, test.want, matching)
452+ })
453+ }
454+}
455+
456+func TestPaletteFuzzyRemoteRecentRouteSendsExactNavigationAction(t *testing.T) {
457+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
458+ defer releaseAll(releases)
459+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{
460+ Generation: 9,
461+ Active: ports.RouteRef{Key: 1, Generation: 1},
462+ Entries: []ports.RecentRouteEntry{{
463+ Key: 8, Generation: 4, Name: "logs", HostLabel: "edge", Kind: ports.RouteKindRemote,
464+ }},
465+ })
466+ token := beginRecentRoutePaletteEffect(t, d, current, ac)
467+
468+ d.handleInputForAttachment(token, []byte("\x1b "))
469+ awaitFrame(t, sends, ports.MsgOutput)
470+ d.handleInputForAttachment(token, []byte("logs@edge\r"))
471+
472+ actionFrame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
473+ action, err := ports.UnmarshalRouteNavigationAction(actionFrame.Payload)
474+ require.NoError(t, err)
475+ require.Equal(t, ports.RouteNavigationAction{SnapshotGeneration: 9, Key: 8, Generation: 4}, action)
476+ require.False(t, ac.overlays.paletteActive())
477+}
478+
422 func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {479 func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {
423 d, sess, ac, sends, releases := newManualTabSession(t, 2)480 d, sess, ac, sends, releases := newManualTabSession(t, 2)
424 defer releases[0]()481 defer releases[0]()
M internal/usecase/daemon/picker.go +7 -0
@@ -179,8 +179,15 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
179179 for _, item := range live {
180180 views = append(views, item.view.pickerView())
181181 }
182+ seenRemoteLifecycles := make(map[domain.SessionLifecycleID]struct{}, catalogRows)
182183 for _, host := range catalog {
183184 for _, session := range host.entry.Sessions {
185+ if session.LifecycleID != (domain.SessionLifecycleID{}) {
186+ if _, duplicate := seenRemoteLifecycles[session.LifecycleID]; duplicate {
187+ continue
188+ }
189+ seenRemoteLifecycles[session.LifecycleID] = struct{}{}
190+ }
184191 key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}
185192 if key.Validate() != nil {
186193 continue
@@ -179,8 +179,15 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
179 for _, item := range live {179 for _, item := range live {
180 views = append(views, item.view.pickerView())180 views = append(views, item.view.pickerView())
181 }181 }
182+ seenRemoteLifecycles := make(map[domain.SessionLifecycleID]struct{}, catalogRows)
182 for _, host := range catalog {183 for _, host := range catalog {
183 for _, session := range host.entry.Sessions {184 for _, session := range host.entry.Sessions {
185+ if session.LifecycleID != (domain.SessionLifecycleID{}) {
186+ if _, duplicate := seenRemoteLifecycles[session.LifecycleID]; duplicate {
187+ continue
188+ }
189+ seenRemoteLifecycles[session.LifecycleID] = struct{}{}
190+ }
184 key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}191 key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}
185 if key.Validate() != nil {192 if key.Validate() != nil {
186 continue193 continue
M internal/usecase/daemon/remote_picker_test.go +28 -0
@@ -82,6 +82,34 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T)
8282 }
8383 }
8484
85+func TestRemotePickerDeduplicatesSameLifecycleReachedThroughHostAliases(t *testing.T) {
86+ store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}}
87+ d := newRemotePickerDaemon(store)
88+ lifecycle := remoteLifecycleForTest()
89+ session := ports.RemoteCatalogSession{
90+ LifecycleID: lifecycle, Name: "work", State: "up",
91+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
92+ }
93+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
94+ {Host: "remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}},
95+ {Host: "vev@remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}},
96+ })
97+ d.remoteCatalog.mu.Lock()
98+ d.remoteCatalog.status["remote"] = remoteHostFresh
99+ d.remoteCatalog.status["vev@remote"] = remoteHostFresh
100+ d.remoteCatalog.mu.Unlock()
101+
102+ views, _ := d.pickerViews(nil, nil)
103+ var targets []*domain.RemoteSessionTarget
104+ for _, view := range views {
105+ if view.RemoteTarget != nil {
106+ targets = append(targets, view.RemoteTarget)
107+ }
108+ }
109+ require.Len(t, targets, 1)
110+ require.Equal(t, "remote", targets[0].Endpoint, "first pinned alias must remain the attach endpoint")
111+}
112+
85113 func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
86114 now := time.Unix(100, 0)
87115 clock := &remotePickerClock{now: now}
@@ -82,6 +82,34 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T)
82 }82 }
83 }83 }
84 84
85+func TestRemotePickerDeduplicatesSameLifecycleReachedThroughHostAliases(t *testing.T) {
86+ store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}}
87+ d := newRemotePickerDaemon(store)
88+ lifecycle := remoteLifecycleForTest()
89+ session := ports.RemoteCatalogSession{
90+ LifecycleID: lifecycle, Name: "work", State: "up",
91+ Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
92+ }
93+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{
94+ {Host: "remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}},
95+ {Host: "vev@remote", FetchedAt: time.Unix(100, 0), Sessions: []ports.RemoteCatalogSession{session}},
96+ })
97+ d.remoteCatalog.mu.Lock()
98+ d.remoteCatalog.status["remote"] = remoteHostFresh
99+ d.remoteCatalog.status["vev@remote"] = remoteHostFresh
100+ d.remoteCatalog.mu.Unlock()
101+
102+ views, _ := d.pickerViews(nil, nil)
103+ var targets []*domain.RemoteSessionTarget
104+ for _, view := range views {
105+ if view.RemoteTarget != nil {
106+ targets = append(targets, view.RemoteTarget)
107+ }
108+ }
109+ require.Len(t, targets, 1)
110+ require.Equal(t, "remote", targets[0].Endpoint, "first pinned alias must remain the attach endpoint")
111+}
112+
85 func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {113 func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
86 now := time.Unix(100, 0)114 now := time.Unix(100, 0)
87 clock := &remotePickerClock{now: now}115 clock := &remotePickerClock{now: now}
M internal/usecase/daemon/render_coordinator_test.go +10 -4
@@ -268,10 +268,16 @@ func awaitLatestCoordinatorTimer(t *testing.T, clk *coordinatorMockClock) *coord
268268
269269 func requireNoCoordinatorOutputFrame(t *testing.T, sends chan ports.Frame) {
270270 t.Helper()
271- select {
272- case frame := <-sends:
273- t.Fatalf("unexpected output frame: %+v", frame)
274- default:
271+ for {
272+ select {
273+ case frame := <-sends:
274+ if frame.Type == ports.MsgRoutePosition {
275+ continue
276+ }
277+ t.Fatalf("unexpected output frame: %+v", frame)
278+ default:
279+ return
280+ }
275281 }
276282 }
277283
@@ -268,10 +268,16 @@ func awaitLatestCoordinatorTimer(t *testing.T, clk *coordinatorMockClock) *coord
268 268
269 func requireNoCoordinatorOutputFrame(t *testing.T, sends chan ports.Frame) {269 func requireNoCoordinatorOutputFrame(t *testing.T, sends chan ports.Frame) {
270 t.Helper()270 t.Helper()
271- select {271+ for {
272- case frame := <-sends:272+ select {
273- t.Fatalf("unexpected output frame: %+v", frame)273+ case frame := <-sends:
274- default:274+ if frame.Type == ports.MsgRoutePosition {
275+ continue
276+ }
277+ t.Fatalf("unexpected output frame: %+v", frame)
278+ default:
279+ return
280+ }
275 }281 }
276 }282 }
277 283
M internal/usecase/daemon/render_pipeline.go +44 -0
@@ -448,6 +448,13 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
448448 }
449449 entry.core().mu.Lock()
450450 _, owned := entry.core().attachments[ac]
451+ position := ports.RoutePosition{
452+ Target: ports.ExactSessionTarget{
453+ LifecycleID: entry.core().incarnation,
454+ SessionName: entry.core().name,
455+ },
456+ ActiveTabID: state.view.tabID,
457+ }
451458 entry.core().mu.Unlock()
452459 if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
453460 ac.sendMu.Unlock()
@@ -558,6 +565,43 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
558565 if ac.renderStages.emit != nil {
559566 ac.renderStages.emit()
560567 }
568+
569+ if position.ActiveTabID != "" && position != ac.lastRoutePosition {
570+ payload, positionMarshalErr := ports.MarshalRoutePosition(position)
571+ if positionMarshalErr != nil {
572+ d.log.Error("marshal route position", "err", positionMarshalErr)
573+ } else {
574+ sendTransport := ac.transportSnapshot()
575+ sendTr = sendTransport.transport
576+ interruptible := false
577+ if marks.attachmentEffect != nil {
578+ interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
579+ if !interruptible {
580+ sendErr = errAttachmentTransition
581+ }
582+ }
583+ if sendErr == nil {
584+ if sendTr == nil {
585+ sendErr = errors.New("client transport is nil")
586+ } else {
587+ send := sendTr.Send
588+ if async, ok := sendTr.(ports.AsyncTransport); ok {
589+ send = async.SendAsync
590+ }
591+ sendErr = send(ports.Frame{Type: ports.MsgRoutePosition, Payload: payload})
592+ }
593+ }
594+ if marks.attachmentEffect != nil && interruptible {
595+ if sendErr != nil {
596+ marks.attachmentEffect.reportTransportFailure(sendTransport)
597+ }
598+ marks.attachmentEffect.endTransportSend()
599+ }
600+ if sendErr == nil {
601+ ac.lastRoutePosition = position
602+ }
603+ }
604+ }
561605 }
562606 ac.sendMu.Unlock()
563607 if sendErr != nil {
@@ -448,6 +448,13 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
448 }448 }
449 entry.core().mu.Lock()449 entry.core().mu.Lock()
450 _, owned := entry.core().attachments[ac]450 _, owned := entry.core().attachments[ac]
451+ position := ports.RoutePosition{
452+ Target: ports.ExactSessionTarget{
453+ LifecycleID: entry.core().incarnation,
454+ SessionName: entry.core().name,
455+ },
456+ ActiveTabID: state.view.tabID,
457+ }
451 entry.core().mu.Unlock()458 entry.core().mu.Unlock()
452 if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {459 if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
453 ac.sendMu.Unlock()460 ac.sendMu.Unlock()
@@ -558,6 +565,43 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
558 if ac.renderStages.emit != nil {565 if ac.renderStages.emit != nil {
559 ac.renderStages.emit()566 ac.renderStages.emit()
560 }567 }
568+
569+ if position.ActiveTabID != "" && position != ac.lastRoutePosition {
570+ payload, positionMarshalErr := ports.MarshalRoutePosition(position)
571+ if positionMarshalErr != nil {
572+ d.log.Error("marshal route position", "err", positionMarshalErr)
573+ } else {
574+ sendTransport := ac.transportSnapshot()
575+ sendTr = sendTransport.transport
576+ interruptible := false
577+ if marks.attachmentEffect != nil {
578+ interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
579+ if !interruptible {
580+ sendErr = errAttachmentTransition
581+ }
582+ }
583+ if sendErr == nil {
584+ if sendTr == nil {
585+ sendErr = errors.New("client transport is nil")
586+ } else {
587+ send := sendTr.Send
588+ if async, ok := sendTr.(ports.AsyncTransport); ok {
589+ send = async.SendAsync
590+ }
591+ sendErr = send(ports.Frame{Type: ports.MsgRoutePosition, Payload: payload})
592+ }
593+ }
594+ if marks.attachmentEffect != nil && interruptible {
595+ if sendErr != nil {
596+ marks.attachmentEffect.reportTransportFailure(sendTransport)
597+ }
598+ marks.attachmentEffect.endTransportSend()
599+ }
600+ if sendErr == nil {
601+ ac.lastRoutePosition = position
602+ }
603+ }
604+ }
561 }605 }
562 ac.sendMu.Unlock()606 ac.sendMu.Unlock()
563 if sendErr != nil {607 if sendErr != nil {
M internal/usecase/daemon/resume.go +3 -0
@@ -641,6 +641,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
641641 }
642642 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
643643 sess.mu.Unlock()
644+ preferredTabIndex := preferredTabIndex(sess, h.PreferredTabID)
644645 // Resume preparation used sendMu -> d.mu, but freeze/drain must run with
645646 // neither held. Reacquire them before returning to preserve this helper's
646647 // locked-caller contract.
@@ -651,6 +652,8 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
651652 next: ac,
652653
653654 expectedTransport: ac.transportSnapshot(),
655+ activateTargetTab: preferredTabIndex >= 0,
656+ targetTabIndex: preferredTabIndex,
654657 ready: false,
655658 })
656659 ac.sendMu.Lock()
@@ -641,6 +641,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
641 }641 }
642 sess.terminal = terminalEnv{TrueColor: h.TrueColor}642 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
643 sess.mu.Unlock()643 sess.mu.Unlock()
644+ preferredTabIndex := preferredTabIndex(sess, h.PreferredTabID)
644 // Resume preparation used sendMu -> d.mu, but freeze/drain must run with645 // Resume preparation used sendMu -> d.mu, but freeze/drain must run with
645 // neither held. Reacquire them before returning to preserve this helper's646 // neither held. Reacquire them before returning to preserve this helper's
646 // locked-caller contract.647 // locked-caller contract.
@@ -651,6 +652,8 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
651 next: ac,652 next: ac,
652 653
653 expectedTransport: ac.transportSnapshot(),654 expectedTransport: ac.transportSnapshot(),
655+ activateTargetTab: preferredTabIndex >= 0,
656+ targetTabIndex: preferredTabIndex,
654 ready: false,657 ready: false,
655 })658 })
656 ac.sendMu.Lock()659 ac.sendMu.Lock()
M internal/usecase/daemon/resume_test.go +3 -3
@@ -1184,7 +1184,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
11841184 require.Same(t, ac, resumedAC)
11851185 d.paint(resumedSess, resumedAC, true, nil)
11861186
1187- sends := newTr.Sends()
1187+ sends := testFramesOfType(newTr.Sends(), ports.MsgOutput)
11881188 require.Len(t, sends, 1)
11891189 first, err := ports.UnmarshalOutput(sends[0].Payload)
11901190 require.NoError(t, err)
@@ -1194,7 +1194,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
11941194
11951195 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))
11961196 d.paint(resumedSess, resumedAC, false, nil)
1197- sends = newTr.Sends()
1197+ sends = testFramesOfType(newTr.Sends(), ports.MsgOutput)
11981198 require.Len(t, sends, 2)
11991199 second, err := ports.UnmarshalOutput(sends[1].Payload)
12001200 require.NoError(t, err)
@@ -1244,7 +1244,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
12441244 require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))
12451245 d.firstPaint(resumedSess, resumedAC)
12461246
1247- sends := newTransport.Sends()
1247+ sends := testFramesOfType(newTransport.Sends(), ports.MsgOutput)
12481248 require.Len(t, sends, 1)
12491249 output, err := ports.UnmarshalOutput(sends[0].Payload)
12501250 require.NoError(t, err)
@@ -1184,7 +1184,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
1184 require.Same(t, ac, resumedAC)1184 require.Same(t, ac, resumedAC)
1185 d.paint(resumedSess, resumedAC, true, nil)1185 d.paint(resumedSess, resumedAC, true, nil)
1186 1186
1187- sends := newTr.Sends()1187+ sends := testFramesOfType(newTr.Sends(), ports.MsgOutput)
1188 require.Len(t, sends, 1)1188 require.Len(t, sends, 1)
1189 first, err := ports.UnmarshalOutput(sends[0].Payload)1189 first, err := ports.UnmarshalOutput(sends[0].Payload)
1190 require.NoError(t, err)1190 require.NoError(t, err)
@@ -1194,7 +1194,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
1194 1194
1195 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))1195 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))
1196 d.paint(resumedSess, resumedAC, false, nil)1196 d.paint(resumedSess, resumedAC, false, nil)
1197- sends = newTr.Sends()1197+ sends = testFramesOfType(newTr.Sends(), ports.MsgOutput)
1198 require.Len(t, sends, 2)1198 require.Len(t, sends, 2)
1199 second, err := ports.UnmarshalOutput(sends[1].Payload)1199 second, err := ports.UnmarshalOutput(sends[1].Payload)
1200 require.NoError(t, err)1200 require.NoError(t, err)
@@ -1244,7 +1244,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
1244 require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))1244 require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))
1245 d.firstPaint(resumedSess, resumedAC)1245 d.firstPaint(resumedSess, resumedAC)
1246 1246
1247- sends := newTransport.Sends()1247+ sends := testFramesOfType(newTransport.Sends(), ports.MsgOutput)
1248 require.Len(t, sends, 1)1248 require.Len(t, sends, 1)
1249 output, err := ports.UnmarshalOutput(sends[0].Payload)1249 output, err := ports.UnmarshalOutput(sends[0].Payload)
1250 require.NoError(t, err)1250 require.NoError(t, err)
A internal/usecase/daemon/route_position_test.go +110 -0
@@ -0,0 +1,110 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestRouteAppliesPreferredTabPerAttachment(t *testing.T) {
14+ for _, test := range []struct {
15+ name string
16+ preferred domain.TabStableID
17+ want domain.TabStableID
18+ }{
19+ {name: "existing preferred tab", preferred: "tab-2", want: "tab-2"},
20+ {name: "missing preferred tab falls back", preferred: "missing", want: "tab-1"},
21+ } {
22+ t.Run(test.name, func(t *testing.T) {
23+ d := newTestDaemon(t, nil, stubClock{})
24+ sess := addControlSession(d, "work", "tab-1", "pane-1")
25+ sess.incarnation = domain.SessionLifecycleID{1}
26+ second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
27+ sess.mu.Lock()
28+ sess.tabs = append(sess.tabs, second)
29+ sess.mu.Unlock()
30+ tr := &closeTrackingTransport{}
31+ target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
32+ hello := ports.Hello{
33+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name,
34+ Size: defaultSize, ExactTarget: &target, PreferredTabID: test.preferred,
35+ EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
36+ }
37+
38+ _, ac, err := d.routeWithContext(context.Background(), hello, tr)
39+ require.NoError(t, err)
40+ sess.repairAttachmentView(ac)
41+ require.Equal(t, test.want, ac.viewSnapshot().tabID)
42+ d.clientGone(sess, ac, tr, false)
43+ })
44+ }
45+}
46+
47+func TestResumeAppliesPreferredTabToParkedAttachment(t *testing.T) {
48+ pty, release := newBlockingPTY(t)
49+ defer release()
50+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
51+ oldTransport := &closeTrackingTransport{}
52+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
53+ require.NoError(t, err)
54+ second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
55+ sess.mu.Lock()
56+ sess.tabs = append(sess.tabs, second)
57+ sess.mu.Unlock()
58+ sess.repairAttachmentView(ac)
59+ token := ac.resumeToken
60+ d.clientGone(sess, ac, oldTransport, false)
61+
62+ hello := helloResumeCapable(ports.IntentResume, "work", token)
63+ hello.PreferredTabID = "tab-2"
64+ newTransport := &closeTrackingTransport{}
65+ resumedSession, resumed, err := d.route(hello, newTransport)
66+ require.NoError(t, err)
67+ require.Same(t, ac, resumed)
68+ require.Equal(t, domain.TabStableID("tab-2"), resumed.viewSnapshot().tabID)
69+ d.clientGone(resumedSession, resumed, newTransport, false)
70+}
71+
72+func TestPaintPublishesChangedAttachmentRoutePosition(t *testing.T) {
73+ d := newTestDaemon(t, nil, stubClock{})
74+ sess := addControlSession(d, "work", "tab-1", "pane-1")
75+ sess.incarnation = domain.SessionLifecycleID{1}
76+ second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
77+ sess.mu.Lock()
78+ sess.tabs = append(sess.tabs, second)
79+ sess.mu.Unlock()
80+ tr := &closeTrackingTransport{}
81+ target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
82+ hello := ports.Hello{
83+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name,
84+ Size: defaultSize, ExactTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
85+ }
86+ _, ac, err := d.routeWithContext(context.Background(), hello, tr)
87+ require.NoError(t, err)
88+ rc := sess.renderCoordinator()
89+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
90+ sess.repairAttachmentView(ac)
91+
92+ require.True(t, d.firstPaintForTransition(sess.attachmentToken(ac, tr)))
93+ sess.selectAttachmentTab(ac, "tab-2")
94+ d.paint(sess, ac, false, nil)
95+
96+ var positions []ports.RoutePosition
97+ for _, frame := range tr.Sends() {
98+ if frame.Type != ports.MsgRoutePosition {
99+ continue
100+ }
101+ position, err := ports.UnmarshalRoutePosition(frame.Payload)
102+ require.NoError(t, err)
103+ positions = append(positions, position)
104+ }
105+ require.Equal(t, []ports.RoutePosition{
106+ {Target: target, ActiveTabID: "tab-1"},
107+ {Target: target, ActiveTabID: "tab-2"},
108+ }, positions)
109+ d.clientGone(sess, ac, tr, false)
110+}
@@ -0,0 +1,110 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestRouteAppliesPreferredTabPerAttachment(t *testing.T) {
14+ for _, test := range []struct {
15+ name string
16+ preferred domain.TabStableID
17+ want domain.TabStableID
18+ }{
19+ {name: "existing preferred tab", preferred: "tab-2", want: "tab-2"},
20+ {name: "missing preferred tab falls back", preferred: "missing", want: "tab-1"},
21+ } {
22+ t.Run(test.name, func(t *testing.T) {
23+ d := newTestDaemon(t, nil, stubClock{})
24+ sess := addControlSession(d, "work", "tab-1", "pane-1")
25+ sess.incarnation = domain.SessionLifecycleID{1}
26+ second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
27+ sess.mu.Lock()
28+ sess.tabs = append(sess.tabs, second)
29+ sess.mu.Unlock()
30+ tr := &closeTrackingTransport{}
31+ target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
32+ hello := ports.Hello{
33+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name,
34+ Size: defaultSize, ExactTarget: &target, PreferredTabID: test.preferred,
35+ EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
36+ }
37+
38+ _, ac, err := d.routeWithContext(context.Background(), hello, tr)
39+ require.NoError(t, err)
40+ sess.repairAttachmentView(ac)
41+ require.Equal(t, test.want, ac.viewSnapshot().tabID)
42+ d.clientGone(sess, ac, tr, false)
43+ })
44+ }
45+}
46+
47+func TestResumeAppliesPreferredTabToParkedAttachment(t *testing.T) {
48+ pty, release := newBlockingPTY(t)
49+ defer release()
50+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
51+ oldTransport := &closeTrackingTransport{}
52+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
53+ require.NoError(t, err)
54+ second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
55+ sess.mu.Lock()
56+ sess.tabs = append(sess.tabs, second)
57+ sess.mu.Unlock()
58+ sess.repairAttachmentView(ac)
59+ token := ac.resumeToken
60+ d.clientGone(sess, ac, oldTransport, false)
61+
62+ hello := helloResumeCapable(ports.IntentResume, "work", token)
63+ hello.PreferredTabID = "tab-2"
64+ newTransport := &closeTrackingTransport{}
65+ resumedSession, resumed, err := d.route(hello, newTransport)
66+ require.NoError(t, err)
67+ require.Same(t, ac, resumed)
68+ require.Equal(t, domain.TabStableID("tab-2"), resumed.viewSnapshot().tabID)
69+ d.clientGone(resumedSession, resumed, newTransport, false)
70+}
71+
72+func TestPaintPublishesChangedAttachmentRoutePosition(t *testing.T) {
73+ d := newTestDaemon(t, nil, stubClock{})
74+ sess := addControlSession(d, "work", "tab-1", "pane-1")
75+ sess.incarnation = domain.SessionLifecycleID{1}
76+ second := newTabWithStableID("tab-2", "pane-2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
77+ sess.mu.Lock()
78+ sess.tabs = append(sess.tabs, second)
79+ sess.mu.Unlock()
80+ tr := &closeTrackingTransport{}
81+ target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
82+ hello := ports.Hello{
83+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name,
84+ Size: defaultSize, ExactTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
85+ }
86+ _, ac, err := d.routeWithContext(context.Background(), hello, tr)
87+ require.NoError(t, err)
88+ rc := sess.renderCoordinator()
89+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
90+ sess.repairAttachmentView(ac)
91+
92+ require.True(t, d.firstPaintForTransition(sess.attachmentToken(ac, tr)))
93+ sess.selectAttachmentTab(ac, "tab-2")
94+ d.paint(sess, ac, false, nil)
95+
96+ var positions []ports.RoutePosition
97+ for _, frame := range tr.Sends() {
98+ if frame.Type != ports.MsgRoutePosition {
99+ continue
100+ }
101+ position, err := ports.UnmarshalRoutePosition(frame.Payload)
102+ require.NoError(t, err)
103+ positions = append(positions, position)
104+ }
105+ require.Equal(t, []ports.RoutePosition{
106+ {Target: target, ActiveTabID: "tab-1"},
107+ {Target: target, ActiveTabID: "tab-2"},
108+ }, positions)
109+ d.clientGone(sess, ac, tr, false)
110+}
M internal/usecase/daemon/status.go +1 -1
@@ -196,7 +196,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
196196 name += "*"
197197 }
198198 if ac != nil && ac.remoteOrigin != "" {
199- name += " at " + ac.remoteOrigin
199+ name += "@" + ac.remoteOrigin
200200 }
201201 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
202202 activeIndex := 0
@@ -196,7 +196,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
196 name += "*"196 name += "*"
197 }197 }
198 if ac != nil && ac.remoteOrigin != "" {198 if ac != nil && ac.remoteOrigin != "" {
199- name += " at " + ac.remoteOrigin199+ name += "@" + ac.remoteOrigin
200 }200 }
201 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}201 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
202 activeIndex := 0202 activeIndex := 0
M internal/usecase/daemon/status_test.go +1 -1
@@ -115,7 +115,7 @@ func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) {
115115 remoteOrigin string
116116 wantAttachmentBar string
117117 }{
118- {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive at arch"},
118+ {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"},
119119 {name: "local attachment", wantAttachmentBar: "vive"},
120120 }
121121
@@ -115,7 +115,7 @@ func TestStatusSegmentsIncludesAttachmentRemoteOrigin(t *testing.T) {
115 remoteOrigin string115 remoteOrigin string
116 wantAttachmentBar string116 wantAttachmentBar string
117 }{117 }{
118- {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive at arch"},118+ {name: "remote origin", remoteOrigin: "arch", wantAttachmentBar: "vive@arch"},
119 {name: "local attachment", wantAttachmentBar: "vive"},119 {name: "local attachment", wantAttachmentBar: "vive"},
120 }120 }
121 121
M internal/usecase/daemon/test_wait_helpers_test.go +13 -0
@@ -9,6 +9,16 @@ import (
99
1010 const testWaitTimeout = 2 * time.Second
1111
12+func testFramesOfType(frames []ports.Frame, typ ports.MsgType) []ports.Frame {
13+ matched := make([]ports.Frame, 0, len(frames))
14+ for _, frame := range frames {
15+ if frame.Type == typ {
16+ matched = append(matched, frame)
17+ }
18+ }
19+ return matched
20+}
21+
1222 func awaitTestValue[T any](t *testing.T, ch <-chan T, failure string) T {
1323 t.Helper()
1424 timer := time.NewTimer(testWaitTimeout)
@@ -41,6 +51,9 @@ func awaitCoordinatorOutput(
4151 for {
4252 select {
4353 case frame := <-sends:
54+ if frame.Type == ports.MsgRoutePosition {
55+ continue
56+ }
4457 if frame.Type != ports.MsgOutput {
4558 t.Fatalf("unexpected frame type %d %s", frame.Type, frameContext)
4659 }
@@ -9,6 +9,16 @@ import (
9 9
10 const testWaitTimeout = 2 * time.Second10 const testWaitTimeout = 2 * time.Second
11 11
12+func testFramesOfType(frames []ports.Frame, typ ports.MsgType) []ports.Frame {
13+ matched := make([]ports.Frame, 0, len(frames))
14+ for _, frame := range frames {
15+ if frame.Type == typ {
16+ matched = append(matched, frame)
17+ }
18+ }
19+ return matched
20+}
21+
12 func awaitTestValue[T any](t *testing.T, ch <-chan T, failure string) T {22 func awaitTestValue[T any](t *testing.T, ch <-chan T, failure string) T {
13 t.Helper()23 t.Helper()
14 timer := time.NewTimer(testWaitTimeout)24 timer := time.NewTimer(testWaitTimeout)
@@ -41,6 +51,9 @@ func awaitCoordinatorOutput(
41 for {51 for {
42 select {52 select {
43 case frame := <-sends:53 case frame := <-sends:
54+ if frame.Type == ports.MsgRoutePosition {
55+ continue
56+ }
44 if frame.Type != ports.MsgOutput {57 if frame.Type != ports.MsgOutput {
45 t.Fatalf("unexpected frame type %d %s", frame.Type, frameContext)58 t.Fatalf("unexpected frame type %d %s", frame.Type, frameContext)
46 }59 }
M internal/usecase/palette/result.go +24 -2
@@ -4,6 +4,7 @@ import (
44 "time"
55
66 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
78 "github.com/bnema/vev/internal/usecase/command"
89 )
910
@@ -14,6 +15,7 @@ const (
1415 ResultKindCommand ResultKind = iota
1516 ResultKindActiveSession
1617 ResultKindStoppedSession
18+ ResultKindRecentRoute
1719 )
1820
1921 // Result is an immutable palette target. Its kind is the sole discriminator
@@ -22,6 +24,7 @@ type Result struct {
2224 kind ResultKind
2325 command command.Command
2426 session sessionPayload
27+ route routePayload
2528 }
2629
2730 type sessionPayload struct {
@@ -30,6 +33,11 @@ type sessionPayload struct {
3033 sessionID domain.SessionID
3134 }
3235
36+type routePayload struct {
37+ label string
38+ action ports.RouteNavigationAction
39+}
40+
3341 // NewCommandResult creates a static command palette target.
3442 func NewCommandResult(cmd command.Command) Result {
3543 return Result{kind: ResultKindCommand, command: cmd}
@@ -51,12 +59,20 @@ func NewStoppedSessionResult(name string, createdAt time.Time) Result {
5159 }
5260 }
5361
62+// NewRecentRouteResult creates an immutable client-ledger route target.
63+func NewRecentRouteResult(label string, action ports.RouteNavigationAction) Result {
64+ return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}}
65+}
66+
5467 func (r Result) Kind() ResultKind { return r.kind }
5568
5669 func (r Result) DisplayText() string {
5770 if r.kind == ResultKindCommand {
5871 return r.command.Code
5972 }
73+ if r.kind == ResultKindRecentRoute {
74+ return activeSessionDisplayPrefix + r.route.label
75+ }
6076 return r.sessionDisplayPrefix() + r.session.name
6177 }
6278
@@ -78,12 +94,12 @@ func (r Result) Command() (command.Command, bool) {
7894
7995 // SessionName returns the session name only for session results.
8096 func (r Result) SessionName() (string, bool) {
81- return r.session.name, r.kind != ResultKindCommand
97+ return r.session.name, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession
8298 }
8399
84100 // SessionCreatedAt returns the session creation time only for session results.
85101 func (r Result) SessionCreatedAt() (time.Time, bool) {
86- return r.session.createdAt, r.kind != ResultKindCommand
102+ return r.session.createdAt, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession
87103 }
88104
89105 // SessionID returns the lifecycle identity only for active session results.
@@ -91,6 +107,12 @@ func (r Result) SessionID() (domain.SessionID, bool) {
91107 return r.session.sessionID, r.kind == ResultKindActiveSession
92108 }
93109
110+// RouteNavigationAction returns the exact client-ledger target only for a
111+// recent-route result.
112+func (r Result) RouteNavigationAction() (ports.RouteNavigationAction, bool) {
113+ return r.route.action, r.kind == ResultKindRecentRoute
114+}
115+
94116 const (
95117 activeSessionDisplayPrefix = "Switch to session "
96118 stoppedSessionDisplayPrefix = "Resume session "
@@ -4,6 +4,7 @@ import (
4 "time"4 "time"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
7 "github.com/bnema/vev/internal/usecase/command"8 "github.com/bnema/vev/internal/usecase/command"
8 )9 )
9 10
@@ -14,6 +15,7 @@ const (
14 ResultKindCommand ResultKind = iota15 ResultKindCommand ResultKind = iota
15 ResultKindActiveSession16 ResultKindActiveSession
16 ResultKindStoppedSession17 ResultKindStoppedSession
18+ ResultKindRecentRoute
17 )19 )
18 20
19 // Result is an immutable palette target. Its kind is the sole discriminator21 // Result is an immutable palette target. Its kind is the sole discriminator
@@ -22,6 +24,7 @@ type Result struct {
22 kind ResultKind24 kind ResultKind
23 command command.Command25 command command.Command
24 session sessionPayload26 session sessionPayload
27+ route routePayload
25 }28 }
26 29
27 type sessionPayload struct {30 type sessionPayload struct {
@@ -30,6 +33,11 @@ type sessionPayload struct {
30 sessionID domain.SessionID33 sessionID domain.SessionID
31 }34 }
32 35
36+type routePayload struct {
37+ label string
38+ action ports.RouteNavigationAction
39+}
40+
33 // NewCommandResult creates a static command palette target.41 // NewCommandResult creates a static command palette target.
34 func NewCommandResult(cmd command.Command) Result {42 func NewCommandResult(cmd command.Command) Result {
35 return Result{kind: ResultKindCommand, command: cmd}43 return Result{kind: ResultKindCommand, command: cmd}
@@ -51,12 +59,20 @@ func NewStoppedSessionResult(name string, createdAt time.Time) Result {
51 }59 }
52 }60 }
53 61
62+// NewRecentRouteResult creates an immutable client-ledger route target.
63+func NewRecentRouteResult(label string, action ports.RouteNavigationAction) Result {
64+ return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}}
65+}
66+
54 func (r Result) Kind() ResultKind { return r.kind }67 func (r Result) Kind() ResultKind { return r.kind }
55 68
56 func (r Result) DisplayText() string {69 func (r Result) DisplayText() string {
57 if r.kind == ResultKindCommand {70 if r.kind == ResultKindCommand {
58 return r.command.Code71 return r.command.Code
59 }72 }
73+ if r.kind == ResultKindRecentRoute {
74+ return activeSessionDisplayPrefix + r.route.label
75+ }
60 return r.sessionDisplayPrefix() + r.session.name76 return r.sessionDisplayPrefix() + r.session.name
61 }77 }
62 78
@@ -78,12 +94,12 @@ func (r Result) Command() (command.Command, bool) {
78 94
79 // SessionName returns the session name only for session results.95 // SessionName returns the session name only for session results.
80 func (r Result) SessionName() (string, bool) {96 func (r Result) SessionName() (string, bool) {
81- return r.session.name, r.kind != ResultKindCommand97+ return r.session.name, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession
82 }98 }
83 99
84 // SessionCreatedAt returns the session creation time only for session results.100 // SessionCreatedAt returns the session creation time only for session results.
85 func (r Result) SessionCreatedAt() (time.Time, bool) {101 func (r Result) SessionCreatedAt() (time.Time, bool) {
86- return r.session.createdAt, r.kind != ResultKindCommand102+ return r.session.createdAt, r.kind == ResultKindActiveSession || r.kind == ResultKindStoppedSession
87 }103 }
88 104
89 // SessionID returns the lifecycle identity only for active session results.105 // SessionID returns the lifecycle identity only for active session results.
@@ -91,6 +107,12 @@ func (r Result) SessionID() (domain.SessionID, bool) {
91 return r.session.sessionID, r.kind == ResultKindActiveSession107 return r.session.sessionID, r.kind == ResultKindActiveSession
92 }108 }
93 109
110+// RouteNavigationAction returns the exact client-ledger target only for a
111+// recent-route result.
112+func (r Result) RouteNavigationAction() (ports.RouteNavigationAction, bool) {
113+ return r.route.action, r.kind == ResultKindRecentRoute
114+}
115+
94 const (116 const (
95 activeSessionDisplayPrefix = "Switch to session "117 activeSessionDisplayPrefix = "Switch to session "
96 stoppedSessionDisplayPrefix = "Resume session "118 stoppedSessionDisplayPrefix = "Resume session "
M internal/usecase/palette/result_test.go +15 -0
@@ -5,6 +5,7 @@ import (
55 "time"
66
77 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
89 "github.com/bnema/vev/internal/usecase/command"
910 "github.com/stretchr/testify/require"
1011 )
@@ -48,6 +49,20 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
4849 require.False(t, ok)
4950 }
5051
52+func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) {
53+ action := ports.RouteNavigationAction{SnapshotGeneration: 4, Key: 7, Generation: 3}
54+ result := NewRecentRouteResult("logs@edge", action)
55+
56+ require.Equal(t, ResultKindRecentRoute, result.Kind())
57+ require.Equal(t, "Switch to session logs@edge", result.DisplayText())
58+ require.Equal(t, "Switch to session logs@edge", result.SearchText())
59+ got, ok := result.RouteNavigationAction()
60+ require.True(t, ok)
61+ require.Equal(t, action, got)
62+ _, ok = result.SessionName()
63+ require.False(t, ok)
64+}
65+
5166 func TestSessionResultsSearchDisplayedActionText(t *testing.T) {
5267 created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC)
5368 tests := []struct {
@@ -5,6 +5,7 @@ import (
5 "time"5 "time"
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/usecase/command"9 "github.com/bnema/vev/internal/usecase/command"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 )11 )
@@ -48,6 +49,20 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
48 require.False(t, ok)49 require.False(t, ok)
49 }50 }
50 51
52+func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) {
53+ action := ports.RouteNavigationAction{SnapshotGeneration: 4, Key: 7, Generation: 3}
54+ result := NewRecentRouteResult("logs@edge", action)
55+
56+ require.Equal(t, ResultKindRecentRoute, result.Kind())
57+ require.Equal(t, "Switch to session logs@edge", result.DisplayText())
58+ require.Equal(t, "Switch to session logs@edge", result.SearchText())
59+ got, ok := result.RouteNavigationAction()
60+ require.True(t, ok)
61+ require.Equal(t, action, got)
62+ _, ok = result.SessionName()
63+ require.False(t, ok)
64+}
65+
51 func TestSessionResultsSearchDisplayedActionText(t *testing.T) {66 func TestSessionResultsSearchDisplayedActionText(t *testing.T) {
52 created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC)67 created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC)
53 tests := []struct {68 tests := []struct {
M scripts/remote-picker-harness/main.go +1 -1
@@ -281,7 +281,7 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
281281 if err := sendRawInput(local, "\r"); err != nil {
282282 return fmt.Errorf("commit remote picker target: %w", err)
283283 }
284- if err := awaitOutputContains(ctx, local, remoteSession+" at remote"); err != nil {
284+ if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
285285 return fmt.Errorf("publish remote content on local transport: %w", err)
286286 }
287287 if !local.probe.contains(previewMarker) {
@@ -281,7 +281,7 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
281 if err := sendRawInput(local, "\r"); err != nil {281 if err := sendRawInput(local, "\r"); err != nil {
282 return fmt.Errorf("commit remote picker target: %w", err)282 return fmt.Errorf("commit remote picker target: %w", err)
283 }283 }
284- if err := awaitOutputContains(ctx, local, remoteSession+" at remote"); err != nil {284+ if err := awaitOutputContains(ctx, local, remoteSession+"@remote"); err != nil {
285 return fmt.Errorf("publish remote content on local transport: %w", err)285 return fmt.Errorf("publish remote content on local transport: %w", err)
286 }286 }
287 if !local.probe.contains(previewMarker) {287 if !local.probe.contains(previewMarker) {

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