1package shell
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "sync"
12 "syscall"
13 "time"
14
15 "github.com/opencode-ai/opencode/internal/config"
16)
17
18type PersistentShell struct {
19 cmd *exec.Cmd
20 stdin *os.File
21 isAlive bool
22 cwd string
23 mu sync.Mutex
24 commandQueue chan *commandExecution
25}
26
27type commandExecution struct {
28 command string
29 timeout time.Duration
30 resultChan chan commandResult
31 ctx context.Context
32}
33
34type commandResult struct {
35 stdout string
36 stderr string
37 exitCode int
38 interrupted bool
39 err error
40}
41
42var (
43 shellInstance *PersistentShell
44 shellInstanceOnce sync.Once
45)
46
47func GetPersistentShell(workingDir string) *PersistentShell {
48 shellInstanceOnce.Do(func() {
49 shellInstance = newPersistentShell(workingDir)
50 })
51
52 if shellInstance == nil {
53 shellInstance = newPersistentShell(workingDir)
54 } else if !shellInstance.isAlive {
55 shellInstance = newPersistentShell(shellInstance.cwd)
56 }
57
58 return shellInstance
59}
60
61func newPersistentShell(cwd string) *PersistentShell {
62 // Get shell configuration from config
63 cfg := config.Get()
64
65 // Default to environment variable if config is not set or nil
66 var shellPath string
67 var shellArgs []string
68
69 if cfg != nil {
70 shellPath = cfg.Shell.Path
71 shellArgs = cfg.Shell.Args
72 }
73
74 if shellPath == "" {
75 shellPath = os.Getenv("SHELL")
76 if shellPath == "" {
77 shellPath = "/bin/bash"
78 }
79 }
80
81 // Default shell args
82 if len(shellArgs) == 0 {
83 shellArgs = []string{"-l"}
84 }
85
86 cmd := exec.Command(shellPath, shellArgs...)
87 cmd.Dir = cwd
88
89 stdinPipe, err := cmd.StdinPipe()
90 if err != nil {
91 return nil
92 }
93
94 cmd.Env = append(os.Environ(), "GIT_EDITOR=true")
95
96 err = cmd.Start()
97 if err != nil {
98 return nil
99 }
100
101 shell := &PersistentShell{
102 cmd: cmd,
103 stdin: stdinPipe.(*os.File),
104 isAlive: true,
105 cwd: cwd,
106 commandQueue: make(chan *commandExecution, 10),
107 }
108
109 go func() {
110 defer func() {
111 if r := recover(); r != nil {
112 fmt.Fprintf(os.Stderr, "Panic in shell command processor: %v\n", r)
113 shell.isAlive = false
114 close(shell.commandQueue)
115 }
116 }()
117 shell.processCommands()
118 }()
119
120 go func() {
121 err := cmd.Wait()
122 if err != nil {
123 // Log the error if needed
124 }
125 shell.isAlive = false
126 close(shell.commandQueue)
127 }()
128
129 return shell
130}
131
132func (s *PersistentShell) processCommands() {
133 for cmd := range s.commandQueue {
134 result := s.execCommand(cmd.command, cmd.timeout, cmd.ctx)
135 cmd.resultChan <- result
136 }
137}
138
139func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx context.Context) commandResult {
140 s.mu.Lock()
141 defer s.mu.Unlock()
142
143 if !s.isAlive {
144 return commandResult{
145 stderr: "Shell is not alive",
146 exitCode: 1,
147 err: errors.New("shell is not alive"),
148 }
149 }
150
151 tempDir := os.TempDir()
152 stdoutFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stdout-%d", time.Now().UnixNano()))
153 stderrFile := filepath.Join(tempDir, fmt.Sprintf("opencode-stderr-%d", time.Now().UnixNano()))
154 statusFile := filepath.Join(tempDir, fmt.Sprintf("opencode-status-%d", time.Now().UnixNano()))
155 cwdFile := filepath.Join(tempDir, fmt.Sprintf("opencode-cwd-%d", time.Now().UnixNano()))
156
157 defer func() {
158 os.Remove(stdoutFile)
159 os.Remove(stderrFile)
160 os.Remove(statusFile)
161 os.Remove(cwdFile)
162 }()
163
164 fullCommand := fmt.Sprintf(`
165eval %s < /dev/null > %s 2> %s
166EXEC_EXIT_CODE=$?
167pwd > %s
168echo $EXEC_EXIT_CODE > %s
169`,
170 shellQuote(command),
171 shellQuote(stdoutFile),
172 shellQuote(stderrFile),
173 shellQuote(cwdFile),
174 shellQuote(statusFile),
175 )
176
177 _, err := s.stdin.Write([]byte(fullCommand + "\n"))
178 if err != nil {
179 return commandResult{
180 stderr: fmt.Sprintf("Failed to write command to shell: %v", err),
181 exitCode: 1,
182 err: err,
183 }
184 }
185
186 interrupted := false
187
188 startTime := time.Now()
189
190 done := make(chan bool)
191 go func() {
192 for {
193 select {
194 case <-ctx.Done():
195 s.killChildren()
196 interrupted = true
197 done <- true
198 return
199
200 case <-time.After(10 * time.Millisecond):
201 if fileExists(statusFile) && fileSize(statusFile) > 0 {
202 done <- true
203 return
204 }
205
206 if timeout > 0 {
207 elapsed := time.Since(startTime)
208 if elapsed > timeout {
209 s.killChildren()
210 interrupted = true
211 done <- true
212 return
213 }
214 }
215 }
216 }
217 }()
218
219 <-done
220
221 stdout := readFileOrEmpty(stdoutFile)
222 stderr := readFileOrEmpty(stderrFile)
223 exitCodeStr := readFileOrEmpty(statusFile)
224 newCwd := readFileOrEmpty(cwdFile)
225
226 exitCode := 0
227 if exitCodeStr != "" {
228 fmt.Sscanf(exitCodeStr, "%d", &exitCode)
229 } else if interrupted {
230 exitCode = 143
231 stderr += "\nCommand execution timed out or was interrupted"
232 }
233
234 if newCwd != "" {
235 s.cwd = strings.TrimSpace(newCwd)
236 }
237
238 return commandResult{
239 stdout: stdout,
240 stderr: stderr,
241 exitCode: exitCode,
242 interrupted: interrupted,
243 }
244}
245
246func (s *PersistentShell) killChildren() {
247 if s.cmd == nil || s.cmd.Process == nil {
248 return
249 }
250
251 pgrepCmd := exec.Command("pgrep", "-P", fmt.Sprintf("%d", s.cmd.Process.Pid))
252 output, err := pgrepCmd.Output()
253 if err != nil {
254 return
255 }
256
257 for pidStr := range strings.SplitSeq(string(output), "\n") {
258 if pidStr = strings.TrimSpace(pidStr); pidStr != "" {
259 var pid int
260 fmt.Sscanf(pidStr, "%d", &pid)
261 if pid > 0 {
262 proc, err := os.FindProcess(pid)
263 if err == nil {
264 proc.Signal(syscall.SIGTERM)
265 }
266 }
267 }
268 }
269}
270
271func (s *PersistentShell) Exec(ctx context.Context, command string, timeoutMs int) (string, string, int, bool, error) {
272 if !s.isAlive {
273 return "", "Shell is not alive", 1, false, errors.New("shell is not alive")
274 }
275
276 timeout := time.Duration(timeoutMs) * time.Millisecond
277
278 resultChan := make(chan commandResult)
279 s.commandQueue <- &commandExecution{
280 command: command,
281 timeout: timeout,
282 resultChan: resultChan,
283 ctx: ctx,
284 }
285
286 result := <-resultChan
287 return result.stdout, result.stderr, result.exitCode, result.interrupted, result.err
288}
289
290func (s *PersistentShell) Close() {
291 s.mu.Lock()
292 defer s.mu.Unlock()
293
294 if !s.isAlive {
295 return
296 }
297
298 s.stdin.Write([]byte("exit\n"))
299
300 s.cmd.Process.Kill()
301 s.isAlive = false
302}
303
304func shellQuote(s string) string {
305 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
306}
307
308func readFileOrEmpty(path string) string {
309 content, err := os.ReadFile(path)
310 if err != nil {
311 return ""
312 }
313 return string(content)
314}
315
316func fileExists(path string) bool {
317 _, err := os.Stat(path)
318 return err == nil
319}
320
321func fileSize(path string) int64 {
322 info, err := os.Stat(path)
323 if err != nil {
324 return 0
325 }
326 return info.Size()
327}