detached fff724e

Merge pull request #197 from bnema/feat/demo-remote-sessions

feat(demo): showcase remote session workflows
commit
fff724e26e58244b66945b260dff629012049211
author
bnema · · 2026-08-11 10:25 UTC
committer
GitHub · · 2026-08-11 10:25 UTC
merge of
bd6fcc2 + 781dead · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
20 files · +257 -105
A .dockerignore +4 -0
@@ -0,0 +1,4 @@
1+.git
2+.worktrees
3+scripts/demo/out
4+docs/assets/demo.gif
@@ -0,0 +1,4 @@
1+.git
2+.worktrees
3+scripts/demo/out
4+docs/assets/demo.gif
M Makefile +3 -4
@@ -53,10 +53,9 @@ demo:
5353 -y -v error -i /out/demo.gif -i /out/border.png -i /out/mask.png \
5454 -filter_complex "[0:v][1:v]overlay=0:0[bordered];[2:v]format=gray[m];[bordered][m]alphamerge,split[a][b];[a]palettegen=reserve_transparent=1[p];[b][p]paletteuse=alpha_threshold=128" \
5555 /out/demo-masked.gif
56- # --lossy above 30 smears the near-identical darks of the window chrome
57- # (window bar vs terminal background) into visible banding, and the
58- # conserve-memory fallback for huge inputs roughly doubles the output.
59- docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 --lossy=30 -o /out/demo.gif /out/demo-masked.gif
56+ # Keep the final GIF lossless: the streaming FFmpeg pass has already
57+ # bounded memory, and preserving terminal chrome avoids color banding.
58+ docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 -o /out/demo.gif /out/demo-masked.gif
6059 # Temp files are owned by the container's subuid, so remove them from
6160 # inside a container rather than the host.
6261 docker run --rm --entrypoint sh -v "$(CURDIR)/scripts/demo/out:/out" vev-demo -c 'rm /out/demo-masked.gif /out/mask.png /out/border.png'
@@ -53,10 +53,9 @@ demo:
53 -y -v error -i /out/demo.gif -i /out/border.png -i /out/mask.png \53 -y -v error -i /out/demo.gif -i /out/border.png -i /out/mask.png \
54 -filter_complex "[0:v][1:v]overlay=0:0[bordered];[2:v]format=gray[m];[bordered][m]alphamerge,split[a][b];[a]palettegen=reserve_transparent=1[p];[b][p]paletteuse=alpha_threshold=128" \54 -filter_complex "[0:v][1:v]overlay=0:0[bordered];[2:v]format=gray[m];[bordered][m]alphamerge,split[a][b];[a]palettegen=reserve_transparent=1[p];[b][p]paletteuse=alpha_threshold=128" \
55 /out/demo-masked.gif55 /out/demo-masked.gif
56- # --lossy above 30 smears the near-identical darks of the window chrome56+ # Keep the final GIF lossless: the streaming FFmpeg pass has already
57- # (window bar vs terminal background) into visible banding, and the57+ # bounded memory, and preserving terminal chrome avoids color banding.
58- # conserve-memory fallback for huge inputs roughly doubles the output.58+ docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 -o /out/demo.gif /out/demo-masked.gif
59- docker run --rm --entrypoint gifsicle -v "$(CURDIR)/scripts/demo/out:/out" vev-demo --no-conserve-memory -O3 --lossy=30 -o /out/demo.gif /out/demo-masked.gif
60 # Temp files are owned by the container's subuid, so remove them from59 # Temp files are owned by the container's subuid, so remove them from
61 # inside a container rather than the host.60 # inside a container rather than the host.
62 docker run --rm --entrypoint sh -v "$(CURDIR)/scripts/demo/out:/out" vev-demo -c 'rm /out/demo-masked.gif /out/mask.png /out/border.png'61 docker run --rm --entrypoint sh -v "$(CURDIR)/scripts/demo/out:/out" vev-demo -c 'rm /out/demo-masked.gif /out/mask.png /out/border.png'
M README.md +9 -2
@@ -1,11 +1,18 @@
11 <h1 align="center">vev</h1>
22
3+<p align="center">
4+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="License: MIT"></a>
5+ <a href="https://github.com/bnema/vev/releases"><img src="https://img.shields.io/badge/platforms-Linux%20%7C%20macOS-blue?style=flat-square" alt="Platforms: Linux and macOS"></a>
6+ <a href="https://github.com/bnema/vev/commits/main"><img src="https://badgen.net/github/last-commit/bnema/vev/main?icon=github" alt="Last commit"></a>
7+ <a href="https://github.com/bnema/vev/stargazers"><img src="https://badgen.net/github/stars/bnema/vev?icon=github" alt="GitHub stars"></a>
8+</p>
9+
310 <p align="center"><em>Norwegian: to weave</em></p>
411
512 <p align="center">A terminal multiplexer for Linux and macOS.<br>
613 Like tmux, minus the prefix key. Like mosh, minus mosh. One binary.</p>
714
8-<p align="center"><img src="docs/assets/demo.gif" alt="vev demo: named sessions, splits, stacked panes, floating window, agent bell notification, detach and re-attach" width="800"></p>
15+<p align="center"><img src="docs/assets/demo.gif" alt="vev demo: local and remote sessions, splits, stacked panes, floating window, notifications, detach and re-attach" width="800"></p>
916
1017 ---
1118
@@ -133,5 +140,5 @@ Optional file at `~/.config/vev/config`, reloaded live. Themes, key bindings, pa
133140 make test # go test ./... -race
134141 make lint # goimports check, go vet
135142 make mocks # regenerate mocks
136-make demo # regenerate docs/assets/demo.gif (needs Docker and ~/.claude credentials)
143+make demo # regenerate docs/assets/demo.gif (needs Docker)
137144 ```
@@ -1,11 +1,18 @@
1 <h1 align="center">vev</h1>1 <h1 align="center">vev</h1>
2 2
3+<p align="center">
4+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="License: MIT"></a>
5+ <a href="https://github.com/bnema/vev/releases"><img src="https://img.shields.io/badge/platforms-Linux%20%7C%20macOS-blue?style=flat-square" alt="Platforms: Linux and macOS"></a>
6+ <a href="https://github.com/bnema/vev/commits/main"><img src="https://badgen.net/github/last-commit/bnema/vev/main?icon=github" alt="Last commit"></a>
7+ <a href="https://github.com/bnema/vev/stargazers"><img src="https://badgen.net/github/stars/bnema/vev?icon=github" alt="GitHub stars"></a>
8+</p>
9+
3 <p align="center"><em>Norwegian: to weave</em></p>10 <p align="center"><em>Norwegian: to weave</em></p>
4 11
5 <p align="center">A terminal multiplexer for Linux and macOS.<br>12 <p align="center">A terminal multiplexer for Linux and macOS.<br>
6 Like tmux, minus the prefix key. Like mosh, minus mosh. One binary.</p>13 Like tmux, minus the prefix key. Like mosh, minus mosh. One binary.</p>
7 14
8-<p align="center"><img src="docs/assets/demo.gif" alt="vev demo: named sessions, splits, stacked panes, floating window, agent bell notification, detach and re-attach" width="800"></p>15+<p align="center"><img src="docs/assets/demo.gif" alt="vev demo: local and remote sessions, splits, stacked panes, floating window, notifications, detach and re-attach" width="800"></p>
9 16
10 ---17 ---
11 18
@@ -133,5 +140,5 @@ Optional file at `~/.config/vev/config`, reloaded live. Themes, key bindings, pa
133 make test # go test ./... -race140 make test # go test ./... -race
134 make lint # goimports check, go vet141 make lint # goimports check, go vet
135 make mocks # regenerate mocks142 make mocks # regenerate mocks
136-make demo # regenerate docs/assets/demo.gif (needs Docker and ~/.claude credentials)143+make demo # regenerate docs/assets/demo.gif (needs Docker)
137 ```144 ```
M docs/assets/demo.gif +0 -0 binary file: no text diff

binary file: no text diff

M internal/usecase/client/routes.go +4 -0
@@ -549,6 +549,10 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachT
549549 }
550550 request := cloneAttachRequest(entry.request)
551551 request.Intent = target.Intent
552+ // The active dialer already reaches this daemon. Retaining the route's
553+ // original discovery target would make its Hello request a second,
554+ // incorrect remote handoff instead of a same-peer session switch.
555+ request.RemoteTarget = nil
552556 request.EnvironmentPolicy = target.EnvironmentPolicy
553557 request.ExactTarget = target.ExactTarget
554558 if target.ExactTarget == nil {
@@ -549,6 +549,10 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target ports.AttachT
549 }549 }
550 request := cloneAttachRequest(entry.request)550 request := cloneAttachRequest(entry.request)
551 request.Intent = target.Intent551 request.Intent = target.Intent
552+ // The active dialer already reaches this daemon. Retaining the route's
553+ // original discovery target would make its Hello request a second,
554+ // incorrect remote handoff instead of a same-peer session switch.
555+ request.RemoteTarget = nil
552 request.EnvironmentPolicy = target.EnvironmentPolicy556 request.EnvironmentPolicy = target.EnvironmentPolicy
553 request.ExactTarget = target.ExactTarget557 request.ExactTarget = target.ExactTarget
554 if target.ExactTarget == nil {558 if target.ExactTarget == nil {
M internal/usecase/client/routes_test.go +33 -0
@@ -148,6 +148,39 @@ func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) {
148148 }
149149 }
150150
151+func TestRouteLedgerSamePeerHandoffDropsOriginalRemoteTarget(t *testing.T) {
152+ ledger := newRouteLedger()
153+ work := routeTestCandidate(1, ports.RouteOriginRemote)
154+ work.originKey = "remote"
155+ work.request.Remote = true
156+ work.request.OriginKey = "remote"
157+ work.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
158+ work.request.RemoteTarget = &domain.RemoteSessionTarget{
159+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: work.target.LifecycleID,
160+ SessionName: work.target.SessionName, LiveTabID: "tab-work",
161+ }
162+ _, err := ledger.commit(work)
163+ require.NoError(t, err)
164+
165+ agents := routeTestCandidate(2, ports.RouteOriginRemote)
166+ agents.originKey = "remote"
167+ agents.request.Remote = true
168+ agents.request.OriginKey = "remote"
169+ agents.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
170+
171+ request := ledger.samePeerHandoff(agents.request, ports.AttachTarget{
172+ Session: work.target.SessionName,
173+ Intent: ports.IntentAttach,
174+ ExactTarget: &work.target,
175+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
176+ })
177+
178+ require.True(t, request.Remote)
179+ require.Equal(t, ports.RouteOriginRemote, request.Origin)
180+ require.Equal(t, "remote", request.OriginKey)
181+ require.Nil(t, request.RemoteTarget)
182+}
183+
151184 func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) {
152185 ledger := newRouteLedger()
153186 first := routeTestCandidate(0, ports.RouteOriginRemote)
@@ -148,6 +148,39 @@ func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) {
148 }148 }
149 }149 }
150 150
151+func TestRouteLedgerSamePeerHandoffDropsOriginalRemoteTarget(t *testing.T) {
152+ ledger := newRouteLedger()
153+ work := routeTestCandidate(1, ports.RouteOriginRemote)
154+ work.originKey = "remote"
155+ work.request.Remote = true
156+ work.request.OriginKey = "remote"
157+ work.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
158+ work.request.RemoteTarget = &domain.RemoteSessionTarget{
159+ Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: work.target.LifecycleID,
160+ SessionName: work.target.SessionName, LiveTabID: "tab-work",
161+ }
162+ _, err := ledger.commit(work)
163+ require.NoError(t, err)
164+
165+ agents := routeTestCandidate(2, ports.RouteOriginRemote)
166+ agents.originKey = "remote"
167+ agents.request.Remote = true
168+ agents.request.OriginKey = "remote"
169+ agents.request.EnvironmentPolicy = ports.EnvironmentPolicyDaemonOwned
170+
171+ request := ledger.samePeerHandoff(agents.request, ports.AttachTarget{
172+ Session: work.target.SessionName,
173+ Intent: ports.IntentAttach,
174+ ExactTarget: &work.target,
175+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
176+ })
177+
178+ require.True(t, request.Remote)
179+ require.Equal(t, ports.RouteOriginRemote, request.Origin)
180+ require.Equal(t, "remote", request.OriginKey)
181+ require.Nil(t, request.RemoteTarget)
182+}
183+
151 func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) {184 func TestRouteAttentionSubscriptionIncludesOnlyActiveOriginRoutes(t *testing.T) {
152 ledger := newRouteLedger()185 ledger := newRouteLedger()
153 first := routeTestCandidate(0, ports.RouteOriginRemote)186 first := routeTestCandidate(0, ports.RouteOriginRemote)
M internal/usecase/daemon/client_frame_routing.go +3 -2
@@ -132,10 +132,11 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
132132 d.log.Warn("malformed recent route snapshot", "err", derr)
133133 break
134134 }
135- if !token.ac.setRouteSnapshot(snapshot) {
135+ replayIdentity := token.ac.setRouteSnapshot(snapshot)
136+ d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-snapshot")
137+ if !replayIdentity {
136138 break
137139 }
138- d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-snapshot")
139140 if err := d.sendCommittedRouteIdentityForAttachment(token); err != nil {
140141 d.detachOnAttachmentSendError(token, token.transport.transport)
141142 }
@@ -132,10 +132,11 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
132 d.log.Warn("malformed recent route snapshot", "err", derr)132 d.log.Warn("malformed recent route snapshot", "err", derr)
133 break133 break
134 }134 }
135- if !token.ac.setRouteSnapshot(snapshot) {135+ replayIdentity := token.ac.setRouteSnapshot(snapshot)
136+ d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-snapshot")
137+ if !replayIdentity {
136 break138 break
137 }139 }
138- d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-snapshot")
139 if err := d.sendCommittedRouteIdentityForAttachment(token); err != nil {140 if err := d.sendCommittedRouteIdentityForAttachment(token); err != nil {
140 d.detachOnAttachmentSendError(token, token.transport.transport)141 d.detachOnAttachmentSendError(token, token.transport.transport)
141 }142 }
M internal/usecase/daemon/palette.go +10 -3
@@ -107,11 +107,18 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
107107 continue
108108 }
109109 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 {
110+ sameOrigin := (entry.Kind == ports.RouteKindLocal && currentRemoteOrigin == "") ||
111+ (entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin)
112+ if sameOrigin {
113113 continue
114114 }
115+ if entry.Kind == ports.RouteKindLocal && currentRemoteOrigin != "" {
116+ label = formatRecentRouteName(recentRoutePresentation{
117+ name: entry.Name,
118+ kind: recentRouteLocal,
119+ ephemeral: entry.Ephemeral,
120+ }, true)
121+ }
115122 }
116123 results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{
117124 SnapshotGeneration: routeSnapshot.Generation,
@@ -107,11 +107,18 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
107 continue107 continue
108 }108 }
109 if _, represented := localNames[entry.Name]; represented {109 if _, represented := localNames[entry.Name]; represented {
110- localRoute := entry.Kind == ports.RouteKindLocal110+ sameOrigin := (entry.Kind == ports.RouteKindLocal && currentRemoteOrigin == "") ||
111- currentRemoteRoute := entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin111+ (entry.Kind == ports.RouteKindRemote && currentRemoteOrigin != "" && entry.HostLabel == currentRemoteOrigin)
112- if localRoute || currentRemoteRoute {112+ if sameOrigin {
113 continue113 continue
114 }114 }
115+ if entry.Kind == ports.RouteKindLocal && currentRemoteOrigin != "" {
116+ label = formatRecentRouteName(recentRoutePresentation{
117+ name: entry.Name,
118+ kind: recentRouteLocal,
119+ ephemeral: entry.Ephemeral,
120+ }, true)
121+ }
115 }122 }
116 results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{123 results = append(results, palette.NewRecentRouteResult(label, ports.RouteNavigationAction{
117 SnapshotGeneration: routeSnapshot.Generation,124 SnapshotGeneration: routeSnapshot.Generation,
M internal/usecase/daemon/palette_test.go +7 -2
@@ -438,13 +438,18 @@ func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T)
438438 want []string
439439 }{
440440 {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}},
441+ {name: "remote daemon keeps distinct local route", currentOrigin: "remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@local"}},
441442 {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}},
442443 } {
443444 t.Run(test.name, func(t *testing.T) {
444- results := d.paletteResults(current, nil, snapshot, test.currentOrigin)
445+ testSnapshot := snapshot
446+ if test.name == "remote daemon keeps distinct local route" {
447+ testSnapshot.Entries = append(testSnapshot.Entries, ports.RecentRouteEntry{Key: 3, Generation: 1, Name: "remote-manual", Kind: ports.RouteKindLocal})
448+ }
449+ results := d.paletteResults(current, nil, testSnapshot, test.currentOrigin)
445450 var matching []string
446451 for _, result := range results {
447- if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@remote" {
452+ if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@local" || result.DisplayText() == "Switch to session remote-manual@remote" {
448453 matching = append(matching, result.DisplayText())
449454 }
450455 }
@@ -438,13 +438,18 @@ func TestPaletteResultsDeduplicateRoutesLocalToCurrentRemoteDaemon(t *testing.T)
438 want []string438 want []string
439 }{439 }{
440 {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}},440 {name: "remote daemon", currentOrigin: "remote", want: []string{"Switch to session remote-manual"}},
441+ {name: "remote daemon keeps distinct local route", currentOrigin: "remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@local"}},
441 {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}},442 {name: "home daemon keeps distinct remote", want: []string{"Switch to session remote-manual", "Switch to session remote-manual@remote"}},
442 } {443 } {
443 t.Run(test.name, func(t *testing.T) {444 t.Run(test.name, func(t *testing.T) {
444- results := d.paletteResults(current, nil, snapshot, test.currentOrigin)445+ testSnapshot := snapshot
446+ if test.name == "remote daemon keeps distinct local route" {
447+ testSnapshot.Entries = append(testSnapshot.Entries, ports.RecentRouteEntry{Key: 3, Generation: 1, Name: "remote-manual", Kind: ports.RouteKindLocal})
448+ }
449+ results := d.paletteResults(current, nil, testSnapshot, test.currentOrigin)
445 var matching []string450 var matching []string
446 for _, result := range results {451 for _, result := range results {
447- if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@remote" {452+ if result.DisplayText() == "Switch to session remote-manual" || result.DisplayText() == "Switch to session remote-manual@local" || result.DisplayText() == "Switch to session remote-manual@remote" {
448 matching = append(matching, result.DisplayText())453 matching = append(matching, result.DisplayText())
449 }454 }
450 }455 }
M internal/usecase/daemon/picker.go +0 -5
@@ -896,11 +896,6 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
896896 if target.RemoteKey != nil {
897897 return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)
898898 }
899- token.effect.bindActionEnd(d, action)
900- token.effect.End()
901- if !token.attachmentCurrent() {
902- return errAttachmentTransition
903- }
904899 return d.sendLocalAttachTargetForAttachment(token, target, guard, action)
905900 }
906901
@@ -896,11 +896,6 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
896 if target.RemoteKey != nil {896 if target.RemoteKey != nil {
897 return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)897 return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)
898 }898 }
899- token.effect.bindActionEnd(d, action)
900- token.effect.End()
901- if !token.attachmentCurrent() {
902- return errAttachmentTransition
903- }
904 return d.sendLocalAttachTargetForAttachment(token, target, guard, action)899 return d.sendLocalAttachTargetForAttachment(token, target, guard, action)
905 }900 }
906 901
M internal/usecase/daemon/recent_route_snapshot_test.go +13 -0
@@ -62,6 +62,19 @@ func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) {
6262 }
6363 }
6464
65+func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) {
66+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
67+ token := sess.attachmentToken(ac, ac.transport())
68+ ac.publishAttachmentCapability(token)
69+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
70+
71+ payload, err := ports.MarshalRecentRouteSnapshot(ports.RecentRouteSnapshot{Generation: 2})
72+ require.NoError(t, err)
73+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload}))
74+
75+ awaitFrame(t, sends, ports.MsgOutput)
76+}
77+
6578 func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) {
6679 _, _, ac, _ := newManualSessionWithPTYs(t, nil)
6780 entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}}
@@ -62,6 +62,19 @@ func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) {
62 }62 }
63 }63 }
64 64
65+func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) {
66+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
67+ token := sess.attachmentToken(ac, ac.transport())
68+ ac.publishAttachmentCapability(token)
69+ ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
70+
71+ payload, err := ports.MarshalRecentRouteSnapshot(ports.RecentRouteSnapshot{Generation: 2})
72+ require.NoError(t, err)
73+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgRecentRouteSnapshot, Payload: payload}))
74+
75+ awaitFrame(t, sends, ports.MsgOutput)
76+}
77+
65 func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) {78 func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) {
66 _, _, ac, _ := newManualSessionWithPTYs(t, nil)79 _, _, ac, _ := newManualSessionWithPTYs(t, nil)
67 entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}}80 entries := []ports.RecentRouteEntry{{Key: 1, Generation: 2, Name: "before", Kind: ports.RouteKindLocal}}
M internal/usecase/daemon/remote_attach_test.go +1 -1
@@ -28,7 +28,7 @@ func TestLocalPickerHandoffCarriesExactLifecycle(t *testing.T) {
2828 token.effect = effect
2929 defer effect.End()
3030
31- require.NoError(t, d.sendLocalAttachTargetForAttachment(token, picker.Target{Session: target.id}, sessionHandoffGuard{}, "picker-select"))
31+ require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: target.id}, sessionHandoffGuard{}, "picker-select"))
3232 frame := receiveRemotePicker(t, sends, "local attach target")
3333 got, err := ports.UnmarshalAttachTarget(frame.Payload)
3434 require.NoError(t, err)
@@ -28,7 +28,7 @@ func TestLocalPickerHandoffCarriesExactLifecycle(t *testing.T) {
28 token.effect = effect28 token.effect = effect
29 defer effect.End()29 defer effect.End()
30 30
31- require.NoError(t, d.sendLocalAttachTargetForAttachment(token, picker.Target{Session: target.id}, sessionHandoffGuard{}, "picker-select"))31+ require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: target.id}, sessionHandoffGuard{}, "picker-select"))
32 frame := receiveRemotePicker(t, sends, "local attach target")32 frame := receiveRemotePicker(t, sends, "local attach target")
33 got, err := ports.UnmarshalAttachTarget(frame.Payload)33 got, err := ports.UnmarshalAttachTarget(frame.Payload)
34 require.NoError(t, err)34 require.NoError(t, err)
M internal/usecase/daemon/session_rename_purge_test.go +19 -0
@@ -65,6 +65,25 @@ func TestRenameDefersCommittedRouteIdentityUntilFirstRouteSnapshot(t *testing.T)
6565 require.False(t, identity.Ephemeral)
6666 }
6767
68+func TestEphemeralPromotionPreservesIncarnation(t *testing.T) {
69+ p, release := newBlockingPTY(t)
70+ defer release()
71+ store, state := newMockStore(t)
72+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
73+ WithStore(t, store)(d)
74+
75+ sess, err := createSessionForTest(d, "0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
76+ require.NoError(t, err)
77+ sess.mu.Lock()
78+ original := sess.incarnation
79+ sess.mu.Unlock()
80+ require.NotZero(t, original)
81+
82+ require.NoError(t, d.renameSession(sess, "local"))
83+ require.Equal(t, original, sess.incarnation)
84+ require.Equal(t, original, state.record(t, "local").IncarnationID)
85+}
86+
6887 func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {
6988 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
7089 repository := &retryablePurgeRepository{}
@@ -65,6 +65,25 @@ func TestRenameDefersCommittedRouteIdentityUntilFirstRouteSnapshot(t *testing.T)
65 require.False(t, identity.Ephemeral)65 require.False(t, identity.Ephemeral)
66 }66 }
67 67
68+func TestEphemeralPromotionPreservesIncarnation(t *testing.T) {
69+ p, release := newBlockingPTY(t)
70+ defer release()
71+ store, state := newMockStore(t)
72+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
73+ WithStore(t, store)(d)
74+
75+ sess, err := createSessionForTest(d, "0", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv)
76+ require.NoError(t, err)
77+ sess.mu.Lock()
78+ original := sess.incarnation
79+ sess.mu.Unlock()
80+ require.NotZero(t, original)
81+
82+ require.NoError(t, d.renameSession(sess, "local"))
83+ require.Equal(t, original, sess.incarnation)
84+ require.Equal(t, original, state.record(t, "local").IncarnationID)
85+}
86+
68 func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {87 func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {
69 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})88 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
70 repository := &retryablePurgeRepository{}89 repository := &retryablePurgeRepository{}
M internal/usecase/recovery/coordinator.go +6 -4
@@ -115,11 +115,13 @@ func (c *Coordinator) Create(ctx context.Context, record domain.CatalogueRecord)
115115 } else if exists {
116116 return domain.CatalogueRecord{}, fmt.Errorf("recovery: session %q already exists", record.Name)
117117 }
118- id, err := domain.NewIncarnationID(c.random)
119- if err != nil {
120- return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err)
118+ if record.IncarnationID == (domain.IncarnationID{}) {
119+ id, err := domain.NewIncarnationID(c.random)
120+ if err != nil {
121+ return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err)
122+ }
123+ record.IncarnationID = id
121124 }
122- record.IncarnationID = id
123125 record.Committed = nil
124126 record.DegradedReason = ""
125127 if err := record.Validate(); err != nil {
@@ -115,11 +115,13 @@ func (c *Coordinator) Create(ctx context.Context, record domain.CatalogueRecord)
115 } else if exists {115 } else if exists {
116 return domain.CatalogueRecord{}, fmt.Errorf("recovery: session %q already exists", record.Name)116 return domain.CatalogueRecord{}, fmt.Errorf("recovery: session %q already exists", record.Name)
117 }117 }
118- id, err := domain.NewIncarnationID(c.random)118+ if record.IncarnationID == (domain.IncarnationID{}) {
119- if err != nil {119+ id, err := domain.NewIncarnationID(c.random)
120- return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err)120+ if err != nil {
121+ return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err)
122+ }
123+ record.IncarnationID = id
121 }124 }
122- record.IncarnationID = id
123 record.Committed = nil125 record.Committed = nil
124 record.DegradedReason = ""126 record.DegradedReason = ""
125 if err := record.Validate(); err != nil {127 if err := record.Validate(); err != nil {
M scripts/demo/Dockerfile +23 -9
@@ -7,23 +7,37 @@ COPY . .
77 RUN CGO_ENABLED=0 go build -o /out/vev .
88
99 FROM archlinux:latest
10-# The base image ships a pacman.conf that skips extracting man/info pages
11-# (NoExtract) to stay small; the demo wants a real `man ls`, so drop that
12-# rule and reinstall coreutils to pull its man page onto disk.
13-RUN pacman -Sy --noconfirm archlinux-keyring \
10+RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \
11+ pacman -Sy --noconfirm archlinux-keyring \
1412 && sed -i '/^NoExtract.*usr\/share\/man/d' /etc/pacman.conf \
1513 && pacman -Syu --noconfirm \
1614 vhs ttyd ffmpeg chromium \
1715 htop fastfetch gifsicle imagemagick \
1816 eza neovim man-db man-pages coreutils \
19- nodejs npm ncurses \
20- ttf-jetbrains-mono-nerd \
21- && pacman -Scc --noconfirm
22-RUN npm install -g --allow-scripts=@anthropic-ai/claude-code @anthropic-ai/claude-code
17+ ncurses openssh util-linux \
18+ ttf-jetbrains-mono-nerd
2319 RUN useradd -m -u 1000 demo
2420 COPY --from=build /out/vev /usr/local/bin/vev
2521 COPY scripts/demo/ticker.sh /usr/local/bin/ticker
26-RUN chmod +x /usr/local/bin/ticker
22+COPY scripts/demo/demo-shell.sh /usr/local/bin/demo-shell
23+RUN chmod +x /usr/local/bin/ticker /usr/local/bin/demo-shell \
24+ && install -d -o demo -g demo -m 0700 /home/demo/.ssh \
25+ && install -d -m 0755 /run/sshd \
26+ && ssh-keygen -A \
27+ && ssh-keygen -q -t ed25519 -N '' -C vev-demo -f /home/demo/.ssh/id_ed25519 \
28+ && cp /home/demo/.ssh/id_ed25519.pub /home/demo/.ssh/authorized_keys \
29+ && { printf 'remote '; cut -d' ' -f1-2 /etc/ssh/ssh_host_ed25519_key.pub; } > /home/demo/.ssh/known_hosts \
30+ && printf '%s\n' \
31+ 'Host remote' \
32+ ' User demo' \
33+ ' IdentityFile ~/.ssh/id_ed25519' \
34+ ' IdentitiesOnly yes' \
35+ ' StrictHostKeyChecking yes' \
36+ ' UserKnownHostsFile ~/.ssh/known_hosts' \
37+ ' LogLevel ERROR' \
38+ > /home/demo/.ssh/config \
39+ && chmod 0600 /home/demo/.ssh/id_ed25519 /home/demo/.ssh/authorized_keys /home/demo/.ssh/known_hosts /home/demo/.ssh/config \
40+ && chown -R demo:demo /home/demo/.ssh
2741 USER demo
2842 WORKDIR /home/demo
2943 ENV VHS_NO_SANDBOX=true
@@ -7,23 +7,37 @@ COPY . .
7 RUN CGO_ENABLED=0 go build -o /out/vev .7 RUN CGO_ENABLED=0 go build -o /out/vev .
8 8
9 FROM archlinux:latest9 FROM archlinux:latest
10-# The base image ships a pacman.conf that skips extracting man/info pages10+RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \
11-# (NoExtract) to stay small; the demo wants a real `man ls`, so drop that11+ pacman -Sy --noconfirm archlinux-keyring \
12-# rule and reinstall coreutils to pull its man page onto disk.
13-RUN pacman -Sy --noconfirm archlinux-keyring \
14 && sed -i '/^NoExtract.*usr\/share\/man/d' /etc/pacman.conf \12 && sed -i '/^NoExtract.*usr\/share\/man/d' /etc/pacman.conf \
15 && pacman -Syu --noconfirm \13 && pacman -Syu --noconfirm \
16 vhs ttyd ffmpeg chromium \14 vhs ttyd ffmpeg chromium \
17 htop fastfetch gifsicle imagemagick \15 htop fastfetch gifsicle imagemagick \
18 eza neovim man-db man-pages coreutils \16 eza neovim man-db man-pages coreutils \
19- nodejs npm ncurses \17+ ncurses openssh util-linux \
20- ttf-jetbrains-mono-nerd \18+ ttf-jetbrains-mono-nerd
21- && pacman -Scc --noconfirm
22-RUN npm install -g --allow-scripts=@anthropic-ai/claude-code @anthropic-ai/claude-code
23 RUN useradd -m -u 1000 demo19 RUN useradd -m -u 1000 demo
24 COPY --from=build /out/vev /usr/local/bin/vev20 COPY --from=build /out/vev /usr/local/bin/vev
25 COPY scripts/demo/ticker.sh /usr/local/bin/ticker21 COPY scripts/demo/ticker.sh /usr/local/bin/ticker
26-RUN chmod +x /usr/local/bin/ticker22+COPY scripts/demo/demo-shell.sh /usr/local/bin/demo-shell
23+RUN chmod +x /usr/local/bin/ticker /usr/local/bin/demo-shell \
24+ && install -d -o demo -g demo -m 0700 /home/demo/.ssh \
25+ && install -d -m 0755 /run/sshd \
26+ && ssh-keygen -A \
27+ && ssh-keygen -q -t ed25519 -N '' -C vev-demo -f /home/demo/.ssh/id_ed25519 \
28+ && cp /home/demo/.ssh/id_ed25519.pub /home/demo/.ssh/authorized_keys \
29+ && { printf 'remote '; cut -d' ' -f1-2 /etc/ssh/ssh_host_ed25519_key.pub; } > /home/demo/.ssh/known_hosts \
30+ && printf '%s\n' \
31+ 'Host remote' \
32+ ' User demo' \
33+ ' IdentityFile ~/.ssh/id_ed25519' \
34+ ' IdentitiesOnly yes' \
35+ ' StrictHostKeyChecking yes' \
36+ ' UserKnownHostsFile ~/.ssh/known_hosts' \
37+ ' LogLevel ERROR' \
38+ > /home/demo/.ssh/config \
39+ && chmod 0600 /home/demo/.ssh/id_ed25519 /home/demo/.ssh/authorized_keys /home/demo/.ssh/known_hosts /home/demo/.ssh/config \
40+ && chown -R demo:demo /home/demo/.ssh
27 USER demo41 USER demo
28 WORKDIR /home/demo42 WORKDIR /home/demo
29 ENV VHS_NO_SANDBOX=true43 ENV VHS_NO_SANDBOX=true
D scripts/demo/claude-settings.json +0 -6
@@ -1,6 +0,0 @@
1-{
2- "preferredNotifChannel": "terminal_bell",
3- "permissions": {
4- "allow": ["Bash(sleep:*)"]
5- }
6-}
@@ -1,6 +0,0 @@
1-{
2- "preferredNotifChannel": "terminal_bell",
3- "permissions": {
4- "allow": ["Bash(sleep:*)"]
5- }
6-}
A scripts/demo/compose.yaml +17 -0
@@ -0,0 +1,17 @@
1+name: vev-demo
2+
3+services:
4+ remote:
5+ image: vev-demo
6+ hostname: remote
7+ user: root
8+ entrypoint: ["/usr/bin/sshd", "-D", "-e"]
9+
10+ client:
11+ image: vev-demo
12+ depends_on:
13+ - remote
14+ volumes:
15+ - ../../scripts/demo:/tape:ro
16+ - ../../scripts/demo/out:/home/demo/out
17+ - ../../scripts/demo/out/local:/home/demo/.local
@@ -0,0 +1,17 @@
1+name: vev-demo
2+
3+services:
4+ remote:
5+ image: vev-demo
6+ hostname: remote
7+ user: root
8+ entrypoint: ["/usr/bin/sshd", "-D", "-e"]
9+
10+ client:
11+ image: vev-demo
12+ depends_on:
13+ - remote
14+ volumes:
15+ - ../../scripts/demo:/tape:ro
16+ - ../../scripts/demo/out:/home/demo/out
17+ - ../../scripts/demo/out/local:/home/demo/.local
A scripts/demo/demo-shell.sh +26 -0
@@ -0,0 +1,26 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+session=${VEV:-}
4+unset VEV
5+
6+case "${VEV_DEMO_SCENE:-}" in
7+remote-deploys)
8+ work_marker=/tmp/vev-demo-work-shown
9+ if [[ $session != session=work,* || -e $work_marker ]]; then
10+ exec /bin/bash --noprofile --norc
11+ fi
12+ : >"$work_marker"
13+ printf '\033]0;deploys · remote\007'
14+ printf '\n\033[1;36mDeploy queue · remote\033[0m\n\n'
15+ printf ' \033[32m✓\033[0m api-gateway production 3m ago\n'
16+ printf ' \033[32m✓\033[0m web-console production 7m ago\n'
17+ printf ' \033[33m•\033[0m worker-pool staging running\n\n'
18+ printf 'Use the session picker to jump between this host and local work.\n\n'
19+ exec /bin/bash --noprofile --norc
20+ ;;
21+remote-ticker)
22+ exec ticker
23+ ;;
24+esac
25+
26+exec /bin/bash --noprofile --norc
@@ -0,0 +1,26 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+session=${VEV:-}
4+unset VEV
5+
6+case "${VEV_DEMO_SCENE:-}" in
7+remote-deploys)
8+ work_marker=/tmp/vev-demo-work-shown
9+ if [[ $session != session=work,* || -e $work_marker ]]; then
10+ exec /bin/bash --noprofile --norc
11+ fi
12+ : >"$work_marker"
13+ printf '\033]0;deploys · remote\007'
14+ printf '\n\033[1;36mDeploy queue · remote\033[0m\n\n'
15+ printf ' \033[32m✓\033[0m api-gateway production 3m ago\n'
16+ printf ' \033[32m✓\033[0m web-console production 7m ago\n'
17+ printf ' \033[33m•\033[0m worker-pool staging running\n\n'
18+ printf 'Use the session picker to jump between this host and local work.\n\n'
19+ exec /bin/bash --noprofile --norc
20+ ;;
21+remote-ticker)
22+ exec ticker
23+ ;;
24+esac
25+
26+exec /bin/bash --noprofile --norc
M scripts/demo/demo.tape +36 -26
@@ -20,13 +20,34 @@ Set WaitTimeout 30s
2020 # (70-160ms) and short hesitations at word boundaries so the rhythm
2121 # reads irregular/human; palette codes (CNS/SSP/…) stay single-speed.
2222
23-# ── Session "work": a colourful split (eza + ticker) ─────
24-Type@90ms "vev "
25-Sleep 160ms
26-Type@75ms "new "
27-Type@140ms "work"
23+# ── Local and remote history, shown immediately ──────────
24+Type@180ms "vev"
25+Sleep 600ms
2826 Enter
2927 Sleep 1.1s
28+# Keep the initial local shell so the final detach/attach cycle is meaningful.
29+Alt+" "
30+Sleep 550ms
31+Type "RNS"
32+Enter
33+Sleep 550ms
34+Backspace
35+Type@95ms "local"
36+Enter
37+Sleep 1s
38+
39+# The picker starts with the local shell plus two ready remote sessions.
40+Alt+" "
41+Sleep 550ms
42+Type "SSP"
43+Enter
44+Sleep 1.5s
45+Down 2
46+Sleep 700ms
47+Enter
48+Sleep 2.5s
49+
50+# The selected work session belongs to the remote container.
3051 Type@85ms "eza "
3152 Sleep 200ms
3253 Type@75ms "-la "
@@ -44,9 +65,7 @@ Enter
4465 Sleep 1.5s
4566
4667 # ── Session "agents": launch the bell job early so its
47-# sleep overlaps the middle scenes. claude -p prints the
48-# answer but emits no bell of its own (verified), so the
49-# pane rings it with printf. ───────────────────────────
68+# sleep overlaps the middle scenes. ─────────────────────
5069 Alt+" "
5170 Sleep 550ms
5271 Type "CNS"
@@ -56,13 +75,9 @@ Type@95ms "agents"
5675 Enter
5776 Sleep 900ms
5877 Type@80ms "sleep "
59-Type@140ms "10 "
60-Sleep 170ms
61-Type@90ms "&& claude "
62-Type@150ms "-p "
63-Type@105ms `"say hello world" `
78+Type@140ms "10"
6479 Sleep 220ms
65-Type@85ms "&& printf "
80+Type@85ms " && printf "
6681 Type@130ms `'\a'`
6782 Enter
6883 Sleep 1s
@@ -148,35 +163,30 @@ Sleep 900ms
148163 Alt+a
149164 Sleep 1.9s
150165
151-# ── Session picker now lists all three sessions ──────────
166+# ── Session picker now lists local and remote sessions ───
152167 Alt+" "
153168 Sleep 550ms
154169 Type "SSP"
155170 Enter
156-Sleep 1.2s
157-Down
158-Sleep 450ms
159-Down
160-Sleep 450ms
161-Down
171+Sleep 1.5s
172+Escape
162173 Sleep 650ms
163-Enter
164-Sleep 1.2s
165174
166-# ── Detach / re-attach: three sessions survive, ticker on ─
175+# ── Detach / re-attach: local sessions survive, ticker on ─
167176 Alt+" "
168177 Sleep 550ms
169178 Type "DET"
170179 Enter
171180 Sleep 1s
172181 Type@90ms "vev "
173-Type@75ms "ls"
182+Type@75ms "ls "
183+Type@120ms "--all"
174184 Enter
175185 Sleep 1.8s
176186 Type@90ms "vev "
177187 Sleep 150ms
178188 Type@80ms "attach "
179-Type@140ms "work"
189+Type@140ms "local"
180190 Enter
181191 Sleep 2.2s
182192 Sleep 400ms
@@ -20,13 +20,34 @@ Set WaitTimeout 30s
20 # (70-160ms) and short hesitations at word boundaries so the rhythm20 # (70-160ms) and short hesitations at word boundaries so the rhythm
21 # reads irregular/human; palette codes (CNS/SSP/…) stay single-speed.21 # reads irregular/human; palette codes (CNS/SSP/…) stay single-speed.
22 22
23-# ── Session "work": a colourful split (eza + ticker) ─────23+# ── Local and remote history, shown immediately ──────────
24-Type@90ms "vev "24+Type@180ms "vev"
25-Sleep 160ms25+Sleep 600ms
26-Type@75ms "new "
27-Type@140ms "work"
28 Enter26 Enter
29 Sleep 1.1s27 Sleep 1.1s
28+# Keep the initial local shell so the final detach/attach cycle is meaningful.
29+Alt+" "
30+Sleep 550ms
31+Type "RNS"
32+Enter
33+Sleep 550ms
34+Backspace
35+Type@95ms "local"
36+Enter
37+Sleep 1s
38+
39+# The picker starts with the local shell plus two ready remote sessions.
40+Alt+" "
41+Sleep 550ms
42+Type "SSP"
43+Enter
44+Sleep 1.5s
45+Down 2
46+Sleep 700ms
47+Enter
48+Sleep 2.5s
49+
50+# The selected work session belongs to the remote container.
30 Type@85ms "eza "51 Type@85ms "eza "
31 Sleep 200ms52 Sleep 200ms
32 Type@75ms "-la "53 Type@75ms "-la "
@@ -44,9 +65,7 @@ Enter
44 Sleep 1.5s65 Sleep 1.5s
45 66
46 # ── Session "agents": launch the bell job early so its67 # ── Session "agents": launch the bell job early so its
47-# sleep overlaps the middle scenes. claude -p prints the68+# sleep overlaps the middle scenes. ─────────────────────
48-# answer but emits no bell of its own (verified), so the
49-# pane rings it with printf. ───────────────────────────
50 Alt+" "69 Alt+" "
51 Sleep 550ms70 Sleep 550ms
52 Type "CNS"71 Type "CNS"
@@ -56,13 +75,9 @@ Type@95ms "agents"
56 Enter75 Enter
57 Sleep 900ms76 Sleep 900ms
58 Type@80ms "sleep "77 Type@80ms "sleep "
59-Type@140ms "10 "78+Type@140ms "10"
60-Sleep 170ms
61-Type@90ms "&& claude "
62-Type@150ms "-p "
63-Type@105ms `"say hello world" `
64 Sleep 220ms79 Sleep 220ms
65-Type@85ms "&& printf "80+Type@85ms " && printf "
66 Type@130ms `'\a'`81 Type@130ms `'\a'`
67 Enter82 Enter
68 Sleep 1s83 Sleep 1s
@@ -148,35 +163,30 @@ Sleep 900ms
148 Alt+a163 Alt+a
149 Sleep 1.9s164 Sleep 1.9s
150 165
151-# ── Session picker now lists all three sessions ──────────166+# ── Session picker now lists local and remote sessions ───
152 Alt+" "167 Alt+" "
153 Sleep 550ms168 Sleep 550ms
154 Type "SSP"169 Type "SSP"
155 Enter170 Enter
156-Sleep 1.2s171+Sleep 1.5s
157-Down172+Escape
158-Sleep 450ms
159-Down
160-Sleep 450ms
161-Down
162 Sleep 650ms173 Sleep 650ms
163-Enter
164-Sleep 1.2s
165 174
166-# ── Detach / re-attach: three sessions survive, ticker on ─175+# ── Detach / re-attach: local sessions survive, ticker on ─
167 Alt+" "176 Alt+" "
168 Sleep 550ms177 Sleep 550ms
169 Type "DET"178 Type "DET"
170 Enter179 Enter
171 Sleep 1s180 Sleep 1s
172 Type@90ms "vev "181 Type@90ms "vev "
173-Type@75ms "ls"182+Type@75ms "ls "
183+Type@120ms "--all"
174 Enter184 Enter
175 Sleep 1.8s185 Sleep 1.8s
176 Type@90ms "vev "186 Type@90ms "vev "
177 Sleep 150ms187 Sleep 150ms
178 Type@80ms "attach "188 Type@80ms "attach "
179-Type@140ms "work"189+Type@140ms "local"
180 Enter190 Enter
181 Sleep 2.2s191 Sleep 2.2s
182 Sleep 400ms192 Sleep 400ms
M scripts/demo/run.sh +43 -41
@@ -3,50 +3,52 @@ set -euo pipefail
33 cd "$(dirname "$0")/../.."
44
55 TAPE="${1:-demo.tape}"
6+COMPOSE_FILE="scripts/demo/compose.yaml"
67 install -d -m 0777 scripts/demo/out
8+# Demo containers use a rootless subuid mapping, so reset generated state
9+# from the same container identity rather than relying on host ownership.
10+docker run --rm --entrypoint sh -v "$PWD/scripts/demo/out:/out" vev-demo -c 'rm -rf /out/local && mkdir -m 0777 /out/local'
11+
12+compose() {
13+ docker compose -f "$COMPOSE_FILE" "$@"
14+}
715
8-CLAUDE_TMP=$(mktemp -d)
9-# claude writes files into the temp HOME as the container's demo user, which
10-# rootless docker maps to a subuid the host cannot delete directly. Clean up
11-# through the container first, where the creating uid can unlink its own
12-# files, then remove the (now-empty) temp dir from the host. This never
13-# suppresses a leftover: if anything still remains, it's reported so a
14-# credentials-bearing directory doesn't silently linger in /tmp.
1516 cleanup() {
16- # Every step here is best-effort: under `set -e`, an unguarded failure in an
17- # EXIT trap would abort the trap early and replace the script's real exit
18- # status with the failing cleanup command's. Guard each step with `|| true`
19- # so cleanup always runs to completion and never masks the vhs run's result;
20- # the final check below is what surfaces an unrecovered leftover.
21- rm -rf "$CLAUDE_TMP" 2>/dev/null || true
22- if [ -d "$CLAUDE_TMP" ]; then
23- docker run --rm -v "$CLAUDE_TMP:/cleanup" --entrypoint sh vev-demo \
24- -c 'rm -rf /cleanup/.claude /cleanup/.claude.json' 2>/dev/null || true
25- rmdir "$CLAUDE_TMP" 2>/dev/null || true
17+ compose down -v --remove-orphans >/dev/null 2>&1 || true
18+}
19+trap cleanup EXIT
20+
21+compose up -d remote
22+
23+ready=""
24+for _ in $(seq 60); do
25+ if compose run --rm --entrypoint ssh client remote true >/dev/null 2>&1; then
26+ ready=yes
27+ break
2628 fi
27- if [ -d "$CLAUDE_TMP" ]; then
28- echo "warning: could not remove claude temp home, left at $CLAUDE_TMP" >&2
29+ sleep 0.5
30+done
31+if [ -z "$ready" ]; then
32+ echo "error: sshd on the remote container never accepted a connection" >&2
33+ compose logs remote >&2 || true
34+ exit 1
35+fi
36+
37+start_remote_session() {
38+ local name=$1 scene=$2
39+ if ! compose exec -u demo -T remote env VEV=demo-bootstrap SHELL=/usr/local/bin/demo-shell VEV_DEMO_SCENE="$scene" vev new "$name"; then
40+ echo "error: could not create remote session '$name'" >&2
41+ return 1
42+ fi
43+ if ! compose exec -u demo -T remote vev ls | grep -q "^$name"; then
44+ echo "error: remote session '$name' was not retained" >&2
45+ compose exec -u demo -T remote vev ls >&2 || true
46+ return 1
2947 fi
30- return 0
3148 }
32-trap cleanup EXIT
33-mkdir -p "$CLAUDE_TMP/.claude"
34-cp "$HOME/.claude/.credentials.json" "$CLAUDE_TMP/.claude/"
35-cp scripts/demo/claude-settings.json "$CLAUDE_TMP/.claude/settings.json"
36-printf '{"hasCompletedOnboarding": true, "theme": "dark", "projects": {"/home/demo": {"hasTrustDialogAccepted": true, "hasCompletedProjectOnboarding": true, "projectOnboardingSeenCount": 1}}}\n' > "$CLAUDE_TMP/.claude.json"
37-
38-# Rootless docker maps host-owned files (uid 1000) to container root, but the
39-# container runs as demo (uid 1000 -> a subuid), so demo cannot read the
40-# credentials or write its config dir. Open up the throwaway home's contents so
41-# claude can authenticate and write transcripts, then restore the mktemp
42-# parent to 0700: bind mounts hand the container the inner paths directly, but
43-# other host users would have to traverse the parent, which now blocks them.
44-chmod -R a+rwX "$CLAUDE_TMP"
45-chmod 0700 "$CLAUDE_TMP"
46-
47-docker run --rm --hostname vev-demo \
48- -v "$PWD/scripts/demo:/tape:ro" \
49- -v "$PWD/scripts/demo/out:/home/demo/out" \
50- -v "$CLAUDE_TMP/.claude:/home/demo/.claude" \
51- -v "$CLAUDE_TMP/.claude.json:/home/demo/.claude.json" \
52- vev-demo "/tape/$TAPE"
49+
50+start_remote_session work remote-deploys
51+start_remote_session ticker remote-ticker
52+
53+compose run --rm --entrypoint vev client host add remote
54+compose run --rm client "/tape/$TAPE"
@@ -3,50 +3,52 @@ set -euo pipefail
3 cd "$(dirname "$0")/../.."3 cd "$(dirname "$0")/../.."
4 4
5 TAPE="${1:-demo.tape}"5 TAPE="${1:-demo.tape}"
6+COMPOSE_FILE="scripts/demo/compose.yaml"
6 install -d -m 0777 scripts/demo/out7 install -d -m 0777 scripts/demo/out
8+# Demo containers use a rootless subuid mapping, so reset generated state
9+# from the same container identity rather than relying on host ownership.
10+docker run --rm --entrypoint sh -v "$PWD/scripts/demo/out:/out" vev-demo -c 'rm -rf /out/local && mkdir -m 0777 /out/local'
11+
12+compose() {
13+ docker compose -f "$COMPOSE_FILE" "$@"
14+}
7 15
8-CLAUDE_TMP=$(mktemp -d)
9-# claude writes files into the temp HOME as the container's demo user, which
10-# rootless docker maps to a subuid the host cannot delete directly. Clean up
11-# through the container first, where the creating uid can unlink its own
12-# files, then remove the (now-empty) temp dir from the host. This never
13-# suppresses a leftover: if anything still remains, it's reported so a
14-# credentials-bearing directory doesn't silently linger in /tmp.
15 cleanup() {16 cleanup() {
16- # Every step here is best-effort: under `set -e`, an unguarded failure in an17+ compose down -v --remove-orphans >/dev/null 2>&1 || true
17- # EXIT trap would abort the trap early and replace the script's real exit18+}
18- # status with the failing cleanup command's. Guard each step with `|| true`19+trap cleanup EXIT
19- # so cleanup always runs to completion and never masks the vhs run's result;20+
20- # the final check below is what surfaces an unrecovered leftover.21+compose up -d remote
21- rm -rf "$CLAUDE_TMP" 2>/dev/null || true22+
22- if [ -d "$CLAUDE_TMP" ]; then23+ready=""
23- docker run --rm -v "$CLAUDE_TMP:/cleanup" --entrypoint sh vev-demo \24+for _ in $(seq 60); do
24- -c 'rm -rf /cleanup/.claude /cleanup/.claude.json' 2>/dev/null || true25+ if compose run --rm --entrypoint ssh client remote true >/dev/null 2>&1; then
25- rmdir "$CLAUDE_TMP" 2>/dev/null || true26+ ready=yes
27+ break
26 fi28 fi
27- if [ -d "$CLAUDE_TMP" ]; then29+ sleep 0.5
28- echo "warning: could not remove claude temp home, left at $CLAUDE_TMP" >&230+done
31+if [ -z "$ready" ]; then
32+ echo "error: sshd on the remote container never accepted a connection" >&2
33+ compose logs remote >&2 || true
34+ exit 1
35+fi
36+
37+start_remote_session() {
38+ local name=$1 scene=$2
39+ if ! compose exec -u demo -T remote env VEV=demo-bootstrap SHELL=/usr/local/bin/demo-shell VEV_DEMO_SCENE="$scene" vev new "$name"; then
40+ echo "error: could not create remote session '$name'" >&2
41+ return 1
42+ fi
43+ if ! compose exec -u demo -T remote vev ls | grep -q "^$name"; then
44+ echo "error: remote session '$name' was not retained" >&2
45+ compose exec -u demo -T remote vev ls >&2 || true
46+ return 1
29 fi47 fi
30- return 0
31 }48 }
32-trap cleanup EXIT49+
33-mkdir -p "$CLAUDE_TMP/.claude"50+start_remote_session work remote-deploys
34-cp "$HOME/.claude/.credentials.json" "$CLAUDE_TMP/.claude/"51+start_remote_session ticker remote-ticker
35-cp scripts/demo/claude-settings.json "$CLAUDE_TMP/.claude/settings.json"52+
36-printf '{"hasCompletedOnboarding": true, "theme": "dark", "projects": {"/home/demo": {"hasTrustDialogAccepted": true, "hasCompletedProjectOnboarding": true, "projectOnboardingSeenCount": 1}}}\n' > "$CLAUDE_TMP/.claude.json"53+compose run --rm --entrypoint vev client host add remote
37-54+compose run --rm client "/tape/$TAPE"
38-# Rootless docker maps host-owned files (uid 1000) to container root, but the
39-# container runs as demo (uid 1000 -> a subuid), so demo cannot read the
40-# credentials or write its config dir. Open up the throwaway home's contents so
41-# claude can authenticate and write transcripts, then restore the mktemp
42-# parent to 0700: bind mounts hand the container the inner paths directly, but
43-# other host users would have to traverse the parent, which now blocks them.
44-chmod -R a+rwX "$CLAUDE_TMP"
45-chmod 0700 "$CLAUDE_TMP"
46-
47-docker run --rm --hostname vev-demo \
48- -v "$PWD/scripts/demo:/tape:ro" \
49- -v "$PWD/scripts/demo/out:/home/demo/out" \
50- -v "$CLAUDE_TMP/.claude:/home/demo/.claude" \
51- -v "$CLAUDE_TMP/.claude.json:/home/demo/.claude.json" \
52- vev-demo "/tape/$TAPE"

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