From dc0307f4c16b6fc37ae3b197969235addb66914a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 19 May 2026 17:02:29 +0800 Subject: [PATCH] fix: Stabilize desktop CLI launcher TTY startup The desktop app exposed the bundled sidecar directly as the user shell command, which could be suspended by macOS job control when the interactive TUI read from the controlling terminal. Unix installs now write a lightweight launcher wrapper, using a nested PTY on macOS interactive terminals while preserving direct exec behavior for non-interactive commands and Windows binaries. Constraint: Bundled desktop sidecars must remain usable from user terminals without requiring a separate CLI installation Rejected: Copy the Bun sidecar directly on macOS | interactive TUI startup can suspend with SIGTTIN Confidence: high Scope-risk: narrow Directive: Do not remove the macOS PTY wrapper without testing interactive TUI startup in a real terminal Tested: bun test src/server/__tests__/desktop-cli-launcher.test.ts Tested: bun run check:server Tested: claude-haha --version Tested: Real TTY interactive launch no longer printed suspended tty input --- .../__tests__/desktop-cli-launcher.test.ts | 7 ++- .../services/desktopCliLauncherService.ts | 60 ++++++++++++++++++- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/server/__tests__/desktop-cli-launcher.test.ts b/src/server/__tests__/desktop-cli-launcher.test.ts index 5be027b7..6d53fde7 100644 --- a/src/server/__tests__/desktop-cli-launcher.test.ts +++ b/src/server/__tests__/desktop-cli-launcher.test.ts @@ -62,7 +62,7 @@ describe('ensureDesktopCliLauncherInstalled', () => { await rm(tempSourceDir, { recursive: true, force: true }) }) - unixOnly('copies the bundled sidecar into the user bin dir and configures PATH', async () => { + unixOnly('installs a launcher wrapper in the user bin dir and configures PATH', async () => { const sourcePath = join(tempSourceDir, 'claude-sidecar') await writeFile(sourcePath, '#!/bin/sh\necho desktop-sidecar\n', 'utf8') await chmod(sourcePath, 0o755) @@ -80,7 +80,10 @@ describe('ensureDesktopCliLauncherInstalled', () => { expect(status.needsTerminalRestart).toBe(true) expect(status.configTarget).toBe(shellConfigPath) - expect(await readFile(launcherPath, 'utf8')).toContain('desktop-sidecar') + const launcher = await readFile(launcherPath, 'utf8') + expect(launcher).toContain(`SIDECAR='${sourcePath}'`) + expect(launcher).toContain('cli --app-root "$APP_ROOT" "$@"') + expect(launcher).toContain('/usr/bin/script -q /dev/null') expect(await readFile(shellConfigPath, 'utf8')).toContain( 'export PATH="$HOME/.local/bin:$PATH"', ) diff --git a/src/server/services/desktopCliLauncherService.ts b/src/server/services/desktopCliLauncherService.ts index 8e1a9b99..e520d6d5 100644 --- a/src/server/services/desktopCliLauncherService.ts +++ b/src/server/services/desktopCliLauncherService.ts @@ -166,7 +166,7 @@ async function ensureDesktopCliLauncherInstalledImpl(): Promise null) + if (existing === wrapper) { + await chmod(targetPath, 0o755) + return + } + + const tempPath = `${targetPath}.tmp.${process.pid}.${Date.now()}` + await writeFile(tempPath, wrapper, { encoding: 'utf8', mode: 0o755 }) + + try { + await rename(tempPath, targetPath) + await chmod(targetPath, 0o755) + } finally { + await unlink(tempPath).catch(() => undefined) + } +} + +function buildUnixLauncherWrapper(sourcePath: string) { + const quotedSource = shellSingleQuote(sourcePath) + const quotedAppRoot = shellSingleQuote(dirname(sourcePath)) + + return `#!/usr/bin/env bash +set -euo pipefail + +SIDECAR=${quotedSource} +APP_ROOT=${quotedAppRoot} + +if [[ ! -x "$SIDECAR" ]]; then + echo "claude-haha launcher could not find bundled sidecar: $SIDECAR" >&2 + exit 127 +fi + +# Bun-compiled macOS sidecars can be suspended by job control while reading the +# controlling TTY directly. A nested PTY keeps the terminal foreground handoff +# stable for the interactive TUI; non-interactive commands keep direct exec +# semantics and exit codes. +if [[ "$(uname -s)" == "Darwin" && -t 0 && -t 1 && -x /usr/bin/script ]]; then + exec /usr/bin/script -q /dev/null "$SIDECAR" cli --app-root "$APP_ROOT" "$@" +fi + +exec "$SIDECAR" cli --app-root "$APP_ROOT" "$@" +` +} + +function shellSingleQuote(value: string) { + return `'${value.replace(/'/g, `'\\''`)}'` +} + async function replaceWindowsBinary(tempPath: string, targetPath: string) { try { await unlink(targetPath)