detached 755f4f5

feat(snapshot): capture foreground process restore metadata

commit
755f4f5947d6526c0c647e52685537afe9904828
author
brice · · 2026-07-06 07:54 UTC
parent
01179be
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +593 -14
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/app/run.go +1 -0
@@ -284,6 +284,7 @@ func runDaemon() error {
284284 }
285285 logConfigWarnings(log, warnings)
286286 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
287+ daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.ProcessInspector{}), daemon.WithDirOrHome(platform.DirOrHome))
287288 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
288289 storePath := persist.StorePath(platform.StateDir())
289290 if store, err := kv.Open(storePath); err != nil {
@@ -284,6 +284,7 @@ func runDaemon() error {
284 }284 }
285 logConfigWarnings(log, warnings)285 logConfigWarnings(log, warnings)
286 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))286 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
287+ daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.ProcessInspector{}), daemon.WithDirOrHome(platform.DirOrHome))
287 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))288 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
288 storePath := persist.StorePath(platform.StateDir())289 storePath := persist.StorePath(platform.StateDir())
289 if store, err := kv.Open(storePath); err != nil {290 if store, err := kv.Open(storePath); err != nil {
A internal/platform/process_linux.go +117 -0
@@ -0,0 +1,117 @@
1+package platform
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strconv"
10+ "strings"
11+)
12+
13+// ProcessInspector implements ports.ProcessInspector using Linux /proc.
14+type ProcessInspector struct{}
15+
16+func (ProcessInspector) Cwd(pid int) (string, error) { return ProcessCwd(pid) }
17+func (ProcessInspector) Comm(pid int) (string, error) { return ProcessComm(pid) }
18+func (ProcessInspector) Argv(pid int) ([]string, error) { return ProcessArgv(pid) }
19+func (ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
20+ return ProcessGroupArgv(pgid, shellPid)
21+}
22+
23+// ProcessArgv returns argv from /proc/<pid>/cmdline.
24+func ProcessArgv(pid int) ([]string, error) {
25+ return processArgv(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"), pid)
26+}
27+
28+func processArgv(path string, pid int) ([]string, error) {
29+ data, err := os.ReadFile(path)
30+ if err != nil {
31+ return nil, err
32+ }
33+ data = bytes.TrimRight(data, "\x00")
34+ if len(data) == 0 {
35+ return nil, fmt.Errorf("process argv: empty argv for pid %d", pid)
36+ }
37+ parts := bytes.Split(data, []byte{0})
38+ argv := make([]string, 0, len(parts))
39+ for _, part := range parts {
40+ if len(part) > 0 {
41+ argv = append(argv, string(part))
42+ }
43+ }
44+ if len(argv) == 0 {
45+ return nil, fmt.Errorf("process argv: empty argv for pid %d", pid)
46+ }
47+ return argv, nil
48+}
49+
50+type processRecord struct{ pid, pgrp int }
51+
52+// ProcessGroupArgv returns argv for a restorable foreground process in pgid.
53+// A bare pane shell (the only process in its foreground group) returns nil argv.
54+func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
55+ recs, err := readProcRecords("/proc")
56+ if err != nil {
57+ return nil, err
58+ }
59+ pid, ok := selectProcessGroupPID(recs, pgid, shellPid)
60+ if !ok {
61+ return nil, nil
62+ }
63+ return ProcessArgv(pid)
64+}
65+
66+func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) {
67+ if pgid <= 0 {
68+ return 0, false
69+ }
70+ matches := make([]int, 0)
71+ for _, r := range recs {
72+ if r.pgrp == pgid && r.pid != shellPid {
73+ matches = append(matches, r.pid)
74+ }
75+ }
76+ if len(matches) == 0 {
77+ return 0, false
78+ }
79+ sort.Ints(matches)
80+ return matches[0], true
81+}
82+
83+func readProcRecords(root string) ([]processRecord, error) {
84+ ents, err := os.ReadDir(root)
85+ if err != nil {
86+ return nil, err
87+ }
88+ var recs []processRecord
89+ for _, ent := range ents {
90+ pid, err := strconv.Atoi(ent.Name())
91+ if err != nil {
92+ continue
93+ }
94+ data, err := os.ReadFile(filepath.Join(root, ent.Name(), "stat"))
95+ if err != nil {
96+ continue
97+ }
98+ pgrp, err := parseStatPgrp(string(data))
99+ if err != nil {
100+ continue
101+ }
102+ recs = append(recs, processRecord{pid: pid, pgrp: pgrp})
103+ }
104+ return recs, nil
105+}
106+
107+func parseStatPgrp(stat string) (int, error) {
108+ end := strings.LastIndex(stat, ")")
109+ if end < 0 || end+2 >= len(stat) {
110+ return 0, fmt.Errorf("invalid stat")
111+ }
112+ fields := strings.Fields(stat[end+2:])
113+ if len(fields) < 3 {
114+ return 0, fmt.Errorf("invalid stat")
115+ }
116+ return strconv.Atoi(fields[2])
117+}
@@ -0,0 +1,117 @@
1+package platform
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strconv"
10+ "strings"
11+)
12+
13+// ProcessInspector implements ports.ProcessInspector using Linux /proc.
14+type ProcessInspector struct{}
15+
16+func (ProcessInspector) Cwd(pid int) (string, error) { return ProcessCwd(pid) }
17+func (ProcessInspector) Comm(pid int) (string, error) { return ProcessComm(pid) }
18+func (ProcessInspector) Argv(pid int) ([]string, error) { return ProcessArgv(pid) }
19+func (ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
20+ return ProcessGroupArgv(pgid, shellPid)
21+}
22+
23+// ProcessArgv returns argv from /proc/<pid>/cmdline.
24+func ProcessArgv(pid int) ([]string, error) {
25+ return processArgv(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"), pid)
26+}
27+
28+func processArgv(path string, pid int) ([]string, error) {
29+ data, err := os.ReadFile(path)
30+ if err != nil {
31+ return nil, err
32+ }
33+ data = bytes.TrimRight(data, "\x00")
34+ if len(data) == 0 {
35+ return nil, fmt.Errorf("process argv: empty argv for pid %d", pid)
36+ }
37+ parts := bytes.Split(data, []byte{0})
38+ argv := make([]string, 0, len(parts))
39+ for _, part := range parts {
40+ if len(part) > 0 {
41+ argv = append(argv, string(part))
42+ }
43+ }
44+ if len(argv) == 0 {
45+ return nil, fmt.Errorf("process argv: empty argv for pid %d", pid)
46+ }
47+ return argv, nil
48+}
49+
50+type processRecord struct{ pid, pgrp int }
51+
52+// ProcessGroupArgv returns argv for a restorable foreground process in pgid.
53+// A bare pane shell (the only process in its foreground group) returns nil argv.
54+func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) {
55+ recs, err := readProcRecords("/proc")
56+ if err != nil {
57+ return nil, err
58+ }
59+ pid, ok := selectProcessGroupPID(recs, pgid, shellPid)
60+ if !ok {
61+ return nil, nil
62+ }
63+ return ProcessArgv(pid)
64+}
65+
66+func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) {
67+ if pgid <= 0 {
68+ return 0, false
69+ }
70+ matches := make([]int, 0)
71+ for _, r := range recs {
72+ if r.pgrp == pgid && r.pid != shellPid {
73+ matches = append(matches, r.pid)
74+ }
75+ }
76+ if len(matches) == 0 {
77+ return 0, false
78+ }
79+ sort.Ints(matches)
80+ return matches[0], true
81+}
82+
83+func readProcRecords(root string) ([]processRecord, error) {
84+ ents, err := os.ReadDir(root)
85+ if err != nil {
86+ return nil, err
87+ }
88+ var recs []processRecord
89+ for _, ent := range ents {
90+ pid, err := strconv.Atoi(ent.Name())
91+ if err != nil {
92+ continue
93+ }
94+ data, err := os.ReadFile(filepath.Join(root, ent.Name(), "stat"))
95+ if err != nil {
96+ continue
97+ }
98+ pgrp, err := parseStatPgrp(string(data))
99+ if err != nil {
100+ continue
101+ }
102+ recs = append(recs, processRecord{pid: pid, pgrp: pgrp})
103+ }
104+ return recs, nil
105+}
106+
107+func parseStatPgrp(stat string) (int, error) {
108+ end := strings.LastIndex(stat, ")")
109+ if end < 0 || end+2 >= len(stat) {
110+ return 0, fmt.Errorf("invalid stat")
111+ }
112+ fields := strings.Fields(stat[end+2:])
113+ if len(fields) < 3 {
114+ return 0, fmt.Errorf("invalid stat")
115+ }
116+ return strconv.Atoi(fields[2])
117+}
A internal/platform/process_linux_test.go +66 -0
@@ -0,0 +1,66 @@
1+package platform
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "reflect"
7+ "testing"
8+)
9+
10+func TestProcessArgv(t *testing.T) {
11+ argv, err := ProcessArgv(os.Getpid())
12+ if err != nil {
13+ t.Fatalf("ProcessArgv current process: %v", err)
14+ }
15+ if len(argv) == 0 {
16+ t.Fatal("ProcessArgv returned empty argv")
17+ }
18+}
19+
20+func TestProcessArgvRejectsEmptyCmdline(t *testing.T) {
21+ path := filepath.Join(t.TempDir(), "cmdline")
22+ if err := os.WriteFile(path, []byte("\x00\x00"), 0o600); err != nil {
23+ t.Fatal(err)
24+ }
25+ if argv, err := processArgv(path, 123); err == nil || argv != nil {
26+ t.Fatalf("processArgv empty = %#v, %v; want nil error", argv, err)
27+ }
28+}
29+
30+func TestProcessGroupArgvSelection(t *testing.T) {
31+ recs := []processRecord{{pid: 10, pgrp: 7}, {pid: 12, pgrp: 7}, {pid: 20, pgrp: 9}}
32+ pid, ok := selectProcessGroupPID(recs, 7, 10)
33+ if !ok || pid != 12 {
34+ t.Fatalf("selectProcessGroupPID = %d, %v; want 12, true", pid, ok)
35+ }
36+}
37+
38+func TestProcessGroupArgvBareShellReturnsNoPID(t *testing.T) {
39+ recs := []processRecord{{pid: 10, pgrp: 7}, {pid: 20, pgrp: 9}}
40+ pid, ok := selectProcessGroupPID(recs, 7, 10)
41+ if ok || pid != 0 {
42+ t.Fatalf("selectProcessGroupPID bare shell = %d, %v; want 0, false", pid, ok)
43+ }
44+}
45+
46+func TestParseStatPgrp(t *testing.T) {
47+ got, err := parseStatPgrp("123 (cmd with spaces) S 1 456 456 0")
48+ if err != nil || got != 456 {
49+ t.Fatalf("parseStatPgrp = %d, %v; want 456, nil", got, err)
50+ }
51+}
52+
53+func TestProcessArgvFileParsing(t *testing.T) {
54+ path := filepath.Join(t.TempDir(), "cmdline")
55+ if err := os.WriteFile(path, []byte("cmd\x00two words\x00"), 0o600); err != nil {
56+ t.Fatal(err)
57+ }
58+ got, err := processArgv(path, 123)
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ want := []string{"cmd", "two words"}
63+ if !reflect.DeepEqual(got, want) {
64+ t.Fatalf("processArgv = %#v; want %#v", got, want)
65+ }
66+}
@@ -0,0 +1,66 @@
1+package platform
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "reflect"
7+ "testing"
8+)
9+
10+func TestProcessArgv(t *testing.T) {
11+ argv, err := ProcessArgv(os.Getpid())
12+ if err != nil {
13+ t.Fatalf("ProcessArgv current process: %v", err)
14+ }
15+ if len(argv) == 0 {
16+ t.Fatal("ProcessArgv returned empty argv")
17+ }
18+}
19+
20+func TestProcessArgvRejectsEmptyCmdline(t *testing.T) {
21+ path := filepath.Join(t.TempDir(), "cmdline")
22+ if err := os.WriteFile(path, []byte("\x00\x00"), 0o600); err != nil {
23+ t.Fatal(err)
24+ }
25+ if argv, err := processArgv(path, 123); err == nil || argv != nil {
26+ t.Fatalf("processArgv empty = %#v, %v; want nil error", argv, err)
27+ }
28+}
29+
30+func TestProcessGroupArgvSelection(t *testing.T) {
31+ recs := []processRecord{{pid: 10, pgrp: 7}, {pid: 12, pgrp: 7}, {pid: 20, pgrp: 9}}
32+ pid, ok := selectProcessGroupPID(recs, 7, 10)
33+ if !ok || pid != 12 {
34+ t.Fatalf("selectProcessGroupPID = %d, %v; want 12, true", pid, ok)
35+ }
36+}
37+
38+func TestProcessGroupArgvBareShellReturnsNoPID(t *testing.T) {
39+ recs := []processRecord{{pid: 10, pgrp: 7}, {pid: 20, pgrp: 9}}
40+ pid, ok := selectProcessGroupPID(recs, 7, 10)
41+ if ok || pid != 0 {
42+ t.Fatalf("selectProcessGroupPID bare shell = %d, %v; want 0, false", pid, ok)
43+ }
44+}
45+
46+func TestParseStatPgrp(t *testing.T) {
47+ got, err := parseStatPgrp("123 (cmd with spaces) S 1 456 456 0")
48+ if err != nil || got != 456 {
49+ t.Fatalf("parseStatPgrp = %d, %v; want 456, nil", got, err)
50+ }
51+}
52+
53+func TestProcessArgvFileParsing(t *testing.T) {
54+ path := filepath.Join(t.TempDir(), "cmdline")
55+ if err := os.WriteFile(path, []byte("cmd\x00two words\x00"), 0o600); err != nil {
56+ t.Fatal(err)
57+ }
58+ got, err := processArgv(path, 123)
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ want := []string{"cmd", "two words"}
63+ if !reflect.DeepEqual(got, want) {
64+ t.Fatalf("processArgv = %#v; want %#v", got, want)
65+ }
66+}
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/usecase/daemon/daemon.go +30 -10
@@ -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 )
@@ -110,6 +109,9 @@ type Daemon struct {
110109 restoreOnce sync.Once
111110 procCwd func(int) (string, error)
112111 procComm func(int) (string, error)
112+ procArgv func(int) ([]string, error)
113+ procGroupArgv func(int, int) ([]string, error)
114+ dirOrHome func(string) string
113115 bindings atomic.Pointer[keys.Bindings]
114116 codeOverrides atomic.Pointer[map[string]string]
115117 themeMode atomic.Uint32
@@ -190,6 +192,28 @@ func WithCwdReader(fn func(int) (string, error)) Option {
190192 }
191193 }
192194
195+// WithProcessInspector installs the platform process-inspection implementation.
196+func WithProcessInspector(ins ports.ProcessInspector) Option {
197+ return func(d *Daemon) {
198+ if ins == nil {
199+ return
200+ }
201+ d.procCwd = ins.Cwd
202+ d.procComm = ins.Comm
203+ d.procArgv = ins.Argv
204+ d.procGroupArgv = ins.GroupArgv
205+ }
206+}
207+
208+// WithDirOrHome installs path fallback behavior from the application layer.
209+func WithDirOrHome(fn func(string) string) Option {
210+ return func(d *Daemon) {
211+ if fn != nil {
212+ d.dirOrHome = fn
213+ }
214+ }
215+}
216+
193217 // WithTempDir overrides the directory clipboard-image-transfer writes temp
194218 // files into (production default: os.TempDir()); tests use this with
195219 // t.TempDir() so writes are isolated and auto-cleaned.
@@ -226,8 +250,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
226250 baseEnv: os.Environ(),
227251 shell: shell,
228252 persist: persist.New(nil),
229- procCwd: platform.ProcessCwd,
230- procComm: platform.ProcessComm,
253+ dirOrHome: dirOrHome,
231254 done: make(chan struct{}),
232255 restoreDone: make(chan struct{}),
233256 animWake: make(chan struct{}, 1),
@@ -238,11 +261,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
238261 if d.persist == nil {
239262 d.persist = persist.New(nil)
240263 }
241- if d.procCwd == nil {
242- d.procCwd = platform.ProcessCwd
243- }
244- if d.procComm == nil {
245- d.procComm = platform.ProcessComm
264+ if d.dirOrHome == nil {
265+ d.dirOrHome = dirOrHome
246266 }
247267 if d.bindings.Load() == nil {
248268 d.bindings.Store(keys.DefaultBindings())
@@ -577,7 +597,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
577597 d.mu.Unlock()
578598 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
579599 }
580- cwd := platform.DirOrHome(stopped.cwd)
600+ cwd := d.dirOrHome(stopped.cwd)
581601 var err error
582602 sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
583603 if err != nil {
@@ -636,7 +656,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
636656 d.mu.Unlock()
637657 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
638658 }
639- cwd := platform.DirOrHome(stopped.cwd)
659+ cwd := d.dirOrHome(stopped.cwd)
640660 var err error
641661 sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
642662 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 )
@@ -110,6 +109,9 @@ type Daemon 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)
112+ procArgv func(int) ([]string, error)
113+ procGroupArgv func(int, int) ([]string, error)
114+ dirOrHome func(string) string
113 bindings atomic.Pointer[keys.Bindings]115 bindings atomic.Pointer[keys.Bindings]
114 codeOverrides atomic.Pointer[map[string]string]116 codeOverrides atomic.Pointer[map[string]string]
115 themeMode atomic.Uint32117 themeMode atomic.Uint32
@@ -190,6 +192,28 @@ func WithCwdReader(fn func(int) (string, error)) Option {
190 }192 }
191 }193 }
192 194
195+// WithProcessInspector installs the platform process-inspection implementation.
196+func WithProcessInspector(ins ports.ProcessInspector) Option {
197+ return func(d *Daemon) {
198+ if ins == nil {
199+ return
200+ }
201+ d.procCwd = ins.Cwd
202+ d.procComm = ins.Comm
203+ d.procArgv = ins.Argv
204+ d.procGroupArgv = ins.GroupArgv
205+ }
206+}
207+
208+// WithDirOrHome installs path fallback behavior from the application layer.
209+func WithDirOrHome(fn func(string) string) Option {
210+ return func(d *Daemon) {
211+ if fn != nil {
212+ d.dirOrHome = fn
213+ }
214+ }
215+}
216+
193 // WithTempDir overrides the directory clipboard-image-transfer writes temp217 // WithTempDir overrides the directory clipboard-image-transfer writes temp
194 // files into (production default: os.TempDir()); tests use this with218 // files into (production default: os.TempDir()); tests use this with
195 // t.TempDir() so writes are isolated and auto-cleaned.219 // t.TempDir() so writes are isolated and auto-cleaned.
@@ -226,8 +250,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
226 baseEnv: os.Environ(),250 baseEnv: os.Environ(),
227 shell: shell,251 shell: shell,
228 persist: persist.New(nil),252 persist: persist.New(nil),
229- procCwd: platform.ProcessCwd,253+ dirOrHome: dirOrHome,
230- procComm: platform.ProcessComm,
231 done: make(chan struct{}),254 done: make(chan struct{}),
232 restoreDone: make(chan struct{}),255 restoreDone: make(chan struct{}),
233 animWake: make(chan struct{}, 1),256 animWake: make(chan struct{}, 1),
@@ -238,11 +261,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
238 if d.persist == nil {261 if d.persist == nil {
239 d.persist = persist.New(nil)262 d.persist = persist.New(nil)
240 }263 }
241- if d.procCwd == nil {264+ if d.dirOrHome == nil {
242- d.procCwd = platform.ProcessCwd265+ d.dirOrHome = dirOrHome
243- }
244- if d.procComm == nil {
245- d.procComm = platform.ProcessComm
246 }266 }
247 if d.bindings.Load() == nil {267 if d.bindings.Load() == nil {
248 d.bindings.Store(keys.DefaultBindings())268 d.bindings.Store(keys.DefaultBindings())
@@ -577,7 +597,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
577 d.mu.Unlock()597 d.mu.Unlock()
578 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}598 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
579 }599 }
580- cwd := platform.DirOrHome(stopped.cwd)600+ cwd := d.dirOrHome(stopped.cwd)
581 var err error601 var err error
582 sess, err = d.createSessionLocked(h.Name, false, cwd, sz)602 sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
583 if err != nil {603 if err != nil {
@@ -636,7 +656,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
636 d.mu.Unlock()656 d.mu.Unlock()
637 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}657 return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
638 }658 }
639- cwd := platform.DirOrHome(stopped.cwd)659+ cwd := d.dirOrHome(stopped.cwd)
640 var err error660 var err error
641 sess, err = d.createSessionLocked(h.Name, false, cwd, sz)661 sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
642 if err != nil {662 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+}
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 +138 -0
@@ -0,0 +1,138 @@
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, "-") {
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"
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 {
73+ return processRestoreDecision{Reason: "missing_process"}
74+ }
75+ strategy := proc.Strategy
76+ if strategy == "" {
77+ strategy = detectProcessStrategy(proc.Argv)
78+ }
79+ name := filepath.Base(proc.Argv[0])
80+ if _, ok := allow[name]; !ok {
81+ return processRestoreDecision{Reason: "not_allowlisted"}
82+ }
83+
84+ id := proc.Opts.AgentSessionID
85+ switch strategy {
86+ case processStrategyPi:
87+ if id != "" {
88+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--resume", id}), Restore: true}
89+ }
90+ return processRestoreDecision{Command: "pi --continue", Restore: true}
91+ case processStrategyClaude:
92+ if id != "" {
93+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"claude", "--resume", id}), Restore: true}
94+ }
95+ case processStrategyOpenCode:
96+ if id != "" {
97+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"opencode", "--session", id}), Restore: true}
98+ }
99+ case processStrategyCodex:
100+ if id != "" {
101+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"codex", "resume", id}), Restore: true}
102+ }
103+ }
104+
105+ command, ok := shellQuoteArgv(proc.Argv)
106+ if !ok {
107+ return processRestoreDecision{Reason: "empty_command"}
108+ }
109+ return processRestoreDecision{Command: command, Restore: true}
110+}
111+
112+func shellQuoteArgvMust(argv []string) string {
113+ command, _ := shellQuoteArgv(argv)
114+ return command
115+}
116+
117+func shellQuoteArgv(argv []string) (string, bool) {
118+ if len(argv) == 0 {
119+ return "", false
120+ }
121+ quoted := make([]string, 0, len(argv))
122+ for _, arg := range argv {
123+ quoted = append(quoted, shellQuote(arg))
124+ }
125+ return strings.Join(quoted, " "), true
126+}
127+
128+func shellQuote(s string) string {
129+ if s == "" {
130+ return "''"
131+ }
132+ if strings.IndexFunc(s, func(r rune) bool {
133+ return r != '-' && r != '_' && r != '.' && r != '/' && r != ':' && r != '+' && r != '=' && !unicode.IsLetter(r) && !unicode.IsDigit(r)
134+ }) == -1 {
135+ return s
136+ }
137+ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
138+}
@@ -0,0 +1,138 @@
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, "-") {
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"
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 {
73+ return processRestoreDecision{Reason: "missing_process"}
74+ }
75+ strategy := proc.Strategy
76+ if strategy == "" {
77+ strategy = detectProcessStrategy(proc.Argv)
78+ }
79+ name := filepath.Base(proc.Argv[0])
80+ if _, ok := allow[name]; !ok {
81+ return processRestoreDecision{Reason: "not_allowlisted"}
82+ }
83+
84+ id := proc.Opts.AgentSessionID
85+ switch strategy {
86+ case processStrategyPi:
87+ if id != "" {
88+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--resume", id}), Restore: true}
89+ }
90+ return processRestoreDecision{Command: "pi --continue", Restore: true}
91+ case processStrategyClaude:
92+ if id != "" {
93+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"claude", "--resume", id}), Restore: true}
94+ }
95+ case processStrategyOpenCode:
96+ if id != "" {
97+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"opencode", "--session", id}), Restore: true}
98+ }
99+ case processStrategyCodex:
100+ if id != "" {
101+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"codex", "resume", id}), Restore: true}
102+ }
103+ }
104+
105+ command, ok := shellQuoteArgv(proc.Argv)
106+ if !ok {
107+ return processRestoreDecision{Reason: "empty_command"}
108+ }
109+ return processRestoreDecision{Command: command, Restore: true}
110+}
111+
112+func shellQuoteArgvMust(argv []string) string {
113+ command, _ := shellQuoteArgv(argv)
114+ return command
115+}
116+
117+func shellQuoteArgv(argv []string) (string, bool) {
118+ if len(argv) == 0 {
119+ return "", false
120+ }
121+ quoted := make([]string, 0, len(argv))
122+ for _, arg := range argv {
123+ quoted = append(quoted, shellQuote(arg))
124+ }
125+ return strings.Join(quoted, " "), true
126+}
127+
128+func shellQuote(s string) string {
129+ if s == "" {
130+ return "''"
131+ }
132+ if strings.IndexFunc(s, func(r rune) bool {
133+ return r != '-' && r != '_' && r != '.' && r != '/' && r != ':' && r != '+' && r != '=' && !unicode.IsLetter(r) && !unicode.IsDigit(r)
134+ }) == -1 {
135+ return s
136+ }
137+ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
138+}
A internal/usecase/daemon/process_restore_test.go +103 -0
@@ -0,0 +1,103 @@
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+ allow := 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+ restore bool
21+ command string
22+ reason string
23+ }{
24+ {
25+ name: "generic less allowed",
26+ proc: &snapcodec.Process{Argv: []string{"/usr/bin/less", "README.md"}, Strategy: processStrategyGeneric},
27+ restore: true,
28+ command: "/usr/bin/less README.md",
29+ },
30+ {
31+ name: "bash denied",
32+ proc: &snapcodec.Process{Argv: []string{"bash", "-lc", "rm -rf /"}, Strategy: processStrategyGeneric},
33+ reason: "not_allowlisted",
34+ },
35+ {
36+ name: "pi with ID resumes",
37+ proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}},
38+ restore: true,
39+ command: "pi --resume session-123",
40+ },
41+ {
42+ name: "pi no ID continues",
43+ proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi},
44+ restore: true,
45+ command: "pi --continue",
46+ },
47+ {
48+ name: "opencode session command",
49+ proc: &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
50+ restore: true,
51+ command: "opencode --session abc",
52+ },
53+ {
54+ name: "codex resume command",
55+ proc: &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
56+ restore: true,
57+ command: "codex resume abc",
58+ },
59+ }
60+
61+ for _, tt := range tests {
62+ t.Run(tt.name, func(t *testing.T) {
63+ got := planProcessRestore(tt.proc, allow)
64+ if got.Restore != tt.restore || got.Command != tt.command || got.Reason != tt.reason {
65+ t.Fatalf("planProcessRestore() = %+v, want restore=%v command=%q reason=%q", got, tt.restore, tt.command, tt.reason)
66+ }
67+ })
68+ }
69+}
70+
71+func TestExtractAgentSessionID(t *testing.T) {
72+ tests := []struct {
73+ name string
74+ strategy string
75+ argv []string
76+ want string
77+ }{
78+ {name: "claude session flag extraction", strategy: processStrategyClaude, argv: []string{"claude", "--session-id", "uuid-123"}, want: "uuid-123"},
79+ {name: "opencode short session flag extraction", strategy: processStrategyOpenCode, argv: []string{"opencode", "-s", "abc"}, want: "abc"},
80+ {name: "codex resume extraction", strategy: processStrategyCodex, argv: []string{"codex", "resume", "abc"}, want: "abc"},
81+ {name: "claude flag-looking ID rejected", strategy: processStrategyClaude, argv: []string{"claude", "--resume", "--continue"}},
82+ {name: "pi flag-looking ID rejected", strategy: processStrategyPi, argv: []string{"pi", "-r", "--last"}},
83+ }
84+
85+ for _, tt := range tests {
86+ t.Run(tt.name, func(t *testing.T) {
87+ if got := extractAgentSessionID(tt.strategy, tt.argv); got != tt.want {
88+ t.Fatalf("extractAgentSessionID() = %q, want %q", got, tt.want)
89+ }
90+ })
91+ }
92+}
93+
94+func TestShellQuoteArgv(t *testing.T) {
95+ got, ok := shellQuoteArgv([]string{"cmd", "two words", "it's"})
96+ if !ok {
97+ t.Fatal("shellQuoteArgv() returned !ok")
98+ }
99+ want := "cmd 'two words' 'it'\\''s'"
100+ if got != want {
101+ t.Fatalf("shellQuoteArgv() = %q, want %q", got, want)
102+ }
103+}
@@ -0,0 +1,103 @@
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+ allow := 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+ restore bool
21+ command string
22+ reason string
23+ }{
24+ {
25+ name: "generic less allowed",
26+ proc: &snapcodec.Process{Argv: []string{"/usr/bin/less", "README.md"}, Strategy: processStrategyGeneric},
27+ restore: true,
28+ command: "/usr/bin/less README.md",
29+ },
30+ {
31+ name: "bash denied",
32+ proc: &snapcodec.Process{Argv: []string{"bash", "-lc", "rm -rf /"}, Strategy: processStrategyGeneric},
33+ reason: "not_allowlisted",
34+ },
35+ {
36+ name: "pi with ID resumes",
37+ proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}},
38+ restore: true,
39+ command: "pi --resume session-123",
40+ },
41+ {
42+ name: "pi no ID continues",
43+ proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi},
44+ restore: true,
45+ command: "pi --continue",
46+ },
47+ {
48+ name: "opencode session command",
49+ proc: &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
50+ restore: true,
51+ command: "opencode --session abc",
52+ },
53+ {
54+ name: "codex resume command",
55+ proc: &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}},
56+ restore: true,
57+ command: "codex resume abc",
58+ },
59+ }
60+
61+ for _, tt := range tests {
62+ t.Run(tt.name, func(t *testing.T) {
63+ got := planProcessRestore(tt.proc, allow)
64+ if got.Restore != tt.restore || got.Command != tt.command || got.Reason != tt.reason {
65+ t.Fatalf("planProcessRestore() = %+v, want restore=%v command=%q reason=%q", got, tt.restore, tt.command, tt.reason)
66+ }
67+ })
68+ }
69+}
70+
71+func TestExtractAgentSessionID(t *testing.T) {
72+ tests := []struct {
73+ name string
74+ strategy string
75+ argv []string
76+ want string
77+ }{
78+ {name: "claude session flag extraction", strategy: processStrategyClaude, argv: []string{"claude", "--session-id", "uuid-123"}, want: "uuid-123"},
79+ {name: "opencode short session flag extraction", strategy: processStrategyOpenCode, argv: []string{"opencode", "-s", "abc"}, want: "abc"},
80+ {name: "codex resume extraction", strategy: processStrategyCodex, argv: []string{"codex", "resume", "abc"}, want: "abc"},
81+ {name: "claude flag-looking ID rejected", strategy: processStrategyClaude, argv: []string{"claude", "--resume", "--continue"}},
82+ {name: "pi flag-looking ID rejected", strategy: processStrategyPi, argv: []string{"pi", "-r", "--last"}},
83+ }
84+
85+ for _, tt := range tests {
86+ t.Run(tt.name, func(t *testing.T) {
87+ if got := extractAgentSessionID(tt.strategy, tt.argv); got != tt.want {
88+ t.Fatalf("extractAgentSessionID() = %q, want %q", got, tt.want)
89+ }
90+ })
91+ }
92+}
93+
94+func TestShellQuoteArgv(t *testing.T) {
95+ got, ok := shellQuoteArgv([]string{"cmd", "two words", "it's"})
96+ if !ok {
97+ t.Fatal("shellQuoteArgv() returned !ok")
98+ }
99+ want := "cmd 'two words' 'it'\\''s'"
100+ if got != want {
101+ t.Fatalf("shellQuoteArgv() = %q, want %q", got, want)
102+ }
103+}
M internal/usecase/daemon/snapshot.go +26 -2
@@ -273,8 +273,9 @@ func (d *Daemon) captureSession(sess *session) bool {
273273 for _, p := range panes {
274274 p.mu.Lock()
275275 pid := 0
276- if p.pty != nil {
277- pid = p.pty.Pid()
276+ pty := p.pty
277+ if pty != nil {
278+ pid = pty.Pid()
278279 }
279280 paneSnap := snapcodec.Pane{
280281 ID: p.id,
@@ -289,6 +290,7 @@ func (d *Daemon) captureSession(sess *session) bool {
289290 paneSnap.Cwd = cwd
290291 }
291292 }
293+ paneSnap.Process = d.capturePaneProcess(pty, pid)
292294 paneIDs[paneSnap.ID] = struct{}{}
293295 paneSnaps = append(paneSnaps, paneSnap)
294296 }
@@ -311,6 +313,28 @@ func (d *Daemon) captureSession(sess *session) bool {
311313 return true
312314 }
313315
316+func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
317+ if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {
318+ return nil
319+ }
320+ pgid, err := pty.ForegroundPgid()
321+ if err != nil || pgid <= 0 || pgid == shellPid {
322+ return nil
323+ }
324+ argv, err := d.procGroupArgv(pgid, shellPid)
325+ if err != nil || len(argv) == 0 || argv[0] == "" {
326+ return nil
327+ }
328+ strategy := detectProcessStrategy(argv)
329+ return &snapcodec.Process{
330+ Argv: append([]string(nil), argv...),
331+ Strategy: strategy,
332+ Opts: snapcodec.ProcessOpts{
333+ AgentSessionID: extractAgentSessionID(strategy, argv),
334+ },
335+ }
336+}
337+
314338 func (d *Daemon) snapshotSaver(ctx context.Context) {
315339 t := d.clock.NewTimer(snapshotInterval)
316340 defer t.Stop()
@@ -273,8 +273,9 @@ func (d *Daemon) captureSession(sess *session) bool {
273 for _, p := range panes {273 for _, p := range panes {
274 p.mu.Lock()274 p.mu.Lock()
275 pid := 0275 pid := 0
276- if p.pty != nil {276+ pty := p.pty
277- pid = p.pty.Pid()277+ if pty != nil {
278+ pid = pty.Pid()
278 }279 }
279 paneSnap := snapcodec.Pane{280 paneSnap := snapcodec.Pane{
280 ID: p.id,281 ID: p.id,
@@ -289,6 +290,7 @@ func (d *Daemon) captureSession(sess *session) bool {
289 paneSnap.Cwd = cwd290 paneSnap.Cwd = cwd
290 }291 }
291 }292 }
293+ paneSnap.Process = d.capturePaneProcess(pty, pid)
292 paneIDs[paneSnap.ID] = struct{}{}294 paneIDs[paneSnap.ID] = struct{}{}
293 paneSnaps = append(paneSnaps, paneSnap)295 paneSnaps = append(paneSnaps, paneSnap)
294 }296 }
@@ -311,6 +313,28 @@ func (d *Daemon) captureSession(sess *session) bool {
311 return true313 return true
312 }314 }
313 315
316+func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process {
317+ if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil {
318+ return nil
319+ }
320+ pgid, err := pty.ForegroundPgid()
321+ if err != nil || pgid <= 0 || pgid == shellPid {
322+ return nil
323+ }
324+ argv, err := d.procGroupArgv(pgid, shellPid)
325+ if err != nil || len(argv) == 0 || argv[0] == "" {
326+ return nil
327+ }
328+ strategy := detectProcessStrategy(argv)
329+ return &snapcodec.Process{
330+ Argv: append([]string(nil), argv...),
331+ Strategy: strategy,
332+ Opts: snapcodec.ProcessOpts{
333+ AgentSessionID: extractAgentSessionID(strategy, argv),
334+ },
335+ }
336+}
337+
314 func (d *Daemon) snapshotSaver(ctx context.Context) {338 func (d *Daemon) snapshotSaver(ctx context.Context) {
315 t := d.clock.NewTimer(snapshotInterval)339 t := d.clock.NewTimer(snapshotInterval)
316 defer t.Stop()340 defer t.Stop()
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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close