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 }