diff --git a/.github/workflows/cli-smoke-test.yml b/.github/workflows/cli-smoke-test.yml index 141d18bca..d9a61fce0 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-app/sources/app/(app)/new/index.tsx b/packages/happy-app/sources/app/(app)/new/index.tsx index bc7167d5d..d63f00b5a 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/package.json b/packages/happy-cli/package.json index b8e380f09..0a9c0a74a 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-cli/src/agent/acp/runAcp.ts b/packages/happy-cli/src/agent/acp/runAcp.ts index 678f413f1..e7fc8eac9 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 0c4c6fb7c..6f711d5ee 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/claude/runClaude.ts b/packages/happy-cli/src/claude/runClaude.ts index b626c62b6..1d648d9a1 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 75889d14e..000053e72 100644 --- a/packages/happy-cli/src/daemon/run.ts +++ b/packages/happy-cli/src/daemon/run.ts @@ -18,6 +18,8 @@ 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'; import { getTmuxUtilities, isTmuxAvailable, parseTmuxSessionIdentifier, formatTmuxSessionIdentifier } from '@/utils/tmux'; @@ -33,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, @@ -64,6 +85,68 @@ 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" + // 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) { + try { + 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}`); + return sessionName; + } + } catch { + // Fall through to next priority + } + } + + // 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: @@ -169,6 +252,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 +264,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') { @@ -281,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; } } @@ -364,31 +476,46 @@ 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) + // 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); - // 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`; + 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). + // 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`; // Spawn in tmux with environment variables // IMPORTANT: Pass complete environment (process.env + extraEnv) because: @@ -399,8 +526,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 +536,19 @@ 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 + // 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, @@ -415,17 +556,17 @@ 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}`); // Validate we got a PID from tmux if (!tmuxResult.pid) { 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, // Real PID from tmux -P flag + pid: tmuxResult.pid, // Shell PID from tmux #{pane_pid} tmuxSessionId: tmuxResult.sessionId, directoryCreated, message: directoryCreated @@ -433,27 +574,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: session=${tmuxResult.sessionId}, window=${windowName}`); 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: session=${tmuxResult.sessionId}, window=${windowName}, token=${spawnToken}`); 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 webhook resolved: session=${tmuxResult.sessionId}, window=${windowName}, sessionId=${completedSession.happySessionId}`); resolve({ type: 'success', sessionId: completedSession.happySessionId! @@ -471,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, @@ -497,12 +631,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 +742,32 @@ 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: 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'); logger.debug(`[DAEMON RUN] Sent SIGTERM to daemon-spawned session ${sessionId}`); diff --git a/packages/happy-cli/src/utils/createSessionMetadata.test.ts b/packages/happy-cli/src/utils/createSessionMetadata.test.ts index e1c03ad5c..6f0801ee0 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/createSessionMetadata.ts b/packages/happy-cli/src/utils/createSessionMetadata.ts index b4e05a3d3..f2a1a59d3 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(), diff --git a/packages/happy-cli/src/utils/tmux.test.ts b/packages/happy-cli/src/utils/tmux.test.ts index c5628e981..6426bda2f 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 f09583586..7d62f095a 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,10 +456,41 @@ 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); } } + /** + * 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); + 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( + ['display-message', '-p', '#{pane_current_command}'], + session, + window + ); + if (result && result.returncode === 0) { + const command = result.stdout.trim(); + if (knownShells.has(command)) { + logger.debug(`[TMUX] Shell ready after ${i * pollInterval}ms (${command})`); + return true; + } + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + return false; + } + /** * Execute command with subprocess and return result */ @@ -753,6 +785,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" @@ -784,9 +829,20 @@ 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 - const createWindowArgs = ['new-window', '-n', windowName]; + // 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']; + + // -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}'); + + // Add window name + createWindowArgs.push('-n', windowName); // Add working directory if specified if (options.cwd) { @@ -811,39 +867,43 @@ 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); - - // 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 + // 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. + // 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`); + } + + // 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}`); + } + logger.debug(`[TMUX] Spawned command in tmux session ${sessionName}, window ${windowName}, PID ${panePid}`); // Return tmux session info and PID @@ -894,8 +954,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}`); diff --git a/packages/happy-wire/package.json b/packages/happy-wire/package.json index 6ac60faf3..8a2946159 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"