From efbc910eb5cdf14319bb202c078795c30e04d2dc Mon Sep 17 00:00:00 2001 From: Joel Green Date: Tue, 14 Apr 2026 00:03:09 -0400 Subject: [PATCH] fix(browse): server persists across Claude Code Bash calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browse server was dying between Bash tool invocations in Claude Code because: 1. SIGTERM: The Claude Code sandbox sends SIGTERM to all child processes when a Bash command completes. The server received this and called shutdown(), deleting the state file and exiting. 2. Parent watchdog: The server polls BROWSE_PARENT_PID every 15s. When the parent Bash shell exits (killed by sandbox), the watchdog detected it and called shutdown(). Both mechanisms made it impossible to use the browse tool across multiple Bash calls — every new `$B` invocation started a fresh server with no cookies, no page state, and no tabs. Fix: - SIGTERM handler: log and ignore instead of shutdown. Explicit shutdown is still available via the /stop command or SIGINT (Ctrl+C). - Parent watchdog: log once and continue instead of shutdown. The existing idle timeout (30 min) handles eventual cleanup. The /stop command and SIGINT still work for intentional shutdown. Windows behavior is unchanged (uses taskkill /F which bypasses signal handlers). Tested: browse server survives across 5+ separate Bash tool calls in Claude Code, maintaining cookies, page state, and navigation. Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/src/server.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/browse/src/server.ts b/browse/src/server.ts index 98f43af0..47575d1a 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -759,12 +759,18 @@ const idleCheckInterval = setInterval(() => { // and self-terminate if it is gone. const BROWSE_PARENT_PID = parseInt(process.env.BROWSE_PARENT_PID || '0', 10); if (BROWSE_PARENT_PID > 0) { + let parentGone = false; setInterval(() => { try { process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent } catch { - console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited, shutting down`); - shutdown(); + // Parent exited. Don't shutdown — the server should persist across + // Bash calls (e.g. Claude Code sandbox kills the parent shell). + // The idle timeout (30 min) handles eventual cleanup instead. + if (!parentGone) { + parentGone = true; + console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited (server stays alive, idle timeout will clean up)`); + } } }, 15_000); } @@ -1225,8 +1231,13 @@ async function shutdown() { } // Handle signals -process.on('SIGTERM', shutdown); +// SIGINT (Ctrl+C): user intentionally stopping → shutdown process.on('SIGINT', shutdown); +// SIGTERM: may come from sandbox killing parent → ignore and let idle timeout handle cleanup. +// Explicit shutdown is available via the /stop command or SIGINT. +process.on('SIGTERM', () => { + console.log('[browse] Received SIGTERM (ignoring — use /stop or Ctrl+C for intentional shutdown)'); +}); // Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths. // Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check. if (process.platform === 'win32') {