1package daemon
2
3import (
4 "path/filepath"
5 "strings"
6 "unicode"
7
8 snapcodec "github.com/bnema/vev/internal/snapshotcodec"
9)
10
11const (
12 processStrategyGeneric = "generic"
13 processStrategyClaude = "claude"
14 processStrategyCodex = "codex"
15 processStrategyOpenCode = "opencode"
16 processStrategyPi = "pi"
17)
18
19type processRestoreDecision struct {
20 Command string
21 Restore bool
22 Reason string
23}
24
25func 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
43func 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
56func 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
71func 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
114func 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
125func 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
134func containsControl(s string) bool {
135 return strings.IndexFunc(s, unicode.IsControl) >= 0
136}
137
138func shellQuoteArgvMust(argv []string) string {
139 command, _ := shellQuoteArgv(argv)
140 return command
141}
142
143func 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
154func 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}