/ src / utils / PersistentShell.ts
PersistentShell.ts
  1  import * as fs from 'fs'
  2  import { homedir } from 'os'
  3  import { existsSync } from 'fs'
  4  import shellquote from 'shell-quote'
  5  import { spawn, execSync, type ChildProcess } from 'child_process'
  6  import { isAbsolute, resolve, join } from 'path'
  7  import { logError } from './log.js'
  8  import * as os from 'os'
  9  import { logEvent } from '../services/statsig.js'
 10  
 11  type ExecResult = {
 12    stdout: string
 13    stderr: string
 14    code: number
 15    interrupted: boolean
 16  }
 17  type QueuedCommand = {
 18    command: string
 19    abortSignal?: AbortSignal
 20    timeout?: number
 21    resolve: (result: ExecResult) => void
 22    reject: (error: Error) => void
 23  }
 24  
 25  const TEMPFILE_PREFIX = os.tmpdir() + '/claude-'
 26  const DEFAULT_TIMEOUT = 30 * 60 * 1000
 27  const SIGTERM_CODE = 143 // Standard exit code for SIGTERM
 28  const FILE_SUFFIXES = {
 29    STATUS: '-status',
 30    STDOUT: '-stdout',
 31    STDERR: '-stderr',
 32    CWD: '-cwd',
 33  }
 34  const SHELL_CONFIGS: Record<string, string> = {
 35    '/bin/bash': '.bashrc',
 36    '/bin/zsh': '.zshrc',
 37  }
 38  
 39  export class PersistentShell {
 40    private commandQueue: QueuedCommand[] = []
 41    private isExecuting: boolean = false
 42    private shell: ChildProcess
 43    private isAlive: boolean = true
 44    private commandInterrupted: boolean = false
 45    private statusFile: string
 46    private stdoutFile: string
 47    private stderrFile: string
 48    private cwdFile: string
 49    private cwd: string
 50    private binShell: string
 51  
 52    constructor(cwd: string) {
 53      this.binShell = process.env.SHELL || '/bin/bash'
 54      this.shell = spawn(this.binShell, ['-l'], {
 55        stdio: ['pipe', 'pipe', 'pipe'],
 56        cwd,
 57        env: {
 58          ...process.env,
 59          GIT_EDITOR: 'true',
 60        },
 61      })
 62  
 63      this.cwd = cwd
 64  
 65      this.shell.on('exit', (code, signal) => {
 66        if (code) {
 67          // TODO: It would be nice to alert the user that shell crashed
 68          logError(`Shell exited with code ${code} and signal ${signal}`)
 69          logEvent('persistent_shell_exit', {
 70            code: code?.toString() || 'null',
 71            signal: signal || 'null',
 72          })
 73        }
 74        for (const file of [
 75          this.statusFile,
 76          this.stdoutFile,
 77          this.stderrFile,
 78          this.cwdFile,
 79        ]) {
 80          if (fs.existsSync(file)) {
 81            fs.unlinkSync(file)
 82          }
 83        }
 84        this.isAlive = false
 85      })
 86  
 87      const id = Math.floor(Math.random() * 0x10000)
 88        .toString(16)
 89        .padStart(4, '0')
 90  
 91      this.statusFile = TEMPFILE_PREFIX + id + FILE_SUFFIXES.STATUS
 92      this.stdoutFile = TEMPFILE_PREFIX + id + FILE_SUFFIXES.STDOUT
 93      this.stderrFile = TEMPFILE_PREFIX + id + FILE_SUFFIXES.STDERR
 94      this.cwdFile = TEMPFILE_PREFIX + id + FILE_SUFFIXES.CWD
 95      for (const file of [this.statusFile, this.stdoutFile, this.stderrFile]) {
 96        fs.writeFileSync(file, '')
 97      }
 98      // Initialize CWD file with initial directory
 99      fs.writeFileSync(this.cwdFile, cwd)
100      const configFile = SHELL_CONFIGS[this.binShell]
101      if (configFile) {
102        const configFilePath = join(homedir(), configFile)
103        if (existsSync(configFilePath)) {
104          this.sendToShell(`source ${configFilePath}`)
105        }
106      }
107    }
108  
109    private static instance: PersistentShell | null = null
110  
111    static restart() {
112      if (PersistentShell.instance) {
113        PersistentShell.instance.close()
114        PersistentShell.instance = null
115      }
116    }
117  
118    static getInstance(): PersistentShell {
119      if (!PersistentShell.instance || !PersistentShell.instance.isAlive) {
120        PersistentShell.instance = new PersistentShell(process.cwd())
121      }
122      return PersistentShell.instance
123    }
124  
125    killChildren() {
126      const parentPid = this.shell.pid
127      try {
128        const childPids = execSync(`pgrep -P ${parentPid}`)
129          .toString()
130          .trim()
131          .split('\n')
132          .filter(Boolean) // Filter out empty strings
133  
134        if (childPids.length > 0) {
135          logEvent('persistent_shell_command_interrupted', {
136            numChildProcesses: childPids.length.toString(),
137          })
138        }
139  
140        childPids.forEach(pid => {
141          try {
142            process.kill(Number(pid), 'SIGTERM')
143          } catch (error) {
144            logError(`Failed to kill process ${pid}: ${error}`)
145            logEvent('persistent_shell_kill_process_error', {
146              error: (error as Error).message.substring(0, 10),
147            })
148          }
149        })
150      } catch {
151        // pgrep returns non-zero when no processes are found - this is expected
152      } finally {
153        this.commandInterrupted = true
154      }
155    }
156  
157    private async processQueue() {
158      /**
159       * Processes commands from the queue one at a time.
160       * Concurrency invariants:
161       * - Only one instance runs at a time (controlled by isExecuting)
162       * - Is the only caller of updateCwd() in the system
163       * - Calls updateCwd() after each command completes
164       * - Ensures commands execute serially via the queue
165       * - Handles interruption via abortSignal by calling killChildren()
166       * - Cleans up abortSignal listeners after command completion or interruption
167       */
168      if (this.isExecuting || this.commandQueue.length === 0) return
169  
170      this.isExecuting = true
171      const { command, abortSignal, timeout, resolve, reject } =
172        this.commandQueue.shift()!
173  
174      const killChildren = () => this.killChildren()
175      if (abortSignal) {
176        abortSignal.addEventListener('abort', killChildren)
177      }
178  
179      try {
180        const result = await this.exec_(command, timeout)
181  
182        // No need to update cwd - it's handled in exec_ via the CWD file
183  
184        resolve(result)
185      } catch (error) {
186        logEvent('persistent_shell_command_error', {
187          error: (error as Error).message.substring(0, 10),
188        })
189        reject(error as Error)
190      } finally {
191        this.isExecuting = false
192        if (abortSignal) {
193          abortSignal.removeEventListener('abort', killChildren)
194        }
195        // Process next command in queue
196        this.processQueue()
197      }
198    }
199  
200    async exec(
201      command: string,
202      abortSignal?: AbortSignal,
203      timeout?: number,
204    ): Promise<ExecResult> {
205      return new Promise((resolve, reject) => {
206        this.commandQueue.push({ command, abortSignal, timeout, resolve, reject })
207        this.processQueue()
208      })
209    }
210  
211    private async exec_(command: string, timeout?: number): Promise<ExecResult> {
212      /**
213       * Direct command execution without going through the queue.
214       * Concurrency invariants:
215       * - Not safe for concurrent calls (uses shared files)
216       * - Called only when queue is idle
217       * - Relies on file-based IPC to handle shell interaction
218       * - Does not modify the command queue state
219       * - Tracks interruption state via commandInterrupted flag
220       * - Resets interruption state at start of new command
221       * - Reports interruption status in result object
222       *
223       * Exit Code & CWD Handling:
224       * - Executes command and immediately captures its exit code into a shell variable
225       * - Updates the CWD file with the working directory after capturing exit code
226       * - Writes the preserved exit code to the status file as the final step
227       * - This sequence eliminates race conditions between exit code capture and CWD updates
228       * - The pwd() method reads the CWD file directly for current directory info
229       */
230      const quotedCommand = shellquote.quote([command])
231  
232      // Check the syntax of the command
233      try {
234        execSync(`${this.binShell} -n -c ${quotedCommand}`, {
235          stdio: 'ignore',
236          timeout: 1000,
237        })
238      } catch (stderr) {
239        // If there's a syntax error, return an error and log it
240        const errorStr =
241          typeof stderr === 'string' ? stderr : String(stderr || '')
242        logEvent('persistent_shell_syntax_error', {
243          error: errorStr.substring(0, 10),
244        })
245        return Promise.resolve({
246          stdout: '',
247          stderr: errorStr,
248          code: 128,
249          interrupted: false,
250        })
251      }
252  
253      const commandTimeout = timeout || DEFAULT_TIMEOUT
254      // Reset interrupted state for new command
255      this.commandInterrupted = false
256      return new Promise<ExecResult>(resolve => {
257        // Truncate output files
258        fs.writeFileSync(this.stdoutFile, '')
259        fs.writeFileSync(this.stderrFile, '')
260        fs.writeFileSync(this.statusFile, '')
261        // Break up the command sequence for clarity using an array of commands
262        const commandParts = []
263  
264        // 1. Execute the main command with redirections
265        commandParts.push(
266          `eval ${quotedCommand} < /dev/null > ${this.stdoutFile} 2> ${this.stderrFile}`,
267        )
268  
269        // 2. Capture exit code immediately after command execution to avoid losing it
270        commandParts.push(`EXEC_EXIT_CODE=$?`)
271  
272        // 3. Update CWD file
273        commandParts.push(`pwd > ${this.cwdFile}`)
274  
275        // 4. Write the preserved exit code to status file to avoid race with pwd
276        commandParts.push(`echo $EXEC_EXIT_CODE > ${this.statusFile}`)
277  
278        // Send the combined commands as a single operation to maintain atomicity
279        this.sendToShell(commandParts.join('\n'))
280  
281        // Check for command completion or timeout
282        const start = Date.now()
283        const checkCompletion = setInterval(() => {
284          try {
285            let statusFileSize = 0
286            if (fs.existsSync(this.statusFile)) {
287              statusFileSize = fs.statSync(this.statusFile).size
288            }
289  
290            if (
291              statusFileSize > 0 ||
292              Date.now() - start > commandTimeout ||
293              this.commandInterrupted
294            ) {
295              clearInterval(checkCompletion)
296              const stdout = fs.existsSync(this.stdoutFile)
297                ? fs.readFileSync(this.stdoutFile, 'utf8')
298                : ''
299              let stderr = fs.existsSync(this.stderrFile)
300                ? fs.readFileSync(this.stderrFile, 'utf8')
301                : ''
302              let code: number
303              if (statusFileSize) {
304                code = Number(fs.readFileSync(this.statusFile, 'utf8'))
305              } else {
306                // Timeout occurred - kill any running processes
307                this.killChildren()
308                code = SIGTERM_CODE
309                stderr += (stderr ? '\n' : '') + 'Command execution timed out'
310                logEvent('persistent_shell_command_timeout', {
311                  command: command.substring(0, 10),
312                  timeout: commandTimeout.toString(),
313                })
314              }
315              resolve({
316                stdout,
317                stderr,
318                code,
319                interrupted: this.commandInterrupted,
320              })
321            }
322          } catch {
323            // Ignore file system errors during polling - they are expected
324            // as we check for completion before files exist
325          }
326        }, 10) // increasing this will introduce latency
327      })
328    }
329  
330    private sendToShell(command: string) {
331      try {
332        this.shell!.stdin!.write(command + '\n')
333      } catch (error) {
334        const errorString =
335          error instanceof Error
336            ? error.message
337            : String(error || 'Unknown error')
338        logError(`Error in sendToShell: ${errorString}`)
339        logEvent('persistent_shell_write_error', {
340          error: errorString.substring(0, 100),
341          command: command.substring(0, 30),
342        })
343        throw error
344      }
345    }
346  
347    pwd(): string {
348      try {
349        const newCwd = fs.readFileSync(this.cwdFile, 'utf8').trim()
350        if (newCwd) {
351          this.cwd = newCwd
352        }
353      } catch (error) {
354        logError(`Shell pwd error ${error}`)
355      }
356      // Always return the cached value
357      return this.cwd
358    }
359  
360    async setCwd(cwd: string) {
361      const resolved = isAbsolute(cwd) ? cwd : resolve(process.cwd(), cwd)
362      if (!existsSync(resolved)) {
363        throw new Error(`Path "${resolved}" does not exist`)
364      }
365      await this.exec(`cd ${resolved}`)
366    }
367  
368    close(): void {
369      this.shell!.stdin!.end()
370      this.shell.kill()
371    }
372  }