From caa76826f4d7c0cae5f715c31fcaf0d9eae1d412 Mon Sep 17 00:00:00 2001 From: ROOOO Date: Tue, 17 Mar 2026 22:55:23 +0800 Subject: [PATCH] fix(cli): preserve invoked cwd for local launcher (#299) --- cli/local-client-launcher.ts | 5 +++ cli/src/agent/runners/runAgentSession.ts | 6 ++- cli/src/agent/sessionFactory.ts | 3 +- cli/src/api/apiMachine.ts | 3 +- cli/src/claude/runClaude.ts | 3 +- cli/src/codex/runCodex.ts | 3 +- cli/src/cursor/runCursor.ts | 3 +- cli/src/gemini/runGemini.ts | 3 +- cli/src/opencode/runOpencode.ts | 3 +- cli/src/opencode/utils/opencodeBackend.ts | 3 +- cli/src/terminal/TerminalManager.ts | 3 +- cli/src/ui/doctor.ts | 3 +- cli/src/utils/invokedCwd.ts | 9 ++++ cli/src/utils/spawnHappyCLI.test.ts | 51 +++++++++++++++++++++++ cli/src/utils/spawnHappyCLI.ts | 39 +++++++++++++++-- cli/src/utils/worktreeEnv.ts | 3 +- 16 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 cli/local-client-launcher.ts create mode 100644 cli/src/utils/invokedCwd.ts diff --git a/cli/local-client-launcher.ts b/cli/local-client-launcher.ts new file mode 100644 index 00000000..7863c957 --- /dev/null +++ b/cli/local-client-launcher.ts @@ -0,0 +1,5 @@ +#!/usr/bin/env bun + +import { runCli } from './src/commands/runCli'; + +await runCli(); diff --git a/cli/src/agent/runners/runAgentSession.ts b/cli/src/agent/runners/runAgentSession.ts index e8f32cab..98f2fd04 100644 --- a/cli/src/agent/runners/runAgentSession.ts +++ b/cli/src/agent/runners/runAgentSession.ts @@ -11,6 +11,7 @@ import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; import { bootstrapSession } from '@/agent/sessionFactory'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { getInvokedCwd } from '@/utils/invokedCwd'; function emitReadyIfIdle(props: { queueSize: () => number; @@ -28,13 +29,14 @@ export async function runAgentSession(opts: { agentType: string; startedBy?: 'runner' | 'terminal'; }): Promise { + const workingDirectory = getInvokedCwd(); const initialState: AgentState = { controlledByUser: false }; const { session } = await bootstrapSession({ flavor: opts.agentType, startedBy: opts.startedBy ?? 'terminal', - workingDirectory: process.cwd(), + workingDirectory, agentState: initialState }); @@ -67,7 +69,7 @@ export async function runAgentSession(opts: { ]; const agentSessionId = await backend.newSession({ - cwd: process.cwd(), + cwd: workingDirectory, mcpServers }); diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index e9b3f591..ec0d2106 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -10,6 +10,7 @@ import { readSettings } from '@/persistence' import { configuration } from '@/configuration' import { logger } from '@/ui/logger' import { runtimePath } from '@/projectPath' +import { getInvokedCwd } from '@/utils/invokedCwd' import { readWorktreeEnv } from '@/utils/worktreeEnv' import packageJson from '../../package.json' @@ -105,7 +106,7 @@ async function reportSessionStarted(sessionId: string, metadata: Metadata): Prom } export async function bootstrapSession(options: SessionBootstrapOptions): Promise { - const workingDirectory = options.workingDirectory ?? process.cwd() + const workingDirectory = options.workingDirectory ?? getInvokedCwd() const startedBy = options.startedBy ?? 'terminal' const sessionTag = options.tag ?? randomUUID() const agentState = options.agentState === undefined ? {} : options.agentState diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 8c6ea7a0..24fbf89d 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -10,6 +10,7 @@ import type { Update, UpdateMachineBody } from '@hapi/protocol' import type { RunnerState, Machine, MachineMetadata } from './types' import { RunnerStateSchema, MachineMetadataSchema } from './types' import { backoff } from '@/utils/time' +import { getInvokedCwd } from '@/utils/invokedCwd' import { RpcHandlerManager } from './rpc/RpcHandlerManager' import { registerCommonHandlers } from '../modules/common/registerCommonHandlers' import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' @@ -77,7 +78,7 @@ export class ApiMachineClient { logger: (msg, data) => logger.debug(msg, data) }) - registerCommonHandlers(this.rpcHandlerManager, process.cwd()) + registerCommonHandlers(this.rpcHandlerManager, getInvokedCwd()) this.rpcHandlerManager.registerHandler('path-exists', async (params) => { const rawPaths = Array.isArray(params?.paths) ? params.paths : [] diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 920d0398..42d514c1 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -18,6 +18,7 @@ import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { PermissionModeSchema } from '@hapi/protocol/schemas'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { normalizeClaudeSessionModel } from './model'; +import { getInvokedCwd } from '@/utils/invokedCwd'; export interface StartOptions { model?: string @@ -30,7 +31,7 @@ export interface StartOptions { } export async function runClaude(options: StartOptions = {}): Promise { - const workingDirectory = process.cwd(); + const workingDirectory = getInvokedCwd(); 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 bab692fb..55fd1c56 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 { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { getInvokedCwd } from '@/utils/invokedCwd'; 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 = getInvokedCwd(); 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 7e3e5fa2..86328d61 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 { getInvokedCwd } from '@/utils/invokedCwd'; 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 = getInvokedCwd(); 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 3c312c38..704d440b 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 { getInvokedCwd } from '@/utils/invokedCwd'; 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 = getInvokedCwd(); 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 6888c36d..9498f9bf 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 { getInvokedCwd } from '@/utils/invokedCwd'; 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 = getInvokedCwd(); const startedBy = opts.startedBy ?? 'terminal'; logger.debug(`[opencode] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`); diff --git a/cli/src/opencode/utils/opencodeBackend.ts b/cli/src/opencode/utils/opencodeBackend.ts index ea0da22a..b49a3455 100644 --- a/cli/src/opencode/utils/opencodeBackend.ts +++ b/cli/src/opencode/utils/opencodeBackend.ts @@ -1,5 +1,6 @@ import { AcpSdkBackend } from '@/agent/backends/acp'; import { buildOpencodeEnv } from './config'; +import { getInvokedCwd } from '@/utils/invokedCwd'; function filterEnv(env: NodeJS.ProcessEnv): Record { const result: Record = {}; @@ -15,7 +16,7 @@ export function createOpencodeBackend(opts: { cwd?: string; }): AcpSdkBackend { const env = buildOpencodeEnv(); - const args = ['acp', '--cwd', opts.cwd ?? process.cwd()]; + const args = ['acp', '--cwd', opts.cwd ?? getInvokedCwd()]; return new AcpSdkBackend({ command: 'opencode', diff --git a/cli/src/terminal/TerminalManager.ts b/cli/src/terminal/TerminalManager.ts index b7bfecca..07f18298 100644 --- a/cli/src/terminal/TerminalManager.ts +++ b/cli/src/terminal/TerminalManager.ts @@ -1,4 +1,5 @@ import { logger } from '@/ui/logger' +import { getInvokedCwd } from '@/utils/invokedCwd' import type { TerminalErrorPayload, TerminalExitPayload, @@ -120,7 +121,7 @@ export class TerminalManager { return } - const sessionPath = this.getSessionPath() ?? process.cwd() + const sessionPath = this.getSessionPath() ?? getInvokedCwd() const shell = resolveShell() const decoder = new TextDecoder() diff --git a/cli/src/ui/doctor.ts b/cli/src/ui/doctor.ts index 62e905e5..b0a6fb2c 100644 --- a/cli/src/ui/doctor.ts +++ b/cli/src/ui/doctor.ts @@ -15,6 +15,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { isBunCompiled, projectPath, runtimePath } from '@/projectPath' +import { getInvokedCwd } from '@/utils/invokedCwd' import packageJson from '../../package.json' /** @@ -30,7 +31,7 @@ export function getEnvironmentInfo(): Record { DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING: process.env.DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING, NODE_ENV: process.env.NODE_ENV, DEBUG: process.env.DEBUG, - workingDirectory: process.cwd(), + workingDirectory: getInvokedCwd(), processArgv: process.argv, happyDir: configuration?.happyHomeDir, apiUrl: configuration?.apiUrl, diff --git a/cli/src/utils/invokedCwd.ts b/cli/src/utils/invokedCwd.ts new file mode 100644 index 00000000..115f86e6 --- /dev/null +++ b/cli/src/utils/invokedCwd.ts @@ -0,0 +1,9 @@ +import { isAbsolute } from 'node:path'; + +export function getInvokedCwd(): string { + const invokedCwd = process.env.HAPI_INVOKED_CWD?.trim(); + if (invokedCwd && isAbsolute(invokedCwd)) { + return invokedCwd; + } + return process.cwd(); +} diff --git a/cli/src/utils/spawnHappyCLI.test.ts b/cli/src/utils/spawnHappyCLI.test.ts index 584e3099..dd8281cc 100644 --- a/cli/src/utils/spawnHappyCLI.test.ts +++ b/cli/src/utils/spawnHappyCLI.test.ts @@ -12,6 +12,7 @@ vi.mock('child_process', async () => { }); const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); +const originalInvokedCwd = process.env.HAPI_INVOKED_CWD; function setPlatform(value: string) { Object.defineProperty(process, 'platform', { @@ -39,6 +40,11 @@ describe('spawnHappyCLI windowsHide behavior', () => { beforeEach(() => { vi.clearAllMocks(); + if (originalInvokedCwd === undefined) { + delete process.env.HAPI_INVOKED_CWD; + } else { + process.env.HAPI_INVOKED_CWD = originalInvokedCwd; + } }); afterAll(() => { @@ -88,4 +94,49 @@ describe('spawnHappyCLI windowsHide behavior', () => { expect(options.detached).toBe(true); expect('windowsHide' in options).toBe(false); }); + + it('forces Bun child processes to run with the cli project root as cwd', async () => { + const { getHappyCliCommand } = await import('./spawnHappyCLI'); + + const command = getHappyCliCommand(['mcp', '--url', 'http://127.0.0.1:1234/']); + const isBunRuntime = Boolean((process.versions as Record).bun); + + expect(command.command).toBe(process.execPath); + if (isBunRuntime) { + expect(command.args[0]).toBe('--cwd'); + expect(command.args[1].replace(/\\/g, '/')).toMatch(/\/hapi\/cli$/); + expect(command.args[2].replace(/\\/g, '/')).toMatch(/\/hapi\/cli\/src\/index\.ts$/); + } else { + expect(command.args.some((arg) => arg.replace(/\\/g, '/').endsWith('/hapi/cli/src/index.ts'))).toBe(true); + } + }); + + it('passes invoked workspace cwd to child processes when cwd is provided', async () => { + const { spawnHappyCLI } = await import('./spawnHappyCLI'); + const childCwd = 'C:\\workspace\\project'; + + spawnHappyCLI(['runner', 'start-sync'], { + cwd: childCwd, + stdio: 'ignore' + }); + + const options = getSpawnOptionsOrThrow(); + expect(options.env?.HAPI_INVOKED_CWD).toBe(childCwd); + }); + + it('keeps an existing absolute HAPI_INVOKED_CWD when provided explicitly', async () => { + const { spawnHappyCLI } = await import('./spawnHappyCLI'); + const inheritedInvokedCwd = 'C:\\workspace\\other-project'; + + spawnHappyCLI(['runner', 'start-sync'], { + cwd: 'C:\\workspace\\project', + env: { + HAPI_INVOKED_CWD: inheritedInvokedCwd + }, + stdio: 'ignore' + }); + + const options = getSpawnOptionsOrThrow(); + expect(options.env?.HAPI_INVOKED_CWD).toBe(inheritedInvokedCwd); + }); }); diff --git a/cli/src/utils/spawnHappyCLI.ts b/cli/src/utils/spawnHappyCLI.ts index 89b91817..d9f4062f 100644 --- a/cli/src/utils/spawnHappyCLI.ts +++ b/cli/src/utils/spawnHappyCLI.ts @@ -26,7 +26,8 @@ */ import { spawn, SpawnOptions, type ChildProcess } from 'child_process'; -import { join } from 'node:path'; +import { join, isAbsolute, resolve, win32 } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { isBunCompiled, projectPath } from '@/projectPath'; import { logger } from '@/ui/logger'; import { existsSync } from 'node:fs'; @@ -48,6 +49,28 @@ export interface HappyCliCommand { args: string[]; } +function isCrossPlatformAbsolutePath(value: string): boolean { + return isAbsolute(value) || win32.isAbsolute(value); +} + +function resolveInvokedCwd(cwd: SpawnOptions['cwd']): string { + if (cwd instanceof URL) { + return fileURLToPath(cwd); + } + + if (typeof cwd === 'string' && cwd.trim().length > 0) { + const normalizedCwd = cwd.trim(); + return isCrossPlatformAbsolutePath(normalizedCwd) ? normalizedCwd : resolve(normalizedCwd); + } + + const inheritedInvokedCwd = process.env.HAPI_INVOKED_CWD?.trim(); + if (inheritedInvokedCwd && isCrossPlatformAbsolutePath(inheritedInvokedCwd)) { + return inheritedInvokedCwd; + } + + return process.cwd(); +} + export function getHappyCliCommand(args: string[]): HappyCliCommand { // Compiled binary mode: just use the executable directly if (isBunCompiled()) { @@ -63,10 +86,12 @@ export function getHappyCliCommand(args: string[]): HappyCliCommand { const isBunRuntime = Boolean((process.versions as Record).bun); if (isBunRuntime) { - // Bun can run TypeScript directly + // Bun can run TypeScript directly. + // Force Bun's cwd to the CLI project root so alias resolution via bunfig.toml + // keeps working even when external tools launch HAPI from another workspace. return { command: process.execPath, - args: [entrypoint, ...args] + args: ['--cwd', projectRoot, entrypoint, ...args] }; } @@ -108,6 +133,14 @@ export function spawnHappyCLI(args: string[], options: SpawnOptions = {}): Child // On Windows, detached processes allocate a new console window by default. // windowsHide: true suppresses this to prevent cmd windows from accumulating. const finalOptions: SpawnOptions = { ...options }; + if (!isBunCompiled()) { + const finalEnv = { ...process.env, ...options.env }; + const invokedCwd = finalEnv.HAPI_INVOKED_CWD?.trim(); + finalEnv.HAPI_INVOKED_CWD = invokedCwd && isCrossPlatformAbsolutePath(invokedCwd) + ? invokedCwd + : resolveInvokedCwd(options.cwd); + finalOptions.env = finalEnv; + } if (process.platform === 'win32' && options.detached) { finalOptions.windowsHide = true; } diff --git a/cli/src/utils/worktreeEnv.ts b/cli/src/utils/worktreeEnv.ts index 720883a8..f8321881 100644 --- a/cli/src/utils/worktreeEnv.ts +++ b/cli/src/utils/worktreeEnv.ts @@ -4,6 +4,7 @@ import { basename, dirname, isAbsolute, resolve } from 'node:path'; import type { WorktreeInfo } from '@/runner/worktree'; import { logger } from '@/ui/logger'; +import { getInvokedCwd } from '@/utils/invokedCwd'; export function readWorktreeEnv(): WorktreeInfo | null { return readWorktreeFromEnv() ?? readWorktreeFromGit(); @@ -39,7 +40,7 @@ function readWorktreeFromGit(): WorktreeInfo | null { let result: WorktreeInfo | null = null; try { - const cwd = process.cwd(); + const cwd = getInvokedCwd(); const isInside = runGit(['rev-parse', '--is-inside-work-tree'], cwd); if (isInside !== 'true') { return null;