detached 14f582a

Merge pull request #29 from bnema/feat/snapshot-restore-v2

feat(snapshot): restore named sessions with process metadata
commit
14f582aa57c487a431c9a43b8689e69781c46d80
author
bnema · · 2026-07-06 14:02 UTC
committer
GitHub · · 2026-07-06 14:02 UTC
merge of
7b6607d + 89277e3 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
34 files · +1965 -84
M README.md +7 -2
@@ -35,9 +35,9 @@ vev --help show help (aliases: -h, help)
3535 vev --version show version (alias: version)
3636 ```
3737
38-Ephemeral sessions get a number (`0`, `1`, `2`, ...) and die when you detach. Named sessions survive detach and can be resumed later. The daemon starts on first use and exits when no sessions remain.
38+Ephemeral sessions get a number (`0`, `1`, `2`, ...) and die when you detach. Named sessions survive detach and can be resumed later. New and renamed session names must start with a letter or number and may contain only letters, numbers, dots, underscores, and dashes.
3939
40-Only one client is attached to a session at a time; attaching again displaces the old client.
40+The daemon starts on first use and exits when no sessions remain. Only one client is attached to a session at a time; attaching again displaces the old client.
4141
4242 ## Configuration
4343
@@ -64,6 +64,11 @@ switch-tab-7 = alt+7
6464 switch-tab-8 = alt+8
6565 switch-tab-9 = alt+9
6666
67+# Named session snapshots restore layout, cwd, visible content, scrollback,
68+# and foreground processes from this allowlist. Omit the key for these defaults;
69+# set it empty to disable process relaunch.
70+snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode
71+
6772 # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits.
6873 code.new-tab = CNT
6974 code.new-session = CNS
@@ -35,9 +35,9 @@ vev --help show help (aliases: -h, help)
35 vev --version show version (alias: version)35 vev --version show version (alias: version)
36 ```36 ```
37 37
38-Ephemeral sessions get a number (`0`, `1`, `2`, ...) and die when you detach. Named sessions survive detach and can be resumed later. The daemon starts on first use and exits when no sessions remain.38+Ephemeral sessions get a number (`0`, `1`, `2`, ...) and die when you detach. Named sessions survive detach and can be resumed later. New and renamed session names must start with a letter or number and may contain only letters, numbers, dots, underscores, and dashes.
39 39
40-Only one client is attached to a session at a time; attaching again displaces the old client.40+The daemon starts on first use and exits when no sessions remain. Only one client is attached to a session at a time; attaching again displaces the old client.
41 41
42 ## Configuration42 ## Configuration
43 43
@@ -64,6 +64,11 @@ switch-tab-7 = alt+7
64 switch-tab-8 = alt+864 switch-tab-8 = alt+8
65 switch-tab-9 = alt+965 switch-tab-9 = alt+9
66 66
67+# Named session snapshots restore layout, cwd, visible content, scrollback,
68+# and foreground processes from this allowlist. Omit the key for these defaults;
69+# set it empty to disable process relaunch.
70+snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode
71+
67 # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits.72 # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits.
68 code.new-tab = CNT73 code.new-tab = CNT
69 code.new-session = CNS74 code.new-session = CNS
M boundary_test.go +5 -0
@@ -29,6 +29,11 @@ func TestImportBoundaries(t *testing.T) {
2929 root: "internal/usecase",
3030 disallowedPrefix: "github.com/bnema/vev/internal/adapters",
3131 },
32+ {
33+ name: "usecase must not import platform",
34+ root: "internal/usecase",
35+ disallowedPrefix: "github.com/bnema/vev/internal/platform",
36+ },
3237 }
3338
3439 for _, rule := range rules {
@@ -29,6 +29,11 @@ func TestImportBoundaries(t *testing.T) {
29 root: "internal/usecase",29 root: "internal/usecase",
30 disallowedPrefix: "github.com/bnema/vev/internal/adapters",30 disallowedPrefix: "github.com/bnema/vev/internal/adapters",
31 },31 },
32+ {
33+ name: "usecase must not import platform",
34+ root: "internal/usecase",
35+ disallowedPrefix: "github.com/bnema/vev/internal/platform",
36+ },
32 }37 }
33 38
34 for _, rule := range rules {39 for _, rule := range rules {
M internal/adapters/config/config.go +34 -0
@@ -7,6 +7,7 @@ import (
77 "fmt"
88 "io"
99 "os"
10+ "regexp"
1011 "strings"
1112 "time"
1213
@@ -16,6 +17,8 @@ import (
1617
1718 const pollInterval = 2 * time.Second
1819
20+var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
21+
1922 // Parse reads vev's flat action = value config format. Duplicate action keys
2023 // are accepted with a warning; the last value wins while first-seen action order
2124 // is preserved for binding conflict resolution.
@@ -67,6 +70,11 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
6770 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
6871 }
6972 cfg.Codes[codeKey] = value
73+ case key == "snapshot.restore_processes":
74+ var processWarnings []domain.Warning
75+ cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo)
76+ warnings = append(warnings, processWarnings...)
77+ cfg.Snapshot.RestoreProcessesSet = true
7078 default:
7179 if seenBindingKeys[key] {
7280 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
@@ -164,6 +172,32 @@ func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
164172 }
165173 }
166174
175+func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
176+ if strings.TrimSpace(value) == "" {
177+ return nil, nil
178+ }
179+ parts := strings.Split(value, ",")
180+ out := make([]string, 0, len(parts))
181+ warnings := make([]domain.Warning, 0)
182+ seen := make(map[string]struct{}, len(parts))
183+ for _, part := range parts {
184+ item := strings.TrimSpace(part)
185+ if item == "" {
186+ continue
187+ }
188+ if !processNamePattern.MatchString(item) {
189+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)})
190+ continue
191+ }
192+ if _, ok := seen[item]; ok {
193+ continue
194+ }
195+ seen[item] = struct{}{}
196+ out = append(out, item)
197+ }
198+ return out, warnings
199+}
200+
167201 func parseTheme(value string) (domain.ThemeMode, bool) {
168202 switch strings.ToLower(strings.TrimSpace(value)) {
169203 case "auto", "":
@@ -7,6 +7,7 @@ import (
7 "fmt"7 "fmt"
8 "io"8 "io"
9 "os"9 "os"
10+ "regexp"
10 "strings"11 "strings"
11 "time"12 "time"
12 13
@@ -16,6 +17,8 @@ import (
16 17
17 const pollInterval = 2 * time.Second18 const pollInterval = 2 * time.Second
18 19
20+var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
21+
19 // Parse reads vev's flat action = value config format. Duplicate action keys22 // Parse reads vev's flat action = value config format. Duplicate action keys
20 // are accepted with a warning; the last value wins while first-seen action order23 // are accepted with a warning; the last value wins while first-seen action order
21 // is preserved for binding conflict resolution.24 // is preserved for binding conflict resolution.
@@ -67,6 +70,11 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
67 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})70 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
68 }71 }
69 cfg.Codes[codeKey] = value72 cfg.Codes[codeKey] = value
73+ case key == "snapshot.restore_processes":
74+ var processWarnings []domain.Warning
75+ cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo)
76+ warnings = append(warnings, processWarnings...)
77+ cfg.Snapshot.RestoreProcessesSet = true
70 default:78 default:
71 if seenBindingKeys[key] {79 if seenBindingKeys[key] {
72 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})80 warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
@@ -164,6 +172,32 @@ func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
164 }172 }
165 }173 }
166 174
175+func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
176+ if strings.TrimSpace(value) == "" {
177+ return nil, nil
178+ }
179+ parts := strings.Split(value, ",")
180+ out := make([]string, 0, len(parts))
181+ warnings := make([]domain.Warning, 0)
182+ seen := make(map[string]struct{}, len(parts))
183+ for _, part := range parts {
184+ item := strings.TrimSpace(part)
185+ if item == "" {
186+ continue
187+ }
188+ if !processNamePattern.MatchString(item) {
189+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)})
190+ continue
191+ }
192+ if _, ok := seen[item]; ok {
193+ continue
194+ }
195+ seen[item] = struct{}{}
196+ out = append(out, item)
197+ }
198+ return out, warnings
199+}
200+
167 func parseTheme(value string) (domain.ThemeMode, bool) {201 func parseTheme(value string) (domain.ThemeMode, bool) {
168 switch strings.ToLower(strings.TrimSpace(value)) {202 switch strings.ToLower(strings.TrimSpace(value)) {
169 case "auto", "":203 case "auto", "":
M internal/adapters/config/config_test.go +55 -0
@@ -91,12 +91,58 @@ func TestParse(t *testing.T) {
9191 {Line: 3, Msg: "missing key"},
9292 },
9393 },
94+ {
95+ name: "snapshot restore processes",
96+ input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n",
97+ want: domain.Config{
98+ Theme: domain.ThemeAuto,
99+ BindingEntries: []domain.ConfigEntry{},
100+ Codes: map[string]string{},
101+ Snapshot: domain.SnapshotConfig{
102+ RestoreProcesses: []string{"claude", "codex", "pi", "opencode", "btop"},
103+ RestoreProcessesSet: true,
104+ },
105+ },
106+ },
107+ {
108+ name: "snapshot restore processes empty disables",
109+ input: "snapshot.restore_processes =\n",
110+ want: domain.Config{
111+ Theme: domain.ThemeAuto,
112+ BindingEntries: []domain.ConfigEntry{},
113+ Codes: map[string]string{},
114+ Snapshot: domain.SnapshotConfig{
115+ RestoreProcessesSet: true,
116+ },
117+ },
118+ },
119+ {
120+ name: "snapshot restore processes warns on malformed entries",
121+ input: "snapshot.restore_processes = claude, custom-tool, /bin/sh, :all:, bad name, claude, , zsh\n",
122+ want: domain.Config{
123+ Theme: domain.ThemeAuto,
124+ BindingEntries: []domain.ConfigEntry{},
125+ Codes: map[string]string{},
126+ Snapshot: domain.SnapshotConfig{
127+ RestoreProcesses: []string{"claude", "custom-tool", "zsh"},
128+ RestoreProcessesSet: true,
129+ },
130+ },
131+ wantWarnings: []domain.Warning{
132+ {Line: 1, Msg: "invalid snapshot restore process \"/bin/sh\""},
133+ {Line: 1, Msg: "invalid snapshot restore process \":all:\""},
134+ {Line: 1, Msg: "invalid snapshot restore process \"bad name\""},
135+ },
136+ },
94137 }
95138
96139 for _, tt := range tests {
97140 t.Run(tt.name, func(t *testing.T) {
98141 t.Parallel()
99142
143+ if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
144+ tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
145+ }
100146 got, warnings, err := Parse(strings.NewReader(tt.input))
101147 if err != nil {
102148 t.Fatalf("Parse() error = %v", err)
@@ -111,6 +157,15 @@ func TestParse(t *testing.T) {
111157 }
112158 }
113159
160+func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
161+ t.Parallel()
162+
163+ first := domain.Defaults()
164+ first.Snapshot.RestoreProcesses[0] = "mutated"
165+ second := domain.Defaults()
166+ require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
167+}
168+
114169 func TestParsePreservesBindingFileOrder(t *testing.T) {
115170 t.Parallel()
116171
@@ -91,12 +91,58 @@ func TestParse(t *testing.T) {
91 {Line: 3, Msg: "missing key"},91 {Line: 3, Msg: "missing key"},
92 },92 },
93 },93 },
94+ {
95+ name: "snapshot restore processes",
96+ input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n",
97+ want: domain.Config{
98+ Theme: domain.ThemeAuto,
99+ BindingEntries: []domain.ConfigEntry{},
100+ Codes: map[string]string{},
101+ Snapshot: domain.SnapshotConfig{
102+ RestoreProcesses: []string{"claude", "codex", "pi", "opencode", "btop"},
103+ RestoreProcessesSet: true,
104+ },
105+ },
106+ },
107+ {
108+ name: "snapshot restore processes empty disables",
109+ input: "snapshot.restore_processes =\n",
110+ want: domain.Config{
111+ Theme: domain.ThemeAuto,
112+ BindingEntries: []domain.ConfigEntry{},
113+ Codes: map[string]string{},
114+ Snapshot: domain.SnapshotConfig{
115+ RestoreProcessesSet: true,
116+ },
117+ },
118+ },
119+ {
120+ name: "snapshot restore processes warns on malformed entries",
121+ input: "snapshot.restore_processes = claude, custom-tool, /bin/sh, :all:, bad name, claude, , zsh\n",
122+ want: domain.Config{
123+ Theme: domain.ThemeAuto,
124+ BindingEntries: []domain.ConfigEntry{},
125+ Codes: map[string]string{},
126+ Snapshot: domain.SnapshotConfig{
127+ RestoreProcesses: []string{"claude", "custom-tool", "zsh"},
128+ RestoreProcessesSet: true,
129+ },
130+ },
131+ wantWarnings: []domain.Warning{
132+ {Line: 1, Msg: "invalid snapshot restore process \"/bin/sh\""},
133+ {Line: 1, Msg: "invalid snapshot restore process \":all:\""},
134+ {Line: 1, Msg: "invalid snapshot restore process \"bad name\""},
135+ },
136+ },
94 }137 }
95 138
96 for _, tt := range tests {139 for _, tt := range tests {
97 t.Run(tt.name, func(t *testing.T) {140 t.Run(tt.name, func(t *testing.T) {
98 t.Parallel()141 t.Parallel()
99 142
143+ if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
144+ tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
145+ }
100 got, warnings, err := Parse(strings.NewReader(tt.input))146 got, warnings, err := Parse(strings.NewReader(tt.input))
101 if err != nil {147 if err != nil {
102 t.Fatalf("Parse() error = %v", err)148 t.Fatalf("Parse() error = %v", err)
@@ -111,6 +157,15 @@ func TestParse(t *testing.T) {
111 }157 }
112 }158 }
113 159
160+func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
161+ t.Parallel()
162+
163+ first := domain.Defaults()
164+ first.Snapshot.RestoreProcesses[0] = "mutated"
165+ second := domain.Defaults()
166+ require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0])
167+}
168+
114 func TestParsePreservesBindingFileOrder(t *testing.T) {169 func TestParsePreservesBindingFileOrder(t *testing.T) {
115 t.Parallel()170 t.Parallel()
116 171
M internal/app/run.go +4 -0
@@ -142,6 +142,9 @@ func parseArgs(args []string) (command, error) {
142142 if len(args) > 2 {
143143 return command{}, usagef("`new` does not support command overrides")
144144 }
145+ if err := domain.ValidateSessionName(args[1]); err != nil {
146+ return command{}, err
147+ }
145148 return command{kind: kindAttach, intent: ports.IntentNew, name: args[1]}, nil
146149 case "attach", "a":
147150 if len(args) < 2 || args[1] == "" {
@@ -281,6 +284,7 @@ func runDaemon() error {
281284 }
282285 logConfigWarnings(log, warnings)
283286 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
287+ daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
284288 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
285289 storePath := persist.StorePath(platform.StateDir())
286290 if store, err := kv.Open(storePath); err != nil {
@@ -142,6 +142,9 @@ func parseArgs(args []string) (command, error) {
142 if len(args) > 2 {142 if len(args) > 2 {
143 return command{}, usagef("`new` does not support command overrides")143 return command{}, usagef("`new` does not support command overrides")
144 }144 }
145+ if err := domain.ValidateSessionName(args[1]); err != nil {
146+ return command{}, err
147+ }
145 return command{kind: kindAttach, intent: ports.IntentNew, name: args[1]}, nil148 return command{kind: kindAttach, intent: ports.IntentNew, name: args[1]}, nil
146 case "attach", "a":149 case "attach", "a":
147 if len(args) < 2 || args[1] == "" {150 if len(args) < 2 || args[1] == "" {
@@ -281,6 +284,7 @@ func runDaemon() error {
281 }284 }
282 logConfigWarnings(log, warnings)285 logConfigWarnings(log, warnings)
283 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))286 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
287+ daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
284 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))288 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
285 storePath := persist.StorePath(platform.StateDir())289 storePath := persist.StorePath(platform.StateDir())
286 if store, err := kv.Open(storePath); err != nil {290 if store, err := kv.Open(storePath); err != nil {
M internal/app/run_test.go +11 -0
@@ -13,6 +13,7 @@ import (
1313 "time"
1414
1515 "github.com/bnema/vev/internal/adapters/snapshot"
16+ "github.com/bnema/vev/internal/domain"
1617 "github.com/bnema/vev/internal/persist"
1718 "github.com/bnema/vev/internal/ports"
1819 "github.com/bnema/vev/internal/usecase/client"
@@ -38,6 +39,7 @@ func TestParseArgs(t *testing.T) {
3839 {name: "new empty name", args: []string{"new", ""}, wantErr: true},
3940 {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true},
4041 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
42+ {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},
4143 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
4244 {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
4345 {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
@@ -47,6 +49,7 @@ func TestParseArgs(t *testing.T) {
4749 {name: "ls", args: []string{"ls"}, wantKind: kindList},
4850 {name: "list", args: []string{"list"}, wantKind: kindList},
4951 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
52+ {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},
5053 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
5154 {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
5255 {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},
@@ -58,6 +61,7 @@ func TestParseArgs(t *testing.T) {
5861 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
5962 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
6063 {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},
64+ {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},
6165 {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
6266 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
6367 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
@@ -103,6 +107,13 @@ func TestParseArgs(t *testing.T) {
103107 }
104108 }
105109
110+func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
111+ _, err := parseArgs([]string{"new", "my work"})
112+ if !errors.Is(err, domain.ErrInvalidSessionName) {
113+ t.Fatalf("parseArgs new unsafe error = %v, want %v", err, domain.ErrInvalidSessionName)
114+ }
115+}
116+
106117 func TestPrintSessionsShowsStoppedState(t *testing.T) {
107118 var out bytes.Buffer
108119 printSessions(&out, []ports.SessionInfo{
@@ -13,6 +13,7 @@ import (
13 "time"13 "time"
14 14
15 "github.com/bnema/vev/internal/adapters/snapshot"15 "github.com/bnema/vev/internal/adapters/snapshot"
16+ "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/persist"17 "github.com/bnema/vev/internal/persist"
17 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
18 "github.com/bnema/vev/internal/usecase/client"19 "github.com/bnema/vev/internal/usecase/client"
@@ -38,6 +39,7 @@ func TestParseArgs(t *testing.T) {
38 {name: "new empty name", args: []string{"new", ""}, wantErr: true},39 {name: "new empty name", args: []string{"new", ""}, wantErr: true},
39 {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true},40 {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true},
40 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},41 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
42+ {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},
41 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},43 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
42 {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},44 {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
43 {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},45 {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
@@ -47,6 +49,7 @@ func TestParseArgs(t *testing.T) {
47 {name: "ls", args: []string{"ls"}, wantKind: kindList},49 {name: "ls", args: []string{"ls"}, wantKind: kindList},
48 {name: "list", args: []string{"list"}, wantKind: kindList},50 {name: "list", args: []string{"list"}, wantKind: kindList},
49 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},51 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
52+ {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},
50 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},53 {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
51 {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},54 {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
52 {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},55 {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},
@@ -58,6 +61,7 @@ func TestParseArgs(t *testing.T) {
58 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},61 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
59 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},62 {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
60 {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},63 {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},
64+ {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},
61 {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},65 {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
62 {name: "help", args: []string{"--help"}, wantKind: kindHelp},66 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
63 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},67 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
@@ -103,6 +107,13 @@ func TestParseArgs(t *testing.T) {
103 }107 }
104 }108 }
105 109
110+func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
111+ _, err := parseArgs([]string{"new", "my work"})
112+ if !errors.Is(err, domain.ErrInvalidSessionName) {
113+ t.Fatalf("parseArgs new unsafe error = %v, want %v", err, domain.ErrInvalidSessionName)
114+ }
115+}
116+
106 func TestPrintSessionsShowsStoppedState(t *testing.T) {117 func TestPrintSessionsShowsStoppedState(t *testing.T) {
107 var out bytes.Buffer118 var out bytes.Buffer
108 printSessions(&out, []ports.SessionInfo{119 printSessions(&out, []ports.SessionInfo{
M internal/domain/config.go +21 -0
@@ -18,6 +18,23 @@ type ConfigEntry struct {
1818 Value string
1919 }
2020
21+// SnapshotConfig contains user-configurable snapshot restore settings.
22+type SnapshotConfig struct {
23+ RestoreProcesses []string
24+ RestoreProcessesSet bool
25+}
26+
27+// defaultSnapshotRestoreProcesses is the default allowlist for process restore.
28+var defaultSnapshotRestoreProcesses = []string{
29+ "vi", "vim", "nvim", "emacs", "man", "less", "more", "tail", "top", "htop", "btop",
30+ "claude", "codex", "pi", "opencode",
31+}
32+
33+// DefaultSnapshotRestoreProcesses returns a copy of the default process restore allowlist.
34+func DefaultSnapshotRestoreProcesses() []string {
35+ return append([]string(nil), defaultSnapshotRestoreProcesses...)
36+}
37+
2138 // Config is the user-editable vev configuration after parsing. Unknown binding
2239 // keys are preserved here (in BindingEntries, in file order) so the usecase
2340 // layer can decide which actions it understands.
@@ -25,6 +42,7 @@ type Config struct {
2542 Theme ThemeMode
2643 BindingEntries []ConfigEntry
2744 Codes map[string]string
45+ Snapshot SnapshotConfig
2846 }
2947
3048 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -40,5 +58,8 @@ func Defaults() Config {
4058 Theme: ThemeAuto,
4159 BindingEntries: []ConfigEntry{},
4260 Codes: map[string]string{},
61+ Snapshot: SnapshotConfig{
62+ RestoreProcesses: DefaultSnapshotRestoreProcesses(),
63+ },
4364 }
4465 }
@@ -18,6 +18,23 @@ type ConfigEntry struct {
18 Value string18 Value string
19 }19 }
20 20
21+// SnapshotConfig contains user-configurable snapshot restore settings.
22+type SnapshotConfig struct {
23+ RestoreProcesses []string
24+ RestoreProcessesSet bool
25+}
26+
27+// defaultSnapshotRestoreProcesses is the default allowlist for process restore.
28+var defaultSnapshotRestoreProcesses = []string{
29+ "vi", "vim", "nvim", "emacs", "man", "less", "more", "tail", "top", "htop", "btop",
30+ "claude", "codex", "pi", "opencode",
31+}
32+
33+// DefaultSnapshotRestoreProcesses returns a copy of the default process restore allowlist.
34+func DefaultSnapshotRestoreProcesses() []string {
35+ return append([]string(nil), defaultSnapshotRestoreProcesses...)
36+}
37+
21 // Config is the user-editable vev configuration after parsing. Unknown binding38 // Config is the user-editable vev configuration after parsing. Unknown binding
22 // keys are preserved here (in BindingEntries, in file order) so the usecase39 // keys are preserved here (in BindingEntries, in file order) so the usecase
23 // layer can decide which actions it understands.40 // layer can decide which actions it understands.
@@ -25,6 +42,7 @@ type Config struct {
25 Theme ThemeMode42 Theme ThemeMode
26 BindingEntries []ConfigEntry43 BindingEntries []ConfigEntry
27 Codes map[string]string44 Codes map[string]string
45+ Snapshot SnapshotConfig
28 }46 }
29 47
30 // Warning describes a non-fatal config problem. Parsers and reloaders should48 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -40,5 +58,8 @@ func Defaults() Config {
40 Theme: ThemeAuto,58 Theme: ThemeAuto,
41 BindingEntries: []ConfigEntry{},59 BindingEntries: []ConfigEntry{},
42 Codes: map[string]string{},60 Codes: map[string]string{},
61+ Snapshot: SnapshotConfig{
62+ RestoreProcesses: DefaultSnapshotRestoreProcesses(),
63+ },
43 }64 }
44 }65 }
A internal/domain/session_name.go +22 -0
@@ -0,0 +1,22 @@
1+package domain
2+
3+import (
4+ "errors"
5+ "regexp"
6+)
7+
8+const sessionNamePattern = `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`
9+
10+var (
11+ // ErrInvalidSessionName is returned when a new or renamed session name is unsafe.
12+ ErrInvalidSessionName = errors.New("invalid session name: must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}")
13+ sessionNameRE = regexp.MustCompile(`^` + sessionNamePattern + `$`)
14+)
15+
16+// ValidateSessionName rejects unsafe names for newly-created or renamed sessions.
17+func ValidateSessionName(name string) error {
18+ if !sessionNameRE.MatchString(name) {
19+ return ErrInvalidSessionName
20+ }
21+ return nil
22+}
@@ -0,0 +1,22 @@
1+package domain
2+
3+import (
4+ "errors"
5+ "regexp"
6+)
7+
8+const sessionNamePattern = `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`
9+
10+var (
11+ // ErrInvalidSessionName is returned when a new or renamed session name is unsafe.
12+ ErrInvalidSessionName = errors.New("invalid session name: must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}")
13+ sessionNameRE = regexp.MustCompile(`^` + sessionNamePattern + `$`)
14+)
15+
16+// ValidateSessionName rejects unsafe names for newly-created or renamed sessions.
17+func ValidateSessionName(name string) error {
18+ if !sessionNameRE.MatchString(name) {
19+ return ErrInvalidSessionName
20+ }
21+ return nil
22+}
A internal/domain/session_name_test.go +34 -0
@@ -0,0 +1,34 @@
1+package domain
2+
3+import "testing"
4+
5+func TestValidateSessionName(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ wantErr bool
9+ }{
10+ {name: "a"},
11+ {name: "work"},
12+ {name: "Work_1.2-3"},
13+ {name: "a123456789012345678901234567890123456789012345678901234567890123"},
14+ {name: "", wantErr: true},
15+ {name: "my work", wantErr: true},
16+ {name: "-work", wantErr: true},
17+ {name: "_work", wantErr: true},
18+ {name: ".work", wantErr: true},
19+ {name: "work/one", wantErr: true},
20+ {name: "a1234567890123456789012345678901234567890123456789012345678901234", wantErr: true},
21+ }
22+
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ err := ValidateSessionName(tt.name)
26+ if tt.wantErr && err == nil {
27+ t.Fatalf("ValidateSessionName(%q) = nil, want error", tt.name)
28+ }
29+ if !tt.wantErr && err != nil {
30+ t.Fatalf("ValidateSessionName(%q) = %v, want nil", tt.name, err)
31+ }
32+ })
33+ }
34+}
@@ -0,0 +1,34 @@
1+package domain
2+
3+import "testing"
4+
5+func TestValidateSessionName(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ wantErr bool
9+ }{
10+ {name: "a"},
11+ {name: "work"},
12+ {name: "Work_1.2-3"},
13+ {name: "a123456789012345678901234567890123456789012345678901234567890123"},
14+ {name: "", wantErr: true},
15+ {name: "my work", wantErr: true},
16+ {name: "-work", wantErr: true},
17+ {name: "_work", wantErr: true},
18+ {name: ".work", wantErr: true},
19+ {name: "work/one", wantErr: true},
20+ {name: "a1234567890123456789012345678901234567890123456789012345678901234", wantErr: true},
21+ }
22+
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ err := ValidateSessionName(tt.name)
26+ if tt.wantErr && err == nil {
27+ t.Fatalf("ValidateSessionName(%q) = nil, want error", tt.name)
28+ }
29+ if !tt.wantErr && err != nil {
30+ t.Fatalf("ValidateSessionName(%q) = %v, want nil", tt.name, err)
31+ }
32+ })
33+ }
34+}
M internal/platform/proc_linux.go +13 -2
@@ -8,15 +8,26 @@ import (
88
99 // ProcessCwd returns the current working directory for a Linux process.
1010 func ProcessCwd(pid int) (string, error) {
11- return os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid))
11+ return processCwd("/proc", pid)
12+}
13+
14+func processCwd(root string, pid int) (string, error) {
15+ if pid <= 0 {
16+ return "", fmt.Errorf("process cwd: invalid pid %d", pid)
17+ }
18+ return os.Readlink(fmt.Sprintf("%s/%d/cwd", root, pid))
1219 }
1320
1421 // ProcessComm returns the command name from /proc/<pid>/comm.
1522 func ProcessComm(pid int) (string, error) {
23+ return processComm("/proc", pid)
24+}
25+
26+func processComm(root string, pid int) (string, error) {
1627 if pid <= 0 {
1728 return "", fmt.Errorf("process comm: invalid pid %d", pid)
1829 }
19- data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
30+ data, err := os.ReadFile(fmt.Sprintf("%s/%d/comm", root, pid))
2031 if err != nil {
2132 return "", fmt.Errorf("process comm: read pid %d: %w", pid, err)
2233 }
@@ -8,15 +8,26 @@ import (
8 8
9 // ProcessCwd returns the current working directory for a Linux process.9 // ProcessCwd returns the current working directory for a Linux process.
10 func ProcessCwd(pid int) (string, error) {10 func ProcessCwd(pid int) (string, error) {
11- return os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid))11+ return processCwd("/proc", pid)
12+}
13+
14+func processCwd(root string, pid int) (string, error) {
15+ if pid <= 0 {
16+ return "", fmt.Errorf("process cwd: invalid pid %d", pid)
17+ }
18+ return os.Readlink(fmt.Sprintf("%s/%d/cwd", root, pid))
12 }19 }
13 20
14 // ProcessComm returns the command name from /proc/<pid>/comm.21 // ProcessComm returns the command name from /proc/<pid>/comm.
15 func ProcessComm(pid int) (string, error) {22 func ProcessComm(pid int) (string, error) {
23+ return processComm("/proc", pid)
24+}
25+
26+func processComm(root string, pid int) (string, error) {
16 if pid <= 0 {27 if pid <= 0 {
17 return "", fmt.Errorf("process comm: invalid pid %d", pid)28 return "", fmt.Errorf("process comm: invalid pid %d", pid)
18 }29 }
19- data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))30+ data, err := os.ReadFile(fmt.Sprintf("%s/%d/comm", root, pid))
20 if err != nil {31 if err != nil {
21 return "", fmt.Errorf("process comm: read pid %d: %w", pid, err)32 return "", fmt.Errorf("process comm: read pid %d: %w", pid, err)
22 }33 }
A internal/platform/process_linux.go +157 -0
@@ -0,0 +1,157 @@
1+package platform
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strconv"
10+ "strings"
11+ "sync"
12+ "time"
13+)
14+
15+const processRecordCacheTTL = 250 * time.Millisecond
16+
17+// ProcessInspector implements ports.ProcessInspector using Linux /proc.
18+type ProcessInspector struct {
19+ root string
20+
21+ mu sync.Mutex
22+ cachedRecords []processRecord
23+ cachedRecordsUntil time.Time
24+}
25+
26+// NewProcessInspector returns a Linux /proc-backed process inspector.
27+func NewProcessInspector() *ProcessInspector { return newProcessInspector("/proc") }
28+
29+func newProcessInspector(root string) *ProcessInspector { return &ProcessInspector{root: root} }
30+
31+func (p *ProcessInspector) Cwd(pid int) (string, error) {
32+ return processCwd(p.root, pid)
33+}
34+func (p *ProcessInspector) Comm(pid int) (string, error) {
35+ return processComm(p.root, pid)
36+}
37+func (p *ProcessInspector) Argv(pid int) ([]string, error) {
38+ return processArgv(filepath.Join(p.root, strconv.Itoa(pid), "cmdline"), pid)
39+}
40+func (p *ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
41+ recs, err := p.processRecords()
42+ if err != nil {
43+ return nil, err
44+ }
45+ pid, ok := selectProcessGroupPID(recs, pgid, shellPid)
46+ if !ok {
47+ return nil, nil
48+ }
49+ return p.Argv(pid)
50+}
51+
52+func (p *ProcessInspector) processRecords() ([]processRecord, error) {
53+ p.mu.Lock()
54+ defer p.mu.Unlock()
55+ now := time.Now()
56+ if now.Before(p.cachedRecordsUntil) {
57+ return p.cachedRecords, nil
58+ }
59+ recs, err := readProcRecords(p.root)
60+ if err != nil {
61+ return nil, err
62+ }
63+ p.cachedRecords = recs
64+ p.cachedRecordsUntil = now.Add(processRecordCacheTTL)
65+ return recs, nil
66+}
67+
68+// ProcessArgv returns argv from /proc/<pid>/cmdline.
69+func ProcessArgv(pid int) ([]string, error) {
70+ return processArgv(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"), pid)
71+}
72+
73+func processArgv(path string, pid int) ([]string, error) {
74+ data, err := os.ReadFile(path)
75+ if err != nil {
76+ return nil, err
77+ }
78+ data = bytes.TrimRight(data, "\x00")
79+ if len(data) == 0 {
80+ return nil, fmt.Errorf("process argv: empty argv for pid %d", pid)
81+ }
82+ parts := bytes.Split(data, []byte{0})
83+ argv := make([]string, len(parts))
84+ for i, part := range parts {
85+ argv[i] = string(part)
86+ }
87+ return argv, nil
88+}
89+
90+type processRecord struct{ pid, pgrp int }
91+
92+// ProcessGroupArgv returns argv for a restorable foreground process in pgid.
93+// A bare pane shell (the only process in its foreground group) returns nil argv.
94+func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
95+ recs, err := readProcRecords("/proc")
96+ if err != nil {
97+ return nil, err
98+ }
99+ pid, ok := selectProcessGroupPID(recs, pgid, shellPid)
100+ if !ok {
101+ return nil, nil
102+ }
103+ return ProcessArgv(pid)
104+}
105+
106+func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) {
107+ if pgid <= 0 {
108+ return 0, false
109+ }
110+ matches := make([]int, 0)
111+ for _, r := range recs {
112+ if r.pgrp == pgid && r.pid != shellPid {
113+ matches = append(matches, r.pid)
114+ }
115+ }
116+ if len(matches) == 0 {
117+ return 0, false
118+ }
119+ sort.Ints(matches)
120+ return matches[0], true
121+}
122+
123+func readProcRecords(root string) ([]processRecord, error) {
124+ ents, err := os.ReadDir(root)
125+ if err != nil {
126+ return nil, err
127+ }
128+ var recs []processRecord
129+ for _, ent := range ents {
130+ pid, err := strconv.Atoi(ent.Name())
131+ if err != nil {
132+ continue
133+ }
134+ data, err := os.ReadFile(filepath.Join(root, ent.Name(), "stat"))
135+ if err != nil {
136+ continue
137+ }
138+ pgrp, err := parseStatPgrp(string(data))
139+ if err != nil {
140+ continue
141+ }
142+ recs = append(recs, processRecord{pid: pid, pgrp: pgrp})
143+ }
144+ return recs, nil
145+}
146+
147+func parseStatPgrp(stat string) (int, error) {
148+ end := strings.LastIndex(stat, ")")
149+ if end < 0 || end+2 >= len(stat) {
150+ return 0, fmt.Errorf("invalid stat")
151+ }
152+ fields := strings.Fields(stat[end+2:])
153+ if len(fields) < 3 {
154+ return 0, fmt.Errorf("invalid stat")
155+ }
156+ return strconv.Atoi(fields[2])
157+}
@@ -0,0 +1,157 @@
1+package platform
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strconv"
10+ "strings"
11+ "sync"
12+ "time"
13+)
14+
15+const processRecordCacheTTL = 250 * time.Millisecond
16+
17+// ProcessInspector implements ports.ProcessInspector using Linux /proc.
18+type ProcessInspector struct {
19+ root string
20+
21+ mu sync.Mutex
22+ cachedRecords []processRecord
23+ cachedRecordsUntil time.Time
24+}
25+
26+// NewProcessInspector returns a Linux /proc-backed process inspector.
27+func NewProcessInspector() *ProcessInspector { return newProcessInspector("/proc") }
28+
29+func newProcessInspector(root string) *ProcessInspector { return &ProcessInspector{root: root} }
30+
31+func (p *ProcessInspector) Cwd(pid int) (string, error) {
32+ return processCwd(p.root, pid)
33+}
34+func (p *ProcessInspector) Comm(pid int) (string, error) {
35+ return processComm(p.root, pid)
36+}
37+func (p *ProcessInspector) Argv(pid int) ([]string, error) {
38+ return processArgv(filepath.Join(p.root, strconv.Itoa(pid), "cmdline"), pid)
39+}
40+func (p *ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
41+ recs, err := p.processRecords()
42+ if err != nil {
43+ return nil, err
44+ }
45+ pid, ok := selectProcessGroupPID(recs, pgid, shellPid)
46+ if !ok {
47+ return nil, nil
48+ }
49+ return p.Argv(pid)
50+}
51+
52+func (p *ProcessInspector) processRecords() ([]processRecord, error) {
53+ p.mu.Lock()
54+ defer p.mu.Unlock()
55+ now := time.Now()
56+ if now.Before(p.cachedRecordsUntil) {
57+ return p.cachedRecords, nil
58+ }
59+ recs, err := readProcRecords(p.root)
60+ if err != nil {
61+ return nil, err
62+ }
63+ p.cachedRecords = recs
64+ p.cachedRecordsUntil = now.Add(processRecordCacheTTL)
65+ return recs, nil
66+}
67+
68+// ProcessArgv returns argv from /proc/<pid>/cmdline.
69+func ProcessArgv(pid int) ([]string, error) {
70+ return processArgv(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"), pid)
71+}
72+
73+func processArgv(path string, pid int) ([]string, error) {
74+ data, err := os.ReadFile(path)
75+ if err != nil {
76+ return nil, err
77+ }
78+ data = bytes.TrimRight(data, "\x00")
79+ if len(data) == 0 {
80+ return nil, fmt.Errorf("process argv: empty argv for pid %d", pid)
81+ }
82+ parts := bytes.Split(data, []byte{0})
83+ argv := make([]string, len(parts))
84+ for i, part := range parts {
85+ argv[i] = string(part)
86+ }
87+ return argv, nil
88+}
89+
90+type processRecord struct{ pid, pgrp int }
91+
92+// ProcessGroupArgv returns argv for a restorable foreground process in pgid.
93+// A bare pane shell (the only process in its foreground group) returns nil argv.
94+func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
95+ recs, err := readProcRecords("/proc")
96+ if err != nil {
97+ return nil, err
98+ }
99+ pid, ok := selectProcessGroupPID(recs, pgid, shellPid)
100+ if !ok {
101+ return nil, nil
102+ }
103+ return ProcessArgv(pid)
104+}
105+
106+func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) {
107+ if pgid <= 0 {
108+ return 0, false
109+ }
110+ matches := make([]int, 0)
111+ for _, r := range recs {
112+ if r.pgrp == pgid && r.pid != shellPid {
113+ matches = append(matches, r.pid)
114+ }
115+ }
116+ if len(matches) == 0 {
117+ return 0, false
118+ }
119+ sort.Ints(matches)
120+ return matches[0], true
121+}
122+
123+func readProcRecords(root string) ([]processRecord, error) {
124+ ents, err := os.ReadDir(root)
125+ if err != nil {
126+ return nil, err
127+ }
128+ var recs []processRecord
129+ for _, ent := range ents {
130+ pid, err := strconv.Atoi(ent.Name())
131+ if err != nil {
132+ continue
133+ }
134+ data, err := os.ReadFile(filepath.Join(root, ent.Name(), "stat"))
135+ if err != nil {
136+ continue
137+ }
138+ pgrp, err := parseStatPgrp(string(data))
139+ if err != nil {
140+ continue
141+ }
142+ recs = append(recs, processRecord{pid: pid, pgrp: pgrp})
143+ }
144+ return recs, nil
145+}
146+
147+func parseStatPgrp(stat string) (int, error) {
148+ end := strings.LastIndex(stat, ")")
149+ if end < 0 || end+2 >= len(stat) {
150+ return 0, fmt.Errorf("invalid stat")
151+ }
152+ fields := strings.Fields(stat[end+2:])
153+ if len(fields) < 3 {
154+ return 0, fmt.Errorf("invalid stat")
155+ }
156+ return strconv.Atoi(fields[2])
157+}
A internal/platform/process_linux_test.go +176 -0
@@ -0,0 +1,176 @@
1+package platform
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "reflect"
8+ "strconv"
9+ "testing"
10+)
11+
12+func TestProcessCwdRejectsInvalidPID(t *testing.T) {
13+ if _, err := processCwd("/proc", 0); err == nil {
14+ t.Fatal("processCwd accepted invalid pid")
15+ }
16+}
17+
18+func TestProcessArgv(t *testing.T) {
19+ argv, err := ProcessArgv(os.Getpid())
20+ if err != nil {
21+ t.Fatalf("ProcessArgv current process: %v", err)
22+ }
23+ if len(argv) == 0 {
24+ t.Fatal("ProcessArgv returned empty argv")
25+ }
26+}
27+
28+func TestProcessArgvRejectsEmptyCmdline(t *testing.T) {
29+ path := filepath.Join(t.TempDir(), "cmdline")
30+ if err := os.WriteFile(path, []byte("\x00\x00"), 0o600); err != nil {
31+ t.Fatal(err)
32+ }
33+ if argv, err := processArgv(path, 123); err == nil || argv != nil {
34+ t.Fatalf("processArgv empty = %#v, %v; want nil error", argv, err)
35+ }
36+}
37+
38+func TestSelectProcessGroupPID(t *testing.T) {
39+ tests := []struct {
40+ name string
41+ recs []processRecord
42+ pgid int
43+ shell int
44+ wantPID int
45+ wantOK bool
46+ }{
47+ {
48+ name: "selects other member",
49+ recs: []processRecord{{pid: 10, pgrp: 7}, {pid: 12, pgrp: 7}, {pid: 20, pgrp: 9}},
50+ pgid: 7,
51+ shell: 10,
52+ wantPID: 12,
53+ wantOK: true,
54+ },
55+ {
56+ name: "bare shell has no candidate",
57+ recs: []processRecord{{pid: 10, pgrp: 7}, {pid: 20, pgrp: 9}},
58+ pgid: 7,
59+ shell: 10,
60+ wantPID: 0,
61+ wantOK: false,
62+ },
63+ }
64+
65+ for _, tt := range tests {
66+ t.Run(tt.name, func(t *testing.T) {
67+ pid, ok := selectProcessGroupPID(tt.recs, tt.pgid, tt.shell)
68+ if ok != tt.wantOK || pid != tt.wantPID {
69+ t.Fatalf("selectProcessGroupPID = %d, %v; want %d, %v", pid, ok, tt.wantPID, tt.wantOK)
70+ }
71+ })
72+ }
73+}
74+
75+func TestProcessInspectorUsesConfiguredRoot(t *testing.T) {
76+ root := t.TempDir()
77+ writeProcStat(t, root, 10, 7)
78+ pidDir := filepath.Join(root, "10")
79+ cwdTarget := t.TempDir()
80+ if err := os.Symlink(cwdTarget, filepath.Join(pidDir, "cwd")); err != nil {
81+ t.Fatal(err)
82+ }
83+ if err := os.WriteFile(filepath.Join(pidDir, "comm"), []byte("custom\n"), 0o600); err != nil {
84+ t.Fatal(err)
85+ }
86+ if err := os.WriteFile(filepath.Join(pidDir, "cmdline"), []byte("custom\x00arg\x00"), 0o600); err != nil {
87+ t.Fatal(err)
88+ }
89+ ins := newProcessInspector(root)
90+
91+ cwd, err := ins.Cwd(10)
92+ if err != nil || cwd != cwdTarget {
93+ t.Fatalf("Cwd = %q, %v; want %q, nil", cwd, err, cwdTarget)
94+ }
95+ comm, err := ins.Comm(10)
96+ if err != nil || comm != "custom" {
97+ t.Fatalf("Comm = %q, %v; want custom, nil", comm, err)
98+ }
99+ argv, err := ins.Argv(10)
100+ if err != nil || !reflect.DeepEqual(argv, []string{"custom", "arg"}) {
101+ t.Fatalf("Argv = %#v, %v; want custom argv, nil", argv, err)
102+ }
103+}
104+
105+func TestProcessInspectorCachesProcessRecordsBriefly(t *testing.T) {
106+ root := t.TempDir()
107+ writeProcStat(t, root, 10, 7)
108+ ins := newProcessInspector(root)
109+
110+ first, err := ins.processRecords()
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ writeProcStat(t, root, 12, 7)
115+ second, err := ins.processRecords()
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ if len(first) != 1 || len(second) != 1 {
120+ t.Fatalf("cached processRecords lengths = %d, %d; want 1, 1", len(first), len(second))
121+ }
122+}
123+
124+func writeProcStat(t *testing.T, root string, pid int, pgrp int) {
125+ t.Helper()
126+ dir := filepath.Join(root, strconv.Itoa(pid))
127+ if err := os.MkdirAll(dir, 0o700); err != nil {
128+ t.Fatal(err)
129+ }
130+ stat := fmt.Sprintf("%d (cmd) S 1 %d 1 0 0", pid, pgrp)
131+ if err := os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o600); err != nil {
132+ t.Fatal(err)
133+ }
134+}
135+
136+func TestParseStatPgrp(t *testing.T) {
137+ got, err := parseStatPgrp("123 (cmd with spaces) S 1 456 456 0")
138+ if err != nil || got != 456 {
139+ t.Fatalf("parseStatPgrp = %d, %v; want 456, nil", got, err)
140+ }
141+}
142+
143+func TestProcessArgvFileParsing(t *testing.T) {
144+ tests := []struct {
145+ name string
146+ data []byte
147+ want []string
148+ }{
149+ {
150+ name: "splits nul-delimited argv",
151+ data: []byte("cmd\x00two words\x00"),
152+ want: []string{"cmd", "two words"},
153+ },
154+ {
155+ name: "preserves empty argument",
156+ data: []byte("cmd\x00\x00arg\x00"),
157+ want: []string{"cmd", "", "arg"},
158+ },
159+ }
160+
161+ for _, tt := range tests {
162+ t.Run(tt.name, func(t *testing.T) {
163+ path := filepath.Join(t.TempDir(), "cmdline")
164+ if err := os.WriteFile(path, tt.data, 0o600); err != nil {
165+ t.Fatal(err)
166+ }
167+ got, err := processArgv(path, 123)
168+ if err != nil {
169+ t.Fatal(err)
170+ }
171+ if !reflect.DeepEqual(got, tt.want) {
172+ t.Fatalf("processArgv = %#v; want %#v", got, tt.want)
173+ }
174+ })
175+ }
176+}
@@ -0,0 +1,176 @@
1+package platform
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "reflect"
8+ "strconv"
9+ "testing"
10+)
11+
12+func TestProcessCwdRejectsInvalidPID(t *testing.T) {
13+ if _, err := processCwd("/proc", 0); err == nil {
14+ t.Fatal("processCwd accepted invalid pid")
15+ }
16+}
17+
18+func TestProcessArgv(t *testing.T) {
19+ argv, err := ProcessArgv(os.Getpid())
20+ if err != nil {
21+ t.Fatalf("ProcessArgv current process: %v", err)
22+ }
23+ if len(argv) == 0 {
24+ t.Fatal("ProcessArgv returned empty argv")
25+ }
26+}
27+
28+func TestProcessArgvRejectsEmptyCmdline(t *testing.T) {
29+ path := filepath.Join(t.TempDir(), "cmdline")
30+ if err := os.WriteFile(path, []byte("\x00\x00"), 0o600); err != nil {
31+ t.Fatal(err)
32+ }
33+ if argv, err := processArgv(path, 123); err == nil || argv != nil {
34+ t.Fatalf("processArgv empty = %#v, %v; want nil error", argv, err)
35+ }
36+}
37+
38+func TestSelectProcessGroupPID(t *testing.T) {
39+ tests := []struct {
40+ name string
41+ recs []processRecord
42+ pgid int
43+ shell int
44+ wantPID int
45+ wantOK bool
46+ }{
47+ {
48+ name: "selects other member",
49+ recs: []processRecord{{pid: 10, pgrp: 7}, {pid: 12, pgrp: 7}, {pid: 20, pgrp: 9}},
50+ pgid: 7,
51+ shell: 10,
52+ wantPID: 12,
53+ wantOK: true,
54+ },
55+ {
56+ name: "bare shell has no candidate",
57+ recs: []processRecord{{pid: 10, pgrp: 7}, {pid: 20, pgrp: 9}},
58+ pgid: 7,
59+ shell: 10,
60+ wantPID: 0,
61+ wantOK: false,
62+ },
63+ }
64+
65+ for _, tt := range tests {
66+ t.Run(tt.name, func(t *testing.T) {
67+ pid, ok := selectProcessGroupPID(tt.recs, tt.pgid, tt.shell)
68+ if ok != tt.wantOK || pid != tt.wantPID {
69+ t.Fatalf("selectProcessGroupPID = %d, %v; want %d, %v", pid, ok, tt.wantPID, tt.wantOK)
70+ }
71+ })
72+ }
73+}
74+
75+func TestProcessInspectorUsesConfiguredRoot(t *testing.T) {
76+ root := t.TempDir()
77+ writeProcStat(t, root, 10, 7)
78+ pidDir := filepath.Join(root, "10")
79+ cwdTarget := t.TempDir()
80+ if err := os.Symlink(cwdTarget, filepath.Join(pidDir, "cwd")); err != nil {
81+ t.Fatal(err)
82+ }
83+ if err := os.WriteFile(filepath.Join(pidDir, "comm"), []byte("custom\n"), 0o600); err != nil {
84+ t.Fatal(err)
85+ }
86+ if err := os.WriteFile(filepath.Join(pidDir, "cmdline"), []byte("custom\x00arg\x00"), 0o600); err != nil {
87+ t.Fatal(err)
88+ }
89+ ins := newProcessInspector(root)
90+
91+ cwd, err := ins.Cwd(10)
92+ if err != nil || cwd != cwdTarget {
93+ t.Fatalf("Cwd = %q, %v; want %q, nil", cwd, err, cwdTarget)
94+ }
95+ comm, err := ins.Comm(10)
96+ if err != nil || comm != "custom" {
97+ t.Fatalf("Comm = %q, %v; want custom, nil", comm, err)
98+ }
99+ argv, err := ins.Argv(10)
100+ if err != nil || !reflect.DeepEqual(argv, []string{"custom", "arg"}) {
101+ t.Fatalf("Argv = %#v, %v; want custom argv, nil", argv, err)
102+ }
103+}
104+
105+func TestProcessInspectorCachesProcessRecordsBriefly(t *testing.T) {
106+ root := t.TempDir()
107+ writeProcStat(t, root, 10, 7)
108+ ins := newProcessInspector(root)
109+
110+ first, err := ins.processRecords()
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ writeProcStat(t, root, 12, 7)
115+ second, err := ins.processRecords()
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ if len(first) != 1 || len(second) != 1 {
120+ t.Fatalf("cached processRecords lengths = %d, %d; want 1, 1", len(first), len(second))
121+ }
122+}
123+
124+func writeProcStat(t *testing.T, root string, pid int, pgrp int) {
125+ t.Helper()
126+ dir := filepath.Join(root, strconv.Itoa(pid))
127+ if err := os.MkdirAll(dir, 0o700); err != nil {
128+ t.Fatal(err)
129+ }
130+ stat := fmt.Sprintf("%d (cmd) S 1 %d 1 0 0", pid, pgrp)
131+ if err := os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o600); err != nil {
132+ t.Fatal(err)
133+ }
134+}
135+
136+func TestParseStatPgrp(t *testing.T) {
137+ got, err := parseStatPgrp("123 (cmd with spaces) S 1 456 456 0")
138+ if err != nil || got != 456 {
139+ t.Fatalf("parseStatPgrp = %d, %v; want 456, nil", got, err)
140+ }
141+}
142+
143+func TestProcessArgvFileParsing(t *testing.T) {
144+ tests := []struct {
145+ name string
146+ data []byte
147+ want []string
148+ }{
149+ {
150+ name: "splits nul-delimited argv",
151+ data: []byte("cmd\x00two words\x00"),
152+ want: []string{"cmd", "two words"},
153+ },
154+ {
155+ name: "preserves empty argument",
156+ data: []byte("cmd\x00\x00arg\x00"),
157+ want: []string{"cmd", "", "arg"},
158+ },
159+ }
160+
161+ for _, tt := range tests {
162+ t.Run(tt.name, func(t *testing.T) {
163+ path := filepath.Join(t.TempDir(), "cmdline")
164+ if err := os.WriteFile(path, tt.data, 0o600); err != nil {
165+ t.Fatal(err)
166+ }
167+ got, err := processArgv(path, 123)
168+ if err != nil {
169+ t.Fatal(err)
170+ }
171+ if !reflect.DeepEqual(got, tt.want) {
172+ t.Fatalf("processArgv = %#v; want %#v", got, tt.want)
173+ }
174+ })
175+ }
176+}
M internal/ports/mocks/mocks.go +277 -0
@@ -2068,6 +2068,283 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal
20682068 return _c
20692069 }
20702070
2071+// NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2072+// The first argument is typically a *testing.T value.
2073+func NewMockProcessInspector(t interface {
2074+ mock.TestingT
2075+ Cleanup(func())
2076+}) *MockProcessInspector {
2077+ mock := &MockProcessInspector{}
2078+ mock.Mock.Test(t)
2079+
2080+ t.Cleanup(func() { mock.AssertExpectations(t) })
2081+
2082+ return mock
2083+}
2084+
2085+// MockProcessInspector is an autogenerated mock type for the ProcessInspector type
2086+type MockProcessInspector struct {
2087+ mock.Mock
2088+}
2089+
2090+type MockProcessInspector_Expecter struct {
2091+ mock *mock.Mock
2092+}
2093+
2094+func (_m *MockProcessInspector) EXPECT() *MockProcessInspector_Expecter {
2095+ return &MockProcessInspector_Expecter{mock: &_m.Mock}
2096+}
2097+
2098+// Argv provides a mock function for the type MockProcessInspector
2099+func (_mock *MockProcessInspector) Argv(pid int) ([]string, error) {
2100+ ret := _mock.Called(pid)
2101+
2102+ if len(ret) == 0 {
2103+ panic("no return value specified for Argv")
2104+ }
2105+
2106+ var r0 []string
2107+ var r1 error
2108+ if returnFunc, ok := ret.Get(0).(func(int) ([]string, error)); ok {
2109+ return returnFunc(pid)
2110+ }
2111+ if returnFunc, ok := ret.Get(0).(func(int) []string); ok {
2112+ r0 = returnFunc(pid)
2113+ } else {
2114+ if ret.Get(0) != nil {
2115+ r0 = ret.Get(0).([]string)
2116+ }
2117+ }
2118+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
2119+ r1 = returnFunc(pid)
2120+ } else {
2121+ r1 = ret.Error(1)
2122+ }
2123+ return r0, r1
2124+}
2125+
2126+// MockProcessInspector_Argv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Argv'
2127+type MockProcessInspector_Argv_Call struct {
2128+ *mock.Call
2129+}
2130+
2131+// Argv is a helper method to define mock.On call
2132+// - pid int
2133+func (_e *MockProcessInspector_Expecter) Argv(pid any) *MockProcessInspector_Argv_Call {
2134+ return &MockProcessInspector_Argv_Call{Call: _e.mock.On("Argv", pid)}
2135+}
2136+
2137+func (_c *MockProcessInspector_Argv_Call) Run(run func(pid int)) *MockProcessInspector_Argv_Call {
2138+ _c.Call.Run(func(args mock.Arguments) {
2139+ var arg0 int
2140+ if args[0] != nil {
2141+ arg0 = args[0].(int)
2142+ }
2143+ run(
2144+ arg0,
2145+ )
2146+ })
2147+ return _c
2148+}
2149+
2150+func (_c *MockProcessInspector_Argv_Call) Return(strings []string, err error) *MockProcessInspector_Argv_Call {
2151+ _c.Call.Return(strings, err)
2152+ return _c
2153+}
2154+
2155+func (_c *MockProcessInspector_Argv_Call) RunAndReturn(run func(pid int) ([]string, error)) *MockProcessInspector_Argv_Call {
2156+ _c.Call.Return(run)
2157+ return _c
2158+}
2159+
2160+// Comm provides a mock function for the type MockProcessInspector
2161+func (_mock *MockProcessInspector) Comm(pid int) (string, error) {
2162+ ret := _mock.Called(pid)
2163+
2164+ if len(ret) == 0 {
2165+ panic("no return value specified for Comm")
2166+ }
2167+
2168+ var r0 string
2169+ var r1 error
2170+ if returnFunc, ok := ret.Get(0).(func(int) (string, error)); ok {
2171+ return returnFunc(pid)
2172+ }
2173+ if returnFunc, ok := ret.Get(0).(func(int) string); ok {
2174+ r0 = returnFunc(pid)
2175+ } else {
2176+ r0 = ret.Get(0).(string)
2177+ }
2178+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
2179+ r1 = returnFunc(pid)
2180+ } else {
2181+ r1 = ret.Error(1)
2182+ }
2183+ return r0, r1
2184+}
2185+
2186+// MockProcessInspector_Comm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Comm'
2187+type MockProcessInspector_Comm_Call struct {
2188+ *mock.Call
2189+}
2190+
2191+// Comm is a helper method to define mock.On call
2192+// - pid int
2193+func (_e *MockProcessInspector_Expecter) Comm(pid any) *MockProcessInspector_Comm_Call {
2194+ return &MockProcessInspector_Comm_Call{Call: _e.mock.On("Comm", pid)}
2195+}
2196+
2197+func (_c *MockProcessInspector_Comm_Call) Run(run func(pid int)) *MockProcessInspector_Comm_Call {
2198+ _c.Call.Run(func(args mock.Arguments) {
2199+ var arg0 int
2200+ if args[0] != nil {
2201+ arg0 = args[0].(int)
2202+ }
2203+ run(
2204+ arg0,
2205+ )
2206+ })
2207+ return _c
2208+}
2209+
2210+func (_c *MockProcessInspector_Comm_Call) Return(s string, err error) *MockProcessInspector_Comm_Call {
2211+ _c.Call.Return(s, err)
2212+ return _c
2213+}
2214+
2215+func (_c *MockProcessInspector_Comm_Call) RunAndReturn(run func(pid int) (string, error)) *MockProcessInspector_Comm_Call {
2216+ _c.Call.Return(run)
2217+ return _c
2218+}
2219+
2220+// Cwd provides a mock function for the type MockProcessInspector
2221+func (_mock *MockProcessInspector) Cwd(pid int) (string, error) {
2222+ ret := _mock.Called(pid)
2223+
2224+ if len(ret) == 0 {
2225+ panic("no return value specified for Cwd")
2226+ }
2227+
2228+ var r0 string
2229+ var r1 error
2230+ if returnFunc, ok := ret.Get(0).(func(int) (string, error)); ok {
2231+ return returnFunc(pid)
2232+ }
2233+ if returnFunc, ok := ret.Get(0).(func(int) string); ok {
2234+ r0 = returnFunc(pid)
2235+ } else {
2236+ r0 = ret.Get(0).(string)
2237+ }
2238+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
2239+ r1 = returnFunc(pid)
2240+ } else {
2241+ r1 = ret.Error(1)
2242+ }
2243+ return r0, r1
2244+}
2245+
2246+// MockProcessInspector_Cwd_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Cwd'
2247+type MockProcessInspector_Cwd_Call struct {
2248+ *mock.Call
2249+}
2250+
2251+// Cwd is a helper method to define mock.On call
2252+// - pid int
2253+func (_e *MockProcessInspector_Expecter) Cwd(pid any) *MockProcessInspector_Cwd_Call {
2254+ return &MockProcessInspector_Cwd_Call{Call: _e.mock.On("Cwd", pid)}
2255+}
2256+
2257+func (_c *MockProcessInspector_Cwd_Call) Run(run func(pid int)) *MockProcessInspector_Cwd_Call {
2258+ _c.Call.Run(func(args mock.Arguments) {
2259+ var arg0 int
2260+ if args[0] != nil {
2261+ arg0 = args[0].(int)
2262+ }
2263+ run(
2264+ arg0,
2265+ )
2266+ })
2267+ return _c
2268+}
2269+
2270+func (_c *MockProcessInspector_Cwd_Call) Return(s string, err error) *MockProcessInspector_Cwd_Call {
2271+ _c.Call.Return(s, err)
2272+ return _c
2273+}
2274+
2275+func (_c *MockProcessInspector_Cwd_Call) RunAndReturn(run func(pid int) (string, error)) *MockProcessInspector_Cwd_Call {
2276+ _c.Call.Return(run)
2277+ return _c
2278+}
2279+
2280+// GroupArgv provides a mock function for the type MockProcessInspector
2281+func (_mock *MockProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
2282+ ret := _mock.Called(pgid, shellPid)
2283+
2284+ if len(ret) == 0 {
2285+ panic("no return value specified for GroupArgv")
2286+ }
2287+
2288+ var r0 []string
2289+ var r1 error
2290+ if returnFunc, ok := ret.Get(0).(func(int, int) ([]string, error)); ok {
2291+ return returnFunc(pgid, shellPid)
2292+ }
2293+ if returnFunc, ok := ret.Get(0).(func(int, int) []string); ok {
2294+ r0 = returnFunc(pgid, shellPid)
2295+ } else {
2296+ if ret.Get(0) != nil {
2297+ r0 = ret.Get(0).([]string)
2298+ }
2299+ }
2300+ if returnFunc, ok := ret.Get(1).(func(int, int) error); ok {
2301+ r1 = returnFunc(pgid, shellPid)
2302+ } else {
2303+ r1 = ret.Error(1)
2304+ }
2305+ return r0, r1
2306+}
2307+
2308+// MockProcessInspector_GroupArgv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GroupArgv'
2309+type MockProcessInspector_GroupArgv_Call struct {
2310+ *mock.Call
2311+}
2312+
2313+// GroupArgv is a helper method to define mock.On call
2314+// - pgid int
2315+// - shellPid int
2316+func (_e *MockProcessInspector_Expecter) GroupArgv(pgid any, shellPid any) *MockProcessInspector_GroupArgv_Call {
2317+ return &MockProcessInspector_GroupArgv_Call{Call: _e.mock.On("GroupArgv", pgid, shellPid)}
2318+}
2319+
2320+func (_c *MockProcessInspector_GroupArgv_Call) Run(run func(pgid int, shellPid int)) *MockProcessInspector_GroupArgv_Call {
2321+ _c.Call.Run(func(args mock.Arguments) {
2322+ var arg0 int
2323+ if args[0] != nil {
2324+ arg0 = args[0].(int)
2325+ }
2326+ var arg1 int
2327+ if args[1] != nil {
2328+ arg1 = args[1].(int)
2329+ }
2330+ run(
2331+ arg0,
2332+ arg1,
2333+ )
2334+ })
2335+ return _c
2336+}
2337+
2338+func (_c *MockProcessInspector_GroupArgv_Call) Return(strings []string, err error) *MockProcessInspector_GroupArgv_Call {
2339+ _c.Call.Return(strings, err)
2340+ return _c
2341+}
2342+
2343+func (_c *MockProcessInspector_GroupArgv_Call) RunAndReturn(run func(pgid int, shellPid int) ([]string, error)) *MockProcessInspector_GroupArgv_Call {
2344+ _c.Call.Return(run)
2345+ return _c
2346+}
2347+
20712348 // NewMockClipboardReader creates a new instance of MockClipboardReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
20722349 // The first argument is typically a *testing.T value.
20732350 func NewMockClipboardReader(t interface {
@@ -2068,6 +2068,283 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal
2068 return _c2068 return _c
2069 }2069 }
2070 2070
2071+// NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2072+// The first argument is typically a *testing.T value.
2073+func NewMockProcessInspector(t interface {
2074+ mock.TestingT
2075+ Cleanup(func())
2076+}) *MockProcessInspector {
2077+ mock := &MockProcessInspector{}
2078+ mock.Mock.Test(t)
2079+
2080+ t.Cleanup(func() { mock.AssertExpectations(t) })
2081+
2082+ return mock
2083+}
2084+
2085+// MockProcessInspector is an autogenerated mock type for the ProcessInspector type
2086+type MockProcessInspector struct {
2087+ mock.Mock
2088+}
2089+
2090+type MockProcessInspector_Expecter struct {
2091+ mock *mock.Mock
2092+}
2093+
2094+func (_m *MockProcessInspector) EXPECT() *MockProcessInspector_Expecter {
2095+ return &MockProcessInspector_Expecter{mock: &_m.Mock}
2096+}
2097+
2098+// Argv provides a mock function for the type MockProcessInspector
2099+func (_mock *MockProcessInspector) Argv(pid int) ([]string, error) {
2100+ ret := _mock.Called(pid)
2101+
2102+ if len(ret) == 0 {
2103+ panic("no return value specified for Argv")
2104+ }
2105+
2106+ var r0 []string
2107+ var r1 error
2108+ if returnFunc, ok := ret.Get(0).(func(int) ([]string, error)); ok {
2109+ return returnFunc(pid)
2110+ }
2111+ if returnFunc, ok := ret.Get(0).(func(int) []string); ok {
2112+ r0 = returnFunc(pid)
2113+ } else {
2114+ if ret.Get(0) != nil {
2115+ r0 = ret.Get(0).([]string)
2116+ }
2117+ }
2118+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
2119+ r1 = returnFunc(pid)
2120+ } else {
2121+ r1 = ret.Error(1)
2122+ }
2123+ return r0, r1
2124+}
2125+
2126+// MockProcessInspector_Argv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Argv'
2127+type MockProcessInspector_Argv_Call struct {
2128+ *mock.Call
2129+}
2130+
2131+// Argv is a helper method to define mock.On call
2132+// - pid int
2133+func (_e *MockProcessInspector_Expecter) Argv(pid any) *MockProcessInspector_Argv_Call {
2134+ return &MockProcessInspector_Argv_Call{Call: _e.mock.On("Argv", pid)}
2135+}
2136+
2137+func (_c *MockProcessInspector_Argv_Call) Run(run func(pid int)) *MockProcessInspector_Argv_Call {
2138+ _c.Call.Run(func(args mock.Arguments) {
2139+ var arg0 int
2140+ if args[0] != nil {
2141+ arg0 = args[0].(int)
2142+ }
2143+ run(
2144+ arg0,
2145+ )
2146+ })
2147+ return _c
2148+}
2149+
2150+func (_c *MockProcessInspector_Argv_Call) Return(strings []string, err error) *MockProcessInspector_Argv_Call {
2151+ _c.Call.Return(strings, err)
2152+ return _c
2153+}
2154+
2155+func (_c *MockProcessInspector_Argv_Call) RunAndReturn(run func(pid int) ([]string, error)) *MockProcessInspector_Argv_Call {
2156+ _c.Call.Return(run)
2157+ return _c
2158+}
2159+
2160+// Comm provides a mock function for the type MockProcessInspector
2161+func (_mock *MockProcessInspector) Comm(pid int) (string, error) {
2162+ ret := _mock.Called(pid)
2163+
2164+ if len(ret) == 0 {
2165+ panic("no return value specified for Comm")
2166+ }
2167+
2168+ var r0 string
2169+ var r1 error
2170+ if returnFunc, ok := ret.Get(0).(func(int) (string, error)); ok {
2171+ return returnFunc(pid)
2172+ }
2173+ if returnFunc, ok := ret.Get(0).(func(int) string); ok {
2174+ r0 = returnFunc(pid)
2175+ } else {
2176+ r0 = ret.Get(0).(string)
2177+ }
2178+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
2179+ r1 = returnFunc(pid)
2180+ } else {
2181+ r1 = ret.Error(1)
2182+ }
2183+ return r0, r1
2184+}
2185+
2186+// MockProcessInspector_Comm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Comm'
2187+type MockProcessInspector_Comm_Call struct {
2188+ *mock.Call
2189+}
2190+
2191+// Comm is a helper method to define mock.On call
2192+// - pid int
2193+func (_e *MockProcessInspector_Expecter) Comm(pid any) *MockProcessInspector_Comm_Call {
2194+ return &MockProcessInspector_Comm_Call{Call: _e.mock.On("Comm", pid)}
2195+}
2196+
2197+func (_c *MockProcessInspector_Comm_Call) Run(run func(pid int)) *MockProcessInspector_Comm_Call {
2198+ _c.Call.Run(func(args mock.Arguments) {
2199+ var arg0 int
2200+ if args[0] != nil {
2201+ arg0 = args[0].(int)
2202+ }
2203+ run(
2204+ arg0,
2205+ )
2206+ })
2207+ return _c
2208+}
2209+
2210+func (_c *MockProcessInspector_Comm_Call) Return(s string, err error) *MockProcessInspector_Comm_Call {
2211+ _c.Call.Return(s, err)
2212+ return _c
2213+}
2214+
2215+func (_c *MockProcessInspector_Comm_Call) RunAndReturn(run func(pid int) (string, error)) *MockProcessInspector_Comm_Call {
2216+ _c.Call.Return(run)
2217+ return _c
2218+}
2219+
2220+// Cwd provides a mock function for the type MockProcessInspector
2221+func (_mock *MockProcessInspector) Cwd(pid int) (string, error) {
2222+ ret := _mock.Called(pid)
2223+
2224+ if len(ret) == 0 {
2225+ panic("no return value specified for Cwd")
2226+ }
2227+
2228+ var r0 string
2229+ var r1 error
2230+ if returnFunc, ok := ret.Get(0).(func(int) (string, error)); ok {
2231+ return returnFunc(pid)
2232+ }
2233+ if returnFunc, ok := ret.Get(0).(func(int) string); ok {
2234+ r0 = returnFunc(pid)
2235+ } else {
2236+ r0 = ret.Get(0).(string)
2237+ }
2238+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
2239+ r1 = returnFunc(pid)
2240+ } else {
2241+ r1 = ret.Error(1)
2242+ }
2243+ return r0, r1
2244+}
2245+
2246+// MockProcessInspector_Cwd_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Cwd'
2247+type MockProcessInspector_Cwd_Call struct {
2248+ *mock.Call
2249+}
2250+
2251+// Cwd is a helper method to define mock.On call
2252+// - pid int
2253+func (_e *MockProcessInspector_Expecter) Cwd(pid any) *MockProcessInspector_Cwd_Call {
2254+ return &MockProcessInspector_Cwd_Call{Call: _e.mock.On("Cwd", pid)}
2255+}
2256+
2257+func (_c *MockProcessInspector_Cwd_Call) Run(run func(pid int)) *MockProcessInspector_Cwd_Call {
2258+ _c.Call.Run(func(args mock.Arguments) {
2259+ var arg0 int
2260+ if args[0] != nil {
2261+ arg0 = args[0].(int)
2262+ }
2263+ run(
2264+ arg0,
2265+ )
2266+ })
2267+ return _c
2268+}
2269+
2270+func (_c *MockProcessInspector_Cwd_Call) Return(s string, err error) *MockProcessInspector_Cwd_Call {
2271+ _c.Call.Return(s, err)
2272+ return _c
2273+}
2274+
2275+func (_c *MockProcessInspector_Cwd_Call) RunAndReturn(run func(pid int) (string, error)) *MockProcessInspector_Cwd_Call {
2276+ _c.Call.Return(run)
2277+ return _c
2278+}
2279+
2280+// GroupArgv provides a mock function for the type MockProcessInspector
2281+func (_mock *MockProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
2282+ ret := _mock.Called(pgid, shellPid)
2283+
2284+ if len(ret) == 0 {
2285+ panic("no return value specified for GroupArgv")
2286+ }
2287+
2288+ var r0 []string
2289+ var r1 error
2290+ if returnFunc, ok := ret.Get(0).(func(int, int) ([]string, error)); ok {
2291+ return returnFunc(pgid, shellPid)
2292+ }
2293+ if returnFunc, ok := ret.Get(0).(func(int, int) []string); ok {
2294+ r0 = returnFunc(pgid, shellPid)
2295+ } else {
2296+ if ret.Get(0) != nil {
2297+ r0 = ret.Get(0).([]string)
2298+ }
2299+ }
2300+ if returnFunc, ok := ret.Get(1).(func(int, int) error); ok {
2301+ r1 = returnFunc(pgid, shellPid)
2302+ } else {
2303+ r1 = ret.Error(1)
2304+ }
2305+ return r0, r1
2306+}
2307+
2308+// MockProcessInspector_GroupArgv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GroupArgv'
2309+type MockProcessInspector_GroupArgv_Call struct {
2310+ *mock.Call
2311+}
2312+
2313+// GroupArgv is a helper method to define mock.On call
2314+// - pgid int
2315+// - shellPid int
2316+func (_e *MockProcessInspector_Expecter) GroupArgv(pgid any, shellPid any) *MockProcessInspector_GroupArgv_Call {
2317+ return &MockProcessInspector_GroupArgv_Call{Call: _e.mock.On("GroupArgv", pgid, shellPid)}
2318+}
2319+
2320+func (_c *MockProcessInspector_GroupArgv_Call) Run(run func(pgid int, shellPid int)) *MockProcessInspector_GroupArgv_Call {
2321+ _c.Call.Run(func(args mock.Arguments) {
2322+ var arg0 int
2323+ if args[0] != nil {
2324+ arg0 = args[0].(int)
2325+ }
2326+ var arg1 int
2327+ if args[1] != nil {
2328+ arg1 = args[1].(int)
2329+ }
2330+ run(
2331+ arg0,
2332+ arg1,
2333+ )
2334+ })
2335+ return _c
2336+}
2337+
2338+func (_c *MockProcessInspector_GroupArgv_Call) Return(strings []string, err error) *MockProcessInspector_GroupArgv_Call {
2339+ _c.Call.Return(strings, err)
2340+ return _c
2341+}
2342+
2343+func (_c *MockProcessInspector_GroupArgv_Call) RunAndReturn(run func(pgid int, shellPid int) ([]string, error)) *MockProcessInspector_GroupArgv_Call {
2344+ _c.Call.Return(run)
2345+ return _c
2346+}
2347+
2071 // NewMockClipboardReader creates a new instance of MockClipboardReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.2348 // NewMockClipboardReader creates a new instance of MockClipboardReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2072 // The first argument is typically a *testing.T value.2349 // The first argument is typically a *testing.T value.
2073 func NewMockClipboardReader(t interface {2350 func NewMockClipboardReader(t interface {
M internal/ports/ports.go +9 -0
@@ -96,6 +96,15 @@ type Timer interface {
9696 Stop() bool
9797 }
9898
99+// ProcessInspector reads process metadata for daemon snapshot/persistence use.
100+// Implementations may live in platform packages; usecases depend on this port.
101+type ProcessInspector interface {
102+ Cwd(pid int) (string, error)
103+ Comm(pid int) (string, error)
104+ Argv(pid int) ([]string, error)
105+ GroupArgv(pgid int, shellPid int) ([]string, error)
106+}
107+
99108 // ClipboardReader reads an image off the CLIENT-side system clipboard, for
100109 // forwarding to a remote session's focused pane. Implementations live in
101110 // internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only
@@ -96,6 +96,15 @@ type Timer interface {
96 Stop() bool96 Stop() bool
97 }97 }
98 98
99+// ProcessInspector reads process metadata for daemon snapshot/persistence use.
100+// Implementations may live in platform packages; usecases depend on this port.
101+type ProcessInspector interface {
102+ Cwd(pid int) (string, error)
103+ Comm(pid int) (string, error)
104+ Argv(pid int) ([]string, error)
105+ GroupArgv(pgid int, shellPid int) ([]string, error)
106+}
107+
99 // ClipboardReader reads an image off the CLIENT-side system clipboard, for108 // ClipboardReader reads an image off the CLIENT-side system clipboard, for
100 // forwarding to a remote session's focused pane. Implementations live in109 // forwarding to a remote session's focused pane. Implementations live in
101 // internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only110 // internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only
M internal/ports/wire.go +6 -5
@@ -40,11 +40,12 @@ const (
4040
4141 // ErrorMsg codes.
4242 const (
43- ErrVersionMismatch uint16 = 1
44- ErrNoSuchSession uint16 = 2
45- ErrNameTaken uint16 = 3
46- ErrServerShutdown uint16 = 4
47- ErrInternal uint16 = 255
43+ ErrVersionMismatch uint16 = 1
44+ ErrNoSuchSession uint16 = 2
45+ ErrNameTaken uint16 = 3
46+ ErrServerShutdown uint16 = 4
47+ ErrInvalidSessionName uint16 = 5
48+ ErrInternal uint16 = 255
4849 )
4950
5051 // Detached reasons.
@@ -40,11 +40,12 @@ const (
40 40
41 // ErrorMsg codes.41 // ErrorMsg codes.
42 const (42 const (
43- ErrVersionMismatch uint16 = 143+ ErrVersionMismatch uint16 = 1
44- ErrNoSuchSession uint16 = 244+ ErrNoSuchSession uint16 = 2
45- ErrNameTaken uint16 = 345+ ErrNameTaken uint16 = 3
46- ErrServerShutdown uint16 = 446+ ErrServerShutdown uint16 = 4
47- ErrInternal uint16 = 25547+ ErrInvalidSessionName uint16 = 5
48+ ErrInternal uint16 = 255
48 )49 )
49 50
50 // Detached reasons.51 // Detached reasons.
M internal/ports/wire_test.go +8 -6
@@ -542,13 +542,15 @@ func TestMsgTypeConstantsDistinct(t *testing.T) {
542542
543543 t.Run("error codes", func(t *testing.T) {
544544 vals := map[uint16]string{
545- ErrVersionMismatch: "ErrVersionMismatch",
546- ErrNoSuchSession: "ErrNoSuchSession",
547- ErrNameTaken: "ErrNameTaken",
548- ErrInternal: "ErrInternal",
545+ ErrVersionMismatch: "ErrVersionMismatch",
546+ ErrNoSuchSession: "ErrNoSuchSession",
547+ ErrNameTaken: "ErrNameTaken",
548+ ErrServerShutdown: "ErrServerShutdown",
549+ ErrInvalidSessionName: "ErrInvalidSessionName",
550+ ErrInternal: "ErrInternal",
549551 }
550- if len(vals) != 4 {
551- t.Fatalf("expected 4 distinct error codes, got %d", len(vals))
552+ if len(vals) != 6 {
553+ t.Fatalf("expected 6 distinct error codes, got %d", len(vals))
552554 }
553555 })
554556
@@ -542,13 +542,15 @@ func TestMsgTypeConstantsDistinct(t *testing.T) {
542 542
543 t.Run("error codes", func(t *testing.T) {543 t.Run("error codes", func(t *testing.T) {
544 vals := map[uint16]string{544 vals := map[uint16]string{
545- ErrVersionMismatch: "ErrVersionMismatch",545+ ErrVersionMismatch: "ErrVersionMismatch",
546- ErrNoSuchSession: "ErrNoSuchSession",546+ ErrNoSuchSession: "ErrNoSuchSession",
547- ErrNameTaken: "ErrNameTaken",547+ ErrNameTaken: "ErrNameTaken",
548- ErrInternal: "ErrInternal",548+ ErrServerShutdown: "ErrServerShutdown",
549+ ErrInvalidSessionName: "ErrInvalidSessionName",
550+ ErrInternal: "ErrInternal",
549 }551 }
550- if len(vals) != 4 {552+ if len(vals) != 6 {
551- t.Fatalf("expected 4 distinct error codes, got %d", len(vals))553+ t.Fatalf("expected 6 distinct error codes, got %d", len(vals))
552 }554 }
553 })555 })
554 556
M internal/usecase/daemon/config.go +34 -3
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "fmt"
5+ "maps"
56 "regexp"
67 "sort"
78 "strings"
@@ -21,8 +22,10 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
2122 d.logConfigWarning(warning)
2223 }
2324 overrides := d.buildCodeOverrides(cfg.Codes)
25+ allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot)
2426 d.bindings.Store(bindings)
2527 d.codeOverrides.Store(&overrides)
28+ d.restoreProcessAllowlist.Store(&allowlist)
2629 d.themeMode.Store(uint32(cfg.Theme))
2730 d.reapplyThemeAllSessions()
2831 d.repaintAllAttachedClients()
@@ -56,9 +59,7 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str
5659 // *different* slug's override — that must be re-checked regardless of
5760 // where the two slugs fall in sort order.
5861 accepted := make(map[string]string, len(desired))
59- for slug, code := range desired {
60- accepted[slug] = code
61- }
62+ maps.Copy(accepted, desired)
6263 effectiveCode := func(slug string) string {
6364 if code, ok := accepted[slug]; ok {
6465 return code
@@ -122,6 +123,36 @@ func sortedKeys(values map[string]string) []string {
122123 return keys
123124 }
124125
126+func restoreProcessAllowlistFromConfig(cfg domain.SnapshotConfig) map[string]struct{} {
127+ if cfg.RestoreProcessesSet {
128+ return buildRestoreProcessAllowlist(cfg.RestoreProcesses)
129+ }
130+ if len(cfg.RestoreProcesses) > 0 {
131+ return buildRestoreProcessAllowlist(cfg.RestoreProcesses)
132+ }
133+ return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
134+}
135+
136+func buildRestoreProcessAllowlist(values []string) map[string]struct{} {
137+ allow := make(map[string]struct{}, len(values))
138+ for _, value := range values {
139+ value = strings.TrimSpace(value)
140+ if value == "" {
141+ continue
142+ }
143+ allow[value] = struct{}{}
144+ }
145+ return allow
146+}
147+
148+func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} {
149+ allow := d.restoreProcessAllowlist.Load()
150+ if allow == nil {
151+ return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
152+ }
153+ return *allow
154+}
155+
125156 func (d *Daemon) logConfigWarning(w domain.Warning) {
126157 if w.Line > 0 {
127158 d.log.Warn("config warning", "line", w.Line, "msg", w.Msg)
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "maps"
5 "regexp"6 "regexp"
6 "sort"7 "sort"
7 "strings"8 "strings"
@@ -21,8 +22,10 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
21 d.logConfigWarning(warning)22 d.logConfigWarning(warning)
22 }23 }
23 overrides := d.buildCodeOverrides(cfg.Codes)24 overrides := d.buildCodeOverrides(cfg.Codes)
25+ allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot)
24 d.bindings.Store(bindings)26 d.bindings.Store(bindings)
25 d.codeOverrides.Store(&overrides)27 d.codeOverrides.Store(&overrides)
28+ d.restoreProcessAllowlist.Store(&allowlist)
26 d.themeMode.Store(uint32(cfg.Theme))29 d.themeMode.Store(uint32(cfg.Theme))
27 d.reapplyThemeAllSessions()30 d.reapplyThemeAllSessions()
28 d.repaintAllAttachedClients()31 d.repaintAllAttachedClients()
@@ -56,9 +59,7 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str
56 // *different* slug's override — that must be re-checked regardless of59 // *different* slug's override — that must be re-checked regardless of
57 // where the two slugs fall in sort order.60 // where the two slugs fall in sort order.
58 accepted := make(map[string]string, len(desired))61 accepted := make(map[string]string, len(desired))
59- for slug, code := range desired {62+ maps.Copy(accepted, desired)
60- accepted[slug] = code
61- }
62 effectiveCode := func(slug string) string {63 effectiveCode := func(slug string) string {
63 if code, ok := accepted[slug]; ok {64 if code, ok := accepted[slug]; ok {
64 return code65 return code
@@ -122,6 +123,36 @@ func sortedKeys(values map[string]string) []string {
122 return keys123 return keys
123 }124 }
124 125
126+func restoreProcessAllowlistFromConfig(cfg domain.SnapshotConfig) map[string]struct{} {
127+ if cfg.RestoreProcessesSet {
128+ return buildRestoreProcessAllowlist(cfg.RestoreProcesses)
129+ }
130+ if len(cfg.RestoreProcesses) > 0 {
131+ return buildRestoreProcessAllowlist(cfg.RestoreProcesses)
132+ }
133+ return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
134+}
135+
136+func buildRestoreProcessAllowlist(values []string) map[string]struct{} {
137+ allow := make(map[string]struct{}, len(values))
138+ for _, value := range values {
139+ value = strings.TrimSpace(value)
140+ if value == "" {
141+ continue
142+ }
143+ allow[value] = struct{}{}
144+ }
145+ return allow
146+}
147+
148+func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} {
149+ allow := d.restoreProcessAllowlist.Load()
150+ if allow == nil {
151+ return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
152+ }
153+ return *allow
154+}
155+
125 func (d *Daemon) logConfigWarning(w domain.Warning) {156 func (d *Daemon) logConfigWarning(w domain.Warning) {
126 if w.Line > 0 {157 if w.Line > 0 {
127 d.log.Warn("config warning", "line", w.Line, "msg", w.Msg)158 d.log.Warn("config warning", "line", w.Line, "msg", w.Msg)
M internal/usecase/daemon/config_test.go +47 -0
@@ -49,6 +49,53 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
4949 require.Equal(t, "NT", cmd.Code)
5050 }
5151
52+func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {
53+ tests := []struct {
54+ name string
55+ cfg domain.Config
56+ check func(t *testing.T, allow map[string]struct{})
57+ }{
58+ {
59+ name: "default allowlist",
60+ check: func(t *testing.T, allow map[string]struct{}) {
61+ t.Helper()
62+ require.Contains(t, allow, "claude")
63+ },
64+ },
65+ {
66+ name: "explicit empty disables restore",
67+ cfg: domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true}},
68+ check: func(t *testing.T, allow map[string]struct{}) {
69+ t.Helper()
70+ require.Empty(t, allow)
71+ },
72+ },
73+ {
74+ name: "explicit values are trimmed",
75+ cfg: domain.Config{Snapshot: domain.SnapshotConfig{
76+ RestoreProcessesSet: true,
77+ RestoreProcesses: []string{"less", "", " pi "},
78+ }},
79+ check: func(t *testing.T, allow map[string]struct{}) {
80+ t.Helper()
81+ require.Contains(t, allow, "less")
82+ require.Contains(t, allow, "pi")
83+ require.NotContains(t, allow, "")
84+ },
85+ },
86+ }
87+
88+ for _, tt := range tests {
89+ t.Run(tt.name, func(t *testing.T) {
90+ d := newTestDaemon(t, nil, stubClock{})
91+ if tt.cfg.Snapshot.RestoreProcessesSet || len(tt.cfg.Snapshot.RestoreProcesses) > 0 {
92+ d.ApplyConfig(tt.cfg)
93+ }
94+ tt.check(t, d.restoreProcessAllowlistSnapshot())
95+ })
96+ }
97+}
98+
5299 func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {
53100 d := newTestDaemon(t, nil, stubClock{})
54101 d.ApplyConfig(domain.Config{
@@ -49,6 +49,53 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
49 require.Equal(t, "NT", cmd.Code)49 require.Equal(t, "NT", cmd.Code)
50 }50 }
51 51
52+func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {
53+ tests := []struct {
54+ name string
55+ cfg domain.Config
56+ check func(t *testing.T, allow map[string]struct{})
57+ }{
58+ {
59+ name: "default allowlist",
60+ check: func(t *testing.T, allow map[string]struct{}) {
61+ t.Helper()
62+ require.Contains(t, allow, "claude")
63+ },
64+ },
65+ {
66+ name: "explicit empty disables restore",
67+ cfg: domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true}},
68+ check: func(t *testing.T, allow map[string]struct{}) {
69+ t.Helper()
70+ require.Empty(t, allow)
71+ },
72+ },
73+ {
74+ name: "explicit values are trimmed",
75+ cfg: domain.Config{Snapshot: domain.SnapshotConfig{
76+ RestoreProcessesSet: true,
77+ RestoreProcesses: []string{"less", "", " pi "},
78+ }},
79+ check: func(t *testing.T, allow map[string]struct{}) {
80+ t.Helper()
81+ require.Contains(t, allow, "less")
82+ require.Contains(t, allow, "pi")
83+ require.NotContains(t, allow, "")
84+ },
85+ },
86+ }
87+
88+ for _, tt := range tests {
89+ t.Run(tt.name, func(t *testing.T) {
90+ d := newTestDaemon(t, nil, stubClock{})
91+ if tt.cfg.Snapshot.RestoreProcessesSet || len(tt.cfg.Snapshot.RestoreProcesses) > 0 {
92+ d.ApplyConfig(tt.cfg)
93+ }
94+ tt.check(t, d.restoreProcessAllowlistSnapshot())
95+ })
96+ }
97+}
98+
52 func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {99 func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {
53 d := newTestDaemon(t, nil, stubClock{})100 d := newTestDaemon(t, nil, stubClock{})
54 d.ApplyConfig(domain.Config{101 d.ApplyConfig(domain.Config{
M internal/usecase/daemon/daemon.go +57 -28
@@ -36,7 +36,6 @@ import (
3636
3737 "github.com/bnema/vev/internal/domain"
3838 "github.com/bnema/vev/internal/persist"
39- "github.com/bnema/vev/internal/platform"
4039 "github.com/bnema/vev/internal/ports"
4140 "github.com/bnema/vev/internal/usecase/keys"
4241 )
@@ -96,23 +95,27 @@ type Daemon struct {
9695 paletteRecentMu sync.Mutex
9796 paletteRecent []string
9897
99- ptys ports.PTYFactory
100- clock ports.Clock
101- log *slog.Logger
102- baseEnv []string
103- shell string
104- shellArgs []string
105- persist *persist.Persister
106- persistEnabled bool
107- snaps ports.SnapshotStore
108- snapsEnabled bool
109- restoreDone chan struct{}
110- restoreOnce sync.Once
111- procCwd func(int) (string, error)
112- procComm func(int) (string, error)
113- bindings atomic.Pointer[keys.Bindings]
114- codeOverrides atomic.Pointer[map[string]string]
115- themeMode atomic.Uint32
98+ ptys ports.PTYFactory
99+ clock ports.Clock
100+ log *slog.Logger
101+ baseEnv []string
102+ shell string
103+ shellArgs []string
104+ persist *persist.Persister
105+ persistEnabled bool
106+ snaps ports.SnapshotStore
107+ snapsEnabled bool
108+ restoreDone chan struct{}
109+ restoreOnce sync.Once
110+ procCwd func(int) (string, error)
111+ procComm func(int) (string, error)
112+ procArgv func(int) ([]string, error)
113+ procGroupArgv func(int, int) ([]string, error)
114+ dirOrHome func(string) string
115+ bindings atomic.Pointer[keys.Bindings]
116+ codeOverrides atomic.Pointer[map[string]string]
117+ restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
118+ themeMode atomic.Uint32
116119 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
117120 // (see clipboard.go); empty means use os.TempDir().
118121 tempDir string
@@ -190,6 +193,28 @@ func WithCwdReader(fn func(int) (string, error)) Option {
190193 }
191194 }
192195
196+// WithProcessInspector installs the platform process-inspection implementation.
197+func WithProcessInspector(ins ports.ProcessInspector) Option {
198+ return func(d *Daemon) {
199+ if ins == nil {
200+ return
201+ }
202+ d.procCwd = ins.Cwd
203+ d.procComm = ins.Comm
204+ d.procArgv = ins.Argv
205+ d.procGroupArgv = ins.GroupArgv
206+ }
207+}
208+
209+// WithDirOrHome installs path fallback behavior from the application layer.
210+func WithDirOrHome(fn func(string) string) Option {
211+ return func(d *Daemon) {
212+ if fn != nil {
213+ d.dirOrHome = fn
214+ }
215+ }
216+}
217+
193218 // WithTempDir overrides the directory clipboard-image-transfer writes temp
194219 // files into (production default: os.TempDir()); tests use this with
195220 // t.TempDir() so writes are isolated and auto-cleaned.
@@ -226,8 +251,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
226251 baseEnv: os.Environ(),
227252 shell: shell,
228253 persist: persist.New(nil),
229- procCwd: platform.ProcessCwd,
230- procComm: platform.ProcessComm,
254+ dirOrHome: dirOrHome,
231255 done: make(chan struct{}),
232256 restoreDone: make(chan struct{}),
233257 animWake: make(chan struct{}, 1),
@@ -238,11 +262,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
238262 if d.persist == nil {
239263 d.persist = persist.New(nil)
240264 }
241- if d.procCwd == nil {
242- d.procCwd = platform.ProcessCwd
243- }
244- if d.procComm == nil {
245- d.procComm = platform.ProcessComm
265+ if d.dirOrHome == nil {
266+ d.dirOrHome = dirOrHome
246267 }
247268 if d.bindings.Load() == nil {
248269 d.bindings.Store(keys.DefaultBindings())
@@ -251,6 +272,10 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
251272 empty := map[string]string{}
252273 d.codeOverrides.Store(&empty)
253274 }
275+ if d.restoreProcessAllowlist.Load() == nil {
276+ allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
277+ d.restoreProcessAllowlist.Store(&allow)
278+ }
254279 if records, err := d.persist.LoadAll(); err != nil {
255280 d.log.Warn("loading persisted sessions failed", "err", err)
256281 } else {
@@ -577,7 +602,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
577602 d.mu.Unlock()
578603 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
579604 }
580- cwd := platform.DirOrHome(stopped.cwd)
605+ cwd := d.dirOrHome(stopped.cwd)
581606 var err error
582607 sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
583608 if err != nil {
@@ -607,7 +632,11 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
607632 case ports.IntentNew:
608633 if h.Name == "" {
609634 d.mu.Unlock()
610- return nil, nil, &protoErr{ports.ErrInternal, "empty session name"}
635+ return nil, nil, &protoErr{ports.ErrInvalidSessionName, "empty session name"}
636+ }
637+ if err := domain.ValidateSessionName(h.Name); err != nil {
638+ d.mu.Unlock()
639+ return nil, nil, &protoErr{ports.ErrInvalidSessionName, err.Error()}
611640 }
612641 if d.nameLiveOrStoppedLocked(h.Name) {
613642 d.mu.Unlock()
@@ -632,7 +661,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
632661 d.mu.Unlock()
633662 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
634663 }
635- cwd := platform.DirOrHome(stopped.cwd)
664+ cwd := d.dirOrHome(stopped.cwd)
636665 var err error
637666 sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
638667 if err != nil {
@@ -36,7 +36,6 @@ import (
36 36
37 "github.com/bnema/vev/internal/domain"37 "github.com/bnema/vev/internal/domain"
38 "github.com/bnema/vev/internal/persist"38 "github.com/bnema/vev/internal/persist"
39- "github.com/bnema/vev/internal/platform"
40 "github.com/bnema/vev/internal/ports"39 "github.com/bnema/vev/internal/ports"
41 "github.com/bnema/vev/internal/usecase/keys"40 "github.com/bnema/vev/internal/usecase/keys"
42 )41 )
@@ -96,23 +95,27 @@ type Daemon struct {
96 paletteRecentMu sync.Mutex95 paletteRecentMu sync.Mutex
97 paletteRecent []string96 paletteRecent []string
98 97
99- ptys ports.PTYFactory98+ ptys ports.PTYFactory
100- clock ports.Clock99+ clock ports.Clock
101- log *slog.Logger100+ log *slog.Logger
102- baseEnv []string101+ baseEnv []string
103- shell string102+ shell string
104- shellArgs []string103+ shellArgs []string
105- persist *persist.Persister104+ persist *persist.Persister
106- persistEnabled bool105+ persistEnabled bool
107- snaps ports.SnapshotStore106+ snaps ports.SnapshotStore
108- snapsEnabled bool107+ snapsEnabled bool
109- restoreDone chan struct{}108+ restoreDone chan struct{}
110- restoreOnce sync.Once109+ restoreOnce sync.Once
111- procCwd func(int) (string, error)110+ procCwd func(int) (string, error)
112- procComm func(int) (string, error)111+ procComm func(int) (string, error)
113- bindings atomic.Pointer[keys.Bindings]112+ procArgv func(int) ([]string, error)
114- codeOverrides atomic.Pointer[map[string]string]113+ procGroupArgv func(int, int) ([]string, error)
115- themeMode atomic.Uint32114+ dirOrHome func(string) string
115+ bindings atomic.Pointer[keys.Bindings]
116+ codeOverrides atomic.Pointer[map[string]string]
117+ restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
118+ themeMode atomic.Uint32
116 // tempDir overrides os.TempDir() for clipboard-image-transfer writes119 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
117 // (see clipboard.go); empty means use os.TempDir().120 // (see clipboard.go); empty means use os.TempDir().
118 tempDir string121 tempDir string
@@ -190,6 +193,28 @@ func WithCwdReader(fn func(int) (string, error)) Option {
190 }193 }
191 }194 }
192 195
196+// WithProcessInspector installs the platform process-inspection implementation.
197+func WithProcessInspector(ins ports.ProcessInspector) Option {
198+ return func(d *Daemon) {
199+ if ins == nil {
200+ return
201+ }
202+ d.procCwd = ins.Cwd
203+ d.procComm = ins.Comm
204+ d.procArgv = ins.Argv
205+ d.procGroupArgv = ins.GroupArgv
206+ }
207+}
208+
209+// WithDirOrHome installs path fallback behavior from the application layer.
210+func WithDirOrHome(fn func(string) string) Option {
211+ return func(d *Daemon) {
212+ if fn != nil {
213+ d.dirOrHome = fn
214+ }
215+ }
216+}
217+
193 // WithTempDir overrides the directory clipboard-image-transfer writes temp218 // WithTempDir overrides the directory clipboard-image-transfer writes temp
194 // files into (production default: os.TempDir()); tests use this with219 // files into (production default: os.TempDir()); tests use this with
195 // t.TempDir() so writes are isolated and auto-cleaned.220 // t.TempDir() so writes are isolated and auto-cleaned.
@@ -226,8 +251,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
226 baseEnv: os.Environ(),251 baseEnv: os.Environ(),
227 shell: shell,252 shell: shell,
228 persist: persist.New(nil),253 persist: persist.New(nil),
229- procCwd: platform.ProcessCwd,254+ dirOrHome: dirOrHome,
230- procComm: platform.ProcessComm,
231 done: make(chan struct{}),255 done: make(chan struct{}),
232 restoreDone: make(chan struct{}),256 restoreDone: make(chan struct{}),
233 animWake: make(chan struct{}, 1),257 animWake: make(chan struct{}, 1),
@@ -238,11 +262,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
238 if d.persist == nil {262 if d.persist == nil {
239 d.persist = persist.New(nil)263 d.persist = persist.New(nil)
240 }264 }
241- if d.procCwd == nil {265+ if d.dirOrHome == nil {
242- d.procCwd = platform.ProcessCwd266+ d.dirOrHome = dirOrHome
243- }
244- if d.procComm == nil {
245- d.procComm = platform.ProcessComm
246 }267 }
247 if d.bindings.Load() == nil {268 if d.bindings.Load() == nil {
248 d.bindings.Store(keys.DefaultBindings())269 d.bindings.Store(keys.DefaultBindings())
@@ -251,6 +272,10 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
251 empty := map[string]string{}272 empty := map[string]string{}
252 d.codeOverrides.Store(&empty)273 d.codeOverrides.Store(&empty)
253 }274 }
275+ if d.restoreProcessAllowlist.Load() == nil {
276+ allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
277+ d.restoreProcessAllowlist.Store(&allow)
278+ }
254 if records, err := d.persist.LoadAll(); err != nil {279 if records, err := d.persist.LoadAll(); err != nil {
255 d.log.Warn("loading persisted sessions failed", "err", err)280 d.log.Warn("loading persisted sessions failed", "err", err)
256 } else {281 } else {
@@ -577,7 +602,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
577 d.mu.Unlock()602 d.mu.Unlock()
578 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}603 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
579 }604 }
580- cwd := platform.DirOrHome(stopped.cwd)605+ cwd := d.dirOrHome(stopped.cwd)
581 var err error606 var err error
582 sess, err = d.createSessionLocked(h.Name, false, cwd, sz)607 sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
583 if err != nil {608 if err != nil {
@@ -607,7 +632,11 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
607 case ports.IntentNew:632 case ports.IntentNew:
608 if h.Name == "" {633 if h.Name == "" {
609 d.mu.Unlock()634 d.mu.Unlock()
610- return nil, nil, &protoErr{ports.ErrInternal, "empty session name"}635+ return nil, nil, &protoErr{ports.ErrInvalidSessionName, "empty session name"}
636+ }
637+ if err := domain.ValidateSessionName(h.Name); err != nil {
638+ d.mu.Unlock()
639+ return nil, nil, &protoErr{ports.ErrInvalidSessionName, err.Error()}
611 }640 }
612 if d.nameLiveOrStoppedLocked(h.Name) {641 if d.nameLiveOrStoppedLocked(h.Name) {
613 d.mu.Unlock()642 d.mu.Unlock()
@@ -632,7 +661,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
632 d.mu.Unlock()661 d.mu.Unlock()
633 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}662 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
634 }663 }
635- cwd := platform.DirOrHome(stopped.cwd)664+ cwd := d.dirOrHome(stopped.cwd)
636 var err error665 var err error
637 sess, err = d.createSessionLocked(h.Name, false, cwd, sz)666 sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
638 if err != nil {667 if err != nil {
A internal/usecase/daemon/dir_fallback.go +15 -0
@@ -0,0 +1,15 @@
1+package daemon
2+
3+import "os"
4+
5+func dirOrHome(dir string) string {
6+ if dir != "" {
7+ if st, err := os.Stat(dir); err == nil && st.IsDir() {
8+ return dir
9+ }
10+ }
11+ if home, err := os.UserHomeDir(); err == nil && home != "" {
12+ return home
13+ }
14+ return "/"
15+}
@@ -0,0 +1,15 @@
1+package daemon
2+
3+import "os"
4+
5+func dirOrHome(dir string) string {
6+ if dir != "" {
7+ if st, err := os.Stat(dir); err == nil && st.IsDir() {
8+ return dir
9+ }
10+ }
11+ if home, err := os.UserHomeDir(); err == nil && home != "" {
12+ return home
13+ }
14+ return "/"
15+}
A internal/usecase/daemon/identity.go +58 -0
@@ -0,0 +1,58 @@
1+package daemon
2+
3+import (
4+ "crypto/rand"
5+ "encoding/base32"
6+ "fmt"
7+ "io"
8+ "strings"
9+ "sync/atomic"
10+ "time"
11+)
12+
13+var fallbackStableIDCounter atomic.Uint64
14+
15+func newStableID(prefix string) (string, error) {
16+ var b [16]byte
17+ if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
18+ return "", fmt.Errorf("generate stable id: %w", err)
19+ }
20+ enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:])
21+ return prefix + "_" + strings.ToLower(enc), nil
22+}
23+
24+func (d *Daemon) newTabPaneStableIDs() (string, string, error) {
25+ tabStableID, err := newStableID("t")
26+ if err != nil {
27+ return "", "", fmt.Errorf("daemon: generating tab identity: %w", err)
28+ }
29+ paneStableID, err := newStableID("p")
30+ if err != nil {
31+ return "", "", fmt.Errorf("daemon: generating pane identity: %w", err)
32+ }
33+ return tabStableID, paneStableID, nil
34+}
35+
36+func fallbackStableID(prefix string) string {
37+ id, err := newStableID(prefix)
38+ if err != nil {
39+ return fmt.Sprintf("%s_fallback_%x_%x", prefix, time.Now().UnixNano(), fallbackStableIDCounter.Add(1))
40+ }
41+ return id
42+}
43+
44+func escapeVEVComponent(s string) string {
45+ var b strings.Builder
46+ for i := 0; i < len(s); i++ {
47+ c := s[i]
48+ if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-' {
49+ b.WriteByte(c)
50+ continue
51+ }
52+ b.WriteByte('%')
53+ const hex = "0123456789ABCDEF"
54+ b.WriteByte(hex[c>>4])
55+ b.WriteByte(hex[c&0x0f])
56+ }
57+ return b.String()
58+}
@@ -0,0 +1,58 @@
1+package daemon
2+
3+import (
4+ "crypto/rand"
5+ "encoding/base32"
6+ "fmt"
7+ "io"
8+ "strings"
9+ "sync/atomic"
10+ "time"
11+)
12+
13+var fallbackStableIDCounter atomic.Uint64
14+
15+func newStableID(prefix string) (string, error) {
16+ var b [16]byte
17+ if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
18+ return "", fmt.Errorf("generate stable id: %w", err)
19+ }
20+ enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:])
21+ return prefix + "_" + strings.ToLower(enc), nil
22+}
23+
24+func (d *Daemon) newTabPaneStableIDs() (string, string, error) {
25+ tabStableID, err := newStableID("t")
26+ if err != nil {
27+ return "", "", fmt.Errorf("daemon: generating tab identity: %w", err)
28+ }
29+ paneStableID, err := newStableID("p")
30+ if err != nil {
31+ return "", "", fmt.Errorf("daemon: generating pane identity: %w", err)
32+ }
33+ return tabStableID, paneStableID, nil
34+}
35+
36+func fallbackStableID(prefix string) string {
37+ id, err := newStableID(prefix)
38+ if err != nil {
39+ return fmt.Sprintf("%s_fallback_%x_%x", prefix, time.Now().UnixNano(), fallbackStableIDCounter.Add(1))
40+ }
41+ return id
42+}
43+
44+func escapeVEVComponent(s string) string {
45+ var b strings.Builder
46+ for i := 0; i < len(s); i++ {
47+ c := s[i]
48+ if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-' {
49+ b.WriteByte(c)
50+ continue
51+ }
52+ b.WriteByte('%')
53+ const hex = "0123456789ABCDEF"
54+ b.WriteByte(hex[c>>4])
55+ b.WriteByte(hex[c&0x0f])
56+ }
57+ return b.String()
58+}
M internal/usecase/daemon/pane.go +6 -0
@@ -18,6 +18,7 @@ import (
1818 // The PTY reader takes only pane.mu, so child output never waits on client IO.
1919 type pane struct {
2020 id layout.PaneID
21+ stableID string
2122 pty ports.PTY
2223 mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
2324 screen *vt.Screen
@@ -34,11 +35,16 @@ type pane struct {
3435 }
3536
3637 func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
38+ return newPaneWithStableID(id, fallbackStableID("p"), pty, sz)
39+}
40+
41+func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
3742 sb := scopy.NewScrollback(defaultScrollbackRows)
3843 screen := vt.NewScreen(sz.Cols, sz.Rows)
3944 screen.OnLineEvicted = sb.Append
4045 return &pane{
4146 id: id,
47+ stableID: stableID,
4248 pty: pty,
4349 screen: screen,
4450 scrollback: sb,
@@ -18,6 +18,7 @@ import (
18 // The PTY reader takes only pane.mu, so child output never waits on client IO.18 // The PTY reader takes only pane.mu, so child output never waits on client IO.
19 type pane struct {19 type pane struct {
20 id layout.PaneID20 id layout.PaneID
21+ stableID string
21 pty ports.PTY22 pty ports.PTY
22 mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title23 mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
23 screen *vt.Screen24 screen *vt.Screen
@@ -34,11 +35,16 @@ type pane struct {
34 }35 }
35 36
36 func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {37 func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
38+ return newPaneWithStableID(id, fallbackStableID("p"), pty, sz)
39+}
40+
41+func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
37 sb := scopy.NewScrollback(defaultScrollbackRows)42 sb := scopy.NewScrollback(defaultScrollbackRows)
38 screen := vt.NewScreen(sz.Cols, sz.Rows)43 screen := vt.NewScreen(sz.Cols, sz.Rows)
39 screen.OnLineEvicted = sb.Append44 screen.OnLineEvicted = sb.Append
40 return &pane{45 return &pane{
41 id: id,46 id: id,
47+ stableID: stableID,
42 pty: pty,48 pty: pty,
43 screen: screen,49 screen: screen,
44 scrollback: sb,50 scrollback: sb,
M internal/usecase/daemon/pane_actions.go +11 -2
@@ -57,9 +57,18 @@ func (d *Daemon) spawnPaneOp(
5757 return layout.ErrTooSmall
5858 }
5959 newRect := placementContent(placements, newID)
60+ tabStableID := tb.stableID
6061 tb.mu.Unlock()
6162
62- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, rectSize(newRect))
63+ paneStableID, err := newStableID("p")
64+ if err != nil {
65+ tb.mu.Lock()
66+ _ = tb.tree.Close(newID)
67+ tb.tree.Focus = oldFocus
68+ tb.mu.Unlock()
69+ return fmt.Errorf("daemon: generating pane identity: %w", err)
70+ }
71+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, rectSize(newRect))
6372 if err != nil {
6473 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
6574 tb.mu.Lock()
@@ -70,7 +79,7 @@ func (d *Daemon) spawnPaneOp(
7079 }
7180
7281 pctx, cancel := context.WithCancel(tb.ctx)
73- p := newPane(newID, pty, rectSize(newRect))
82+ p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect))
7483 p.ctx, p.cancel = pctx, cancel
7584 p.rect = newRect
7685
@@ -57,9 +57,18 @@ func (d *Daemon) spawnPaneOp(
57 return layout.ErrTooSmall57 return layout.ErrTooSmall
58 }58 }
59 newRect := placementContent(placements, newID)59 newRect := placementContent(placements, newID)
60+ tabStableID := tb.stableID
60 tb.mu.Unlock()61 tb.mu.Unlock()
61 62
62- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, rectSize(newRect))63+ paneStableID, err := newStableID("p")
64+ if err != nil {
65+ tb.mu.Lock()
66+ _ = tb.tree.Close(newID)
67+ tb.tree.Focus = oldFocus
68+ tb.mu.Unlock()
69+ return fmt.Errorf("daemon: generating pane identity: %w", err)
70+ }
71+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, rectSize(newRect))
63 if err != nil {72 if err != nil {
64 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")73 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
65 tb.mu.Lock()74 tb.mu.Lock()
@@ -70,7 +79,7 @@ func (d *Daemon) spawnPaneOp(
70 }79 }
71 80
72 pctx, cancel := context.WithCancel(tb.ctx)81 pctx, cancel := context.WithCancel(tb.ctx)
73- p := newPane(newID, pty, rectSize(newRect))82+ p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect))
74 p.ctx, p.cancel = pctx, cancel83 p.ctx, p.cancel = pctx, cancel
75 p.rect = newRect84 p.rect = newRect
76 85
M internal/usecase/daemon/picker.go +1 -2
@@ -29,7 +29,6 @@ import (
2929 "strconv"
3030
3131 "github.com/bnema/vev/internal/domain"
32- "github.com/bnema/vev/internal/platform"
3332 "github.com/bnema/vev/internal/ports"
3433 "github.com/bnema/vev/internal/usecase/keys"
3534 "github.com/bnema/vev/internal/usecase/layout"
@@ -421,7 +420,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
421420 d.mu.Unlock()
422421 return
423422 }
424- cwd := platform.DirOrHome(stopped.cwd)
423+ cwd := d.dirOrHome(stopped.cwd)
425424 targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size)
426425 if err != nil {
427426 from.mu.Unlock()
@@ -29,7 +29,6 @@ import (
29 "strconv"29 "strconv"
30 30
31 "github.com/bnema/vev/internal/domain"31 "github.com/bnema/vev/internal/domain"
32- "github.com/bnema/vev/internal/platform"
33 "github.com/bnema/vev/internal/ports"32 "github.com/bnema/vev/internal/ports"
34 "github.com/bnema/vev/internal/usecase/keys"33 "github.com/bnema/vev/internal/usecase/keys"
35 "github.com/bnema/vev/internal/usecase/layout"34 "github.com/bnema/vev/internal/usecase/layout"
@@ -421,7 +420,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
421 d.mu.Unlock()420 d.mu.Unlock()
422 return421 return
423 }422 }
424- cwd := platform.DirOrHome(stopped.cwd)423+ cwd := d.dirOrHome(stopped.cwd)
425 targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size)424 targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size)
426 if err != nil {425 if err != nil {
427 from.mu.Unlock()426 from.mu.Unlock()
A internal/usecase/daemon/process_restore.go +164 -0
@@ -0,0 +1,164 @@
1+package daemon
2+
3+import (
4+ "path/filepath"
5+ "strings"
6+ "unicode"
7+
8+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
9+)
10+
11+const (
12+ processStrategyGeneric = "generic"
13+ processStrategyClaude = "claude"
14+ processStrategyCodex = "codex"
15+ processStrategyOpenCode = "opencode"
16+ processStrategyPi = "pi"
17+)
18+
19+type processRestoreDecision struct {
20+ Command string
21+ Restore bool
22+ Reason string
23+}
24+
25+func detectProcessStrategy(argv []string) string {
26+ if len(argv) == 0 {
27+ return processStrategyGeneric
28+ }
29+ switch filepath.Base(argv[0]) {
30+ case "claude":
31+ return processStrategyClaude
32+ case "codex":
33+ return processStrategyCodex
34+ case "opencode":
35+ return processStrategyOpenCode
36+ case "pi":
37+ return processStrategyPi
38+ default:
39+ return processStrategyGeneric
40+ }
41+}
42+
43+func extractAgentSessionID(strategy string, argv []string) string {
44+ for i, arg := range argv {
45+ if !agentSessionFlag(strategy, arg) || i+1 >= len(argv) {
46+ continue
47+ }
48+ candidate := argv[i+1]
49+ if candidate != "" && !strings.HasPrefix(candidate, "-") && !containsControl(candidate) {
50+ return candidate
51+ }
52+ }
53+ return ""
54+}
55+
56+func agentSessionFlag(strategy, arg string) bool {
57+ switch strategy {
58+ case processStrategyClaude:
59+ return arg == "--session-id" || arg == "--resume" || arg == "-r"
60+ case processStrategyOpenCode:
61+ return arg == "-s" || arg == "--session"
62+ case processStrategyCodex:
63+ return arg == "resume"
64+ case processStrategyPi:
65+ return arg == "--resume" || arg == "-r"
66+ default:
67+ return false
68+ }
69+}
70+
71+func planProcessRestore(proc *snapcodec.Process, allow map[string]struct{}) processRestoreDecision {
72+ if proc == nil || len(proc.Argv) == 0 || proc.Argv[0] == "" {
73+ return processRestoreDecision{Reason: "missing_process"}
74+ }
75+ strategy := normalizeProcessStrategy(proc.Strategy, proc.Argv)
76+ if _, ok := allow[processAllowlistKey(strategy, proc.Argv)]; !ok {
77+ return processRestoreDecision{Reason: "not_allowlisted"}
78+ }
79+
80+ id := proc.Opts.AgentSessionID
81+ if containsControl(id) {
82+ return processRestoreDecision{Reason: "invalid_agent_session_id"}
83+ }
84+ switch strategy {
85+ case processStrategyPi:
86+ if id != "" {
87+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--resume", id}), Restore: true}
88+ }
89+ return processRestoreDecision{Command: "pi --continue", Restore: true}
90+ case processStrategyClaude:
91+ if id != "" {
92+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"claude", "--resume", id}), Restore: true}
93+ }
94+ return processRestoreDecision{Command: "claude --continue", Restore: true}
95+ case processStrategyOpenCode:
96+ if id != "" {
97+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"opencode", "--session", id}), Restore: true}
98+ }
99+ return processRestoreDecision{Command: "opencode --continue", Restore: true}
100+ case processStrategyCodex:
101+ if id != "" {
102+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"codex", "resume", id}), Restore: true}
103+ }
104+ return processRestoreDecision{Command: "codex resume --last", Restore: true}
105+ }
106+
107+ command, ok := shellQuoteArgv(proc.Argv)
108+ if !ok {
109+ return processRestoreDecision{Reason: "empty_command"}
110+ }
111+ return processRestoreDecision{Command: command, Restore: true}
112+}
113+
114+func normalizeProcessStrategy(strategy string, argv []string) string {
115+ switch strategy {
116+ case processStrategyClaude, processStrategyCodex, processStrategyOpenCode, processStrategyPi:
117+ return strategy
118+ case "", processStrategyGeneric:
119+ return detectProcessStrategy(argv)
120+ default:
121+ return processStrategyGeneric
122+ }
123+}
124+
125+func processAllowlistKey(strategy string, argv []string) string {
126+ switch strategy {
127+ case processStrategyClaude, processStrategyCodex, processStrategyOpenCode, processStrategyPi:
128+ return strategy
129+ default:
130+ return filepath.Base(argv[0])
131+ }
132+}
133+
134+func containsControl(s string) bool {
135+ return strings.IndexFunc(s, unicode.IsControl) >= 0
136+}
137+
138+func shellQuoteArgvMust(argv []string) string {
139+ command, _ := shellQuoteArgv(argv)
140+ return command
141+}
142+
143+func shellQuoteArgv(argv []string) (string, bool) {
144+ if len(argv) == 0 {
145+ return "", false
146+ }
147+ quoted := make([]string, 0, len(argv))
148+ for _, arg := range argv {
149+ quoted = append(quoted, shellQuote(arg))
150+ }
151+ return strings.Join(quoted, " "), true
152+}
153+
154+func shellQuote(s string) string {
155+ if s == "" {
156+ return "''"
157+ }
158+ if strings.IndexFunc(s, func(r rune) bool {
159+ return r != '-' && r != '_' && r != '.' && r != '/' && r != ':' && r != '+' && r != '=' && !unicode.IsLetter(r) && !unicode.IsDigit(r)
160+ }) == -1 {
161+ return s
162+ }
163+ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
164+}
@@ -0,0 +1,164 @@
1+package daemon
2+
3+import (
4+ "path/filepath"
5+ "strings"
6+ "unicode"
7+
8+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
9+)
10+
11+const (
12+ processStrategyGeneric = "generic"
13+ processStrategyClaude = "claude"
14+ processStrategyCodex = "codex"
15+ processStrategyOpenCode = "opencode"
16+ processStrategyPi = "pi"
17+)
18+
19+type processRestoreDecision struct {
20+ Command string
21+ Restore bool
22+ Reason string
23+}
24+
25+func detectProcessStrategy(argv []string) string {
26+ if len(argv) == 0 {
27+ return processStrategyGeneric
28+ }
29+ switch filepath.Base(argv[0]) {
30+ case "claude":
31+ return processStrategyClaude
32+ case "codex":
33+ return processStrategyCodex
34+ case "opencode":
35+ return processStrategyOpenCode
36+ case "pi":
37+ return processStrategyPi
38+ default:
39+ return processStrategyGeneric
40+ }
41+}
42+
43+func extractAgentSessionID(strategy string, argv []string) string {
44+ for i, arg := range argv {
45+ if !agentSessionFlag(strategy, arg) || i+1 >= len(argv) {
46+ continue
47+ }
48+ candidate := argv[i+1]
49+ if candidate != "" && !strings.HasPrefix(candidate, "-") && !containsControl(candidate) {
50+ return candidate
51+ }
52+ }
53+ return ""
54+}
55+
56+func agentSessionFlag(strategy, arg string) bool {
57+ switch strategy {
58+ case processStrategyClaude:
59+ return arg == "--session-id" || arg == "--resume" || arg == "-r"
60+ case processStrategyOpenCode:
61+ return arg == "-s" || arg == "--session"
62+ case processStrategyCodex:
63+ return arg == "resume"
64+ case processStrategyPi:
65+ return arg == "--resume" || arg == "-r"
66+ default:
67+ return false
68+ }
69+}
70+
71+func planProcessRestore(proc *snapcodec.Process, allow map[string]struct{}) processRestoreDecision {
72+ if proc == nil || len(proc.Argv) == 0 || proc.Argv[0] == "" {
73+ return processRestoreDecision{Reason: "missing_process"}
74+ }
75+ strategy := normalizeProcessStrategy(proc.Strategy, proc.Argv)
76+ if _, ok := allow[processAllowlistKey(strategy, proc.Argv)]; !ok {
77+ return processRestoreDecision{Reason: "not_allowlisted"}
78+ }
79+
80+ id := proc.Opts.AgentSessionID
81+ if containsControl(id) {
82+ return processRestoreDecision{Reason: "invalid_agent_session_id"}
83+ }
84+ switch strategy {
85+ case processStrategyPi:
86+ if id != "" {
87+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--resume", id}), Restore: true}
88+ }
89+ return processRestoreDecision{Command: "pi --continue", Restore: true}
90+ case processStrategyClaude:
91+ if id != "" {
92+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"claude", "--resume", id}), Restore: true}
93+ }
94+ return processRestoreDecision{Command: "claude --continue", Restore: true}
95+ case processStrategyOpenCode:
96+ if id != "" {
97+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"opencode", "--session", id}), Restore: true}
98+ }
99+ return processRestoreDecision{Command: "opencode --continue", Restore: true}
100+ case processStrategyCodex:
101+ if id != "" {
102+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"codex", "resume", id}), Restore: true}
103+ }
104+ return processRestoreDecision{Command: "codex resume --last", Restore: true}
105+ }
106+
107+ command, ok := shellQuoteArgv(proc.Argv)
108+ if !ok {
109+ return processRestoreDecision{Reason: "empty_command"}
110+ }
111+ return processRestoreDecision{Command: command, Restore: true}
112+}
113+
114+func normalizeProcessStrategy(strategy string, argv []string) string {
115+ switch strategy {
116+ case processStrategyClaude, processStrategyCodex, processStrategyOpenCode, processStrategyPi:
117+ return strategy
118+ case "", processStrategyGeneric:
119+ return detectProcessStrategy(argv)
120+ default:
121+ return processStrategyGeneric
122+ }
123+}
124+
125+func processAllowlistKey(strategy string, argv []string) string {
126+ switch strategy {
127+ case processStrategyClaude, processStrategyCodex, processStrategyOpenCode, processStrategyPi:
128+ return strategy
129+ default:
130+ return filepath.Base(argv[0])
131+ }
132+}
133+
134+func containsControl(s string) bool {
135+ return strings.IndexFunc(s, unicode.IsControl) >= 0
136+}
137+
138+func shellQuoteArgvMust(argv []string) string {
139+ command, _ := shellQuoteArgv(argv)
140+ return command
141+}
142+
143+func shellQuoteArgv(argv []string) (string, bool) {
144+ if len(argv) == 0 {
145+ return "", false
146+ }
147+ quoted := make([]string, 0, len(argv))
148+ for _, arg := range argv {
149+ quoted = append(quoted, shellQuote(arg))
150+ }
151+ return strings.Join(quoted, " "), true
152+}
153+
154+func shellQuote(s string) string {
155+ if s == "" {
156+ return "''"
157+ }
158+ if strings.IndexFunc(s, func(r rune) bool {
159+ return r != '-' && r != '_' && r != '.' && r != '/' && r != ':' && r != '+' && r != '=' && !unicode.IsLetter(r) && !unicode.IsDigit(r)
160+ }) == -1 {
161+ return s
162+ }
163+ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
164+}
A internal/usecase/daemon/process_restore_test.go +156 -0
@@ -0,0 +1,156 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
7+)
8+
9+func TestProcessRestorePlan(t *testing.T) {
10+ defaultAllow := map[string]struct{}{
11+ "less": {},
12+ "pi": {},
13+ "claude": {},
14+ "opencode": {},
15+ "codex": {},
16+ }
17+ tests := []struct {
18+ name string
19+ proc *snapcodec.Process
20+ allow map[string]struct{}
21+ restore bool
22+ command string
23+ reason string
24+ }{
25+ {
26+ name: "generic less allowed",
27+ proc: &snapcodec.Process{Argv: []string{"/usr/bin/less", "README.md"}, Strategy: processStrategyGeneric},
28+ restore: true,
29+ command: "/usr/bin/less README.md",
30+ },
31+ {
32+ name: "bash denied",
33+ proc: &snapcodec.Process{Argv: []string{"bash", "-lc", "rm -rf /"}, Strategy: processStrategyGeneric},
34+ reason: "not_allowlisted",
35+ },
36+ {
37+ name: "pi with ID resumes",
38+ proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}},
39+ restore: true,
40+ command: "pi --resume session-123",
41+ },
42+ {
43+ name: "pi no ID continues",
44+ proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi},
45+ restore: true,
46+ command: "pi --continue",
47+ },
48+ {
49+ name: "claude no ID continues",
50+ proc: &snapcodec.Process{Argv: []string{"claude"}, Strategy: processStrategyClaude},
51+ restore: true,
52+ command: "claude --continue",
53+ },
54+ {
55+ name: "opencode session command",
56+ proc: &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
57+ restore: true,
58+ command: "opencode --session abc",
59+ },
60+ {
61+ name: "opencode no ID continues",
62+ proc: &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode},
63+ restore: true,
64+ command: "opencode --continue",
65+ },
66+ {
67+ name: "codex resume command",
68+ proc: &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
69+ restore: true,
70+ command: "codex resume abc",
71+ },
72+ {
73+ name: "codex no ID resumes last",
74+ proc: &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex},
75+ restore: true,
76+ command: "codex resume --last",
77+ },
78+ {
79+ name: "agent strategy matches allowlist by strategy not argv basename",
80+ proc: &snapcodec.Process{Argv: []string{"less"}, Strategy: processStrategyClaude, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
81+ allow: map[string]struct{}{"less": {}},
82+ reason: "not_allowlisted",
83+ },
84+ {
85+ name: "agent ID with control byte rejected",
86+ proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session\rmalicious"}},
87+ reason: "invalid_agent_session_id",
88+ },
89+ {
90+ name: "empty executable rejected",
91+ proc: &snapcodec.Process{Argv: []string{""}, Strategy: processStrategyGeneric},
92+ reason: "missing_process",
93+ },
94+ }
95+
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ allow := tt.allow
99+ if allow == nil {
100+ allow = defaultAllow
101+ }
102+ got := planProcessRestore(tt.proc, allow)
103+ if got.Restore != tt.restore || got.Command != tt.command || got.Reason != tt.reason {
104+ t.Fatalf("planProcessRestore() = %+v, want restore=%v command=%q reason=%q", got, tt.restore, tt.command, tt.reason)
105+ }
106+ })
107+ }
108+}
109+
110+func TestExtractAgentSessionID(t *testing.T) {
111+ tests := []struct {
112+ name string
113+ strategy string
114+ argv []string
115+ want string
116+ }{
117+ {name: "claude session flag extraction", strategy: processStrategyClaude, argv: []string{"claude", "--session-id", "uuid-123"}, want: "uuid-123"},
118+ {name: "claude short resume extraction", strategy: processStrategyClaude, argv: []string{"claude", "-r", "uuid-456"}, want: "uuid-456"},
119+ {name: "opencode short session flag extraction", strategy: processStrategyOpenCode, argv: []string{"opencode", "-s", "abc"}, want: "abc"},
120+ {name: "codex resume extraction", strategy: processStrategyCodex, argv: []string{"codex", "resume", "abc"}, want: "abc"},
121+ {name: "claude flag-looking ID rejected", strategy: processStrategyClaude, argv: []string{"claude", "--resume", "--continue"}},
122+ {name: "pi flag-looking ID rejected", strategy: processStrategyPi, argv: []string{"pi", "-r", "--last"}},
123+ {name: "control byte ID rejected", strategy: processStrategyPi, argv: []string{"pi", "--resume", "abc\nmalicious"}},
124+ }
125+
126+ for _, tt := range tests {
127+ t.Run(tt.name, func(t *testing.T) {
128+ if got := extractAgentSessionID(tt.strategy, tt.argv); got != tt.want {
129+ t.Fatalf("extractAgentSessionID() = %q, want %q", got, tt.want)
130+ }
131+ })
132+ }
133+}
134+
135+func TestShellQuoteArgv(t *testing.T) {
136+ tests := []struct {
137+ name string
138+ argv []string
139+ want string
140+ ok bool
141+ }{
142+ {name: "empty argv", ok: false},
143+ {name: "spaces and single quote", argv: []string{"cmd", "two words", "it's"}, want: "cmd 'two words' 'it'\\''s'", ok: true},
144+ {name: "shell metacharacters", argv: []string{"cmd", "$(touch /tmp/pwned)", "a;b", "line\nbreak"}, want: "cmd '$(touch /tmp/pwned)' 'a;b' 'line\nbreak'", ok: true},
145+ {name: "empty argument", argv: []string{"cmd", ""}, want: "cmd ''", ok: true},
146+ }
147+
148+ for _, tt := range tests {
149+ t.Run(tt.name, func(t *testing.T) {
150+ got, ok := shellQuoteArgv(tt.argv)
151+ if ok != tt.ok || got != tt.want {
152+ t.Fatalf("shellQuoteArgv() = %q, %v; want %q, %v", got, ok, tt.want, tt.ok)
153+ }
154+ })
155+ }
156+}
@@ -0,0 +1,156 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
7+)
8+
9+func TestProcessRestorePlan(t *testing.T) {
10+ defaultAllow := map[string]struct{}{
11+ "less": {},
12+ "pi": {},
13+ "claude": {},
14+ "opencode": {},
15+ "codex": {},
16+ }
17+ tests := []struct {
18+ name string
19+ proc *snapcodec.Process
20+ allow map[string]struct{}
21+ restore bool
22+ command string
23+ reason string
24+ }{
25+ {
26+ name: "generic less allowed",
27+ proc: &snapcodec.Process{Argv: []string{"/usr/bin/less", "README.md"}, Strategy: processStrategyGeneric},
28+ restore: true,
29+ command: "/usr/bin/less README.md",
30+ },
31+ {
32+ name: "bash denied",
33+ proc: &snapcodec.Process{Argv: []string{"bash", "-lc", "rm -rf /"}, Strategy: processStrategyGeneric},
34+ reason: "not_allowlisted",
35+ },
36+ {
37+ name: "pi with ID resumes",
38+ proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}},
39+ restore: true,
40+ command: "pi --resume session-123",
41+ },
42+ {
43+ name: "pi no ID continues",
44+ proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi},
45+ restore: true,
46+ command: "pi --continue",
47+ },
48+ {
49+ name: "claude no ID continues",
50+ proc: &snapcodec.Process{Argv: []string{"claude"}, Strategy: processStrategyClaude},
51+ restore: true,
52+ command: "claude --continue",
53+ },
54+ {
55+ name: "opencode session command",
56+ proc: &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
57+ restore: true,
58+ command: "opencode --session abc",
59+ },
60+ {
61+ name: "opencode no ID continues",
62+ proc: &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode},
63+ restore: true,
64+ command: "opencode --continue",
65+ },
66+ {
67+ name: "codex resume command",
68+ proc: &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
69+ restore: true,
70+ command: "codex resume abc",
71+ },
72+ {
73+ name: "codex no ID resumes last",
74+ proc: &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex},
75+ restore: true,
76+ command: "codex resume --last",
77+ },
78+ {
79+ name: "agent strategy matches allowlist by strategy not argv basename",
80+ proc: &snapcodec.Process{Argv: []string{"less"}, Strategy: processStrategyClaude, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
81+ allow: map[string]struct{}{"less": {}},
82+ reason: "not_allowlisted",
83+ },
84+ {
85+ name: "agent ID with control byte rejected",
86+ proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session\rmalicious"}},
87+ reason: "invalid_agent_session_id",
88+ },
89+ {
90+ name: "empty executable rejected",
91+ proc: &snapcodec.Process{Argv: []string{""}, Strategy: processStrategyGeneric},
92+ reason: "missing_process",
93+ },
94+ }
95+
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ allow := tt.allow
99+ if allow == nil {
100+ allow = defaultAllow
101+ }
102+ got := planProcessRestore(tt.proc, allow)
103+ if got.Restore != tt.restore || got.Command != tt.command || got.Reason != tt.reason {
104+ t.Fatalf("planProcessRestore() = %+v, want restore=%v command=%q reason=%q", got, tt.restore, tt.command, tt.reason)
105+ }
106+ })
107+ }
108+}
109+
110+func TestExtractAgentSessionID(t *testing.T) {
111+ tests := []struct {
112+ name string
113+ strategy string
114+ argv []string
115+ want string
116+ }{
117+ {name: "claude session flag extraction", strategy: processStrategyClaude, argv: []string{"claude", "--session-id", "uuid-123"}, want: "uuid-123"},
118+ {name: "claude short resume extraction", strategy: processStrategyClaude, argv: []string{"claude", "-r", "uuid-456"}, want: "uuid-456"},
119+ {name: "opencode short session flag extraction", strategy: processStrategyOpenCode, argv: []string{"opencode", "-s", "abc"}, want: "abc"},
120+ {name: "codex resume extraction", strategy: processStrategyCodex, argv: []string{"codex", "resume", "abc"}, want: "abc"},
121+ {name: "claude flag-looking ID rejected", strategy: processStrategyClaude, argv: []string{"claude", "--resume", "--continue"}},
122+ {name: "pi flag-looking ID rejected", strategy: processStrategyPi, argv: []string{"pi", "-r", "--last"}},
123+ {name: "control byte ID rejected", strategy: processStrategyPi, argv: []string{"pi", "--resume", "abc\nmalicious"}},
124+ }
125+
126+ for _, tt := range tests {
127+ t.Run(tt.name, func(t *testing.T) {
128+ if got := extractAgentSessionID(tt.strategy, tt.argv); got != tt.want {
129+ t.Fatalf("extractAgentSessionID() = %q, want %q", got, tt.want)
130+ }
131+ })
132+ }
133+}
134+
135+func TestShellQuoteArgv(t *testing.T) {
136+ tests := []struct {
137+ name string
138+ argv []string
139+ want string
140+ ok bool
141+ }{
142+ {name: "empty argv", ok: false},
143+ {name: "spaces and single quote", argv: []string{"cmd", "two words", "it's"}, want: "cmd 'two words' 'it'\\''s'", ok: true},
144+ {name: "shell metacharacters", argv: []string{"cmd", "$(touch /tmp/pwned)", "a;b", "line\nbreak"}, want: "cmd '$(touch /tmp/pwned)' 'a;b' 'line\nbreak'", ok: true},
145+ {name: "empty argument", argv: []string{"cmd", ""}, want: "cmd ''", ok: true},
146+ }
147+
148+ for _, tt := range tests {
149+ t.Run(tt.name, func(t *testing.T) {
150+ got, ok := shellQuoteArgv(tt.argv)
151+ if ok != tt.ok || got != tt.want {
152+ t.Fatalf("shellQuoteArgv() = %q, %v; want %q, %v", got, ok, tt.want, tt.ok)
153+ }
154+ })
155+ }
156+}
M internal/usecase/daemon/session.go +27 -7
@@ -70,6 +70,7 @@ type session struct {
7070 type tab struct {
7171 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership
7272
73+ stableID string
7374 tree *layout.Tree
7475 panes map[layout.PaneID]*pane
7576 nextPaneID int
@@ -122,7 +123,11 @@ func (d *Daemon) touchMRU(sess *session) {
122123
123124 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
124125 tbSize := tabSize(sz)
125- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
126+ tabStableID, paneStableID, err := d.newTabPaneStableIDs()
127+ if err != nil {
128+ return nil, err
129+ }
130+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)
126131 if err != nil {
127132 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
128133 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
@@ -132,7 +137,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
132137 d.nextID++
133138 createdAt := time.Now().UnixNano()
134139
135- tb := newTab(pty, tbSize)
140+ tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
136141 sctx, cancel := context.WithCancel(d.serveCtx)
137142 tb.ctx, tb.cancel = context.WithCancel(sctx)
138143 lastUsedSeq := uint64(0)
@@ -177,6 +182,9 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
177182 if name == "" {
178183 return errors.New("name required")
179184 }
185+ if err := domain.ValidateSessionName(name); err != nil {
186+ return err
187+ }
180188 sz := ac.size
181189 d.mu.Lock()
182190 if d.closing {
@@ -231,12 +239,16 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
231239 cwd := sess.cwd
232240 client := sess.client
233241 sess.mu.Unlock()
234- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
242+ tabStableID, paneStableID, err := d.newTabPaneStableIDs()
243+ if err != nil {
244+ return err
245+ }
246+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)
235247 if err != nil {
236248 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
237249 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
238250 }
239- tb := newTab(pty, tbSize)
251+ tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
240252 if client != nil {
241253 t := d.effectiveTheme(client.getClientTheme())
242254 tb.mu.Lock()
@@ -271,9 +283,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
271283 }
272284
273285 func newTab(pty ports.PTY, sz domain.Size) *tab {
286+ return newTabWithStableID(fallbackStableID("t"), fallbackStableID("p"), pty, sz)
287+}
288+
289+func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab {
274290 id := layout.PaneID("pane-1")
275- p := newPane(id, pty, sz)
291+ p := newPaneWithStableID(id, paneStableID, pty, sz)
276292 return &tab{
293+ stableID: tabStableID,
277294 tree: layout.NewTree(id),
278295 panes: map[layout.PaneID]*pane{id: p},
279296 nextPaneID: 2,
@@ -390,6 +407,9 @@ func (d *Daemon) renameSession(sess *session, name string) error {
390407 if name == "" {
391408 return errors.New("name required")
392409 }
410+ if err := domain.ValidateSessionName(name); err != nil {
411+ return err
412+ }
393413 d.mu.Lock()
394414 defer d.mu.Unlock()
395415 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
@@ -701,7 +721,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
701721
702722 // childEnv builds the session child's environment: the daemon's own, with TERM
703723 // and VEV forced to well-known values.
704-func (d *Daemon) childEnv(name string) []string {
724+func (d *Daemon) childEnv(name, tabStableID, paneStableID string) []string {
705725 out := make([]string, 0, len(d.baseEnv)+2)
706726 for _, e := range d.baseEnv {
707727 if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") {
@@ -709,5 +729,5 @@ func (d *Daemon) childEnv(name string) []string {
709729 }
710730 out = append(out, e)
711731 }
712- return append(out, "TERM=xterm-256color", "VEV="+name)
732+ return append(out, "TERM=xterm-direct", "VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID)
713733 }
@@ -70,6 +70,7 @@ type session struct {
70 type tab struct {70 type tab struct {
71 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership71 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership
72 72
73+ stableID string
73 tree *layout.Tree74 tree *layout.Tree
74 panes map[layout.PaneID]*pane75 panes map[layout.PaneID]*pane
75 nextPaneID int76 nextPaneID int
@@ -122,7 +123,11 @@ func (d *Daemon) touchMRU(sess *session) {
122 123
123 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {124 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
124 tbSize := tabSize(sz)125 tbSize := tabSize(sz)
125- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)126+ tabStableID, paneStableID, err := d.newTabPaneStableIDs()
127+ if err != nil {
128+ return nil, err
129+ }
130+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)
126 if err != nil {131 if err != nil {
127 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")132 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
128 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)133 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
@@ -132,7 +137,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
132 d.nextID++137 d.nextID++
133 createdAt := time.Now().UnixNano()138 createdAt := time.Now().UnixNano()
134 139
135- tb := newTab(pty, tbSize)140+ tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
136 sctx, cancel := context.WithCancel(d.serveCtx)141 sctx, cancel := context.WithCancel(d.serveCtx)
137 tb.ctx, tb.cancel = context.WithCancel(sctx)142 tb.ctx, tb.cancel = context.WithCancel(sctx)
138 lastUsedSeq := uint64(0)143 lastUsedSeq := uint64(0)
@@ -177,6 +182,9 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
177 if name == "" {182 if name == "" {
178 return errors.New("name required")183 return errors.New("name required")
179 }184 }
185+ if err := domain.ValidateSessionName(name); err != nil {
186+ return err
187+ }
180 sz := ac.size188 sz := ac.size
181 d.mu.Lock()189 d.mu.Lock()
182 if d.closing {190 if d.closing {
@@ -231,12 +239,16 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
231 cwd := sess.cwd239 cwd := sess.cwd
232 client := sess.client240 client := sess.client
233 sess.mu.Unlock()241 sess.mu.Unlock()
234- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)242+ tabStableID, paneStableID, err := d.newTabPaneStableIDs()
243+ if err != nil {
244+ return err
245+ }
246+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)
235 if err != nil {247 if err != nil {
236 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")248 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
237 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)249 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
238 }250 }
239- tb := newTab(pty, tbSize)251+ tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
240 if client != nil {252 if client != nil {
241 t := d.effectiveTheme(client.getClientTheme())253 t := d.effectiveTheme(client.getClientTheme())
242 tb.mu.Lock()254 tb.mu.Lock()
@@ -271,9 +283,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
271 }283 }
272 284
273 func newTab(pty ports.PTY, sz domain.Size) *tab {285 func newTab(pty ports.PTY, sz domain.Size) *tab {
286+ return newTabWithStableID(fallbackStableID("t"), fallbackStableID("p"), pty, sz)
287+}
288+
289+func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab {
274 id := layout.PaneID("pane-1")290 id := layout.PaneID("pane-1")
275- p := newPane(id, pty, sz)291+ p := newPaneWithStableID(id, paneStableID, pty, sz)
276 return &tab{292 return &tab{
293+ stableID: tabStableID,
277 tree: layout.NewTree(id),294 tree: layout.NewTree(id),
278 panes: map[layout.PaneID]*pane{id: p},295 panes: map[layout.PaneID]*pane{id: p},
279 nextPaneID: 2,296 nextPaneID: 2,
@@ -390,6 +407,9 @@ func (d *Daemon) renameSession(sess *session, name string) error {
390 if name == "" {407 if name == "" {
391 return errors.New("name required")408 return errors.New("name required")
392 }409 }
410+ if err := domain.ValidateSessionName(name); err != nil {
411+ return err
412+ }
393 d.mu.Lock()413 d.mu.Lock()
394 defer d.mu.Unlock()414 defer d.mu.Unlock()
395 if taken := d.findByNameLocked(name); taken != nil && taken != sess {415 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
@@ -701,7 +721,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
701 721
702 // childEnv builds the session child's environment: the daemon's own, with TERM722 // childEnv builds the session child's environment: the daemon's own, with TERM
703 // and VEV forced to well-known values.723 // and VEV forced to well-known values.
704-func (d *Daemon) childEnv(name string) []string {724+func (d *Daemon) childEnv(name, tabStableID, paneStableID string) []string {
705 out := make([]string, 0, len(d.baseEnv)+2)725 out := make([]string, 0, len(d.baseEnv)+2)
706 for _, e := range d.baseEnv {726 for _, e := range d.baseEnv {
707 if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") {727 if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") {
@@ -709,5 +729,5 @@ func (d *Daemon) childEnv(name string) []string {
709 }729 }
710 out = append(out, e)730 out = append(out, e)
711 }731 }
712- return append(out, "TERM=xterm-256color", "VEV="+name)732+ return append(out, "TERM=xterm-direct", "VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID)
713 }733 }
M internal/usecase/daemon/session_test.go +74 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "io"
66 "log/slog"
7+ "strings"
78 "sync"
89 "sync/atomic"
910 "testing"
@@ -892,6 +893,57 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
892893 require.False(t, ok)
893894 }
894895
896+func TestChildEnvEscapesLegacySessionName(t *testing.T) {
897+ d := newTestDaemon(t, nil, stubClock{})
898+
899+ got := d.childEnv("legacy,name=value", "t_alpha", "p_beta")
900+
901+ require.Contains(t, got, "VEV=session=legacy%2Cname%3Dvalue,tab=t_alpha,pane=p_beta")
902+}
903+
904+func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
905+ sz := domain.Size{Cols: 80, Rows: 24}
906+ p, releasePTY := newBlockingPTY(t)
907+ defer releasePTY()
908+
909+ var gotEnv []string
910+ f := portsmocks.NewMockPTYFactory(t)
911+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
912+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
913+ gotEnv = append([]string(nil), env...)
914+ return p, nil
915+ },
916+ ).Once()
917+ d := newTestDaemon(t, f, stubClock{})
918+ d.baseEnv = []string{"KEEP=1", "TERM=old", "VEV=old"}
919+
920+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
921+ require.NoError(t, err)
922+ defer func() {
923+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
924+ releasePTY()
925+ d.sessWg.Wait()
926+ }()
927+
928+ sess.mu.Lock()
929+ require.Len(t, sess.tabs, 1)
930+ tb := sess.tabs[0]
931+ sess.mu.Unlock()
932+ tb.mu.Lock()
933+ paneStableID := tb.panes["pane-1"].stableID
934+ tabStableID := tb.stableID
935+ tb.mu.Unlock()
936+
937+ require.True(t, strings.HasPrefix(tabStableID, "t_"), tabStableID)
938+ require.True(t, strings.HasPrefix(paneStableID, "p_"), paneStableID)
939+ require.NotEqual(t, tabStableID, paneStableID)
940+ require.Contains(t, gotEnv, "KEEP=1")
941+ require.NotContains(t, gotEnv, "TERM=old")
942+ require.NotContains(t, gotEnv, "VEV=old")
943+ require.Contains(t, gotEnv, "TERM=xterm-direct")
944+ require.Contains(t, gotEnv, "VEV=session=work,tab="+tabStableID+",pane="+paneStableID)
945+}
946+
895947 func TestIntentNewStoppedNameRejected(t *testing.T) {
896948 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
897949 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
@@ -900,6 +952,28 @@ func TestIntentNewStoppedNameRejected(t *testing.T) {
900952 require.ErrorContains(t, err, "name already in use")
901953 }
902954
955+func TestIntentNewUnsafeNameRejected(t *testing.T) {
956+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
957+ tr := portsmocks.NewMockTransport(t)
958+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "my work", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
959+ require.ErrorContains(t, err, domain.ErrInvalidSessionName.Error())
960+}
961+
962+func TestCreateSessionAndSwitchUnsafeNameRejected(t *testing.T) {
963+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
964+ require.ErrorIs(t, d.createSessionAndSwitch(nil, nil, "my work"), domain.ErrInvalidSessionName)
965+}
966+
967+func TestRenameSessionUnsafeNameRejected(t *testing.T) {
968+ sz := domain.Size{Cols: 80, Rows: 24}
969+ p, release := newBlockingPTY(t)
970+ defer release()
971+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
972+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
973+ require.NoError(t, err)
974+ require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName)
975+}
976+
903977 func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
904978 sz := domain.Size{Cols: 80, Rows: 24}
905979 p1, release1 := newBlockingPTY(t)
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "io"5 "io"
6 "log/slog"6 "log/slog"
7+ "strings"
7 "sync"8 "sync"
8 "sync/atomic"9 "sync/atomic"
9 "testing"10 "testing"
@@ -892,6 +893,57 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
892 require.False(t, ok)893 require.False(t, ok)
893 }894 }
894 895
896+func TestChildEnvEscapesLegacySessionName(t *testing.T) {
897+ d := newTestDaemon(t, nil, stubClock{})
898+
899+ got := d.childEnv("legacy,name=value", "t_alpha", "p_beta")
900+
901+ require.Contains(t, got, "VEV=session=legacy%2Cname%3Dvalue,tab=t_alpha,pane=p_beta")
902+}
903+
904+func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
905+ sz := domain.Size{Cols: 80, Rows: 24}
906+ p, releasePTY := newBlockingPTY(t)
907+ defer releasePTY()
908+
909+ var gotEnv []string
910+ f := portsmocks.NewMockPTYFactory(t)
911+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
912+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
913+ gotEnv = append([]string(nil), env...)
914+ return p, nil
915+ },
916+ ).Once()
917+ d := newTestDaemon(t, f, stubClock{})
918+ d.baseEnv = []string{"KEEP=1", "TERM=old", "VEV=old"}
919+
920+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
921+ require.NoError(t, err)
922+ defer func() {
923+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
924+ releasePTY()
925+ d.sessWg.Wait()
926+ }()
927+
928+ sess.mu.Lock()
929+ require.Len(t, sess.tabs, 1)
930+ tb := sess.tabs[0]
931+ sess.mu.Unlock()
932+ tb.mu.Lock()
933+ paneStableID := tb.panes["pane-1"].stableID
934+ tabStableID := tb.stableID
935+ tb.mu.Unlock()
936+
937+ require.True(t, strings.HasPrefix(tabStableID, "t_"), tabStableID)
938+ require.True(t, strings.HasPrefix(paneStableID, "p_"), paneStableID)
939+ require.NotEqual(t, tabStableID, paneStableID)
940+ require.Contains(t, gotEnv, "KEEP=1")
941+ require.NotContains(t, gotEnv, "TERM=old")
942+ require.NotContains(t, gotEnv, "VEV=old")
943+ require.Contains(t, gotEnv, "TERM=xterm-direct")
944+ require.Contains(t, gotEnv, "VEV=session=work,tab="+tabStableID+",pane="+paneStableID)
945+}
946+
895 func TestIntentNewStoppedNameRejected(t *testing.T) {947 func TestIntentNewStoppedNameRejected(t *testing.T) {
896 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})948 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
897 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}949 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
@@ -900,6 +952,28 @@ func TestIntentNewStoppedNameRejected(t *testing.T) {
900 require.ErrorContains(t, err, "name already in use")952 require.ErrorContains(t, err, "name already in use")
901 }953 }
902 954
955+func TestIntentNewUnsafeNameRejected(t *testing.T) {
956+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
957+ tr := portsmocks.NewMockTransport(t)
958+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "my work", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
959+ require.ErrorContains(t, err, domain.ErrInvalidSessionName.Error())
960+}
961+
962+func TestCreateSessionAndSwitchUnsafeNameRejected(t *testing.T) {
963+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
964+ require.ErrorIs(t, d.createSessionAndSwitch(nil, nil, "my work"), domain.ErrInvalidSessionName)
965+}
966+
967+func TestRenameSessionUnsafeNameRejected(t *testing.T) {
968+ sz := domain.Size{Cols: 80, Rows: 24}
969+ p, release := newBlockingPTY(t)
970+ defer release()
971+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
972+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
973+ require.NoError(t, err)
974+ require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName)
975+}
976+
903 func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {977 func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
904 sz := domain.Size{Cols: 80, Rows: 24}978 sz := domain.Size{Cols: 80, Rows: 24}
905 p1, release1 := newBlockingPTY(t)979 p1, release1 := newBlockingPTY(t)
M internal/usecase/daemon/snapshot.go +59 -5
@@ -73,6 +73,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
7373 }
7474 cancel()
7575 }
76+ allowlist := d.restoreProcessAllowlistSnapshot()
7677 for _, tabSnap := range snap.Tabs {
7778 if err := ctx.Err(); err != nil {
7879 closeOpened()
@@ -92,13 +93,26 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
9293 for _, pl := range placements {
9394 placementByPane[pl.ID] = pl.Content
9495 }
95- tb := &tab{tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
96+ tabStableID := tabSnap.StableID
97+ if tabStableID == "" {
98+ var err error
99+ tabStableID, err = newStableID("t")
100+ if err != nil {
101+ closeOpened()
102+ return fmt.Errorf("snapshot: generating tab identity: %w", err)
103+ }
104+ }
105+ tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
96106 if tb.nextPaneID <= 0 {
97107 tb.nextPaneID = 1
98108 }
99109 tb.ctx, tb.cancel = context.WithCancel(sctx)
100110 opened = append(opened, tb)
101111 for _, paneSnap := range tabSnap.Panes {
112+ restoreCommand := ""
113+ if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore {
114+ restoreCommand = decision.Command
115+ }
102116 if err := ctx.Err(); err != nil {
103117 closeOpened()
104118 return err
@@ -109,15 +123,29 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
109123 return fmt.Errorf("snapshot: missing pane placement")
110124 }
111125 contentSize := restorePTYSize(contentRect, tbSize)
112- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name), paneSnap.Cwd, contentSize)
126+ paneStableID := paneSnap.StableID
127+ if paneStableID == "" {
128+ var err error
129+ paneStableID, err = newStableID("p")
130+ if err != nil {
131+ closeOpened()
132+ return fmt.Errorf("snapshot: generating pane identity: %w", err)
133+ }
134+ }
135+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID), paneSnap.Cwd, contentSize)
113136 if err != nil {
114137 closeOpened()
115138 return err
116139 }
117- p := newPane(paneSnap.ID, pty, contentSize)
140+ p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)
118141 p.ctx, p.cancel = context.WithCancel(tb.ctx)
119142 p.rect = contentRect
120143 seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)
144+ if restoreCommand != "" {
145+ if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil {
146+ d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID)
147+ }
148+ }
121149 tb.panes[paneSnap.ID] = p
122150 }
123151 }
@@ -235,6 +263,7 @@ func (d *Daemon) captureSession(sess *session) bool {
235263 for _, tb := range tabs {
236264 tb.mu.Lock()
237265 tabSnap := snapcodec.Tab{
266+ StableID: tb.stableID,
238267 Cols: uint16(max(tb.size.Cols, 0)),
239268 Rows: uint16(max(tb.size.Rows, 0)),
240269 NextPaneID: uint64(max(tb.nextPaneID, 0)),
@@ -254,11 +283,13 @@ func (d *Daemon) captureSession(sess *session) bool {
254283 for _, p := range panes {
255284 p.mu.Lock()
256285 pid := 0
257- if p.pty != nil {
258- pid = p.pty.Pid()
286+ pty := p.pty
287+ if pty != nil {
288+ pid = pty.Pid()
259289 }
260290 paneSnap := snapcodec.Pane{
261291 ID: p.id,
292+ StableID: p.stableID,
262293 Scrollback: cloneRows(p.scrollback.Snapshot()),
263294 Visible: cloneRows(p.screen.PrimaryVisibleRows()),
264295 }
@@ -269,6 +300,7 @@ func (d *Daemon) captureSession(sess *session) bool {
269300 paneSnap.Cwd = cwd
270301 }
271302 }
303+ paneSnap.Process = d.capturePaneProcess(pty, pid)
272304 paneIDs[paneSnap.ID] = struct{}{}
273305 paneSnaps = append(paneSnaps, paneSnap)
274306 }
@@ -291,6 +323,28 @@ func (d *Daemon) captureSession(sess *session) bool {
291323 return true
292324 }
293325
326+func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
327+ if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {
328+ return nil
329+ }
330+ pgid, err := pty.ForegroundPgid()
331+ if err != nil || pgid <= 0 || pgid == shellPid {
332+ return nil
333+ }
334+ argv, err := d.procGroupArgv(pgid, shellPid)
335+ if err != nil || len(argv) == 0 || argv[0] == "" {
336+ return nil
337+ }
338+ strategy := detectProcessStrategy(argv)
339+ return &snapcodec.Process{
340+ Argv: append([]string(nil), argv...),
341+ Strategy: strategy,
342+ Opts: snapcodec.ProcessOpts{
343+ AgentSessionID: extractAgentSessionID(strategy, argv),
344+ },
345+ }
346+}
347+
294348 func (d *Daemon) snapshotSaver(ctx context.Context) {
295349 t := d.clock.NewTimer(snapshotInterval)
296350 defer t.Stop()
@@ -73,6 +73,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
73 }73 }
74 cancel()74 cancel()
75 }75 }
76+ allowlist := d.restoreProcessAllowlistSnapshot()
76 for _, tabSnap := range snap.Tabs {77 for _, tabSnap := range snap.Tabs {
77 if err := ctx.Err(); err != nil {78 if err := ctx.Err(); err != nil {
78 closeOpened()79 closeOpened()
@@ -92,13 +93,26 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
92 for _, pl := range placements {93 for _, pl := range placements {
93 placementByPane[pl.ID] = pl.Content94 placementByPane[pl.ID] = pl.Content
94 }95 }
95- tb := &tab{tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}96+ tabStableID := tabSnap.StableID
97+ if tabStableID == "" {
98+ var err error
99+ tabStableID, err = newStableID("t")
100+ if err != nil {
101+ closeOpened()
102+ return fmt.Errorf("snapshot: generating tab identity: %w", err)
103+ }
104+ }
105+ tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
96 if tb.nextPaneID <= 0 {106 if tb.nextPaneID <= 0 {
97 tb.nextPaneID = 1107 tb.nextPaneID = 1
98 }108 }
99 tb.ctx, tb.cancel = context.WithCancel(sctx)109 tb.ctx, tb.cancel = context.WithCancel(sctx)
100 opened = append(opened, tb)110 opened = append(opened, tb)
101 for _, paneSnap := range tabSnap.Panes {111 for _, paneSnap := range tabSnap.Panes {
112+ restoreCommand := ""
113+ if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore {
114+ restoreCommand = decision.Command
115+ }
102 if err := ctx.Err(); err != nil {116 if err := ctx.Err(); err != nil {
103 closeOpened()117 closeOpened()
104 return err118 return err
@@ -109,15 +123,29 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
109 return fmt.Errorf("snapshot: missing pane placement")123 return fmt.Errorf("snapshot: missing pane placement")
110 }124 }
111 contentSize := restorePTYSize(contentRect, tbSize)125 contentSize := restorePTYSize(contentRect, tbSize)
112- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name), paneSnap.Cwd, contentSize)126+ paneStableID := paneSnap.StableID
127+ if paneStableID == "" {
128+ var err error
129+ paneStableID, err = newStableID("p")
130+ if err != nil {
131+ closeOpened()
132+ return fmt.Errorf("snapshot: generating pane identity: %w", err)
133+ }
134+ }
135+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID), paneSnap.Cwd, contentSize)
113 if err != nil {136 if err != nil {
114 closeOpened()137 closeOpened()
115 return err138 return err
116 }139 }
117- p := newPane(paneSnap.ID, pty, contentSize)140+ p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize)
118 p.ctx, p.cancel = context.WithCancel(tb.ctx)141 p.ctx, p.cancel = context.WithCancel(tb.ctx)
119 p.rect = contentRect142 p.rect = contentRect
120 seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)143 seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)
144+ if restoreCommand != "" {
145+ if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil {
146+ d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID)
147+ }
148+ }
121 tb.panes[paneSnap.ID] = p149 tb.panes[paneSnap.ID] = p
122 }150 }
123 }151 }
@@ -235,6 +263,7 @@ func (d *Daemon) captureSession(sess *session) bool {
235 for _, tb := range tabs {263 for _, tb := range tabs {
236 tb.mu.Lock()264 tb.mu.Lock()
237 tabSnap := snapcodec.Tab{265 tabSnap := snapcodec.Tab{
266+ StableID: tb.stableID,
238 Cols: uint16(max(tb.size.Cols, 0)),267 Cols: uint16(max(tb.size.Cols, 0)),
239 Rows: uint16(max(tb.size.Rows, 0)),268 Rows: uint16(max(tb.size.Rows, 0)),
240 NextPaneID: uint64(max(tb.nextPaneID, 0)),269 NextPaneID: uint64(max(tb.nextPaneID, 0)),
@@ -254,11 +283,13 @@ func (d *Daemon) captureSession(sess *session) bool {
254 for _, p := range panes {283 for _, p := range panes {
255 p.mu.Lock()284 p.mu.Lock()
256 pid := 0285 pid := 0
257- if p.pty != nil {286+ pty := p.pty
258- pid = p.pty.Pid()287+ if pty != nil {
288+ pid = pty.Pid()
259 }289 }
260 paneSnap := snapcodec.Pane{290 paneSnap := snapcodec.Pane{
261 ID: p.id,291 ID: p.id,
292+ StableID: p.stableID,
262 Scrollback: cloneRows(p.scrollback.Snapshot()),293 Scrollback: cloneRows(p.scrollback.Snapshot()),
263 Visible: cloneRows(p.screen.PrimaryVisibleRows()),294 Visible: cloneRows(p.screen.PrimaryVisibleRows()),
264 }295 }
@@ -269,6 +300,7 @@ func (d *Daemon) captureSession(sess *session) bool {
269 paneSnap.Cwd = cwd300 paneSnap.Cwd = cwd
270 }301 }
271 }302 }
303+ paneSnap.Process = d.capturePaneProcess(pty, pid)
272 paneIDs[paneSnap.ID] = struct{}{}304 paneIDs[paneSnap.ID] = struct{}{}
273 paneSnaps = append(paneSnaps, paneSnap)305 paneSnaps = append(paneSnaps, paneSnap)
274 }306 }
@@ -291,6 +323,28 @@ func (d *Daemon) captureSession(sess *session) bool {
291 return true323 return true
292 }324 }
293 325
326+func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
327+ if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {
328+ return nil
329+ }
330+ pgid, err := pty.ForegroundPgid()
331+ if err != nil || pgid <= 0 || pgid == shellPid {
332+ return nil
333+ }
334+ argv, err := d.procGroupArgv(pgid, shellPid)
335+ if err != nil || len(argv) == 0 || argv[0] == "" {
336+ return nil
337+ }
338+ strategy := detectProcessStrategy(argv)
339+ return &snapcodec.Process{
340+ Argv: append([]string(nil), argv...),
341+ Strategy: strategy,
342+ Opts: snapcodec.ProcessOpts{
343+ AgentSessionID: extractAgentSessionID(strategy, argv),
344+ },
345+ }
346+}
347+
294 func (d *Daemon) snapshotSaver(ctx context.Context) {348 func (d *Daemon) snapshotSaver(ctx context.Context) {
295 t := d.clock.NewTimer(snapshotInterval)349 t := d.clock.NewTimer(snapshotInterval)
296 defer t.Stop()350 defer t.Stop()
M internal/usecase/daemon/snapshot_restore_test.go +160 -10
@@ -2,6 +2,8 @@ package daemon
22
33 import (
44 "context"
5+ "encoding/binary"
6+ "errors"
57 "io"
68 "math"
79 "sync"
@@ -25,6 +27,7 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
2527 CreatedAt: 99,
2628 Active: 0,
2729 Tabs: []snapcodec.Tab{{
30+ StableID: "t_saved",
2831 Cols: 80,
2932 Rows: 24,
3033 NextPaneID: 3,
@@ -34,8 +37,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
3437 layout.NewLeaf("pane-2"),
3538 }}},
3639 Panes: []snapcodec.Pane{
37- {ID: "pane-1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},
38- {ID: "pane-2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},
40+ {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},
41+ {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},
3942 },
4043 }},
4144 })}}
@@ -76,6 +79,134 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
7679 require.Equal(t, "old2", rowText(copySnap.Rows[0][:4]))
7780 require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4]))
7881 p.mu.Unlock()
82+
83+ tb.mu.Lock()
84+ tabStableID := tb.stableID
85+ pane1StableID := tb.panes["pane-1"].stableID
86+ pane2StableID := tb.panes["pane-2"].stableID
87+ tb.mu.Unlock()
88+ require.Equal(t, "t_saved", tabStableID)
89+ require.Equal(t, "p_saved_1", pane1StableID)
90+ require.Equal(t, "p_saved_2", pane2StableID)
91+ require.Contains(t, factory.opens[0].env, "TERM=xterm-direct")
92+ require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID)
93+ require.Contains(t, factory.opens[1].env, "TERM=xterm-direct")
94+ require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)
95+}
96+
97+func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {
98+ t.Helper()
99+ return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{
100+ Name: name,
101+ Tabs: []snapcodec.Tab{{
102+ Cols: 80,
103+ Rows: 24,
104+ Tree: layout.NewTree("pane-1"),
105+ Panes: []snapcodec.Pane{{
106+ ID: "pane-1",
107+ Cwd: "/tmp",
108+ Process: proc,
109+ }},
110+ }},
111+ })}}}
112+}
113+
114+func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) {
115+ lessProc := &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric}
116+ agentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
117+ controlAgentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc\nmalicious"}}
118+
119+ tests := []struct {
120+ name string
121+ store *restoreSnapshotStore
122+ allow []string
123+ wantWrites [][]string
124+ }{
125+ {
126+ name: "allowlisted generic command",
127+ store: processRestoreTestStore(t, "allowlisted", lessProc),
128+ allow: []string{"less"},
129+ wantWrites: [][]string{{"less README.md\n"}},
130+ },
131+ {
132+ name: "denied generic command",
133+ store: processRestoreTestStore(t, "denied", lessProc),
134+ allow: []string{"vim"},
135+ wantWrites: [][]string{{}},
136+ },
137+ {
138+ name: "pane scoped command",
139+ store: &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "scoped", Data: mustSnapshotBytes(t, snapcodec.Session{
140+ Name: "scoped",
141+ Tabs: []snapcodec.Tab{
142+ {
143+ Cols: 80,
144+ Rows: 24,
145+ Tree: layout.NewTree("pane-1"),
146+ Panes: []snapcodec.Pane{{
147+ ID: "pane-1",
148+ Cwd: "/one",
149+ Process: lessProc,
150+ }},
151+ },
152+ {
153+ Cols: 80,
154+ Rows: 24,
155+ Tree: layout.NewTree("pane-1"),
156+ Panes: []snapcodec.Pane{{
157+ ID: "pane-1",
158+ Cwd: "/two",
159+ }},
160+ },
161+ },
162+ })}}},
163+ allow: []string{"less"},
164+ wantWrites: [][]string{{"less README.md\n"}, {}},
165+ },
166+ {
167+ name: "agent ID command",
168+ store: processRestoreTestStore(t, "agent", agentProc),
169+ allow: []string{"pi"},
170+ wantWrites: [][]string{{"pi --resume abc123\n"}},
171+ },
172+ {
173+ name: "agent ID with control byte rejected",
174+ store: processRestoreTestStore(t, "agent-control", controlAgentProc),
175+ allow: []string{"pi"},
176+ wantWrites: [][]string{{}},
177+ },
178+ }
179+
180+ for _, tt := range tests {
181+ t.Run(tt.name, func(t *testing.T) {
182+ factory := &restorePTYFactory{}
183+ d := newTestDaemon(t, factory, stubClock{})
184+ WithSnapshotStore(tt.store)(d)
185+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: tt.allow}})
186+
187+ d.restoreSnapshots(context.Background())
188+
189+ require.Len(t, factory.opens, len(tt.wantWrites))
190+ for i, want := range tt.wantWrites {
191+ if len(want) == 0 {
192+ require.Empty(t, factory.opens[i].pty.writes)
193+ continue
194+ }
195+ require.Equal(t, want, factory.opens[i].pty.writes)
196+ }
197+ })
198+ }
199+}
200+
201+func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {
202+ proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
203+ factory := &restorePTYFactory{writeErr: errors.New("boom")}
204+ d := newTestDaemon(t, factory, stubClock{})
205+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
206+
207+ require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp", Process: proc}}}}}))
208+ require.Len(t, factory.opens, 1)
209+ require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
79210 }
80211
81212 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
@@ -118,6 +249,8 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
118249
119250 func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
120251 valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}})
252+ badVersion := append([]byte(nil), valid...)
253+ binary.BigEndian.PutUint16(badVersion[4:6], 1)
121254 tests := []struct {
122255 name string
123256 blobs []ports.SnapshotBlob
@@ -126,6 +259,7 @@ func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
126259 }{
127260 {name: "empty store"},
128261 {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}},
262+ {name: "old v1 blob", blobs: []ports.SnapshotBlob{{Name: "old", Data: badVersion}}},
129263 {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"},
130264 }
131265 for _, tt := range tests {
@@ -256,25 +390,33 @@ func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
256390 }
257391
258392 type restorePTYFactory struct {
259- mu sync.Mutex
260- opens []restorePTYOpen
393+ mu sync.Mutex
394+ opens []restorePTYOpen
395+ writeErr error
261396 }
262397
263398 type restorePTYOpen struct {
264399 dir string
265400 size domain.Size
401+ env []string
402+ pty *restorePTY
266403 }
267404
268-func (f *restorePTYFactory) Open(_ string, _ []string, _ []string, dir string, sz domain.Size) (ports.PTY, error) {
405+func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
269406 f.mu.Lock()
270407 defer f.mu.Unlock()
271- f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz})
272- return newRestorePTY(), nil
408+ pty := newRestorePTY()
409+ pty.writeErr = f.writeErr
410+ f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
411+ return pty, nil
273412 }
274413
275414 type restorePTY struct {
276- done chan struct{}
277- once sync.Once
415+ mu sync.Mutex
416+ writes []string
417+ writeErr error
418+ done chan struct{}
419+ once sync.Once
278420 }
279421
280422 func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} }
@@ -282,7 +424,15 @@ func (p *restorePTY) Read([]byte) (int, error) {
282424 <-p.done
283425 return 0, io.EOF
284426 }
285-func (p *restorePTY) Write(b []byte) (int, error) { return len(b), nil }
427+func (p *restorePTY) Write(b []byte) (int, error) {
428+ p.mu.Lock()
429+ defer p.mu.Unlock()
430+ p.writes = append(p.writes, string(b))
431+ if p.writeErr != nil {
432+ return 0, p.writeErr
433+ }
434+ return len(b), nil
435+}
286436 func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil }
287437 func (p *restorePTY) Resize(domain.Size) error { return nil }
288438 func (p *restorePTY) Pid() int { return 0 }
@@ -2,6 +2,8 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/binary"
6+ "errors"
5 "io"7 "io"
6 "math"8 "math"
7 "sync"9 "sync"
@@ -25,6 +27,7 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
25 CreatedAt: 99,27 CreatedAt: 99,
26 Active: 0,28 Active: 0,
27 Tabs: []snapcodec.Tab{{29 Tabs: []snapcodec.Tab{{
30+ StableID: "t_saved",
28 Cols: 80,31 Cols: 80,
29 Rows: 24,32 Rows: 24,
30 NextPaneID: 3,33 NextPaneID: 3,
@@ -34,8 +37,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
34 layout.NewLeaf("pane-2"),37 layout.NewLeaf("pane-2"),
35 }}},38 }}},
36 Panes: []snapcodec.Pane{39 Panes: []snapcodec.Pane{
37- {ID: "pane-1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},40+ {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}},
38- {ID: "pane-2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},41+ {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}},
39 },42 },
40 }},43 }},
41 })}}44 })}}
@@ -76,6 +79,134 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
76 require.Equal(t, "old2", rowText(copySnap.Rows[0][:4]))79 require.Equal(t, "old2", rowText(copySnap.Rows[0][:4]))
77 require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4]))80 require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4]))
78 p.mu.Unlock()81 p.mu.Unlock()
82+
83+ tb.mu.Lock()
84+ tabStableID := tb.stableID
85+ pane1StableID := tb.panes["pane-1"].stableID
86+ pane2StableID := tb.panes["pane-2"].stableID
87+ tb.mu.Unlock()
88+ require.Equal(t, "t_saved", tabStableID)
89+ require.Equal(t, "p_saved_1", pane1StableID)
90+ require.Equal(t, "p_saved_2", pane2StableID)
91+ require.Contains(t, factory.opens[0].env, "TERM=xterm-direct")
92+ require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID)
93+ require.Contains(t, factory.opens[1].env, "TERM=xterm-direct")
94+ require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)
95+}
96+
97+func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {
98+ t.Helper()
99+ return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{
100+ Name: name,
101+ Tabs: []snapcodec.Tab{{
102+ Cols: 80,
103+ Rows: 24,
104+ Tree: layout.NewTree("pane-1"),
105+ Panes: []snapcodec.Pane{{
106+ ID: "pane-1",
107+ Cwd: "/tmp",
108+ Process: proc,
109+ }},
110+ }},
111+ })}}}
112+}
113+
114+func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) {
115+ lessProc := &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric}
116+ agentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
117+ controlAgentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc\nmalicious"}}
118+
119+ tests := []struct {
120+ name string
121+ store *restoreSnapshotStore
122+ allow []string
123+ wantWrites [][]string
124+ }{
125+ {
126+ name: "allowlisted generic command",
127+ store: processRestoreTestStore(t, "allowlisted", lessProc),
128+ allow: []string{"less"},
129+ wantWrites: [][]string{{"less README.md\n"}},
130+ },
131+ {
132+ name: "denied generic command",
133+ store: processRestoreTestStore(t, "denied", lessProc),
134+ allow: []string{"vim"},
135+ wantWrites: [][]string{{}},
136+ },
137+ {
138+ name: "pane scoped command",
139+ store: &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "scoped", Data: mustSnapshotBytes(t, snapcodec.Session{
140+ Name: "scoped",
141+ Tabs: []snapcodec.Tab{
142+ {
143+ Cols: 80,
144+ Rows: 24,
145+ Tree: layout.NewTree("pane-1"),
146+ Panes: []snapcodec.Pane{{
147+ ID: "pane-1",
148+ Cwd: "/one",
149+ Process: lessProc,
150+ }},
151+ },
152+ {
153+ Cols: 80,
154+ Rows: 24,
155+ Tree: layout.NewTree("pane-1"),
156+ Panes: []snapcodec.Pane{{
157+ ID: "pane-1",
158+ Cwd: "/two",
159+ }},
160+ },
161+ },
162+ })}}},
163+ allow: []string{"less"},
164+ wantWrites: [][]string{{"less README.md\n"}, {}},
165+ },
166+ {
167+ name: "agent ID command",
168+ store: processRestoreTestStore(t, "agent", agentProc),
169+ allow: []string{"pi"},
170+ wantWrites: [][]string{{"pi --resume abc123\n"}},
171+ },
172+ {
173+ name: "agent ID with control byte rejected",
174+ store: processRestoreTestStore(t, "agent-control", controlAgentProc),
175+ allow: []string{"pi"},
176+ wantWrites: [][]string{{}},
177+ },
178+ }
179+
180+ for _, tt := range tests {
181+ t.Run(tt.name, func(t *testing.T) {
182+ factory := &restorePTYFactory{}
183+ d := newTestDaemon(t, factory, stubClock{})
184+ WithSnapshotStore(tt.store)(d)
185+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: tt.allow}})
186+
187+ d.restoreSnapshots(context.Background())
188+
189+ require.Len(t, factory.opens, len(tt.wantWrites))
190+ for i, want := range tt.wantWrites {
191+ if len(want) == 0 {
192+ require.Empty(t, factory.opens[i].pty.writes)
193+ continue
194+ }
195+ require.Equal(t, want, factory.opens[i].pty.writes)
196+ }
197+ })
198+ }
199+}
200+
201+func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) {
202+ proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
203+ factory := &restorePTYFactory{writeErr: errors.New("boom")}
204+ d := newTestDaemon(t, factory, stubClock{})
205+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
206+
207+ require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp", Process: proc}}}}}))
208+ require.Len(t, factory.opens, 1)
209+ require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes)
79 }210 }
80 211
81 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {212 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
@@ -118,6 +249,8 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T)
118 249
119 func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {250 func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
120 valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}})251 valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}})
252+ badVersion := append([]byte(nil), valid...)
253+ binary.BigEndian.PutUint16(badVersion[4:6], 1)
121 tests := []struct {254 tests := []struct {
122 name string255 name string
123 blobs []ports.SnapshotBlob256 blobs []ports.SnapshotBlob
@@ -126,6 +259,7 @@ func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
126 }{259 }{
127 {name: "empty store"},260 {name: "empty store"},
128 {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}},261 {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}},
262+ {name: "old v1 blob", blobs: []ports.SnapshotBlob{{Name: "old", Data: badVersion}}},
129 {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"},263 {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"},
130 }264 }
131 for _, tt := range tests {265 for _, tt := range tests {
@@ -256,25 +390,33 @@ func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
256 }390 }
257 391
258 type restorePTYFactory struct {392 type restorePTYFactory struct {
259- mu sync.Mutex393+ mu sync.Mutex
260- opens []restorePTYOpen394+ opens []restorePTYOpen
395+ writeErr error
261 }396 }
262 397
263 type restorePTYOpen struct {398 type restorePTYOpen struct {
264 dir string399 dir string
265 size domain.Size400 size domain.Size
401+ env []string
402+ pty *restorePTY
266 }403 }
267 404
268-func (f *restorePTYFactory) Open(_ string, _ []string, _ []string, dir string, sz domain.Size) (ports.PTY, error) {405+func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
269 f.mu.Lock()406 f.mu.Lock()
270 defer f.mu.Unlock()407 defer f.mu.Unlock()
271- f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz})408+ pty := newRestorePTY()
272- return newRestorePTY(), nil409+ pty.writeErr = f.writeErr
410+ f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
411+ return pty, nil
273 }412 }
274 413
275 type restorePTY struct {414 type restorePTY struct {
276- done chan struct{}415+ mu sync.Mutex
277- once sync.Once416+ writes []string
417+ writeErr error
418+ done chan struct{}
419+ once sync.Once
278 }420 }
279 421
280 func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} }422 func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} }
@@ -282,7 +424,15 @@ func (p *restorePTY) Read([]byte) (int, error) {
282 <-p.done424 <-p.done
283 return 0, io.EOF425 return 0, io.EOF
284 }426 }
285-func (p *restorePTY) Write(b []byte) (int, error) { return len(b), nil }427+func (p *restorePTY) Write(b []byte) (int, error) {
428+ p.mu.Lock()
429+ defer p.mu.Unlock()
430+ p.writes = append(p.writes, string(b))
431+ if p.writeErr != nil {
432+ return 0, p.writeErr
433+ }
434+ return len(b), nil
435+}
286 func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil }436 func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil }
287 func (p *restorePTY) Resize(domain.Size) error { return nil }437 func (p *restorePTY) Resize(domain.Size) error { return nil }
288 func (p *restorePTY) Pid() int { return 0 }438 func (p *restorePTY) Pid() int { return 0 }
M internal/usecase/daemon/snapshot_test.go +82 -0
@@ -63,6 +63,77 @@ func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) {
6363 }
6464 }
6565
66+func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
67+ store := portsmocks.NewMockSnapshotStore(t)
68+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
69+ WithSnapshotStore(store)(d)
70+ d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) {
71+ require.Equal(t, 4321, pgid)
72+ require.Equal(t, 1234, shellPid)
73+ return []string{"claude", "--session-id", "agent-123"}, nil
74+ }
75+
76+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
77+ pty := snapshotProcessPTY(t, 4321)
78+ sess.tabs[0].panes["pane-1"].pty = pty
79+
80+ store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
81+ snap, err := snapcodec.Unmarshal(data)
82+ require.NoError(t, err)
83+ proc := snap.Tabs[0].Panes[0].Process
84+ require.NotNil(t, proc)
85+ require.Equal(t, []string{"claude", "--session-id", "agent-123"}, proc.Argv)
86+ require.Equal(t, processStrategyClaude, proc.Strategy)
87+ require.Equal(t, "agent-123", proc.Opts.AgentSessionID)
88+ return nil
89+ }).Once()
90+
91+ require.True(t, d.captureSession(sess))
92+}
93+
94+func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) {
95+ store := portsmocks.NewMockSnapshotStore(t)
96+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
97+ WithSnapshotStore(store)(d)
98+ d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") }
99+
100+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
101+ pty := snapshotProcessPTY(t, 4321)
102+ sess.tabs[0].panes["pane-1"].pty = pty
103+
104+ store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
105+ snap, err := snapcodec.Unmarshal(data)
106+ require.NoError(t, err)
107+ require.Nil(t, snap.Tabs[0].Panes[0].Process)
108+ return nil
109+ }).Once()
110+
111+ require.True(t, d.captureSession(sess))
112+}
113+
114+func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) {
115+ store := portsmocks.NewMockSnapshotStore(t)
116+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
117+ WithSnapshotStore(store)(d)
118+ d.procGroupArgv = func(int, int) ([]string, error) {
119+ t.Fatal("bare shell should not inspect argv")
120+ return nil, nil
121+ }
122+
123+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
124+ pty := snapshotProcessPTY(t, 1234)
125+ sess.tabs[0].panes["pane-1"].pty = pty
126+
127+ store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
128+ snap, err := snapcodec.Unmarshal(data)
129+ require.NoError(t, err)
130+ require.Nil(t, snap.Tabs[0].Panes[0].Process)
131+ return nil
132+ }).Once()
133+
134+ require.True(t, d.captureSession(sess))
135+}
136+
66137 func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) {
67138 store := portsmocks.NewMockSnapshotStore(t)
68139 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
@@ -293,6 +364,17 @@ func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
293364 }
294365 }
295366
367+func snapshotProcessPTY(t *testing.T, pgid int) *portsmocks.MockPTY {
368+ t.Helper()
369+ pty := portsmocks.NewMockPTY(t)
370+ pty.EXPECT().Pid().Return(1234).Maybe()
371+ pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe()
372+ pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
373+ pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
374+ pty.EXPECT().ForegroundPgid().Return(pgid, nil).Once()
375+ return pty
376+}
377+
296378 func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session {
297379 t.Helper()
298380 pty := portsmocks.NewMockPTY(t)
@@ -63,6 +63,77 @@ func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) {
63 }63 }
64 }64 }
65 65
66+func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
67+ store := portsmocks.NewMockSnapshotStore(t)
68+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
69+ WithSnapshotStore(store)(d)
70+ d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) {
71+ require.Equal(t, 4321, pgid)
72+ require.Equal(t, 1234, shellPid)
73+ return []string{"claude", "--session-id", "agent-123"}, nil
74+ }
75+
76+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
77+ pty := snapshotProcessPTY(t, 4321)
78+ sess.tabs[0].panes["pane-1"].pty = pty
79+
80+ store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
81+ snap, err := snapcodec.Unmarshal(data)
82+ require.NoError(t, err)
83+ proc := snap.Tabs[0].Panes[0].Process
84+ require.NotNil(t, proc)
85+ require.Equal(t, []string{"claude", "--session-id", "agent-123"}, proc.Argv)
86+ require.Equal(t, processStrategyClaude, proc.Strategy)
87+ require.Equal(t, "agent-123", proc.Opts.AgentSessionID)
88+ return nil
89+ }).Once()
90+
91+ require.True(t, d.captureSession(sess))
92+}
93+
94+func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) {
95+ store := portsmocks.NewMockSnapshotStore(t)
96+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
97+ WithSnapshotStore(store)(d)
98+ d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") }
99+
100+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
101+ pty := snapshotProcessPTY(t, 4321)
102+ sess.tabs[0].panes["pane-1"].pty = pty
103+
104+ store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
105+ snap, err := snapcodec.Unmarshal(data)
106+ require.NoError(t, err)
107+ require.Nil(t, snap.Tabs[0].Panes[0].Process)
108+ return nil
109+ }).Once()
110+
111+ require.True(t, d.captureSession(sess))
112+}
113+
114+func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) {
115+ store := portsmocks.NewMockSnapshotStore(t)
116+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
117+ WithSnapshotStore(store)(d)
118+ d.procGroupArgv = func(int, int) ([]string, error) {
119+ t.Fatal("bare shell should not inspect argv")
120+ return nil, nil
121+ }
122+
123+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
124+ pty := snapshotProcessPTY(t, 1234)
125+ sess.tabs[0].panes["pane-1"].pty = pty
126+
127+ store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
128+ snap, err := snapcodec.Unmarshal(data)
129+ require.NoError(t, err)
130+ require.Nil(t, snap.Tabs[0].Panes[0].Process)
131+ return nil
132+ }).Once()
133+
134+ require.True(t, d.captureSession(sess))
135+}
136+
66 func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) {137 func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) {
67 store := portsmocks.NewMockSnapshotStore(t)138 store := portsmocks.NewMockSnapshotStore(t)
68 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})139 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
@@ -293,6 +364,17 @@ func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) {
293 }364 }
294 }365 }
295 366
367+func snapshotProcessPTY(t *testing.T, pgid int) *portsmocks.MockPTY {
368+ t.Helper()
369+ pty := portsmocks.NewMockPTY(t)
370+ pty.EXPECT().Pid().Return(1234).Maybe()
371+ pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe()
372+ pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
373+ pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
374+ pty.EXPECT().ForegroundPgid().Return(pgid, nil).Once()
375+ return pty
376+}
377+
296 func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session {378 func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session {
297 t.Helper()379 t.Helper()
298 pty := portsmocks.NewMockPTY(t)380 pty := portsmocks.NewMockPTY(t)
M internal/usecase/snapshot/codec.go +101 -6
@@ -27,7 +27,7 @@ var (
2727
2828 const (
2929 magic = "VEVS"
30- version = uint16(1)
30+ version = uint16(2)
3131 flagFlate = uint16(1 << 0)
3232 flateCutoff = 4 << 10
3333 maxDecodedBodySize = 256 << 20
@@ -36,7 +36,7 @@ const (
3636 maxDecodedCells = 16 << 20
3737 )
3838
39-// Marshal encodes a v1 durable snapshot.
39+// Marshal encodes a v2 durable snapshot.
4040 func Marshal(s Session) ([]byte, error) {
4141 var w payloadWriter
4242 if err := writeSession(&w, s); err != nil {
@@ -73,7 +73,7 @@ func Marshal(s Session) ([]byte, error) {
7373 return out, nil
7474 }
7575
76-// Unmarshal decodes a strict v1 durable snapshot.
76+// Unmarshal decodes a strict v2 durable snapshot.
7777 func Unmarshal(b []byte) (Session, error) {
7878 if len(b) < 16 {
7979 return Session{}, ErrShortPayload
@@ -166,6 +166,18 @@ func (w *payloadWriter) putString(s string) error {
166166 w.b = append(w.b, s...)
167167 return nil
168168 }
169+func (w *payloadWriter) putStrings(ss []string) error {
170+ if len(ss) > math.MaxUint16 {
171+ return fmt.Errorf("%w: too many strings", ErrInvalidData)
172+ }
173+ w.putUint16(uint16(len(ss)))
174+ for _, s := range ss {
175+ if err := w.putString(s); err != nil {
176+ return err
177+ }
178+ }
179+ return nil
180+}
169181
170182 type payloadReader struct{ b []byte }
171183
@@ -213,6 +225,21 @@ func (r *payloadReader) getString() (string, error) {
213225 r.b = r.b[n:]
214226 return s, nil
215227 }
228+func (r *payloadReader) getStrings() ([]string, error) {
229+ n, err := r.getUint16()
230+ if err != nil {
231+ return nil, err
232+ }
233+ out := make([]string, n)
234+ for i := range out {
235+ s, err := r.getString()
236+ if err != nil {
237+ return nil, err
238+ }
239+ out[i] = s
240+ }
241+ return out, nil
242+}
216243 func (r *payloadReader) done() error {
217244 if len(r.b) != 0 {
218245 return ErrTrailingBytes
@@ -267,6 +294,9 @@ func readSession(r *payloadReader) (Session, error) {
267294 }
268295
269296 func writeTab(w *payloadWriter, t Tab) error {
297+ if err := w.putString(t.StableID); err != nil {
298+ return err
299+ }
270300 w.putUint16(t.Cols)
271301 w.putUint16(t.Rows)
272302 w.putUint64(t.NextPaneID)
@@ -293,6 +323,10 @@ func writeTab(w *payloadWriter, t Tab) error {
293323 }
294324
295325 func readTab(r *payloadReader) (Tab, error) {
326+ stableID, err := r.getString()
327+ if err != nil {
328+ return Tab{}, err
329+ }
296330 cols, err := r.getUint16()
297331 if err != nil {
298332 return Tab{}, err
@@ -317,7 +351,7 @@ func readTab(r *payloadReader) (Tab, error) {
317351 if err != nil {
318352 return Tab{}, err
319353 }
320- t := Tab{Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)}
354+ t := Tab{StableID: stableID, Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)}
321355 for i := range t.Panes {
322356 p, err := readPane(r)
323357 if err != nil {
@@ -424,6 +458,9 @@ func writePane(w *payloadWriter, p Pane) error {
424458 if err := w.putString(string(p.ID)); err != nil {
425459 return err
426460 }
461+ if err := w.putString(p.StableID); err != nil {
462+ return err
463+ }
427464 if err := w.putString(p.Cwd); err != nil {
428465 return err
429466 }
@@ -442,7 +479,10 @@ func writePane(w *payloadWriter, p Pane) error {
442479 if err := writeRows(w, p.Scrollback, idx, false); err != nil {
443480 return err
444481 }
445- return writeRows(w, p.Visible, idx, true)
482+ if err := writeRows(w, p.Visible, idx, true); err != nil {
483+ return err
484+ }
485+ return writeProcess(w, p.Process)
446486 }
447487
448488 func readPane(r *payloadReader) (Pane, error) {
@@ -450,6 +490,10 @@ func readPane(r *payloadReader) (Pane, error) {
450490 if err != nil {
451491 return Pane{}, err
452492 }
493+ stableID, err := r.getString()
494+ if err != nil {
495+ return Pane{}, err
496+ }
453497 cwd, err := r.getString()
454498 if err != nil {
455499 return Pane{}, err
@@ -475,7 +519,58 @@ func readPane(r *payloadReader) (Pane, error) {
475519 if err != nil {
476520 return Pane{}, err
477521 }
478- return Pane{ID: layout.PaneID(id), Cwd: cwd, Scrollback: sb, Visible: vis}, nil
522+ proc, err := readProcess(r)
523+ if err != nil {
524+ return Pane{}, err
525+ }
526+ return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Scrollback: sb, Visible: vis, Process: proc}, nil
527+}
528+
529+func writeProcess(w *payloadWriter, p *Process) error {
530+ if p == nil {
531+ w.putUint8(0)
532+ return nil
533+ }
534+ if len(p.Argv) == 0 {
535+ return fmt.Errorf("%w: process argv empty", ErrInvalidData)
536+ }
537+ w.putUint8(1)
538+ if err := w.putStrings(p.Argv); err != nil {
539+ return err
540+ }
541+ if err := w.putString(p.Strategy); err != nil {
542+ return err
543+ }
544+ return w.putString(p.Opts.AgentSessionID)
545+}
546+
547+func readProcess(r *payloadReader) (*Process, error) {
548+ present, err := r.getUint8()
549+ if err != nil {
550+ return nil, err
551+ }
552+ if present == 0 {
553+ return nil, nil
554+ }
555+ if present != 1 {
556+ return nil, fmt.Errorf("%w: process presence", ErrInvalidData)
557+ }
558+ argv, err := r.getStrings()
559+ if err != nil {
560+ return nil, err
561+ }
562+ if len(argv) == 0 {
563+ return nil, fmt.Errorf("%w: process argv empty", ErrInvalidData)
564+ }
565+ strategy, err := r.getString()
566+ if err != nil {
567+ return nil, err
568+ }
569+ agentSessionID, err := r.getString()
570+ if err != nil {
571+ return nil, err
572+ }
573+ return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil
479574 }
480575
481576 func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error {
@@ -27,7 +27,7 @@ var (
27 27
28 const (28 const (
29 magic = "VEVS"29 magic = "VEVS"
30- version = uint16(1)30+ version = uint16(2)
31 flagFlate = uint16(1 << 0)31 flagFlate = uint16(1 << 0)
32 flateCutoff = 4 << 1032 flateCutoff = 4 << 10
33 maxDecodedBodySize = 256 << 2033 maxDecodedBodySize = 256 << 20
@@ -36,7 +36,7 @@ const (
36 maxDecodedCells = 16 << 2036 maxDecodedCells = 16 << 20
37 )37 )
38 38
39-// Marshal encodes a v1 durable snapshot.39+// Marshal encodes a v2 durable snapshot.
40 func Marshal(s Session) ([]byte, error) {40 func Marshal(s Session) ([]byte, error) {
41 var w payloadWriter41 var w payloadWriter
42 if err := writeSession(&w, s); err != nil {42 if err := writeSession(&w, s); err != nil {
@@ -73,7 +73,7 @@ func Marshal(s Session) ([]byte, error) {
73 return out, nil73 return out, nil
74 }74 }
75 75
76-// Unmarshal decodes a strict v1 durable snapshot.76+// Unmarshal decodes a strict v2 durable snapshot.
77 func Unmarshal(b []byte) (Session, error) {77 func Unmarshal(b []byte) (Session, error) {
78 if len(b) < 16 {78 if len(b) < 16 {
79 return Session{}, ErrShortPayload79 return Session{}, ErrShortPayload
@@ -166,6 +166,18 @@ func (w *payloadWriter) putString(s string) error {
166 w.b = append(w.b, s...)166 w.b = append(w.b, s...)
167 return nil167 return nil
168 }168 }
169+func (w *payloadWriter) putStrings(ss []string) error {
170+ if len(ss) > math.MaxUint16 {
171+ return fmt.Errorf("%w: too many strings", ErrInvalidData)
172+ }
173+ w.putUint16(uint16(len(ss)))
174+ for _, s := range ss {
175+ if err := w.putString(s); err != nil {
176+ return err
177+ }
178+ }
179+ return nil
180+}
169 181
170 type payloadReader struct{ b []byte }182 type payloadReader struct{ b []byte }
171 183
@@ -213,6 +225,21 @@ func (r *payloadReader) getString() (string, error) {
213 r.b = r.b[n:]225 r.b = r.b[n:]
214 return s, nil226 return s, nil
215 }227 }
228+func (r *payloadReader) getStrings() ([]string, error) {
229+ n, err := r.getUint16()
230+ if err != nil {
231+ return nil, err
232+ }
233+ out := make([]string, n)
234+ for i := range out {
235+ s, err := r.getString()
236+ if err != nil {
237+ return nil, err
238+ }
239+ out[i] = s
240+ }
241+ return out, nil
242+}
216 func (r *payloadReader) done() error {243 func (r *payloadReader) done() error {
217 if len(r.b) != 0 {244 if len(r.b) != 0 {
218 return ErrTrailingBytes245 return ErrTrailingBytes
@@ -267,6 +294,9 @@ func readSession(r *payloadReader) (Session, error) {
267 }294 }
268 295
269 func writeTab(w *payloadWriter, t Tab) error {296 func writeTab(w *payloadWriter, t Tab) error {
297+ if err := w.putString(t.StableID); err != nil {
298+ return err
299+ }
270 w.putUint16(t.Cols)300 w.putUint16(t.Cols)
271 w.putUint16(t.Rows)301 w.putUint16(t.Rows)
272 w.putUint64(t.NextPaneID)302 w.putUint64(t.NextPaneID)
@@ -293,6 +323,10 @@ func writeTab(w *payloadWriter, t Tab) error {
293 }323 }
294 324
295 func readTab(r *payloadReader) (Tab, error) {325 func readTab(r *payloadReader) (Tab, error) {
326+ stableID, err := r.getString()
327+ if err != nil {
328+ return Tab{}, err
329+ }
296 cols, err := r.getUint16()330 cols, err := r.getUint16()
297 if err != nil {331 if err != nil {
298 return Tab{}, err332 return Tab{}, err
@@ -317,7 +351,7 @@ func readTab(r *payloadReader) (Tab, error) {
317 if err != nil {351 if err != nil {
318 return Tab{}, err352 return Tab{}, err
319 }353 }
320- t := Tab{Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)}354+ t := Tab{StableID: stableID, Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)}
321 for i := range t.Panes {355 for i := range t.Panes {
322 p, err := readPane(r)356 p, err := readPane(r)
323 if err != nil {357 if err != nil {
@@ -424,6 +458,9 @@ func writePane(w *payloadWriter, p Pane) error {
424 if err := w.putString(string(p.ID)); err != nil {458 if err := w.putString(string(p.ID)); err != nil {
425 return err459 return err
426 }460 }
461+ if err := w.putString(p.StableID); err != nil {
462+ return err
463+ }
427 if err := w.putString(p.Cwd); err != nil {464 if err := w.putString(p.Cwd); err != nil {
428 return err465 return err
429 }466 }
@@ -442,7 +479,10 @@ func writePane(w *payloadWriter, p Pane) error {
442 if err := writeRows(w, p.Scrollback, idx, false); err != nil {479 if err := writeRows(w, p.Scrollback, idx, false); err != nil {
443 return err480 return err
444 }481 }
445- return writeRows(w, p.Visible, idx, true)482+ if err := writeRows(w, p.Visible, idx, true); err != nil {
483+ return err
484+ }
485+ return writeProcess(w, p.Process)
446 }486 }
447 487
448 func readPane(r *payloadReader) (Pane, error) {488 func readPane(r *payloadReader) (Pane, error) {
@@ -450,6 +490,10 @@ func readPane(r *payloadReader) (Pane, error) {
450 if err != nil {490 if err != nil {
451 return Pane{}, err491 return Pane{}, err
452 }492 }
493+ stableID, err := r.getString()
494+ if err != nil {
495+ return Pane{}, err
496+ }
453 cwd, err := r.getString()497 cwd, err := r.getString()
454 if err != nil {498 if err != nil {
455 return Pane{}, err499 return Pane{}, err
@@ -475,7 +519,58 @@ func readPane(r *payloadReader) (Pane, error) {
475 if err != nil {519 if err != nil {
476 return Pane{}, err520 return Pane{}, err
477 }521 }
478- return Pane{ID: layout.PaneID(id), Cwd: cwd, Scrollback: sb, Visible: vis}, nil522+ proc, err := readProcess(r)
523+ if err != nil {
524+ return Pane{}, err
525+ }
526+ return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Scrollback: sb, Visible: vis, Process: proc}, nil
527+}
528+
529+func writeProcess(w *payloadWriter, p *Process) error {
530+ if p == nil {
531+ w.putUint8(0)
532+ return nil
533+ }
534+ if len(p.Argv) == 0 {
535+ return fmt.Errorf("%w: process argv empty", ErrInvalidData)
536+ }
537+ w.putUint8(1)
538+ if err := w.putStrings(p.Argv); err != nil {
539+ return err
540+ }
541+ if err := w.putString(p.Strategy); err != nil {
542+ return err
543+ }
544+ return w.putString(p.Opts.AgentSessionID)
545+}
546+
547+func readProcess(r *payloadReader) (*Process, error) {
548+ present, err := r.getUint8()
549+ if err != nil {
550+ return nil, err
551+ }
552+ if present == 0 {
553+ return nil, nil
554+ }
555+ if present != 1 {
556+ return nil, fmt.Errorf("%w: process presence", ErrInvalidData)
557+ }
558+ argv, err := r.getStrings()
559+ if err != nil {
560+ return nil, err
561+ }
562+ if len(argv) == 0 {
563+ return nil, fmt.Errorf("%w: process argv empty", ErrInvalidData)
564+ }
565+ strategy, err := r.getString()
566+ if err != nil {
567+ return nil, err
568+ }
569+ agentSessionID, err := r.getString()
570+ if err != nil {
571+ return nil, err
572+ }
573+ return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil
479 }574 }
480 575
481 func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error {576 func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error {
M internal/usecase/snapshot/codec_test.go +59 -6
@@ -18,7 +18,7 @@ func TestMarshalMinimalGolden(t *testing.T) {
1818 t.Fatalf("Marshal() error = %v", err)
1919 }
2020 want := []byte{
21- 'V', 'E', 'V', 'S', 0, 1, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb,
21+ 'V', 'E', 'V', 'S', 0, 2, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb,
2222 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0,
2323 }
2424 if !bytes.Equal(got, want) {
@@ -76,7 +76,7 @@ func TestRoundTripSessions(t *testing.T) {
7676 {Rune: 'x', Style: indexed},
7777 }}
7878 multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
79- {Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v")}, {ID: "2", Cwd: "/b", Visible: rows("focus")}, {ID: "3", Cwd: "/c"}}},
79+ {StableID: "t_stable", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", StableID: "p_one", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v"), Process: &Process{Argv: []string{"claude", "--resume"}, Strategy: "claude", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, {ID: "2", StableID: "p_two", Cwd: "/b", Visible: rows("focus")}, {ID: "3", StableID: "p_three", Cwd: "/c"}}},
8080 {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}},
8181 }}
8282 empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}}
@@ -116,7 +116,7 @@ func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
116116 data []byte
117117 }{
118118 {"bad magic", append([]byte("NOPE"), good[4:]...)},
119- {"bad version", replaceU16(good, 4, 2)},
119+ {"bad version", replaceU16(good, 4, 1)},
120120 {"bad crc", append([]byte(nil), good[:len(good)-1]...)},
121121 {"trailing", append(append([]byte(nil), good...), 0)},
122122 {"body len overrun", replaceU32(good, 8, 999999)},
@@ -143,6 +143,42 @@ func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
143143 }
144144 }
145145
146+func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {
147+ _, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}}})
148+ if !errors.Is(err, ErrInvalidData) {
149+ t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
150+ }
151+}
152+
153+func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
154+ var w payloadWriter
155+ _ = w.putString("x")
156+ w.putUint64(0)
157+ w.putUint16(0)
158+ w.putUint16(1)
159+ _ = w.putString("t")
160+ w.putUint16(80)
161+ w.putUint16(24)
162+ w.putUint64(1)
163+ _ = w.putString("p")
164+ _ = writeNode(&w, layout.NewLeaf("p"))
165+ w.putUint16(1)
166+ _ = w.putString("p")
167+ _ = w.putString("p_stable")
168+ _ = w.putString("")
169+ w.putUint16(0)
170+ w.putUint32(0)
171+ w.putUint32(0)
172+ w.putUint8(1)
173+ w.putUint16(0)
174+ _ = w.putString("generic")
175+ _ = w.putString("")
176+ _, err := Unmarshal(reheader(w.b, 0))
177+ if !errors.Is(err, ErrInvalidData) {
178+ t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
179+ }
180+}
181+
146182 func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) {
147183 body := []byte{0}
148184 oversizedRaw := reheader(body, 0)
@@ -248,11 +284,26 @@ func equalSession(a, b Session) bool {
248284 }
249285
250286 func equalTab(a, b Tab) bool {
251- if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) {
287+ if a.StableID != b.StableID || a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) {
252288 return false
253289 }
254290 for i := range a.Panes {
255- if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) {
291+ if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].StableID != b.Panes[i].StableID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalProcess(a.Panes[i].Process, b.Panes[i].Process) || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) {
292+ return false
293+ }
294+ }
295+ return true
296+}
297+
298+func equalProcess(a, b *Process) bool {
299+ if a == nil || b == nil {
300+ return a == b
301+ }
302+ if a.Strategy != b.Strategy || a.Opts.AgentSessionID != b.Opts.AgentSessionID || len(a.Argv) != len(b.Argv) {
303+ return false
304+ }
305+ for i := range a.Argv {
306+ if a.Argv[i] != b.Argv[i] {
256307 return false
257308 }
258309 }
@@ -311,7 +362,7 @@ func trimSession(s Session) Session {
311362 func reheader(body []byte, flags uint16) []byte {
312363 out := make([]byte, 16+len(body))
313364 copy(out[:4], []byte("VEVS"))
314- binary.BigEndian.PutUint16(out[4:6], 1)
365+ binary.BigEndian.PutUint16(out[4:6], version)
315366 binary.BigEndian.PutUint16(out[6:8], flags)
316367 binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
317368 binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
@@ -336,6 +387,7 @@ func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {
336387 w.putUint64(0)
337388 w.putUint16(0)
338389 w.putUint16(1)
390+ _ = w.putString("")
339391 w.putUint16(0)
340392 w.putUint16(0)
341393 w.putUint64(0)
@@ -344,6 +396,7 @@ func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {
344396 w.putUint16(1)
345397 _ = w.putString("p")
346398 _ = w.putString("")
399+ _ = w.putString("")
347400 w.putUint16(0)
348401 w.putUint32(rowCount)
349402 if rowCount > 0 {
@@ -18,7 +18,7 @@ func TestMarshalMinimalGolden(t *testing.T) {
18 t.Fatalf("Marshal() error = %v", err)18 t.Fatalf("Marshal() error = %v", err)
19 }19 }
20 want := []byte{20 want := []byte{
21- 'V', 'E', 'V', 'S', 0, 1, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb,21+ 'V', 'E', 'V', 'S', 0, 2, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb,
22 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0,22 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0,
23 }23 }
24 if !bytes.Equal(got, want) {24 if !bytes.Equal(got, want) {
@@ -76,7 +76,7 @@ func TestRoundTripSessions(t *testing.T) {
76 {Rune: 'x', Style: indexed},76 {Rune: 'x', Style: indexed},
77 }}77 }}
78 multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{78 multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
79- {Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v")}, {ID: "2", Cwd: "/b", Visible: rows("focus")}, {ID: "3", Cwd: "/c"}}},79+ {StableID: "t_stable", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", StableID: "p_one", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v"), Process: &Process{Argv: []string{"claude", "--resume"}, Strategy: "claude", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, {ID: "2", StableID: "p_two", Cwd: "/b", Visible: rows("focus")}, {ID: "3", StableID: "p_three", Cwd: "/c"}}},
80 {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}},80 {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}},
81 }}81 }}
82 empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}}82 empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}}
@@ -116,7 +116,7 @@ func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
116 data []byte116 data []byte
117 }{117 }{
118 {"bad magic", append([]byte("NOPE"), good[4:]...)},118 {"bad magic", append([]byte("NOPE"), good[4:]...)},
119- {"bad version", replaceU16(good, 4, 2)},119+ {"bad version", replaceU16(good, 4, 1)},
120 {"bad crc", append([]byte(nil), good[:len(good)-1]...)},120 {"bad crc", append([]byte(nil), good[:len(good)-1]...)},
121 {"trailing", append(append([]byte(nil), good...), 0)},121 {"trailing", append(append([]byte(nil), good...), 0)},
122 {"body len overrun", replaceU32(good, 8, 999999)},122 {"body len overrun", replaceU32(good, 8, 999999)},
@@ -143,6 +143,42 @@ func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) {
143 }143 }
144 }144 }
145 145
146+func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {
147+ _, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}}})
148+ if !errors.Is(err, ErrInvalidData) {
149+ t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData)
150+ }
151+}
152+
153+func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
154+ var w payloadWriter
155+ _ = w.putString("x")
156+ w.putUint64(0)
157+ w.putUint16(0)
158+ w.putUint16(1)
159+ _ = w.putString("t")
160+ w.putUint16(80)
161+ w.putUint16(24)
162+ w.putUint64(1)
163+ _ = w.putString("p")
164+ _ = writeNode(&w, layout.NewLeaf("p"))
165+ w.putUint16(1)
166+ _ = w.putString("p")
167+ _ = w.putString("p_stable")
168+ _ = w.putString("")
169+ w.putUint16(0)
170+ w.putUint32(0)
171+ w.putUint32(0)
172+ w.putUint8(1)
173+ w.putUint16(0)
174+ _ = w.putString("generic")
175+ _ = w.putString("")
176+ _, err := Unmarshal(reheader(w.b, 0))
177+ if !errors.Is(err, ErrInvalidData) {
178+ t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
179+ }
180+}
181+
146 func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) {182 func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) {
147 body := []byte{0}183 body := []byte{0}
148 oversizedRaw := reheader(body, 0)184 oversizedRaw := reheader(body, 0)
@@ -248,11 +284,26 @@ func equalSession(a, b Session) bool {
248 }284 }
249 285
250 func equalTab(a, b Tab) bool {286 func equalTab(a, b Tab) bool {
251- if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) {287+ if a.StableID != b.StableID || a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) {
252 return false288 return false
253 }289 }
254 for i := range a.Panes {290 for i := range a.Panes {
255- if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) {291+ if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].StableID != b.Panes[i].StableID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalProcess(a.Panes[i].Process, b.Panes[i].Process) || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) {
292+ return false
293+ }
294+ }
295+ return true
296+}
297+
298+func equalProcess(a, b *Process) bool {
299+ if a == nil || b == nil {
300+ return a == b
301+ }
302+ if a.Strategy != b.Strategy || a.Opts.AgentSessionID != b.Opts.AgentSessionID || len(a.Argv) != len(b.Argv) {
303+ return false
304+ }
305+ for i := range a.Argv {
306+ if a.Argv[i] != b.Argv[i] {
256 return false307 return false
257 }308 }
258 }309 }
@@ -311,7 +362,7 @@ func trimSession(s Session) Session {
311 func reheader(body []byte, flags uint16) []byte {362 func reheader(body []byte, flags uint16) []byte {
312 out := make([]byte, 16+len(body))363 out := make([]byte, 16+len(body))
313 copy(out[:4], []byte("VEVS"))364 copy(out[:4], []byte("VEVS"))
314- binary.BigEndian.PutUint16(out[4:6], 1)365+ binary.BigEndian.PutUint16(out[4:6], version)
315 binary.BigEndian.PutUint16(out[6:8], flags)366 binary.BigEndian.PutUint16(out[6:8], flags)
316 binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))367 binary.BigEndian.PutUint32(out[8:12], uint32(len(body)))
317 binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))368 binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body))
@@ -336,6 +387,7 @@ func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {
336 w.putUint64(0)387 w.putUint64(0)
337 w.putUint16(0)388 w.putUint16(0)
338 w.putUint16(1)389 w.putUint16(1)
390+ _ = w.putString("")
339 w.putUint16(0)391 w.putUint16(0)
340 w.putUint16(0)392 w.putUint16(0)
341 w.putUint64(0)393 w.putUint64(0)
@@ -344,6 +396,7 @@ func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte {
344 w.putUint16(1)396 w.putUint16(1)
345 _ = w.putString("p")397 _ = w.putString("p")
346 _ = w.putString("")398 _ = w.putString("")
399+ _ = w.putString("")
347 w.putUint16(0)400 w.putUint16(0)
348 w.putUint32(rowCount)401 w.putUint32(rowCount)
349 if rowCount > 0 {402 if rowCount > 0 {
M internal/usecase/snapshot/snapshot.go +15 -0
@@ -15,6 +15,7 @@ type Session struct {
1515
1616 // Tab captures one tab's layout, dimensions, and pane snapshots.
1717 type Tab struct {
18+ StableID string
1819 Cols uint16
1920 Rows uint16
2021 NextPaneID uint64
@@ -27,7 +28,21 @@ type Tab struct {
2728 // with its scrollback and final visible primary screen.
2829 type Pane struct {
2930 ID layout.PaneID
31+ StableID string
3032 Cwd string
3133 Scrollback [][]renderer.Cell
3234 Visible [][]renderer.Cell
35+ Process *Process
36+}
37+
38+// Process captures restorable foreground process metadata.
39+type Process struct {
40+ Argv []string
41+ Strategy string
42+ Opts ProcessOpts
43+}
44+
45+// ProcessOpts captures strategy-specific process restore options.
46+type ProcessOpts struct {
47+ AgentSessionID string
3348 }
@@ -15,6 +15,7 @@ type Session struct {
15 15
16 // Tab captures one tab's layout, dimensions, and pane snapshots.16 // Tab captures one tab's layout, dimensions, and pane snapshots.
17 type Tab struct {17 type Tab struct {
18+ StableID string
18 Cols uint1619 Cols uint16
19 Rows uint1620 Rows uint16
20 NextPaneID uint6421 NextPaneID uint64
@@ -27,7 +28,21 @@ type Tab struct {
27 // with its scrollback and final visible primary screen.28 // with its scrollback and final visible primary screen.
28 type Pane struct {29 type Pane struct {
29 ID layout.PaneID30 ID layout.PaneID
31+ StableID string
30 Cwd string32 Cwd string
31 Scrollback [][]renderer.Cell33 Scrollback [][]renderer.Cell
32 Visible [][]renderer.Cell34 Visible [][]renderer.Cell
35+ Process *Process
36+}
37+
38+// Process captures restorable foreground process metadata.
39+type Process struct {
40+ Argv []string
41+ Strategy string
42+ Opts ProcessOpts
43+}
44+
45+// ProcessOpts captures strategy-specific process restore options.
46+type ProcessOpts struct {
47+ AgentSessionID string
33 }48 }

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 2

Tags 13

↑↓ moveenter openesc close