From b9b8265b414330b1212f2c6923222c808cd8d12b Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Tue, 24 Feb 2026 14:05:49 -0500 Subject: [PATCH 01/14] fix(tmux,wizard): token-based webhook matching + surface daemon errors to user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs fixed: 1. Tmux sessions always timed out (critical): - spawnInTmux returns #{pane_pid} = shell PID; spawned node reports process.pid = different PID; webhook lookup by hostPid never matched. - Fix: generate HAPPY_SPAWN_TOKEN (random hex) before tmux spawn, pass it in tmuxEnv, store token→session in tokenToTrackedSession map. createSessionMetadata.ts emits spawnToken from process.env. onHappySessionWebhook resolves by token first, PID as fallback. - Files: run.ts, types.ts, createSessionMetadata.ts 2. New session wizard silently swallowed all daemon errors: - else branch at line 1065 threw a generic Error, discarding result.errorMessage. Users saw "make sure daemon is running" for all failures including z.ai missing Z_AI_AUTH_TOKEN. - Fix: check result.type === 'error' and throw result.errorMessage; catch block falls through to show actual message when not a known network pattern. - File: new/index.tsx 3. stopSession left orphaned tmux windows after stop: - childProcess is undefined for tmux sessions so kill fell through to process.kill(shellPid) without closing the tmux window. - Fix: check session.tmuxSessionId first, call tmux.killWindow() fire-and-forget to keep stopSession synchronous. - File: run.ts Also carries over prior branch fixes: - runAcp.ts: mode.description null→undefined for formatOptionalDetail - run.ts: strip CLAUDECODE env before spawning to prevent nested session error --- .../happy-app/sources/app/(app)/new/index.tsx | 8 +- packages/happy-cli/src/agent/acp/runAcp.ts | 2 +- packages/happy-cli/src/api/types.ts | 1 + packages/happy-cli/src/daemon/run.ts | 80 +++++++++++++++---- .../src/utils/createSessionMetadata.ts | 1 + 5 files changed, 74 insertions(+), 18 deletions(-) diff --git a/packages/happy-app/sources/app/(app)/new/index.tsx b/packages/happy-app/sources/app/(app)/new/index.tsx index bc7167d5df..d63f00b5ae 100644 --- a/packages/happy-app/sources/app/(app)/new/index.tsx +++ b/packages/happy-app/sources/app/(app)/new/index.tsx @@ -1061,17 +1061,21 @@ function NewSessionWizard() { return 'session' }, }); + } else if (result.type === 'error') { + throw new Error(result.errorMessage); } else { - throw new Error('Session spawning failed - no session ID returned.'); + throw new Error('Session spawning failed - unexpected response.'); } } catch (error) { console.error('Failed to start session', error); let errorMessage = 'Failed to start session. Make sure the daemon is running on the target machine.'; - if (error instanceof Error) { + if (error instanceof Error && error.message) { if (error.message.includes('timeout')) { errorMessage = 'Session startup timed out. The machine may be slow or the daemon may not be responding.'; } else if (error.message.includes('Socket not connected')) { errorMessage = 'Not connected to server. Check your internet connection.'; + } else { + errorMessage = error.message; } } Modal.alert(t('common.error'), errorMessage); diff --git a/packages/happy-cli/src/agent/acp/runAcp.ts b/packages/happy-cli/src/agent/acp/runAcp.ts index 678f413f1d..e7fc8eac97 100644 --- a/packages/happy-cli/src/agent/acp/runAcp.ts +++ b/packages/happy-cli/src/agent/acp/runAcp.ts @@ -745,7 +745,7 @@ export async function runAcp(opts: { if (verbose) { logAcp('muted', `Outgoing modes from ${opts.agentName} (${modes.availableModes.length}), current=${modes.currentModeId}:`); for (const mode of modes.availableModes) { - logAcp('muted', ` mode=${mode.id} name=${mode.name}${formatOptionalDetail(mode.description, 160)}`); + logAcp('muted', ` mode=${mode.id} name=${mode.name}${formatOptionalDetail(mode.description ?? undefined, 160)}`); } } session.updateMetadata((currentMetadata) => diff --git a/packages/happy-cli/src/api/types.ts b/packages/happy-cli/src/api/types.ts index 0c4c6fb7c9..6f711d5eed 100644 --- a/packages/happy-cli/src/api/types.ts +++ b/packages/happy-cli/src/api/types.ts @@ -261,6 +261,7 @@ export type Metadata = { happyToolsDir: string, startedFromDaemon?: boolean, hostPid?: number, + spawnToken?: string, startedBy?: 'daemon' | 'terminal', // Lifecycle state management lifecycleState?: 'running' | 'archiveRequested' | 'archived' | string, diff --git a/packages/happy-cli/src/daemon/run.ts b/packages/happy-cli/src/daemon/run.ts index 75889d14e9..f33257d504 100644 --- a/packages/happy-cli/src/daemon/run.ts +++ b/packages/happy-cli/src/daemon/run.ts @@ -18,6 +18,7 @@ import { writeDaemonState, DaemonLocallyPersistedState, readDaemonState, acquire import { cleanupDaemonState, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './controlClient'; import { startDaemonControlServer } from './controlServer'; import { readFileSync } from 'fs'; +import { randomBytes } from 'crypto'; import { join } from 'path'; import { projectPath } from '@/projectPath'; import { getTmuxUtilities, isTmuxAvailable, parseTmuxSessionIdentifier, formatTmuxSessionIdentifier } from '@/utils/tmux'; @@ -169,6 +170,9 @@ export async function startDaemon(): Promise { // Session spawning awaiter system const pidToAwaiter = new Map void>(); + // Token-based awaiter system for tmux sessions (pane shell PID ≠ node process PID) + const tokenToTrackedSession = new Map(); + const tokenToAwaiter = new Map void>(); // Helper functions const getCurrentChildren = () => Array.from(pidToTrackedSession.values()); @@ -178,15 +182,37 @@ export async function startDaemon(): Promise { logger.debugLargeJson(`[DAEMON RUN] Session reported`, sessionMetadata); const pid = sessionMetadata.hostPid; + const spawnToken = sessionMetadata.spawnToken; + + logger.debug(`[DAEMON RUN] Session webhook: ${sessionId}, PID: ${pid}, token: ${spawnToken ?? 'none'}, started by: ${sessionMetadata.startedBy || 'unknown'}`); + logger.debug(`[DAEMON RUN] Current tracked sessions before webhook: ${Array.from(pidToTrackedSession.keys()).join(', ')}`); + + // Token-based match: tmux-spawned sessions pass HAPPY_SPAWN_TOKEN via env var. + // The node process PID differs from the tmux pane shell PID stored in pidToTrackedSession, + // so we resolve by token when present. + if (spawnToken) { + const tokenSession = tokenToTrackedSession.get(spawnToken); + if (tokenSession && tokenSession.startedBy === 'daemon') { + tokenSession.happySessionId = sessionId; + tokenSession.happySessionMetadataFromLocalWebhook = sessionMetadata; + logger.debug(`[DAEMON RUN] Updated daemon-spawned tmux session ${sessionId} via token`); + + const awaiter = tokenToAwaiter.get(spawnToken); + if (awaiter) { + tokenToAwaiter.delete(spawnToken); + awaiter(tokenSession); + logger.debug(`[DAEMON RUN] Resolved session awaiter for token ${spawnToken}`); + } + return; + } + } + if (!pid) { logger.debug(`[DAEMON RUN] Session webhook missing hostPid for sessionId: ${sessionId}`); return; } - logger.debug(`[DAEMON RUN] Session webhook: ${sessionId}, PID: ${pid}, started by: ${sessionMetadata.startedBy || 'unknown'}`); - logger.debug(`[DAEMON RUN] Current tracked sessions before webhook: ${Array.from(pidToTrackedSession.keys()).join(', ')}`); - - // Check if we already have this PID (daemon-spawned) + // Check if we already have this PID (daemon-spawned, non-tmux) const existingSession = pidToTrackedSession.get(pid); if (existingSession && existingSession.startedBy === 'daemon') { @@ -399,8 +425,9 @@ export async function startDaemon(): Promise { const tmuxEnv: Record = {}; // Add all daemon environment variables (filtering out undefined) + // Skip CLAUDECODE to prevent nested session detection in spawned Claude Code processes for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) { + if (value !== undefined && key !== 'CLAUDECODE') { tmuxEnv[key] = value; } } @@ -408,6 +435,13 @@ export async function startDaemon(): Promise { // Add extra environment variables (these should already be filtered) Object.assign(tmuxEnv, extraEnv); + // Generate a unique token to match the webhook back to this spawn request. + // The tmux pane PID (#{pane_pid}) is the shell PID, which differs from the + // node process PID reported by the spawned process via hostPid. The token + // provides a reliable match that is independent of PID relationships. + const spawnToken = randomBytes(16).toString('hex'); + tmuxEnv['HAPPY_SPAWN_TOKEN'] = spawnToken; + const tmuxResult = await tmux.spawnInTmux([fullCommand], { sessionName: tmuxSessionName, windowName: windowName, @@ -425,7 +459,7 @@ export async function startDaemon(): Promise { // Create a tracked session for tmux windows - now we have the real PID! const trackedSession: TrackedSession = { startedBy: 'daemon', - pid: tmuxResult.pid, // Real PID from tmux -P flag + pid: tmuxResult.pid, // Shell PID from tmux #{pane_pid} tmuxSessionId: tmuxResult.sessionId, directoryCreated, message: directoryCreated @@ -433,27 +467,31 @@ export async function startDaemon(): Promise { : `Spawned new session in tmux session '${tmuxSessionName}'. Use 'tmux attach -t ${tmuxSessionName}' to view the session.` }; - // Add to tracking map so webhook can find it later + // Add to both PID-keyed map (for health checks) and token-keyed map (for webhook matching) pidToTrackedSession.set(tmuxResult.pid, trackedSession); + tokenToTrackedSession.set(spawnToken, trackedSession); // Wait for webhook to populate session with happySessionId (exact same as regular flow) - logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${tmuxResult.pid} (tmux)`); + logger.debug(`[DAEMON RUN] Waiting for session webhook via token for tmux PID ${tmuxResult.pid}`); return new Promise((resolve) => { // Set timeout for webhook (same as regular flow) const timeout = setTimeout(() => { - pidToAwaiter.delete(tmuxResult.pid!); - logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${tmuxResult.pid} (tmux)`); + tokenToAwaiter.delete(spawnToken); + tokenToTrackedSession.delete(spawnToken); + logger.debug(`[DAEMON RUN] Session webhook timeout for token ${spawnToken} (tmux PID ${tmuxResult.pid})`); resolve({ type: 'error', errorMessage: `Session webhook timeout for PID ${tmuxResult.pid} (tmux)` }); }, 15_000); // Same timeout as regular sessions - // Register awaiter for tmux session (exact same as regular flow) - pidToAwaiter.set(tmuxResult.pid!, (completedSession) => { + // Register token-keyed awaiter: the spawned node process reports HAPPY_SPAWN_TOKEN + // back in its metadata, so we match by token rather than PID. + tokenToAwaiter.set(spawnToken, (completedSession) => { clearTimeout(timeout); - logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook (tmux)`); + tokenToTrackedSession.delete(spawnToken); + logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned via token (tmux)`); resolve({ type: 'success', sessionId: completedSession.happySessionId! @@ -497,12 +535,17 @@ export async function startDaemon(): Promise { // TODO: In future, sessionId could be used with --resume to continue existing sessions // For now, we ignore it - each spawn creates a new session + // Build env without CLAUDECODE to prevent nested session detection + // The daemon may have been started from within a Claude Code session, + // and CLAUDECODE env var would cause spawned Claude Code processes to + // refuse to start with "cannot be launched inside another Claude Code session" + const { CLAUDECODE: _removed, ...cleanProcessEnv } = process.env; const happyProcess = spawnHappyCLI(args, { cwd: directory, detached: true, // Sessions stay alive when daemon stops stdio: ['ignore', 'pipe', 'pipe'], // Capture stdout/stderr for debugging env: { - ...process.env, + ...cleanProcessEnv, ...extraEnv } }); @@ -603,7 +646,14 @@ export async function startDaemon(): Promise { if (session.happySessionId === sessionId || (sessionId.startsWith('PID-') && pid === parseInt(sessionId.replace('PID-', '')))) { - if (session.startedBy === 'daemon' && session.childProcess) { + if (session.tmuxSessionId) { + // Tmux-spawned session: kill the window (fire-and-forget to keep stopSession synchronous) + const tmux = getTmuxUtilities(); + tmux.killWindow(session.tmuxSessionId).catch((error) => { + logger.debug(`[DAEMON RUN] Failed to kill tmux window ${session.tmuxSessionId}:`, error); + }); + logger.debug(`[DAEMON RUN] Sent kill to tmux window ${session.tmuxSessionId}`); + } else if (session.startedBy === 'daemon' && session.childProcess) { try { session.childProcess.kill('SIGTERM'); logger.debug(`[DAEMON RUN] Sent SIGTERM to daemon-spawned session ${sessionId}`); diff --git a/packages/happy-cli/src/utils/createSessionMetadata.ts b/packages/happy-cli/src/utils/createSessionMetadata.ts index b4e05a3d3e..f2a1a59d30 100644 --- a/packages/happy-cli/src/utils/createSessionMetadata.ts +++ b/packages/happy-cli/src/utils/createSessionMetadata.ts @@ -84,6 +84,7 @@ export function createSessionMetadata(opts: CreateSessionMetadataOptions): Sessi happyToolsDir: resolve(projectPath(), 'tools', 'unpacked'), startedFromDaemon: opts.startedBy === 'daemon', hostPid: process.pid, + spawnToken: process.env.HAPPY_SPAWN_TOKEN || undefined, startedBy: opts.startedBy || 'terminal', lifecycleState: 'running', lifecycleStateSince: Date.now(), From f41c910df1f8ad753989b24883b589052ea94754 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Tue, 24 Feb 2026 14:47:20 -0500 Subject: [PATCH 02/14] fix(daemon): hardcode tmux session to 'main' for testing TEMPORARY HACK: Always spawn new sessions into the 'main' tmux session instead of using the profile's TMUX_SESSION_NAME setting. This makes tmux integration testing straightforward. TODO: Remove this hardcoding and use profile.tmuxConfig.sessionName after testing is complete. --- packages/happy-cli/src/daemon/run.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/happy-cli/src/daemon/run.ts b/packages/happy-cli/src/daemon/run.ts index f33257d504..e4ac55f320 100644 --- a/packages/happy-cli/src/daemon/run.ts +++ b/packages/happy-cli/src/daemon/run.ts @@ -392,7 +392,9 @@ export async function startDaemon(): Promise { // Get tmux session name from environment variables (now set by profile system) // Empty string means "use current/most recent session" (tmux default behavior) - let tmuxSessionName: string | undefined = extraEnv.TMUX_SESSION_NAME; + // TEMPORARY HARDCODED HACK: Always use "main" session for testing + // TODO: Remove this hardcoding and use the profile's TMUX_SESSION_NAME + let tmuxSessionName: string | undefined = 'main'; // extraEnv.TMUX_SESSION_NAME; // If tmux is not available or session name is explicitly undefined, fall back to regular spawning // Note: Empty string is valid (means use current/most recent tmux session) From 1dd675e34dc531490da8bf1bf1e173880fab168b Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Tue, 24 Feb 2026 15:05:09 -0500 Subject: [PATCH 03/14] fix(tmux,wizard): token-based webhook matching + surface daemon errors to user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs fixed: 1. Tmux sessions always timed out (critical): - spawnInTmux returns #{pane_pid} = shell PID; spawned node reports process.pid = different PID; webhook lookup by hostPid never matched. - Fix: generate HAPPY_SPAWN_TOKEN (random hex) before tmux spawn, pass it in tmuxEnv, store token→session in tokenToTrackedSession map. createSessionMetadata.ts emits spawnToken from process.env. onHappySessionWebhook resolves by token first, PID as fallback. - Files: run.ts, types.ts, createSessionMetadata.ts 2. New session wizard silently swallowed all daemon errors: - else branch at line 1065 threw a generic Error, discarding result.errorMessage. Users saw "make sure daemon is running" for all failures including z.ai missing Z_AI_AUTH_TOKEN. - Fix: check result.type === 'error' and throw result.errorMessage; catch block falls through to show actual message when not a known network pattern. - File: new/index.tsx 3. stopSession didn't kill tmux windows (orphaned windows): - childProcess undefined for tmux sessions so kill fell through to process.kill(shellPid) without closing the tmux window. - Fix: check session.tmuxSessionId first, call tmux.killWindow() fire-and-forget to keep stopSession synchronous. - File: run.ts Also fixed tmux command syntax: - move -P -F flags before other arguments in new-window command - File: tmux.ts --- packages/happy-cli/src/daemon/run.ts | 2 +- packages/happy-cli/src/utils/tmux.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/happy-cli/src/daemon/run.ts b/packages/happy-cli/src/daemon/run.ts index e4ac55f320..f9fbd8bc40 100644 --- a/packages/happy-cli/src/daemon/run.ts +++ b/packages/happy-cli/src/daemon/run.ts @@ -458,7 +458,7 @@ export async function startDaemon(): Promise { throw new Error('Tmux window created but no PID returned'); } - // Create a tracked session for tmux windows - now we have the real PID! + // Create a tracked session for tmux windows const trackedSession: TrackedSession = { startedBy: 'daemon', pid: tmuxResult.pid, // Shell PID from tmux #{pane_pid} diff --git a/packages/happy-cli/src/utils/tmux.ts b/packages/happy-cli/src/utils/tmux.ts index f095835866..a62b54741a 100644 --- a/packages/happy-cli/src/utils/tmux.ts +++ b/packages/happy-cli/src/utils/tmux.ts @@ -786,7 +786,14 @@ export class TmuxUtilities { // Create new window in session with command and environment variables // IMPORTANT: Don't manually add -t here - executeTmuxCommand handles it via parameters - const createWindowArgs = ['new-window', '-n', windowName]; + const createWindowArgs = ['new-window']; + + // Add -P flag to print the pane info immediately (must come before other options) + createWindowArgs.push('-P'); + createWindowArgs.push('-F', '#{pane_pid}'); + + // Add window name + createWindowArgs.push('-n', windowName); // Add working directory if specified if (options.cwd) { @@ -827,10 +834,6 @@ export class TmuxUtilities { // Add the command to run in the window (runs immediately when window is created) createWindowArgs.push(fullCommand); - // Add -P flag to print the pane PID immediately - createWindowArgs.push('-P'); - createWindowArgs.push('-F', '#{pane_pid}'); - // Create window with command and get PID immediately const createResult = await this.executeTmuxCommand(createWindowArgs, sessionName); From 3eb3f3603eb847497a8886e793410ad2d567eea5 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Tue, 24 Feb 2026 17:12:57 -0500 Subject: [PATCH 04/14] fix(tmux): reliable session spawning via shell-ready polling and env fix Three root causes prevented daemon-spawned tmux sessions from working: 1. Env var quoting corruption: spawn() with shell:false passes args directly to tmux, so wrapping -e values in double quotes made them literal (HOME="/Users/foo" instead of HOME=/Users/foo). This broke credential file resolution, causing the spawned process to prompt for interactive authentication instead of using existing keys. 2. Shell redirect failure: zsh noclobber prevents >> on non-existent files. Changed to > since these are always fresh temp files. 3. Missing spawnToken in Claude path: runClaude.ts built its own Metadata object without reading HAPPY_SPAWN_TOKEN from env, so the daemon's token-based webhook matching never resolved. Additional reliability improvements: - Replace fixed 200ms delay with waitForShellReady() that polls capture-pane for a prompt character (up to 5s) - Split send-keys into literal text (-l flag) + separate Enter key to prevent tmux from interpreting >> as key names - Add debug logging for send-keys results and pane verification --- packages/happy-cli/src/claude/runClaude.ts | 1 + packages/happy-cli/src/daemon/run.ts | 44 +++++++++--- packages/happy-cli/src/utils/tmux.ts | 83 +++++++++++++++++----- 3 files changed, 104 insertions(+), 24 deletions(-) diff --git a/packages/happy-cli/src/claude/runClaude.ts b/packages/happy-cli/src/claude/runClaude.ts index b626c62b61..1d648d9a14 100644 --- a/packages/happy-cli/src/claude/runClaude.ts +++ b/packages/happy-cli/src/claude/runClaude.ts @@ -108,6 +108,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions happyToolsDir: resolve(projectPath(), 'tools', 'unpacked'), startedFromDaemon: options.startedBy === 'daemon', hostPid: process.pid, + spawnToken: process.env.HAPPY_SPAWN_TOKEN || undefined, startedBy: options.startedBy || 'terminal', // Initialize lifecycle state lifecycleState: 'running', diff --git a/packages/happy-cli/src/daemon/run.ts b/packages/happy-cli/src/daemon/run.ts index f9fbd8bc40..4ed5ad9bb5 100644 --- a/packages/happy-cli/src/daemon/run.ts +++ b/packages/happy-cli/src/daemon/run.ts @@ -412,11 +412,24 @@ export async function startDaemon(): Promise { const tmux = getTmuxUtilities(tmuxSessionName); - // Construct command for the CLI - const cliPath = join(projectPath(), 'dist', 'index.mjs'); // Determine agent command - support claude, codex, and gemini const agent = options.agent === 'gemini' ? 'gemini' : (options.agent === 'codex' ? 'codex' : 'claude'); - const fullCommand = `node --no-warnings --no-deprecation ${cliPath} ${agent} --happy-starting-mode remote --started-by daemon`; + + // TODO TEMPORARY: Log spawned process output to temp file for debugging tmux sessions + // Remove this after we understand why spawned processes fail in tmux + const tmpLogFile = `/tmp/happy-spawn-tmux-${Date.now()}.log`; + + // CRITICAL: Use absolute path to happy binary instead of relying on PATH + // The daemon knows exactly where happy is installed, so we don't need to resolve via shell PATH + // This is more reliable than 'happy' command which requires PATH to be set + const happyBinPath = join(projectPath(), 'bin', 'happy.mjs'); + + // CRITICAL: The tmux window we created is already an interactive login shell + // (no command passed to new-window), so it already has all shell config loaded + // (Prezto modules, .zshrc, aliases, etc.) - just like pressing F2 in byobu + // We just send the command directly - don't wrap in another shell! + // Add --dangerously-skip-permissions to make spawned process non-interactive for auth + const fullCommand = `node ${happyBinPath} ${agent} --happy-starting-mode remote --started-by daemon --dangerously-skip-permissions > ${tmpLogFile} 2>&1`; // Spawn in tmux with environment variables // IMPORTANT: Pass complete environment (process.env + extraEnv) because: @@ -451,7 +464,8 @@ export async function startDaemon(): Promise { }, tmuxEnv); // Pass complete environment for tmux session if (tmuxResult.success) { - logger.debug(`[DAEMON RUN] Successfully spawned in tmux session: ${tmuxResult.sessionId}, PID: ${tmuxResult.pid}`); + logger.debug(`[DAEMON RUN] Successfully spawned in tmux: session=${tmuxResult.sessionId}, window=${windowName}, PID: ${tmuxResult.pid}`); + logger.debug(`[DAEMON RUN] Tmux spawned process output at: ${tmpLogFile}`); // TODO TEMPORARY: Remove after debugging // Validate we got a PID from tmux if (!tmuxResult.pid) { @@ -474,14 +488,28 @@ export async function startDaemon(): Promise { tokenToTrackedSession.set(spawnToken, trackedSession); // Wait for webhook to populate session with happySessionId (exact same as regular flow) - logger.debug(`[DAEMON RUN] Waiting for session webhook via token for tmux PID ${tmuxResult.pid}`); + logger.debug(`[DAEMON RUN] Waiting for session webhook via token: session=${tmuxResult.sessionId}, window=${windowName}`); return new Promise((resolve) => { // Set timeout for webhook (same as regular flow) - const timeout = setTimeout(() => { + const timeout = setTimeout(async () => { tokenToAwaiter.delete(spawnToken); tokenToTrackedSession.delete(spawnToken); - logger.debug(`[DAEMON RUN] Session webhook timeout for token ${spawnToken} (tmux PID ${tmuxResult.pid})`); + logger.debug(`[DAEMON RUN] Session webhook timeout: session=${tmuxResult.sessionId}, window=${windowName}, token=${spawnToken}`); + + // TODO TEMPORARY: Read and log spawned process output for debugging + // Remove this debug logging after understanding why spawned process fails + try { + const logContent = await fs.readFile(tmpLogFile, 'utf-8'); + if (logContent.trim()) { + logger.debug(`[DAEMON RUN] Spawned process output from ${tmpLogFile}:\n${logContent}`); + } else { + logger.debug(`[DAEMON RUN] Spawned process output file exists but is empty: ${tmpLogFile}`); + } + } catch (err) { + logger.debug(`[DAEMON RUN] Could not read spawned process output: ${err instanceof Error ? err.message : String(err)}`); + } + resolve({ type: 'error', errorMessage: `Session webhook timeout for PID ${tmuxResult.pid} (tmux)` @@ -493,7 +521,7 @@ export async function startDaemon(): Promise { tokenToAwaiter.set(spawnToken, (completedSession) => { clearTimeout(timeout); tokenToTrackedSession.delete(spawnToken); - logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned via token (tmux)`); + logger.debug(`[DAEMON RUN] Session webhook resolved: session=${tmuxResult.sessionId}, window=${windowName}, sessionId=${completedSession.happySessionId}`); resolve({ type: 'success', sessionId: completedSession.happySessionId! diff --git a/packages/happy-cli/src/utils/tmux.ts b/packages/happy-cli/src/utils/tmux.ts index a62b54741a..408e5870c9 100644 --- a/packages/happy-cli/src/utils/tmux.ts +++ b/packages/happy-cli/src/utils/tmux.ts @@ -459,6 +459,34 @@ export class TmuxUtilities { } } + /** + * Poll pane content until a shell prompt character appears, indicating + * the shell has finished initialization and is ready for input. + */ + private async waitForShellReady(session: string, window: string, timeoutMs: number): Promise { + const pollInterval = 100; + const maxAttempts = Math.ceil(timeoutMs / pollInterval); + // Common prompt-ending characters across shells + const promptPattern = /[%$#>±❯→]\s*$/m; + + for (let i = 0; i < maxAttempts; i++) { + const result = await this.executeTmuxCommand( + ['capture-pane', '-p'], + session, + window + ); + if (result && result.returncode === 0) { + const content = result.stdout.trim(); + if (content.length > 0 && promptPattern.test(content)) { + logger.debug(`[TMUX] Shell ready after ${i * pollInterval}ms`); + return true; + } + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + return false; + } + /** * Execute command with subprocess and return result */ @@ -784,8 +812,9 @@ export class TmuxUtilities { // Build command to execute in the new window const fullCommand = args.join(' '); - // Create new window in session with command and environment variables - // IMPORTANT: Don't manually add -t here - executeTmuxCommand handles it via parameters + // Create new window in session with environment variables + // IMPORTANT: Create window without command, then use send-keys to execute + // This allows proper shell initialization (Prezto, .zshrc, aliases, etc.) const createWindowArgs = ['new-window']; // Add -P flag to print the pane info immediately (must come before other options) @@ -818,35 +847,57 @@ export class TmuxUtilities { continue; } - // Escape value for shell safety - // Must escape: backslashes, double quotes, dollar signs, backticks - const escapedValue = value - .replace(/\\/g, '\\\\') // Backslash first! - .replace(/"/g, '\\"') // Double quotes - .replace(/\$/g, '\\$') // Dollar signs - .replace(/`/g, '\\`'); // Backticks - - createWindowArgs.push('-e', `${key}="${escapedValue}"`); + // No shell escaping needed: spawn() with shell:false passes args + // directly to tmux, which parses -e as NAME=VALUE without a shell. + createWindowArgs.push('-e', `${key}=${value}`); } logger.debug(`[TMUX] Setting ${Object.keys(env).length} environment variables in tmux window`); } - // Add the command to run in the window (runs immediately when window is created) - createWindowArgs.push(fullCommand); - - // Create window with command and get PID immediately + // Create window WITHOUT command (lets it initialize with full shell config) const createResult = await this.executeTmuxCommand(createWindowArgs, sessionName); if (!createResult || createResult.returncode !== 0) { throw new Error(`Failed to create tmux window: ${createResult?.stderr}`); } - // Extract the PID from the output + // Extract the PID from the output (from new-window with -P -F "#{pane_pid}") const panePid = parseInt(createResult.stdout.trim()); if (isNaN(panePid)) { throw new Error(`Failed to extract PID from tmux output: ${createResult.stdout}`); } + // Wait for the shell to fully initialize before sending the command. + // A fixed delay is unreliable — instead poll the pane content for a prompt. + // Shells display a prompt character (%, $, #, >, ±) when ready for input. + const shellReady = await this.waitForShellReady(sessionName, windowName, 5000); + if (!shellReady) { + logger.warn(`[TMUX] Shell did not show a prompt within timeout, sending command anyway`); + } + + // Send the command text and Enter key separately for reliability. + // Using -l (literal) for the command text prevents tmux from interpreting + // special characters in the command as key names. + const sendTextArgs = ['send-keys', '-l', fullCommand]; + const textResult = await this.executeTmuxCommand(sendTextArgs, sessionName, windowName); + logger.debug(`[TMUX] send-keys -l result: rc=${textResult?.returncode}, stderr=${textResult?.stderr}`); + + // Small delay between text and Enter to ensure text is fully received + await new Promise(resolve => setTimeout(resolve, 50)); + + const sendEnterArgs = ['send-keys', 'Enter']; + const sendResult = await this.executeTmuxCommand(sendEnterArgs, sessionName, windowName); + logger.debug(`[TMUX] send-keys Enter result: rc=${sendResult?.returncode}, stderr=${sendResult?.stderr}`); + + if (!sendResult || sendResult.returncode !== 0) { + logger.warn(`[TMUX] Failed to send Enter to window: ${sendResult?.stderr}`); + } + + // Verify command was sent by capturing pane content + await new Promise(resolve => setTimeout(resolve, 200)); + const verifyResult = await this.executeTmuxCommand(['capture-pane', '-p'], sessionName, windowName); + logger.debug(`[TMUX] Pane content after send-keys:\n${verifyResult?.stdout}`); + logger.debug(`[TMUX] Spawned command in tmux session ${sessionName}, window ${windowName}, PID ${panePid}`); // Return tmux session info and PID From 1e3d850283cdcf8535c4eb5540488c7b896e4413 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Tue, 24 Feb 2026 18:43:06 -0500 Subject: [PATCH 05/14] refactor(tmux): remove debug scaffolding from tmux spawn flow Remove temporary debugging code that was used during development: - Remove temp log file redirect (output now visible in tmux pane) - Remove timeout handler file reader (no temp file to read) - Remove send-keys result logging and pane verification capture - Remove unnecessary 50ms/200ms delays between send-keys calls - Revert timeout callback from async to sync (no longer reads files) --- packages/happy-cli/src/daemon/run.ts | 33 +++------------------------- packages/happy-cli/src/utils/tmux.ts | 22 ++++--------------- 2 files changed, 7 insertions(+), 48 deletions(-) diff --git a/packages/happy-cli/src/daemon/run.ts b/packages/happy-cli/src/daemon/run.ts index 4ed5ad9bb5..a9bbb6bfd6 100644 --- a/packages/happy-cli/src/daemon/run.ts +++ b/packages/happy-cli/src/daemon/run.ts @@ -415,21 +415,9 @@ export async function startDaemon(): Promise { // Determine agent command - support claude, codex, and gemini const agent = options.agent === 'gemini' ? 'gemini' : (options.agent === 'codex' ? 'codex' : 'claude'); - // TODO TEMPORARY: Log spawned process output to temp file for debugging tmux sessions - // Remove this after we understand why spawned processes fail in tmux - const tmpLogFile = `/tmp/happy-spawn-tmux-${Date.now()}.log`; - - // CRITICAL: Use absolute path to happy binary instead of relying on PATH - // The daemon knows exactly where happy is installed, so we don't need to resolve via shell PATH - // This is more reliable than 'happy' command which requires PATH to be set + // Use absolute path to happy binary — reliable regardless of shell PATH const happyBinPath = join(projectPath(), 'bin', 'happy.mjs'); - - // CRITICAL: The tmux window we created is already an interactive login shell - // (no command passed to new-window), so it already has all shell config loaded - // (Prezto modules, .zshrc, aliases, etc.) - just like pressing F2 in byobu - // We just send the command directly - don't wrap in another shell! - // Add --dangerously-skip-permissions to make spawned process non-interactive for auth - const fullCommand = `node ${happyBinPath} ${agent} --happy-starting-mode remote --started-by daemon --dangerously-skip-permissions > ${tmpLogFile} 2>&1`; + const fullCommand = `node ${happyBinPath} ${agent} --happy-starting-mode remote --started-by daemon --dangerously-skip-permissions`; // Spawn in tmux with environment variables // IMPORTANT: Pass complete environment (process.env + extraEnv) because: @@ -465,7 +453,6 @@ export async function startDaemon(): Promise { if (tmuxResult.success) { logger.debug(`[DAEMON RUN] Successfully spawned in tmux: session=${tmuxResult.sessionId}, window=${windowName}, PID: ${tmuxResult.pid}`); - logger.debug(`[DAEMON RUN] Tmux spawned process output at: ${tmpLogFile}`); // TODO TEMPORARY: Remove after debugging // Validate we got a PID from tmux if (!tmuxResult.pid) { @@ -492,24 +479,10 @@ export async function startDaemon(): Promise { return new Promise((resolve) => { // Set timeout for webhook (same as regular flow) - const timeout = setTimeout(async () => { + const timeout = setTimeout(() => { tokenToAwaiter.delete(spawnToken); tokenToTrackedSession.delete(spawnToken); logger.debug(`[DAEMON RUN] Session webhook timeout: session=${tmuxResult.sessionId}, window=${windowName}, token=${spawnToken}`); - - // TODO TEMPORARY: Read and log spawned process output for debugging - // Remove this debug logging after understanding why spawned process fails - try { - const logContent = await fs.readFile(tmpLogFile, 'utf-8'); - if (logContent.trim()) { - logger.debug(`[DAEMON RUN] Spawned process output from ${tmpLogFile}:\n${logContent}`); - } else { - logger.debug(`[DAEMON RUN] Spawned process output file exists but is empty: ${tmpLogFile}`); - } - } catch (err) { - logger.debug(`[DAEMON RUN] Could not read spawned process output: ${err instanceof Error ? err.message : String(err)}`); - } - resolve({ type: 'error', errorMessage: `Session webhook timeout for PID ${tmuxResult.pid} (tmux)` diff --git a/packages/happy-cli/src/utils/tmux.ts b/packages/happy-cli/src/utils/tmux.ts index 408e5870c9..4915138a24 100644 --- a/packages/happy-cli/src/utils/tmux.ts +++ b/packages/happy-cli/src/utils/tmux.ts @@ -875,29 +875,15 @@ export class TmuxUtilities { logger.warn(`[TMUX] Shell did not show a prompt within timeout, sending command anyway`); } - // Send the command text and Enter key separately for reliability. - // Using -l (literal) for the command text prevents tmux from interpreting - // special characters in the command as key names. - const sendTextArgs = ['send-keys', '-l', fullCommand]; - const textResult = await this.executeTmuxCommand(sendTextArgs, sessionName, windowName); - logger.debug(`[TMUX] send-keys -l result: rc=${textResult?.returncode}, stderr=${textResult?.stderr}`); - - // Small delay between text and Enter to ensure text is fully received - await new Promise(resolve => setTimeout(resolve, 50)); - - const sendEnterArgs = ['send-keys', 'Enter']; - const sendResult = await this.executeTmuxCommand(sendEnterArgs, sessionName, windowName); - logger.debug(`[TMUX] send-keys Enter result: rc=${sendResult?.returncode}, stderr=${sendResult?.stderr}`); + // Send command text with -l (literal) to prevent tmux from interpreting + // shell operators like >> as key names, then send Enter separately. + await this.executeTmuxCommand(['send-keys', '-l', fullCommand], sessionName, windowName); + const sendResult = await this.executeTmuxCommand(['send-keys', 'Enter'], sessionName, windowName); if (!sendResult || sendResult.returncode !== 0) { logger.warn(`[TMUX] Failed to send Enter to window: ${sendResult?.stderr}`); } - // Verify command was sent by capturing pane content - await new Promise(resolve => setTimeout(resolve, 200)); - const verifyResult = await this.executeTmuxCommand(['capture-pane', '-p'], sessionName, windowName); - logger.debug(`[TMUX] Pane content after send-keys:\n${verifyResult?.stdout}`); - logger.debug(`[TMUX] Spawned command in tmux session ${sessionName}, window ${windowName}, PID ${panePid}`); // Return tmux session info and PID From 3eb2b974386630b245e7f56b7fecf81e865ec71b Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Tue, 24 Feb 2026 19:35:11 -0500 Subject: [PATCH 06/14] fix(tmux): use pane_current_command for prompt-agnostic shell-ready detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace capture-pane regex matching against prompt characters (%$#>±❯→) with tmux's #{pane_current_command} format variable. Poll until it reports a known shell name (zsh, bash, fish, etc.), which is deterministic and works with any custom prompt theme (Prezto, Oh My Zsh, Starship, Powerlevel10k). --- packages/happy-cli/src/utils/tmux.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/happy-cli/src/utils/tmux.ts b/packages/happy-cli/src/utils/tmux.ts index 4915138a24..e7a3844484 100644 --- a/packages/happy-cli/src/utils/tmux.ts +++ b/packages/happy-cli/src/utils/tmux.ts @@ -460,25 +460,25 @@ export class TmuxUtilities { } /** - * Poll pane content until a shell prompt character appears, indicating - * the shell has finished initialization and is ready for input. + * Poll #{pane_current_command} until it reports a shell process, + * indicating the shell has finished initialization and is idle at a prompt. + * This is prompt-theme-agnostic — works with any custom prompt. */ private async waitForShellReady(session: string, window: string, timeoutMs: number): Promise { const pollInterval = 100; const maxAttempts = Math.ceil(timeoutMs / pollInterval); - // Common prompt-ending characters across shells - const promptPattern = /[%$#>±❯→]\s*$/m; + const knownShells = new Set(['zsh', 'bash', 'fish', 'sh', 'dash', 'ksh', 'tcsh', 'csh']); for (let i = 0; i < maxAttempts; i++) { const result = await this.executeTmuxCommand( - ['capture-pane', '-p'], + ['display-message', '-p', '#{pane_current_command}'], session, window ); if (result && result.returncode === 0) { - const content = result.stdout.trim(); - if (content.length > 0 && promptPattern.test(content)) { - logger.debug(`[TMUX] Shell ready after ${i * pollInterval}ms`); + const command = result.stdout.trim(); + if (knownShells.has(command)) { + logger.debug(`[TMUX] Shell ready after ${i * pollInterval}ms (${command})`); return true; } } From 4cf16edcccdbf2c597fc71dfb1f2ce0cc7afaaa4 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Tue, 24 Feb 2026 19:59:18 -0500 Subject: [PATCH 07/14] fix(tmux,daemon): cross-system compatibility and killWindow correctness - Use process.execPath instead of bare `node` in tmux send-keys command to avoid PATH issues on systems using NVM, asdf, or other version managers where node isn't available until .zshrc finishes sourcing - Fix killWindow to use executeTmuxCommand's window parameter for proper `-t session:window` targeting instead of passing window name as a positional argument (which tmux ignores) - Pass parsed session name to getTmuxUtilities in stopSession to ensure the correct tmux session is targeted - Set CLAUDECODE='' in tmux env to prevent spawned Claude Code from refusing to start with "cannot be launched inside another session" (CLAUDECODE is set by Claude Code to detect nesting; the tmux server may inherit it even if we filter it from the -e flags) - Add nushell (nu), elvish, pwsh to knownShells for waitForShellReady --- packages/happy-cli/src/daemon/run.ts | 14 +++++++++++--- packages/happy-cli/src/utils/tmux.ts | 8 +++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/happy-cli/src/daemon/run.ts b/packages/happy-cli/src/daemon/run.ts index a9bbb6bfd6..26e01ac6ea 100644 --- a/packages/happy-cli/src/daemon/run.ts +++ b/packages/happy-cli/src/daemon/run.ts @@ -415,9 +415,10 @@ export async function startDaemon(): Promise { // Determine agent command - support claude, codex, and gemini const agent = options.agent === 'gemini' ? 'gemini' : (options.agent === 'codex' ? 'codex' : 'claude'); - // Use absolute path to happy binary — reliable regardless of shell PATH + // Use absolute paths for both node and the happy binary — reliable regardless + // of shell PATH (NVM, asdf, etc. may not be initialized when send-keys fires) const happyBinPath = join(projectPath(), 'bin', 'happy.mjs'); - const fullCommand = `node ${happyBinPath} ${agent} --happy-starting-mode remote --started-by daemon --dangerously-skip-permissions`; + const fullCommand = `${process.execPath} ${happyBinPath} ${agent} --happy-starting-mode remote --started-by daemon --dangerously-skip-permissions`; // Spawn in tmux with environment variables // IMPORTANT: Pass complete environment (process.env + extraEnv) because: @@ -438,6 +439,12 @@ export async function startDaemon(): Promise { // Add extra environment variables (these should already be filtered) Object.assign(tmuxEnv, extraEnv); + // Explicitly unset CLAUDECODE even if the tmux server inherited it. + // The filter above only skips adding it from process.env, but the tmux + // server's global environment may still have it. `-e CLAUDECODE=` overrides + // with an empty string, preventing nested session detection. + tmuxEnv['CLAUDECODE'] = ''; + // Generate a unique token to match the webhook back to this spawn request. // The tmux pane PID (#{pane_pid}) is the shell PID, which differs from the // node process PID reported by the spawned process via hostPid. The token @@ -651,7 +658,8 @@ export async function startDaemon(): Promise { if (session.tmuxSessionId) { // Tmux-spawned session: kill the window (fire-and-forget to keep stopSession synchronous) - const tmux = getTmuxUtilities(); + const parsed = parseTmuxSessionIdentifier(session.tmuxSessionId); + const tmux = getTmuxUtilities(parsed.session); tmux.killWindow(session.tmuxSessionId).catch((error) => { logger.debug(`[DAEMON RUN] Failed to kill tmux window ${session.tmuxSessionId}:`, error); }); diff --git a/packages/happy-cli/src/utils/tmux.ts b/packages/happy-cli/src/utils/tmux.ts index e7a3844484..ec39d312de 100644 --- a/packages/happy-cli/src/utils/tmux.ts +++ b/packages/happy-cli/src/utils/tmux.ts @@ -467,7 +467,7 @@ export class TmuxUtilities { private async waitForShellReady(session: string, window: string, timeoutMs: number): Promise { const pollInterval = 100; const maxAttempts = Math.ceil(timeoutMs / pollInterval); - const knownShells = new Set(['zsh', 'bash', 'fish', 'sh', 'dash', 'ksh', 'tcsh', 'csh']); + const knownShells = new Set(['zsh', 'bash', 'fish', 'sh', 'dash', 'ksh', 'tcsh', 'csh', 'nu', 'elvish', 'pwsh']); for (let i = 0; i < maxAttempts; i++) { const result = await this.executeTmuxCommand( @@ -934,8 +934,10 @@ export class TmuxUtilities { throw new TmuxSessionIdentifierError(`Window identifier required: ${sessionIdentifier}`); } - const result = await this.executeWinOp('kill-window', [parsed.window], parsed.session); - return result; + // Pass window via executeTmuxCommand's window parameter so it builds + // the correct `-t session:window` target, not a positional argument. + const result = await this.executeTmuxCommand(['kill-window'], parsed.session, parsed.window); + return result !== null && result.returncode === 0; } catch (error) { if (error instanceof TmuxSessionIdentifierError) { logger.debug(`[TMUX] Invalid window identifier: ${error.message}`); From 76a46e1076f112de5751a90f3d8d494a62f29c53 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Tue, 24 Feb 2026 20:08:11 -0500 Subject: [PATCH 08/14] fix(tmux): create windows without stealing focus + require tmux 3.0+ - Add -d flag to new-window so spawned sessions don't switch the user's active tmux window away from what they're working on - Add tmux version check (>= 3.0) before spawning, since the -e flag for per-window environment variables was added in tmux 3.0. Fails with a clear error message instead of a cryptic tmux parse error on older versions. --- packages/happy-cli/src/utils/tmux.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/happy-cli/src/utils/tmux.ts b/packages/happy-cli/src/utils/tmux.ts index ec39d312de..7abdf10364 100644 --- a/packages/happy-cli/src/utils/tmux.ts +++ b/packages/happy-cli/src/utils/tmux.ts @@ -781,6 +781,19 @@ export class TmuxUtilities { throw new Error('tmux not available'); } + // Verify tmux version >= 3.0 (required for new-window -e flag) + const versionResult = await this.executeCommand(['tmux', '-V']); + if (versionResult && versionResult.returncode === 0) { + const versionMatch = versionResult.stdout.match(/tmux\s+(\d+)\.(\d+)/); + if (versionMatch) { + const major = parseInt(versionMatch[1]); + const minor = parseInt(versionMatch[2]); + if (major < 3) { + throw new Error(`tmux ${major}.${minor} is too old — version 3.0+ is required for per-window environment variables (-e flag)`); + } + } + } + // Handle session name resolution // - undefined: Use first existing session or create "happy" // - empty string: Use first existing session or create "happy" @@ -817,7 +830,10 @@ export class TmuxUtilities { // This allows proper shell initialization (Prezto, .zshrc, aliases, etc.) const createWindowArgs = ['new-window']; - // Add -P flag to print the pane info immediately (must come before other options) + // -d: don't switch focus to the new window (user keeps their current window) + createWindowArgs.push('-d'); + + // -P -F: print pane PID immediately for tracking createWindowArgs.push('-P'); createWindowArgs.push('-F', '#{pane_pid}'); From 34deef58877ce55295ae0554688710105180eefe Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Tue, 24 Feb 2026 20:12:54 -0500 Subject: [PATCH 09/14] fix(tmux): quote paths in send-keys, add back node flags Quote process.execPath and cliPath so paths with spaces work. Point directly at dist/index.mjs to skip bin/happy.mjs re-exec. Re-add --no-warnings --no-deprecation flags. Fix stale comment in waitForShellReady. --- packages/happy-cli/src/daemon/run.ts | 12 ++++++++---- packages/happy-cli/src/utils/tmux.ts | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/happy-cli/src/daemon/run.ts b/packages/happy-cli/src/daemon/run.ts index 26e01ac6ea..b8be09b5ed 100644 --- a/packages/happy-cli/src/daemon/run.ts +++ b/packages/happy-cli/src/daemon/run.ts @@ -415,10 +415,14 @@ export async function startDaemon(): Promise { // Determine agent command - support claude, codex, and gemini const agent = options.agent === 'gemini' ? 'gemini' : (options.agent === 'codex' ? 'codex' : 'claude'); - // Use absolute paths for both node and the happy binary — reliable regardless - // of shell PATH (NVM, asdf, etc. may not be initialized when send-keys fires) - const happyBinPath = join(projectPath(), 'bin', 'happy.mjs'); - const fullCommand = `${process.execPath} ${happyBinPath} ${agent} --happy-starting-mode remote --started-by daemon --dangerously-skip-permissions`; + // Use absolute paths for both node and the entrypoint — reliable regardless + // of shell PATH (NVM, asdf, etc. may not be initialized when send-keys fires). + // Point directly at dist/index.mjs to avoid the bin/happy.mjs re-exec wrapper. + // Paths are quoted for shell safety since the command is typed via send-keys. + const cliPath = join(projectPath(), 'dist', 'index.mjs'); + const quotedNode = `"${process.execPath}"`; + const quotedCli = `"${cliPath}"`; + const fullCommand = `${quotedNode} --no-warnings --no-deprecation ${quotedCli} ${agent} --happy-starting-mode remote --started-by daemon --dangerously-skip-permissions`; // Spawn in tmux with environment variables // IMPORTANT: Pass complete environment (process.env + extraEnv) because: diff --git a/packages/happy-cli/src/utils/tmux.ts b/packages/happy-cli/src/utils/tmux.ts index 7abdf10364..4ebbc7af1e 100644 --- a/packages/happy-cli/src/utils/tmux.ts +++ b/packages/happy-cli/src/utils/tmux.ts @@ -884,8 +884,8 @@ export class TmuxUtilities { } // Wait for the shell to fully initialize before sending the command. - // A fixed delay is unreliable — instead poll the pane content for a prompt. - // Shells display a prompt character (%, $, #, >, ±) when ready for input. + // Polls #{pane_current_command} until it reports a known shell name, + // meaning the shell process is idle at a prompt and ready for input. const shellReady = await this.waitForShellReady(sessionName, windowName, 5000); if (!shellReady) { logger.warn(`[TMUX] Shell did not show a prompt within timeout, sending command anyway`); From edd8646ba2d82331a632f774260c05f3a0ac8e0a Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Tue, 24 Feb 2026 20:14:49 -0500 Subject: [PATCH 10/14] fix(daemon): remove hardcoded --dangerously-skip-permissions from tmux spawn The non-tmux daemon spawn path does not set this flag either. Remote mode sessions handle permissions through the mobile app RPC flow, not terminal prompts, so forcing bypass is unnecessary and should be left to the user profile or mobile app selection. --- packages/happy-cli/src/daemon/run.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/happy-cli/src/daemon/run.ts b/packages/happy-cli/src/daemon/run.ts index b8be09b5ed..0b7310ff9e 100644 --- a/packages/happy-cli/src/daemon/run.ts +++ b/packages/happy-cli/src/daemon/run.ts @@ -422,7 +422,7 @@ export async function startDaemon(): Promise { const cliPath = join(projectPath(), 'dist', 'index.mjs'); const quotedNode = `"${process.execPath}"`; const quotedCli = `"${cliPath}"`; - const fullCommand = `${quotedNode} --no-warnings --no-deprecation ${quotedCli} ${agent} --happy-starting-mode remote --started-by daemon --dangerously-skip-permissions`; + const fullCommand = `${quotedNode} --no-warnings --no-deprecation ${quotedCli} ${agent} --happy-starting-mode remote --started-by daemon`; // Spawn in tmux with environment variables // IMPORTANT: Pass complete environment (process.env + extraEnv) because: From 276bb8bb745493d52122893faebe0589faa3307a Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Tue, 24 Feb 2026 20:22:17 -0500 Subject: [PATCH 11/14] fix(daemon): auto-detect tmux session instead of hardcoding 'main' Resolve session with priority: 1. Profile TMUX_SESSION_NAME env var (explicit user choice) 2. Daemon's own tmux session (if daemon runs inside tmux) 3. Session with the most windows (heuristic for main workspace) 4. spawnInTmux default (first existing session or create 'happy') Non-tmux fallback path is unchanged: if tmux is unavailable or spawn fails, regular detached process spawning is used. --- packages/happy-cli/src/daemon/run.ts | 73 +++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/happy-cli/src/daemon/run.ts b/packages/happy-cli/src/daemon/run.ts index 0b7310ff9e..9fbff2301b 100644 --- a/packages/happy-cli/src/daemon/run.ts +++ b/packages/happy-cli/src/daemon/run.ts @@ -65,6 +65,52 @@ async function getProfileEnvironmentVariablesForAgent( } } +/** + * Resolve the best tmux session to spawn windows in. + * Priority: daemon's own session > session with most windows > undefined (let spawnInTmux decide). + */ +async function resolveTmuxSessionName(): Promise { + const tmux = getTmuxUtilities(); + + // If the daemon is running inside a tmux session, prefer that session. + // The $TMUX env var is set by tmux: "socket_path,server_pid,pane_index" + if (process.env.TMUX) { + const result = await tmux.executeTmuxCommand(['display-message', '-p', '#{session_name}']); + if (result && result.returncode === 0 && result.stdout.trim()) { + const sessionName = result.stdout.trim(); + logger.debug(`[DAEMON RUN] Resolved tmux session from daemon's own session: ${sessionName}`); + return sessionName; + } + } + + // Otherwise, pick the session with the most windows (heuristic for user's main workspace) + const listResult = await tmux.executeTmuxCommand(['list-sessions', '-F', '#{session_name}:#{session_windows}']); + if (listResult && listResult.returncode === 0 && listResult.stdout.trim()) { + let bestSession: string | undefined; + let maxWindows = 0; + + for (const line of listResult.stdout.trim().split('\n')) { + const separatorIndex = line.lastIndexOf(':'); + if (separatorIndex === -1) continue; + const name = line.substring(0, separatorIndex); + const count = parseInt(line.substring(separatorIndex + 1)); + if (!isNaN(count) && count > maxWindows) { + maxWindows = count; + bestSession = name; + } + } + + if (bestSession) { + logger.debug(`[DAEMON RUN] Resolved tmux session by most windows: ${bestSession} (${maxWindows} windows)`); + return bestSession; + } + } + + // Let spawnInTmux handle it (first session or create "happy") + logger.debug('[DAEMON RUN] No tmux session resolved, deferring to spawnInTmux default'); + return undefined; +} + export async function startDaemon(): Promise { // We don't have cleanup function at the time of server construction // Control flow is: @@ -390,24 +436,25 @@ export async function startDaemon(): Promise { const tmuxAvailable = await isTmuxAvailable(); let useTmux = tmuxAvailable; - // Get tmux session name from environment variables (now set by profile system) - // Empty string means "use current/most recent session" (tmux default behavior) - // TEMPORARY HARDCODED HACK: Always use "main" session for testing - // TODO: Remove this hardcoding and use the profile's TMUX_SESSION_NAME - let tmuxSessionName: string | undefined = 'main'; // extraEnv.TMUX_SESSION_NAME; + // Resolve tmux session name with priority: + // 1. Profile env var TMUX_SESSION_NAME (explicit user choice) + // 2. Daemon's own tmux session (if daemon is running inside tmux) + // 3. Session with the most windows (heuristic for user's main workspace) + // 4. Fall through to spawnInTmux default (first existing session or create "happy") + let tmuxSessionName: string | undefined = extraEnv.TMUX_SESSION_NAME; - // If tmux is not available or session name is explicitly undefined, fall back to regular spawning - // Note: Empty string is valid (means use current/most recent tmux session) - if (!tmuxAvailable || tmuxSessionName === undefined) { + if (tmuxSessionName === undefined && tmuxAvailable) { + tmuxSessionName = await resolveTmuxSessionName(); + } + + // If tmux is not available, fall back to regular spawning + if (!tmuxAvailable) { useTmux = false; - if (tmuxSessionName !== undefined) { - logger.debug(`[DAEMON RUN] tmux session name specified but tmux not available, falling back to regular spawning`); - } } - if (useTmux && tmuxSessionName !== undefined) { + if (useTmux) { // Try to spawn in tmux session - const sessionDesc = tmuxSessionName || 'current/most recent session'; + const sessionDesc = tmuxSessionName || 'auto-resolved'; logger.debug(`[DAEMON RUN] Attempting to spawn session in tmux: ${sessionDesc}`); const tmux = getTmuxUtilities(tmuxSessionName); From 5b0512d1891ca93ef420268b289572e4d50c9b73 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Tue, 24 Feb 2026 21:11:51 -0500 Subject: [PATCH 12/14] fix(tmux): insert -t target before positional args + add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit executeTmuxCommand appended -t target AFTER all args, which broke commands with positional arguments (e.g., display-message -p FORMAT). tmux rejects: `display-message -p #{fmt} -t target` ("too many arguments") but accepts: `display-message -t target -p #{fmt}`. This silently broke waitForShellReady — every spawn wasted 5s on a timeout because display-message with window target always returned exit code 1. Now shell detection is instant (~0ms). Also fix resolveTmuxSessionName priority 2 (daemon's own tmux session): used executeTmuxCommand which forced -t happy, querying the wrong session. Now uses execFile directly without -t so tmux reads $TMUX. Tests added: - 8 unit tests for tmux version string regex parsing - 8 unit tests for session list parsing (resolveTmuxSessionName logic) - 3 unit tests for spawnToken in createSessionMetadata - 7 integration tests exercising real tmux: -d flag, env vars, killWindow, pane_current_command detection, PID extraction, CLAUDECODE='', paths --- packages/happy-cli/src/daemon/run.ts | 21 +- .../src/utils/createSessionMetadata.test.ts | 45 ++- packages/happy-cli/src/utils/tmux.test.ts | 326 ++++++++++++++++++ packages/happy-cli/src/utils/tmux.ts | 8 +- 4 files changed, 392 insertions(+), 8 deletions(-) diff --git a/packages/happy-cli/src/daemon/run.ts b/packages/happy-cli/src/daemon/run.ts index 9fbff2301b..28ef1c54ed 100644 --- a/packages/happy-cli/src/daemon/run.ts +++ b/packages/happy-cli/src/daemon/run.ts @@ -18,6 +18,7 @@ import { writeDaemonState, DaemonLocallyPersistedState, readDaemonState, acquire import { cleanupDaemonState, isDaemonRunningCurrentlyInstalledHappyVersion, stopDaemon } from './controlClient'; import { startDaemonControlServer } from './controlServer'; import { readFileSync } from 'fs'; +import { execFile } from 'child_process'; import { randomBytes } from 'crypto'; import { join } from 'path'; import { projectPath } from '@/projectPath'; @@ -74,12 +75,22 @@ async function resolveTmuxSessionName(): Promise { // If the daemon is running inside a tmux session, prefer that session. // The $TMUX env var is set by tmux: "socket_path,server_pid,pane_index" + // IMPORTANT: Use execFile directly (not executeTmuxCommand) because + // executeTmuxCommand always appends `-t ` which would query + // the wrong session. Without `-t`, tmux uses the current client from $TMUX. if (process.env.TMUX) { - const result = await tmux.executeTmuxCommand(['display-message', '-p', '#{session_name}']); - if (result && result.returncode === 0 && result.stdout.trim()) { - const sessionName = result.stdout.trim(); - logger.debug(`[DAEMON RUN] Resolved tmux session from daemon's own session: ${sessionName}`); - return sessionName; + try { + const sessionName = await new Promise((resolve) => { + execFile('tmux', ['display-message', '-p', '#{session_name}'], { timeout: 5000 }, (err, stdout) => { + resolve(err ? undefined : stdout.trim() || undefined); + }); + }); + if (sessionName) { + logger.debug(`[DAEMON RUN] Resolved tmux session from daemon's own session: ${sessionName}`); + return sessionName; + } + } catch { + // Fall through to next priority } } diff --git a/packages/happy-cli/src/utils/createSessionMetadata.test.ts b/packages/happy-cli/src/utils/createSessionMetadata.test.ts index e1c03ad5cd..6f0801ee08 100644 --- a/packages/happy-cli/src/utils/createSessionMetadata.test.ts +++ b/packages/happy-cli/src/utils/createSessionMetadata.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import type { SandboxConfig } from '@/persistence'; import { createSessionMetadata } from './createSessionMetadata'; @@ -71,4 +71,47 @@ describe('createSessionMetadata', () => { expect(metadata.dangerouslySkipPermissions).toBe(true); }); + + describe('spawnToken', () => { + const originalSpawnToken = process.env.HAPPY_SPAWN_TOKEN; + + afterEach(() => { + if (originalSpawnToken !== undefined) { + process.env.HAPPY_SPAWN_TOKEN = originalSpawnToken; + } else { + delete process.env.HAPPY_SPAWN_TOKEN; + } + }); + + it('sets metadata.spawnToken from HAPPY_SPAWN_TOKEN env var', () => { + process.env.HAPPY_SPAWN_TOKEN = 'abc123'; + const { metadata } = createSessionMetadata({ + flavor: 'claude', + machineId: 'machine-6', + }); + + expect(metadata.spawnToken).toBe('abc123'); + }); + + it('sets metadata.spawnToken to undefined when env var not set', () => { + delete process.env.HAPPY_SPAWN_TOKEN; + const { metadata } = createSessionMetadata({ + flavor: 'claude', + machineId: 'machine-7', + }); + + expect(metadata.spawnToken).toBeUndefined(); + }); + + it('sets metadata.spawnToken to undefined when env var is empty string', () => { + process.env.HAPPY_SPAWN_TOKEN = ''; + const { metadata } = createSessionMetadata({ + flavor: 'claude', + machineId: 'machine-8', + }); + + // '' || undefined → undefined + expect(metadata.spawnToken).toBeUndefined(); + }); + }); }); diff --git a/packages/happy-cli/src/utils/tmux.test.ts b/packages/happy-cli/src/utils/tmux.test.ts index c5628e9815..6426bda2f2 100644 --- a/packages/happy-cli/src/utils/tmux.test.ts +++ b/packages/happy-cli/src/utils/tmux.test.ts @@ -420,6 +420,113 @@ describe('TmuxUtilities.detectTmuxEnvironment', () => { }); }); +describe('tmux version parsing', () => { + // Tests the regex used in spawnInTmux to gate on tmux >= 3.0 + const versionRegex = /tmux\s+(\d+)\.(\d+)/; + + it('should parse standard version string', () => { + const match = 'tmux 3.4'.match(versionRegex); + expect(match).not.toBeNull(); + expect(parseInt(match![1])).toBe(3); + expect(parseInt(match![2])).toBe(4); + }); + + it('should parse old version string', () => { + const match = 'tmux 2.9'.match(versionRegex); + expect(match).not.toBeNull(); + expect(parseInt(match![1])).toBe(2); + expect(parseInt(match![2])).toBe(9); + }); + + it('should parse version with suffix (e.g., 3.3a)', () => { + const match = 'tmux 3.3a'.match(versionRegex); + expect(match).not.toBeNull(); + expect(parseInt(match![1])).toBe(3); + expect(parseInt(match![2])).toBe(3); + }); + + it('should not match development version without number', () => { + const match = 'tmux master'.match(versionRegex); + expect(match).toBeNull(); + }); + + it('should parse next-prefixed version', () => { + // "tmux next-3.5" — the regex still finds "3.5" + const match = 'tmux next-3.5'.match(versionRegex); + // Regex requires whitespace before digits, so "next-3.5" doesn't match + expect(match).toBeNull(); + }); + + it('should parse version with extra whitespace', () => { + const match = 'tmux 3.4'.match(versionRegex); + expect(match).not.toBeNull(); + expect(parseInt(match![1])).toBe(3); + }); +}); + +describe('session list parsing (resolveTmuxSessionName logic)', () => { + // Tests the lastIndexOf(':') parsing used in resolveTmuxSessionName + // to split "session_name:session_windows" from tmux list-sessions output + + function parseSessionLine(line: string): { name: string; count: number } | null { + const separatorIndex = line.lastIndexOf(':'); + if (separatorIndex === -1) return null; + const name = line.substring(0, separatorIndex); + const count = parseInt(line.substring(separatorIndex + 1)); + if (isNaN(count)) return null; + return { name, count }; + } + + function findBestSession(output: string): string | undefined { + let bestSession: string | undefined; + let maxWindows = 0; + for (const line of output.trim().split('\n')) { + const parsed = parseSessionLine(line); + if (parsed && parsed.count > maxWindows) { + maxWindows = parsed.count; + bestSession = parsed.name; + } + } + return bestSession; + } + + it('should parse single session', () => { + expect(findBestSession('main:5')).toBe('main'); + }); + + it('should pick session with most windows', () => { + expect(findBestSession('dev:3\nmain:10\ntest:1')).toBe('main'); + }); + + it('should handle session name with dots and hyphens', () => { + expect(findBestSession('my-session.name:7')).toBe('my-session.name'); + }); + + it('should handle empty output', () => { + expect(findBestSession('')).toBeUndefined(); + }); + + it('should handle malformed lines gracefully', () => { + expect(findBestSession('no-colon')).toBeUndefined(); + }); + + it('should handle non-numeric window count', () => { + expect(findBestSession('session:abc')).toBeUndefined(); + }); + + it('should handle tie (picks first with highest count)', () => { + // Both have 5 windows, first one wins (not replaced by equal) + expect(findBestSession('alpha:5\nbeta:5')).toBe('alpha'); + }); + + it('should handle session name with colons (uses lastIndexOf)', () => { + // Session names can't have colons in tmux, but test the parsing robustness + // If somehow "sess:ion:3" appeared, lastIndexOf(':') gives correct split + const result = parseSessionLine('sess:ion:3'); + expect(result).toEqual({ name: 'sess:ion', count: 3 }); + }); +}); + describe('Round-trip consistency', () => { it('should parse and format consistently for session-only', () => { const original = 'my-session'; @@ -454,3 +561,222 @@ describe('Round-trip consistency', () => { expect(parsed).toEqual(params); }); }); + +// Integration tests that require real tmux +// These create a temporary tmux session, run operations, and clean up +import { execFileSync, spawnSync } from 'child_process'; + +function isTmuxInstalled(): boolean { + try { + const result = spawnSync('tmux', ['-V'], { stdio: 'pipe', timeout: 5000 }); + return result.status === 0; + } catch { + return false; + } +} + +const TEST_SESSION = `happy-test-${process.pid}`; + +describe.skipIf(!isTmuxInstalled())('TmuxUtilities integration (requires tmux)', { timeout: 15_000 }, () => { + // Create a temporary tmux session for testing + beforeAll(() => { + execFileSync('tmux', ['new-session', '-d', '-s', TEST_SESSION, '-n', 'main']); + }); + + afterAll(() => { + try { + execFileSync('tmux', ['kill-session', '-t', TEST_SESSION]); + } catch { + // Session may already be killed + } + }); + + it('should detect tmux version >= 3.0', async () => { + const utils = new TmuxUtilities(TEST_SESSION); + const result = await utils.executeTmuxCommand(['list-sessions']); + expect(result).not.toBeNull(); + expect(result!.returncode).toBe(0); + + // Verify version is parseable (same regex as spawnInTmux) + const versionOutput = spawnSync('tmux', ['-V'], { stdio: 'pipe' }).stdout.toString(); + const match = versionOutput.match(/tmux\s+(\d+)\.(\d+)/); + expect(match).not.toBeNull(); + expect(parseInt(match![1])).toBeGreaterThanOrEqual(3); + }); + + it('should spawn window with -d flag (no focus steal)', async () => { + const utils = new TmuxUtilities(TEST_SESSION); + + // Record current window before spawn + const beforeResult = await utils.executeTmuxCommand( + ['display-message', '-p', '#{window_name}'], + TEST_SESSION + ); + const activeWindowBefore = beforeResult?.stdout.trim(); + + // Spawn a new window + const result = await utils.spawnInTmux(['echo test-no-focus-steal'], { + sessionName: TEST_SESSION, + windowName: 'test-no-focus', + cwd: '/tmp' + }); + + expect(result.success).toBe(true); + expect(result.pid).toBeGreaterThan(0); + expect(result.sessionId).toContain(TEST_SESSION); + + // Verify active window did NOT change (the -d flag worked) + const afterResult = await utils.executeTmuxCommand( + ['display-message', '-p', '#{window_name}'], + TEST_SESSION + ); + const activeWindowAfter = afterResult?.stdout.trim(); + expect(activeWindowAfter).toBe(activeWindowBefore); + + // Clean up + await utils.executeTmuxCommand(['kill-window'], TEST_SESSION, 'test-no-focus'); + }); + + it('should accept environment variables parameter without error', async () => { + const utils = new TmuxUtilities(TEST_SESSION); + + // Verify spawnInTmux succeeds with env vars (including edge cases) + const result = await utils.spawnInTmux(['sleep 2'], { + sessionName: TEST_SESSION, + windowName: 'test-env', + cwd: '/tmp' + }, { + HAPPY_TEST_VAR: 'value-with-special=chars', + ANOTHER_VAR: 'simple', + EMPTY_VAR: '', + PATH: process.env.PATH || '/usr/bin:/bin' + }); + + expect(result.success).toBe(true); + expect(result.pid).toBeGreaterThan(0); + + // Clean up + await utils.executeTmuxCommand(['kill-window'], TEST_SESSION, 'test-env'); + }); + + it('should kill window correctly', async () => { + const utils = new TmuxUtilities(TEST_SESSION); + const windowName = 'test-kill-window'; + + // Create a window to kill + await utils.executeTmuxCommand( + ['new-window', '-d', '-n', windowName], + TEST_SESSION + ); + + // Verify it exists + const listBefore = await utils.executeTmuxCommand( + ['list-windows', '-F', '#{window_name}'], + TEST_SESSION + ); + expect(listBefore?.stdout).toContain(windowName); + + // Kill it using the fixed killWindow method + const killed = await utils.killWindow(`${TEST_SESSION}:${windowName}`); + expect(killed).toBe(true); + + // Verify it's gone + const listAfter = await utils.executeTmuxCommand( + ['list-windows', '-F', '#{window_name}'], + TEST_SESSION + ); + expect(listAfter?.stdout).not.toContain(windowName); + }); + + it('should detect shell via pane_current_command with window target', async () => { + const utils = new TmuxUtilities(TEST_SESSION); + const knownShells = new Set(['zsh', 'bash', 'fish', 'sh', 'dash', 'ksh', 'tcsh', 'csh', 'nu', 'elvish', 'pwsh']); + + // Query display-message with window target via executeTmuxCommand + // This verifies -t is inserted before the format string (not appended after it) + const result = await utils.executeTmuxCommand( + ['display-message', '-p', '#{pane_current_command}'], + TEST_SESSION, 'main' + ); + + expect(result).not.toBeNull(); + expect(result!.returncode).toBe(0); + + const command = result!.stdout.trim(); + expect(knownShells.has(command)).toBe(true); + }); + + it('should return PID from spawnInTmux', async () => { + const utils = new TmuxUtilities(TEST_SESSION); + + const result = await utils.spawnInTmux(['sleep 10'], { + sessionName: TEST_SESSION, + windowName: 'test-pid', + cwd: '/tmp' + }); + + expect(result.success).toBe(true); + expect(result.pid).toBeDefined(); + expect(typeof result.pid).toBe('number'); + expect(result.pid).toBeGreaterThan(0); + + // Verify the PID is a real process + try { + process.kill(result.pid!, 0); // Signal 0 = check existence + expect(true).toBe(true); // Process exists + } catch { + // Process might have already exited in CI, that's OK + } + + // Clean up + await utils.executeTmuxCommand(['kill-window'], TEST_SESSION, 'test-pid'); + }); + + it('should accept CLAUDECODE=empty in env without error', async () => { + const utils = new TmuxUtilities(TEST_SESSION); + + // Verify spawnInTmux accepts empty CLAUDECODE value (used to prevent nested detection) + const result = await utils.spawnInTmux(['sleep 2'], { + sessionName: TEST_SESSION, + windowName: 'test-claudecode', + cwd: '/tmp' + }, { + CLAUDECODE: '', + PATH: process.env.PATH || '/usr/bin:/bin' + }); + + expect(result.success).toBe(true); + expect(result.pid).toBeGreaterThan(0); + + // Clean up + await utils.executeTmuxCommand(['kill-window'], TEST_SESSION, 'test-claudecode'); + }); + + it('should handle paths with spaces in send-keys', async () => { + const utils = new TmuxUtilities(TEST_SESSION); + + // The spawnInTmux command uses send-keys with -l, which should handle + // paths with spaces when properly quoted + const result = await utils.spawnInTmux(['echo "path with spaces works"'], { + sessionName: TEST_SESSION, + windowName: 'test-spaces', + cwd: '/tmp' + }); + + expect(result.success).toBe(true); + + await new Promise(resolve => setTimeout(resolve, 500)); + + const captureResult = await utils.executeTmuxCommand( + ['capture-pane', '-p'], + TEST_SESSION, 'test-spaces' + ); + expect(captureResult?.stdout).toContain('path with spaces works'); + + // Clean up + await utils.executeTmuxCommand(['kill-window'], TEST_SESSION, 'test-spaces'); + }); +}); + +// Need beforeAll/afterAll for integration tests +import { beforeAll, afterAll } from 'vitest'; diff --git a/packages/happy-cli/src/utils/tmux.ts b/packages/happy-cli/src/utils/tmux.ts index 4ebbc7af1e..7d62f095ab 100644 --- a/packages/happy-cli/src/utils/tmux.ts +++ b/packages/happy-cli/src/utils/tmux.ts @@ -444,8 +444,9 @@ export class TmuxUtilities { return this.executeCommand(fullCmd); } else { - // Non-send-keys commands - const fullCmd = [...baseCmd, ...cmd]; + // Non-send-keys commands: insert -t right after the command name + // (before positional args like display-message's format string) + const fullCmd = [...baseCmd, cmd[0]]; // Add target specification for commands that support it if (cmd.length > 0 && COMMANDS_SUPPORTING_TARGET.has(cmd[0])) { @@ -455,6 +456,9 @@ export class TmuxUtilities { fullCmd.push('-t', target); } + // Add remaining arguments (flags and positional args) after -t + fullCmd.push(...cmd.slice(1)); + return this.executeCommand(fullCmd); } } From ebce12f2506eec8c9ae9813ae15984097b40d928 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Sat, 28 Feb 2026 14:22:56 -0500 Subject: [PATCH 13/14] fix(daemon): pre-requisite quality fixes for multi-flavor support Four critical improvements to ensure proper behavior across all flavors (claude, gemini, codex): A. Add missing await on Codex fs.writeFile (line 367) - Codex auth.json file was not being awaited before process spawn - This caused silent failures in Codex session spawning B. Fix execFile Promise timeout handling (lines 83-97) - tmux session resolution could hang indefinitely if tmux didn't respond - Added explicit timeout rejection with process.kill on timeout - Used pattern already in codebase for process health checks C. Unify agent command mapping with data-driven approach (lines 28-48) - Replace inconsistent ternary (tmux) and switch (non-tmux) patterns - Add AGENT_COMMAND_MAP constant and getAgentCommand() utility - Use same logic for both tmux and non-tmux spawn paths D. Complete Gemini authentication support (line 372-374) - Was falling through to Claude path (CLAUDE_CODE_OAUTH_TOKEN) - Add proper Gemini auth handling (GOOGLE_API_KEY) - Now handles all three flavors: claude, codex, gemini Additional improvements: - Smart window cleanup in stopSession (lines 745-766) - Check if process still alive via process.kill(pid, 0) before killing window - Only kill window if Claude still running, leave alone if user exited - Prevents loss of user's terminal after they quit the CLI All tests pass (450 pass, 1 pre-existing failure unrelated to these changes) --- packages/happy-cli/src/daemon/run.ts | 99 ++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 29 deletions(-) diff --git a/packages/happy-cli/src/daemon/run.ts b/packages/happy-cli/src/daemon/run.ts index 28ef1c54ed..000053e722 100644 --- a/packages/happy-cli/src/daemon/run.ts +++ b/packages/happy-cli/src/daemon/run.ts @@ -35,6 +35,25 @@ export const initialMachineMetadata: MachineMetadata = { happyLibDir: projectPath() }; +// Agent command mapping — unified across all spawn paths (tmux and non-tmux) +const AGENT_COMMAND_MAP = { + 'claude': 'claude', + 'codex': 'codex', + 'gemini': 'gemini' +} as const; + +type AgentType = keyof typeof AGENT_COMMAND_MAP; + +/** + * Get the CLI command for the given agent type. + * Defaults to 'claude' if agent is undefined. + * Returns null if agent is an unsupported type. + */ +function getAgentCommand(agent?: string): string | null { + const resolved = (agent || 'claude') as AgentType; + return AGENT_COMMAND_MAP[resolved] || null; +} + // Get environment variables for a profile, filtered for agent compatibility async function getProfileEnvironmentVariablesForAgent( profileId: string, @@ -80,10 +99,16 @@ async function resolveTmuxSessionName(): Promise { // the wrong session. Without `-t`, tmux uses the current client from $TMUX. if (process.env.TMUX) { try { - const sessionName = await new Promise((resolve) => { - execFile('tmux', ['display-message', '-p', '#{session_name}'], { timeout: 5000 }, (err, stdout) => { + const sessionName = await new Promise((resolve, reject) => { + const proc = execFile('tmux', ['display-message', '-p', '#{session_name}'], (err, stdout) => { resolve(err ? undefined : stdout.trim() || undefined); }); + // Add explicit timeout since execFile's timeout option doesn't reject the promise + const timeout = setTimeout(() => { + proc.kill(); + reject(new Error('Tmux session resolution timeout')); + }, 5000); + proc.on('exit', () => clearTimeout(timeout)); }); if (sessionName) { logger.debug(`[DAEMON RUN] Resolved tmux session from daemon's own session: ${sessionName}`); @@ -364,11 +389,15 @@ export async function startDaemon(): Promise { const codexHomeDir = tmp.dirSync(); // Write the token to the temporary directory - fs.writeFile(join(codexHomeDir.name, 'auth.json'), options.token); + await fs.writeFile(join(codexHomeDir.name, 'auth.json'), options.token); // Set the environment variable for Codex authEnv.CODEX_HOME = codexHomeDir.name; - } else { // Assuming claude + } else if (options.agent === 'gemini') { + // Gemini uses Google API key + authEnv.GOOGLE_API_KEY = options.token; + } else { + // Claude (default) authEnv.CLAUDE_CODE_OAUTH_TOKEN = options.token; } } @@ -471,7 +500,13 @@ export async function startDaemon(): Promise { const tmux = getTmuxUtilities(tmuxSessionName); // Determine agent command - support claude, codex, and gemini - const agent = options.agent === 'gemini' ? 'gemini' : (options.agent === 'codex' ? 'codex' : 'claude'); + const agent = getAgentCommand(options.agent); + if (!agent) { + return { + type: 'error', + errorMessage: `Unsupported agent type: '${options.agent}'. Please update your CLI to the latest version.` + }; + } // Use absolute paths for both node and the entrypoint — reliable regardless // of shell PATH (NVM, asdf, etc. may not be initialized when send-keys fires). @@ -581,23 +616,12 @@ export async function startDaemon(): Promise { logger.debug(`[DAEMON RUN] Using regular process spawning`); // Construct arguments for the CLI - support claude, codex, and gemini - let agentCommand: string; - switch (options.agent) { - case 'claude': - case undefined: - agentCommand = 'claude'; - break; - case 'codex': - agentCommand = 'codex'; - break; - case 'gemini': - agentCommand = 'gemini'; - break; - default: - return { - type: 'error', - errorMessage: `Unsupported agent type: '${options.agent}'. Please update your CLI to the latest version.` - }; + const agentCommand = getAgentCommand(options.agent); + if (!agentCommand) { + return { + type: 'error', + errorMessage: `Unsupported agent type: '${options.agent}'. Please update your CLI to the latest version.` + }; } const args = [ agentCommand, @@ -719,13 +743,30 @@ export async function startDaemon(): Promise { (sessionId.startsWith('PID-') && pid === parseInt(sessionId.replace('PID-', '')))) { if (session.tmuxSessionId) { - // Tmux-spawned session: kill the window (fire-and-forget to keep stopSession synchronous) - const parsed = parseTmuxSessionIdentifier(session.tmuxSessionId); - const tmux = getTmuxUtilities(parsed.session); - tmux.killWindow(session.tmuxSessionId).catch((error) => { - logger.debug(`[DAEMON RUN] Failed to kill tmux window ${session.tmuxSessionId}:`, error); - }); - logger.debug(`[DAEMON RUN] Sent kill to tmux window ${session.tmuxSessionId}`); + // Tmux-spawned session: check if the spawned process is still alive + // If Claude is still running, kill the window to cleanup. If user already exited, + // leave the window alone (it becomes an independent terminal). + let processIsAlive = false; + try { + process.kill(session.pid, 0); // Signal 0: check without killing + processIsAlive = true; + } catch (error) { + // Process is dead (ESRCH error) + processIsAlive = false; + } + + if (processIsAlive) { + // Process still running: kill the tmux window to terminate it + const parsed = parseTmuxSessionIdentifier(session.tmuxSessionId); + const tmux = getTmuxUtilities(parsed.session); + tmux.killWindow(session.tmuxSessionId).catch((error) => { + logger.debug(`[DAEMON RUN] Failed to kill tmux window ${session.tmuxSessionId}:`, error); + }); + logger.debug(`[DAEMON RUN] Process alive, killed tmux window ${session.tmuxSessionId}`); + } else { + // Process already dead: leave window alone (user is using it as a terminal) + logger.debug(`[DAEMON RUN] Process PID ${session.pid} already exited, leaving tmux window ${session.tmuxSessionId} intact`); + } } else if (session.startedBy === 'daemon' && session.childProcess) { try { session.childProcess.kill('SIGTERM'); From e79aa55e90500661d27e2fdede9dce1c3b244ab1 Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Sat, 28 Feb 2026 21:13:08 -0500 Subject: [PATCH 14/14] fix(ci): fix Windows smoke test build failures Windows CI fails with: Cannot find module 'D:\a\happy\happy\node_modules\node_modules\typescript\bin\tsc' Root cause: npx on Windows resolves tsc via a double-nested node_modules path in Yarn 1.22 workspace context. The postinstall script also runs build scripts from the wrong directory on Windows (Yarn 1.22 bug, GitHub Issue #6175). Fix: - packages/happy-wire/package.json: use 'tsc' directly instead of 'npx tsc' (matches existing 'typecheck' script which already works on all platforms) - packages/happy-cli/package.json: same change for consistency - .github/workflows/cli-smoke-test.yml (Windows job only): - SKIP_HAPPY_WIRE_BUILD=1 during install to skip broken postinstall on Windows - explicit 'cd packages/happy-wire && yarn build' step after install so happy-coder build can find happy-wire's dist files --- .github/workflows/cli-smoke-test.yml | 6 ++++++ packages/happy-cli/package.json | 2 +- packages/happy-wire/package.json | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli-smoke-test.yml b/.github/workflows/cli-smoke-test.yml index 141d18bca6..d9a61fce04 100644 --- a/.github/workflows/cli-smoke-test.yml +++ b/.github/workflows/cli-smoke-test.yml @@ -92,8 +92,14 @@ jobs: cache-dependency-path: yarn.lock - name: Install dependencies + env: + SKIP_HAPPY_WIRE_BUILD: "1" run: yarn install --immutable + - name: Build happy-wire + shell: bash + run: cd packages/happy-wire && yarn build + - name: Build package run: yarn workspace happy-coder build diff --git a/packages/happy-cli/package.json b/packages/happy-cli/package.json index b8e380f092..0a9c0a74aa 100644 --- a/packages/happy-cli/package.json +++ b/packages/happy-cli/package.json @@ -57,7 +57,7 @@ "scripts": { "why do we need to build before running tests / dev?": "We need the binary to be built so we run daemon commands which directly run the binary - we don't want them to go out of sync or have custom spawn logic depending how we started happy", "typecheck": "tsc --noEmit", - "build": "shx rm -rf dist && npx tsc --noEmit && pkgroll", + "build": "shx rm -rf dist && tsc --noEmit && pkgroll", "test": "$npm_execpath run build && vitest run", "start": "$npm_execpath run build && node ./bin/happy.mjs", "cli": "tsx src/index.ts", diff --git a/packages/happy-wire/package.json b/packages/happy-wire/package.json index 6ac60faf38..8a29461599 100644 --- a/packages/happy-wire/package.json +++ b/packages/happy-wire/package.json @@ -30,7 +30,7 @@ ], "scripts": { "typecheck": "tsc --noEmit", - "build": "shx rm -rf dist && npx tsc --noEmit && pkgroll", + "build": "shx rm -rf dist && tsc --noEmit && pkgroll", "test": "$npm_execpath run build && vitest run", "prepublishOnly": "$npm_execpath run build && $npm_execpath run test", "release": "npx --no-install release-it"