From f0e9963d1cc9b89ff9d9942e2a71e3d5fec336b7 Mon Sep 17 00:00:00 2001 From: Kan Lu Date: Mon, 23 Mar 2026 19:09:31 +0800 Subject: [PATCH] fix: resolve three macOS startup issues 1. spawn-helper missing execute permission (posix_spawnp failed) node-pty's spawn-helper binary loses its execute bit after npm install on macOS. Added a postinstall script that detects and repairs the permission, fixing the "posix_spawnp failed" error that affects all macOS users on a fresh install. 2. nvm-installed claude not found under launchd/systemd When cc-web runs as a background service (launchd on macOS, systemd on Linux) PATH does not include nvm's bin directory. findClaudeCommand() now dynamically discovers all nvm node versions and probes their bin dirs before falling back to short-name lookup, so the absolute path is always returned and node-pty can spawn it correctly. 3. node-pty cannot posix_spawnp a JS script (nvm installs) The claude binary installed via nvm is a symlink to cli.js. node-pty's posix_spawnp cannot exec a bare JS file. startSession() now resolves symlinks and, when the target is a .js file, spawns `process.execPath cli.js [args]` instead of the script directly. 4. Restored sessions hide "Start Claude" overlay on page reload init() was calling hideOverlay() unconditionally after switchToTab(), racing with and overriding the startPrompt overlay that session_joined correctly shows for inactive sessions. Removed the redundant hideOverlay() call so the session_joined handler remains authoritative over overlay state. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 ++- scripts/postinstall.js | 36 ++++++++++++++++++++++++++++++++++++ src/claude-bridge.js | 37 ++++++++++++++++++++++++++++++++----- src/public/app.js | 8 +++----- 4 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 scripts/postinstall.js diff --git a/package.json b/package.json index ba3c6c6..4bf8931 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "start": "node bin/cc-web.js", "dev": "node bin/cc-web.js --dev", "test": "mocha test/*.test.js", - "release:pr": "bash scripts/release-pr.sh" + "release:pr": "bash scripts/release-pr.sh", + "postinstall": "node scripts/postinstall.js" }, "keywords": [ "claude", diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100644 index 0000000..7fefdfd --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * Post-install script: ensure node-pty's spawn-helper binaries are executable. + * + * When npm installs a package, file permissions from the tarball are not always + * preserved on macOS. node-pty's spawn-helper binary must be executable or + * node-pty will throw "posix_spawnp failed" on every pty.spawn() call. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const prebuildsDirs = [ + path.join(__dirname, '..', 'node_modules', 'node-pty', 'prebuilds'), +]; + +let fixed = 0; +for (const prebuildsDir of prebuildsDirs) { + if (!fs.existsSync(prebuildsDir)) continue; + for (const platform of fs.readdirSync(prebuildsDir)) { + const helperPath = path.join(prebuildsDir, platform, 'spawn-helper'); + if (!fs.existsSync(helperPath)) continue; + const mode = fs.statSync(helperPath).mode; + const execBit = 0o111; + if ((mode & execBit) !== execBit) { + fs.chmodSync(helperPath, mode | execBit); + console.log(`Fixed permissions for ${helperPath}`); + fixed++; + } + } +} + +if (fixed > 0) { + console.log(`postinstall: fixed execute permissions on ${fixed} spawn-helper file(s).`); +} diff --git a/src/claude-bridge.js b/src/claude-bridge.js index c7b4fee..dacf756 100644 --- a/src/claude-bridge.js +++ b/src/claude-bridge.js @@ -9,14 +9,28 @@ class ClaudeBridge { } findClaudeCommand() { + // Dynamically discover all nvm node versions that may have claude installed + const nvmBase = path.join(process.env.HOME || '/', '.nvm', 'versions', 'node'); + const nvmPaths = []; + try { + const versions = fs.readdirSync(nvmBase); + for (const v of versions.reverse()) { + nvmPaths.push(path.join(nvmBase, v, 'bin', 'claude')); + } + } catch (e) { /* nvm not installed */ } + const possibleCommands = [ + // Absolute paths first — more reliable when running under launchd/systemd + // where PATH may not include nvm or user-local bin directories + ...nvmPaths, '/home/ec2-user/.claude/local/claude', - 'claude', - 'claude-code', path.join(process.env.HOME || '/', '.claude', 'local', 'claude'), path.join(process.env.HOME || '/', '.local', 'bin', 'claude'), '/usr/local/bin/claude', - '/usr/bin/claude' + '/usr/bin/claude', + // Short names as last resort (relies on PATH being set correctly) + 'claude', + 'claude-code', ]; for (const cmd of possibleCommands) { @@ -67,8 +81,21 @@ class ClaudeBridge { console.log(`⚠️ WARNING: Skipping permissions with --dangerously-skip-permissions flag`); } - const args = dangerouslySkipPermissions ? ['--dangerously-skip-permissions'] : []; - const claudeProcess = spawn(this.claudeCommand, args, { + // When the resolved claude command is a .js script (e.g. installed via nvm), + // node-pty cannot posix_spawnp a script file directly. Detect this case and + // invoke it via the current node binary instead. + let spawnCmd = this.claudeCommand; + let baseArgs = dangerouslySkipPermissions ? ['--dangerously-skip-permissions'] : []; + try { + const resolvedCmd = fs.realpathSync(this.claudeCommand); + if (resolvedCmd.endsWith('.js')) { + baseArgs = [resolvedCmd, ...baseArgs]; + spawnCmd = process.execPath; + console.log(`Resolved to JS script, spawning via node: ${spawnCmd} ${resolvedCmd}`); + } + } catch (e) { /* use spawnCmd as-is */ } + const args = baseArgs; + const claudeProcess = spawn(spawnCmd, args, { cwd: workingDir, env: { ...process.env, diff --git a/src/public/app.js b/src/public/app.js index b35e564..5148ec3 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -103,11 +103,9 @@ class ClaudeCodeWebInterface { const firstTabId = this.sessionTabManager.tabs.keys().next().value; console.log('[Init] Switching to tab:', firstTabId); await this.sessionTabManager.switchToTab(firstTabId); - - // Hide overlay completely since we have sessions - console.log('[Init] About to hide overlay'); - this.hideOverlay(); - console.log('[Init] Overlay should be hidden now'); + // Overlay state is handled by the session_joined message handler: + // it hides the overlay when Claude is active, or shows startPrompt when not. + // Do NOT call hideOverlay() here — it would race with and override that logic. } else { console.log('[Init] No sessions found, showing folder browser'); // No sessions - hide loading overlay and show folder picker to create first session