Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cli/src/agent/runners/runAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
});

Expand Down Expand Up @@ -67,7 +67,7 @@ export async function runAgentSession(opts: {
];

const agentSessionId = await backend.newSession({
cwd: process.cwd(),
cwd: getSpawnedWorkingDirectory(),
mcpServers
});

Expand Down
3 changes: 2 additions & 1 deletion cli/src/claude/runClaude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -29,7 +30,7 @@ export interface StartOptions {
}

export async function runClaude(options: StartOptions = {}): Promise<void> {
const workingDirectory = process.cwd();
const workingDirectory = getSpawnedWorkingDirectory();
const startedBy = options.startedBy ?? 'terminal';

// Log environment info at startup
Expand Down
3 changes: 2 additions & 1 deletion cli/src/codex/runCodex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -21,7 +22,7 @@ export async function runCodex(opts: {
resumeSessionId?: string;
model?: string;
}): Promise<void> {
const workingDirectory = process.cwd();
const workingDirectory = getSpawnedWorkingDirectory();
const startedBy = opts.startedBy ?? 'terminal';

logger.debug(`[codex] Starting with options: startedBy=${startedBy}`);
Expand Down
3 changes: 2 additions & 1 deletion cli/src/cursor/runCursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,7 +27,7 @@ export async function runCursor(opts: {
resumeSessionId?: string;
model?: string;
}): Promise<void> {
const workingDirectory = process.cwd();
const workingDirectory = getSpawnedWorkingDirectory();
const startedBy = opts.startedBy ?? 'terminal';

logger.debug(`[cursor] Starting with options: startedBy=${startedBy}`);
Expand Down
3 changes: 2 additions & 1 deletion cli/src/gemini/runGemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ 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';
startingMode?: 'local' | 'remote';
permissionMode?: PermissionMode;
model?: string;
} = {}): Promise<void> {
const workingDirectory = process.cwd();
const workingDirectory = getSpawnedWorkingDirectory();
const startedBy = opts.startedBy ?? 'terminal';

logger.debug(`[gemini] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`);
Expand Down
3 changes: 2 additions & 1 deletion cli/src/opencode/runOpencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ 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';
startingMode?: 'local' | 'remote';
permissionMode?: PermissionMode;
resumeSessionId?: string;
} = {}): Promise<void> {
const workingDirectory = process.cwd();
const workingDirectory = getSpawnedWorkingDirectory();
const startedBy = opts.startedBy ?? 'terminal';

logger.debug(`[opencode] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`);
Expand Down
18 changes: 18 additions & 0 deletions cli/src/runner/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export async function startRunner(): Promise<void> {

// Session spawning awaiter system
const pidToAwaiter = new Map<number, (session: TrackedSession) => void>();
const pidToSpawnReject = new Map<number, (errorMessage: string) => void>();

// Helper functions
const getCurrentChildren = () => Array.from(pidToTrackedSession.values());
Expand Down Expand Up @@ -425,6 +426,7 @@ export async function startRunner(): Promise<void> {
// 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({
Expand All @@ -435,9 +437,19 @@ export async function startRunner(): Promise<void> {
// 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',
Expand Down Expand Up @@ -500,6 +512,12 @@ export async function startRunner(): Promise<void> {
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
Expand Down
25 changes: 25 additions & 0 deletions cli/src/utils/spawnHappyCLI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Loading