From 5833529c0c8bc9814d9d87066e9ae7934aa8279c Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 12:47:05 +0200 Subject: [PATCH] fix(desktop): default USE_TMUX to auto so quitting the app stops killing sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop shell pinned USE_TMUX=0 on every platform, not just Windows. Without tmux the shell is a direct child of the embedded server, so SessionManager.shutdown takes the kill branch and quitting the app — the tray Quit, a logout, an update — ends every session with it. That defeats the app's premise. The point is to hand Claude Code a task, walk away, and reconnect from another device; a session that cannot survive the window closing does not do that. With tmux the node-pty process is only a tmux CLIENT, so shutdown detaches and the shell keeps running, which is exactly what the standalone server has always done (USE_TMUX unset → 'auto' → is the binary present?). Non-Windows now defaults to 'auto'. Windows stays forced off — tmux is a *nix keepalive. An explicit ambient USE_TMUX still wins either way, so USE_TMUX=0 remains the opt-out. Tests assert the end-to-end result, not just the emitted string: buildServerEnv fed through loadConfig yields useTmux === true with tmux installed, and false for an explicit opt-out. The original bug was these two layers disagreeing about what a value meant, so checking the string alone would not have caught it. Sessions started in the app before this change are NOT retroactively protected — they have no tmux session behind them and will still die with the app. Only sessions created after the new build is installed survive. --- desktop/src/server-config.ts | 22 +++++++++++++++--- test/desktop/server-config.test.ts | 36 +++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/desktop/src/server-config.ts b/desktop/src/server-config.ts index 8175513..8af18a6 100644 --- a/desktop/src/server-config.ts +++ b/desktop/src/server-config.ts @@ -7,7 +7,8 @@ * BIND_HOST (private 127.0.0.1 by default; 0.0.0.0 only when LAN sharing is on), * SHELL_PATH (platform-aware default so Windows gets PowerShell), and USE_TMUX * (forced off on Windows — tmux is a *nix keepalive; elsewhere respect the - * ambient env, defaulting off). Every other ambient var passes through untouched. + * ambient env, defaulting to auto-detect). Every other ambient var passes through + * untouched. */ import type { DesktopPrefs } from './types.js' @@ -17,8 +18,23 @@ import { defaultShellForPlatform } from './shell.js' const PRIVATE_BIND_HOST = '127.0.0.1' /** Wildcard bind host — LAN sharing (phones/tablets reconnect); opt-in, no auth. */ const LAN_BIND_HOST = '0.0.0.0' -/** tmux keepalive is a *nix feature; the desktop shell defaults it off (and forces off on Windows). */ +/** tmux is a *nix keepalive, so Windows forces it off outright. */ const TMUX_OFF = '0' +/** + * Everywhere else the desktop shell auto-detects (loadConfig's 'auto' → is the tmux + * binary present?), which is what the standalone server already did. + * + * It used to default OFF, and that quietly broke the app's whole premise. Without + * tmux the shell is a direct child of the embedded server, so quitting the app — + * including the tray "Quit", including a logout — kills every session with it. The + * point of this app is to hand Claude Code a task, walk away, and reconnect from + * another device; a session that cannot survive the window closing does not do that. + * With tmux the node-pty process is only a tmux CLIENT, so shutdown detaches and the + * shell keeps running (see SessionManager.shutdown). + * + * An explicit ambient USE_TMUX still wins, so USE_TMUX=0 remains the way to opt out. + */ +const TMUX_AUTO = 'auto' export interface ServerEnvInput { readonly prefs: DesktopPrefs @@ -38,6 +54,6 @@ export function buildServerEnv(input: ServerEnvInput): Record { + try { + execFileSync('tmux', ['-V'], { stdio: 'ignore' }) + return true + } catch { + return false + } +})() + const BASE_PREFS: DesktopPrefs = { port: null, lanSharing: false, @@ -69,11 +81,33 @@ describe('buildServerEnv', () => { expect(result.USE_TMUX).toBe('1') }) - test('defaults USE_TMUX to off on non-Windows when the env is unset', () => { + test('defaults USE_TMUX to auto-detect on non-Windows when the env is unset', () => { + // Not '0'. Without tmux the shell is a direct child of the embedded server, so + // quitting the app kills every session — which defeats the point of walking away + // from a task and reconnecting later. 'auto' means "use tmux if it is installed". const result = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'linux', env: {} }) + expect(result.USE_TMUX).toBe('auto') + }) + + test('an explicit ambient USE_TMUX=0 still wins — opting out stays possible', () => { + const result = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'darwin', env: { USE_TMUX: '0' } }) expect(result.USE_TMUX).toBe('0') }) + // The bug this guards against was the two layers disagreeing: buildServerEnv emitted + // a value that loadConfig then resolved to something else than intended. Assert the + // end-to-end result, not just the string. + test('the default resolves to tmux ON once loadConfig sees it', () => { + if (!tmuxInstalled) return // 'auto' correctly resolves to false without the binary + const env = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'darwin', env: {} }) + expect(loadConfig({ ...env, BIND_HOST: '127.0.0.1' }).useTmux).toBe(true) + }) + + test('an explicit opt-out resolves to tmux OFF through loadConfig', () => { + const env = buildServerEnv({ prefs: BASE_PREFS, port: 3000, platform: 'darwin', env: { USE_TMUX: '0' } }) + expect(loadConfig({ ...env, BIND_HOST: '127.0.0.1' }).useTmux).toBe(false) + }) + test('does not mutate the input env', () => { // Arrange const env = { PATH: '/usr/bin', USE_TMUX: '1' }