diff --git a/cli/src/agent/runners/runAgentSession.ts b/cli/src/agent/runners/runAgentSession.ts index e8f32cabd..f0cdcf817 100644 --- a/cli/src/agent/runners/runAgentSession.ts +++ b/cli/src/agent/runners/runAgentSession.ts @@ -7,7 +7,7 @@ import { convertAgentMessage } from '@/agent/messageConverter'; import { PermissionAdapter } from '@/agent/permissionAdapter'; import type { AgentBackend, PromptContent } from '@/agent/types'; import { startHappyServer } from '@/claude/utils/startHappyServer'; -import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; +import { getHappyCliCommand, getSpawnedWorkingDirectory } from '@/utils/spawnHappyCLI'; import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; import { bootstrapSession } from '@/agent/sessionFactory'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; @@ -34,7 +34,7 @@ export async function runAgentSession(opts: { const { session } = await bootstrapSession({ flavor: opts.agentType, startedBy: opts.startedBy ?? 'terminal', - workingDirectory: process.cwd(), + workingDirectory: getSpawnedWorkingDirectory(), agentState: initialState }); @@ -67,7 +67,7 @@ export async function runAgentSession(opts: { ]; const agentSessionId = await backend.newSession({ - cwd: process.cwd(), + cwd: getSpawnedWorkingDirectory(), mcpServers }); diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 796365b3a..f9200fa5e 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -8,6 +8,7 @@ import { extractSDKMetadataAsync } from '@/claude/sdk/metadataExtractor'; import { parseSpecialCommand } from '@/parsers/specialCommands'; import { getEnvironmentInfo } from '@/ui/doctor'; import { startHappyServer } from '@/claude/utils/startHappyServer'; +import { getSpawnedWorkingDirectory } from '@/utils/spawnHappyCLI'; import { startHookServer } from '@/claude/utils/startHookServer'; import { generateHookSettingsFile, cleanupHookSettingsFile } from '@/modules/common/hooks/generateHookSettings'; import { registerKillSessionHandler } from './registerKillSessionHandler'; @@ -29,7 +30,7 @@ export interface StartOptions { } export async function runClaude(options: StartOptions = {}): Promise { - const workingDirectory = process.cwd(); + const workingDirectory = getSpawnedWorkingDirectory(); const startedBy = options.startedBy ?? 'terminal'; // Log environment info at startup diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index 91657ecad..d56eb8c6a 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -11,6 +11,7 @@ import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } f import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { PermissionModeSchema } from '@hapi/protocol/schemas'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { getSpawnedWorkingDirectory } from '@/utils/spawnHappyCLI'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; @@ -21,7 +22,7 @@ export async function runCodex(opts: { resumeSessionId?: string; model?: string; }): Promise { - const workingDirectory = process.cwd(); + const workingDirectory = getSpawnedWorkingDirectory(); const startedBy = opts.startedBy ?? 'terminal'; logger.debug(`[codex] Starting with options: startedBy=${startedBy}`); diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts index 124c7b4f4..1d775515e 100644 --- a/cli/src/cursor/runCursor.ts +++ b/cli/src/cursor/runCursor.ts @@ -10,6 +10,7 @@ import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } f import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { PermissionModeSchema } from '@hapi/protocol/schemas'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { getSpawnedWorkingDirectory } from '@/utils/spawnHappyCLI'; const formatFailureReason = (message: string): string => { const maxLength = 200; @@ -26,7 +27,7 @@ export async function runCursor(opts: { resumeSessionId?: string; model?: string; }): Promise { - const workingDirectory = process.cwd(); + const workingDirectory = getSpawnedWorkingDirectory(); const startedBy = opts.startedBy ?? 'terminal'; logger.debug(`[cursor] Starting with options: startedBy=${startedBy}`); diff --git a/cli/src/gemini/runGemini.ts b/cli/src/gemini/runGemini.ts index 5cef176b8..dd1de14db 100644 --- a/cli/src/gemini/runGemini.ts +++ b/cli/src/gemini/runGemini.ts @@ -14,6 +14,7 @@ import { resolveGeminiRuntimeConfig } from './utils/config'; import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { PermissionModeSchema } from '@hapi/protocol/schemas'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { getSpawnedWorkingDirectory } from '@/utils/spawnHappyCLI'; export async function runGemini(opts: { startedBy?: 'runner' | 'terminal'; @@ -21,7 +22,7 @@ export async function runGemini(opts: { permissionMode?: PermissionMode; model?: string; } = {}): Promise { - const workingDirectory = process.cwd(); + const workingDirectory = getSpawnedWorkingDirectory(); const startedBy = opts.startedBy ?? 'terminal'; logger.debug(`[gemini] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`); diff --git a/cli/src/opencode/runOpencode.ts b/cli/src/opencode/runOpencode.ts index 6888c36d4..97af4703c 100644 --- a/cli/src/opencode/runOpencode.ts +++ b/cli/src/opencode/runOpencode.ts @@ -12,6 +12,7 @@ import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { PermissionModeSchema } from '@hapi/protocol/schemas'; import { startOpencodeHookServer } from './utils/startOpencodeHookServer'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { getSpawnedWorkingDirectory } from '@/utils/spawnHappyCLI'; export async function runOpencode(opts: { startedBy?: 'runner' | 'terminal'; @@ -19,7 +20,7 @@ export async function runOpencode(opts: { permissionMode?: PermissionMode; resumeSessionId?: string; } = {}): Promise { - const workingDirectory = process.cwd(); + const workingDirectory = getSpawnedWorkingDirectory(); const startedBy = opts.startedBy ?? 'terminal'; logger.debug(`[opencode] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`); diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 3ae5ac441..c8f1e99d2 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -127,6 +127,7 @@ export async function startRunner(): Promise { // Session spawning awaiter system const pidToAwaiter = new Map void>(); + const pidToSpawnReject = new Map void>(); // Helper functions const getCurrentChildren = () => Array.from(pidToTrackedSession.values()); @@ -425,6 +426,7 @@ export async function startRunner(): Promise { // Set timeout for webhook const timeout = setTimeout(() => { pidToAwaiter.delete(pid); + pidToSpawnReject.delete(pid); logger.debug(`[RUNNER RUN] Session webhook timeout for PID ${pid}`); logStderrTail(); resolve({ @@ -435,9 +437,19 @@ export async function startRunner(): Promise { // even though session was still created successfully in ~2 more seconds }, 15_000); + // Register reject for early child exit + pidToSpawnReject.set(pid, (errorMessage) => { + clearTimeout(timeout); + pidToAwaiter.delete(pid); + pidToSpawnReject.delete(pid); + logStderrTail(); + resolve({ type: 'error', errorMessage }); + }); + // Register awaiter pidToAwaiter.set(pid, (completedSession) => { clearTimeout(timeout); + pidToSpawnReject.delete(pid); logger.debug(`[RUNNER RUN] Session ${completedSession.happySessionId} fully spawned with webhook`); resolve({ type: 'success', @@ -500,6 +512,12 @@ export async function startRunner(): Promise { const onChildExited = (pid: number) => { logger.debug(`[RUNNER RUN] Removing exited process PID ${pid} from tracking`); pidToTrackedSession.delete(pid); + + // Resolve any pending spawn awaiter immediately on child exit + const reject = pidToSpawnReject.get(pid); + if (reject) { + reject(`Agent process exited unexpectedly (PID ${pid})`); + } }; // Start control server diff --git a/cli/src/utils/spawnHappyCLI.ts b/cli/src/utils/spawnHappyCLI.ts index 12557872d..cb2f45b0e 100644 --- a/cli/src/utils/spawnHappyCLI.ts +++ b/cli/src/utils/spawnHappyCLI.ts @@ -77,6 +77,15 @@ export function getHappyCliCommand(args: string[]): HappyCliCommand { }; } +/** + * Get the real working directory for spawned CLI processes. + * In dev mode, spawnHappyCLI overrides cwd to cli/ for tsconfig resolution + * and passes the real project directory via HAPI_SPAWN_CWD. + */ +export function getSpawnedWorkingDirectory(): string { + return process.env.HAPI_SPAWN_CWD || process.cwd(); +} + export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): ChildProcess { let directory: string | URL | undefined; @@ -105,5 +114,21 @@ export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): Child } } + // In dev mode, Bun resolves @/ path aliases from tsconfig.json relative to cwd. + // Override cwd to cli/ so aliases resolve, and pass the real working directory via env. + if (!isBunCompiled() && options.cwd) { + const projectRoot = projectPath(); + const realCwd = typeof options.cwd === 'string' ? options.cwd : options.cwd.toString(); + options = { + ...options, + cwd: projectRoot, + env: { + ...options.env, + ...process.env, + HAPI_SPAWN_CWD: realCwd + } + }; + } + return spawn(spawnCommand, spawnArgs, options); }