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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 17:02:29 +08:00
parent e1c2f54f14
commit dc0307f4c1
2 changed files with 63 additions and 4 deletions

View File

@ -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"',
)

View File

@ -166,7 +166,7 @@ async function ensureDesktopCliLauncherInstalledImpl(): Promise<DesktopCliLaunch
let lastError: string | null = null
try {
await syncLauncherBinary(sourcePath, launcherPath)
await syncLauncher(sourcePath, launcherPath)
} catch (error) {
lastError = error instanceof Error ? error.message : String(error)
}
@ -236,9 +236,14 @@ function resolveBundledSidecarSourcePath(): string | null {
return launcher.command
}
async function syncLauncherBinary(sourcePath: string, targetPath: string) {
async function syncLauncher(sourcePath: string, targetPath: string) {
await mkdir(dirname(targetPath), { recursive: true })
if (process.platform !== 'win32') {
await syncUnixLauncherWrapper(sourcePath, targetPath)
return
}
if (await filesMatch(sourcePath, targetPath)) {
return
}
@ -262,6 +267,57 @@ async function syncLauncherBinary(sourcePath: string, targetPath: string) {
}
}
async function syncUnixLauncherWrapper(sourcePath: string, targetPath: string) {
const wrapper = buildUnixLauncherWrapper(sourcePath)
const existing = await readFile(targetPath, 'utf8').catch(() => 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)