* Spawn the shell subprocess. Idempotent — safe to call multiple times.
()
| 52 | * Spawn the shell subprocess. Idempotent — safe to call multiple times. |
| 53 | */ |
| 54 | async start() { |
| 55 | if (this.proc && !this._dead) return true; |
| 56 | if (this.starting) return this.starting; |
| 57 | |
| 58 | this.starting = (async () => { |
| 59 | // Fresh spawn — clear any leftover buffer + mark live before we attach |
| 60 | // handlers, so a restart after stop() starts from a clean slate. |
| 61 | this.buffer = ''; |
| 62 | this._dead = false; |
| 63 | const isWin = process.platform === 'win32'; |
| 64 | // POSIX: always use bash, never $SHELL. The sentinel command-wrapper |
| 65 | // below emits bash/POSIX syntax (`printf '\n%s_%d_\n' $?`) and passes |
| 66 | // bash-only flags (--norc --noprofile -i). Honouring $SHELL broke every |
| 67 | // zsh user: `zsh --norc ...` errors with "zsh: no such option: norc" |
| 68 | // and the shell exits immediately, so every bash tool call failed with |
| 69 | // "shell exited" (issue #58). cmd.exe on Windows. |
| 70 | const shellCmd = isWin ? 'cmd.exe' : 'bash'; |
| 71 | |
| 72 | const shellArgs = isWin ? ['/Q', '/K', 'echo off & prompt $G'] : ['--norc', '--noprofile', '-i']; |
| 73 | |
| 74 | try { |
| 75 | this.proc = spawn(shellCmd, shellArgs, { |
| 76 | cwd: this.cwd, |
| 77 | env: { ...process.env, PS1: '', PROMPT_COMMAND: '' }, |
| 78 | stdio: ['pipe', 'pipe', 'pipe'], |
| 79 | shell: false, |
| 80 | }); |
| 81 | // Unref so this child process doesn't keep the parent's event loop |
| 82 | // alive after all other work is done (e.g. on --non-interactive exit). |
| 83 | // We still clean up explicitly via process.on('exit') and SIGINT. |
| 84 | try { this.proc.unref(); } catch {} |
| 85 | } catch (err) { |
| 86 | this._dead = true; |
| 87 | return false; |
| 88 | } |
| 89 | |
| 90 | // Capture the specific child so a late event from a PREVIOUS shell can't |
| 91 | // stomp a freshly-spawned one. Without this guard, `stop()` immediately |
| 92 | // followed by `run()` races: the old proc's async 'exit' fires after the |
| 93 | // new shell spawned and flips this._dead=true on the live session, |
| 94 | // killing it (and producing empty output). |
| 95 | const proc = this.proc; |
| 96 | const isCurrent = () => this.proc === proc; |
| 97 | |
| 98 | proc.on('error', () => { if (isCurrent()) this._dead = true; }); |
| 99 | proc.on('exit', () => { if (isCurrent()) { this._dead = true; this._failPending('shell exited'); } }); |
| 100 | |
| 101 | // Guard against async EPIPE on stdin. When the shell dies mid-write, |
| 102 | // `proc.stdin` emits an async 'error' (EPIPE) event. With no listener, |
| 103 | // Node escalates it to an uncaught exception and crashes the whole |
| 104 | // process — the try/catch around stdin.write() only catches the |
| 105 | // synchronous throw, not the async event. Mark the shell dead and let |
| 106 | // the next run() auto-restart it (issue #58). |
| 107 | proc.stdin.on('error', () => { if (isCurrent()) { this._dead = true; this._failPending('shell stdin error'); } }); |
| 108 | |
| 109 | // Demux output via sentinel matching. Ignore late chunks from a |
| 110 | // superseded process so they can't corrupt a freshly-spawned shell's |
| 111 | // buffer (same race as the exit-handler guard above). |
no test coverage detected