Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7c0e187
fix: resolve Windows CLI spawn ENOENT error in Agent Manager
marius-kilocode Dec 19, 2025
e27bd9a
fix: capture shell PATH for CLI spawn on macOS
marius-kilocode Dec 19, 2025
0c6b142
Merge remote-tracking branch 'origin/main' into kilocode-win-fix
marius-kilocode Dec 19, 2025
b50f1f2
fix: address PR review comments
marius-kilocode Dec 19, 2025
36b9bf8
fix: merge Windows CLI spawn fix with macOS PATH fix
marius-kilocode Dec 19, 2025
23fadd7
Merge remote-tracking branch 'origin/kilocode-win-fix'
marius-kilocode Dec 19, 2025
ff77e24
chore: restore slackbot.md to match main
marius-kilocode Dec 19, 2025
ddc6fc6
fix: add missing getPlatformDiagnostics mock in AgentManagerProvider …
marius-kilocode Dec 19, 2025
d0160a5
fix: use platform-appropriate paths in Windows tests
marius-kilocode Dec 19, 2025
e9bdb3c
Merge remote-tracking branch 'origin/kilocode-win-fix'
marius-kilocode Dec 19, 2025
268a5c8
fix: separate Windows simulation tests from native Windows tests
marius-kilocode Dec 19, 2025
285c427
fix: remove platform simulation tests, keep only native platform tests
marius-kilocode Dec 19, 2025
ca63cc7
Merge branch 'kilocode-win-fix'
marius-kilocode Dec 19, 2025
665f4bd
fix: handle spawnSync errors in getLoginShellPath
marius-kilocode Dec 19, 2025
8771621
Merge origin/main into 4579_agent_manager_fails_to_start_local
marius-kilocode Dec 19, 2025
8ca5992
fix: update CliSessionLauncher for CliDiscoveryResult return type
marius-kilocode Dec 19, 2025
af6aaf6
chore: add changeset for macOS CLI spawn PATH fix
marius-kilocode Dec 19, 2025
1d4dd90
fix: use platform-aware path in CliPathResolver mock for Windows CI
marius-kilocode Dec 19, 2025
6014298
Fix remaining cli path discovery issues
marius-kilocode Dec 20, 2025
43e5190
Merge branch 'main' into 4579_agent_manager_fails_to_start_local
marius-kilocode Dec 22, 2025
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
5 changes: 5 additions & 0 deletions .changeset/fix-macos-cli-spawn-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Fix Agent Manager failing to start on macOS when launched from Finder/Spotlight
79 changes: 54 additions & 25 deletions src/core/kilocode/agent-manager/AgentManagerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,21 @@ import {
isParallelModeCompletionMessage,
parseParallelModeCompletionBranch,
} from "./parallelModeParser"
import { findKilocodeCli, type CliDiscoveryResult } from "./CliPathResolver"
import { canInstallCli, getCliInstallCommand, getLocalCliInstallCommand, getLocalCliBinDir } from "./CliInstaller"
import { CliProcessHandler, type CliProcessHandlerCallbacks } from "./CliProcessHandler"
import type { StreamEvent, KilocodeStreamEvent, KilocodePayload, WelcomeStreamEvent } from "./CliOutputParser"
import { extractRawText, tryParsePayloadJson } from "./askErrorParser"
import { RemoteSessionService } from "./RemoteSessionService"
import { KilocodeEventProcessor } from "./KilocodeEventProcessor"
import { CliSessionLauncher } from "./CliSessionLauncher"
import type { RemoteSession } from "./types"
import { getUri } from "../../webview/getUri"
import { getNonce } from "../../webview/getNonce"
import { getViteDevServerConfig } from "../../webview/getViteDevServerConfig"
import { getRemoteUrl } from "../../../services/code-index/managed/git-utils"
import { normalizeGitUrl } from "./normalizeGitUrl"
import type { ClineMessage, ProviderSettings } from "@roo-code/types"
import type { ClineMessage } from "@roo-code/types"
import type { ProviderSettings } from "@roo-code/types"
import {
captureAgentManagerOpened,
captureAgentManagerSessionStarted,
Expand Down Expand Up @@ -53,7 +54,6 @@ export class AgentManagerProvider implements vscode.Disposable {
private remoteSessionService: RemoteSessionService
private processHandler: CliProcessHandler
private eventProcessor: KilocodeEventProcessor
private sessionLauncher: CliSessionLauncher
private sessionMessages: Map<string, ClineMessage[]> = new Map()
// Track first api_req_started per session to filter user-input echoes
private firstApiReqStarted: Map<string, boolean> = new Map()
Expand All @@ -73,12 +73,6 @@ export class AgentManagerProvider implements vscode.Disposable {
this.registry = new AgentRegistry()
this.remoteSessionService = new RemoteSessionService({ outputChannel })

// Initialize session launcher with pre-warming
// Pre-warming starts slow lookups (CLI: 500-2000ms, git: 50-100ms) immediately
// so they complete before the user clicks "Start" to reduce time-to-first-token
this.sessionLauncher = new CliSessionLauncher(outputChannel, () => this.getApiConfigurationForCli())
this.sessionLauncher.startPrewarm()

// Initialize currentGitUrl from workspace
void this.initializeCurrentGitUrl()

Expand Down Expand Up @@ -204,8 +198,6 @@ export class AgentManagerProvider implements vscode.Disposable {
() => {
this.panel = undefined
this.stopAllAgents()
// Clear pre-warm state when panel closes
this.sessionLauncher.clearPrewarm()
},
null,
this.disposables,
Expand All @@ -217,7 +209,7 @@ export class AgentManagerProvider implements vscode.Disposable {
captureAgentManagerOpened()
}

/** Rename session key in all session-keyed maps. */
/** Rename session key in all session-keyed maps when provisional session is upgraded. */
private handleSessionRenamed(oldId: string, newId: string): void {
this.outputChannel.appendLine(`[AgentManager] Renaming session: ${oldId} -> ${newId}`)

Expand Down Expand Up @@ -444,23 +436,36 @@ export class AgentManagerProvider implements vscode.Disposable {
return
}

// Get workspace folder early to fetch git URL before spawning
// Note: we intentionally allow starting parallel mode from within an existing git worktree.
// Git worktrees share a common .git dir, so `git worktree add/remove` still works from a worktree root.
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath

// Get git URL for the workspace (used for filtering sessions)
let gitUrl: string | undefined
if (workspaceFolder) {
try {
gitUrl = normalizeGitUrl(await getRemoteUrl(workspaceFolder))
} catch (error) {
this.outputChannel.appendLine(
`[AgentManager] Could not get git URL: ${error instanceof Error ? error.message : String(error)}`,
)
}
}

const onSetupFailed = () => {
if (!workspaceFolder) {
void vscode.window.showErrorMessage("Please open a folder before starting an agent.")
}
this.postMessage({ type: "agentManager.startSessionFailed" })
}

// Git URL lookup is now handled by spawnCliWithCommonSetup using pre-warmed promise
await this.spawnCliWithCommonSetup(
prompt,
{
parallelMode: options?.parallelMode,
label: options?.labelOverride,
gitUrl,
existingBranch: options?.existingBranch,
},
onSetupFailed,
Expand All @@ -474,7 +479,7 @@ export class AgentManagerProvider implements vscode.Disposable {

/**
* Common helper to spawn a CLI process with standard setup.
* Delegates to CliSessionLauncher for pre-warming and spawning.
* Handles CLI path lookup, workspace folder validation, API config, and event callback wiring.
* @returns true if process was spawned, false if setup failed
*/
private async spawnCliWithCommonSetup(
Expand All @@ -488,23 +493,47 @@ export class AgentManagerProvider implements vscode.Disposable {
},
onSetupFailed?: () => void,
): Promise<boolean> {
const result = await this.sessionLauncher.spawn(
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceFolder) {
this.outputChannel.appendLine("ERROR: No workspace folder open")
onSetupFailed?.()
return false
}

const cliDiscovery = await findKilocodeCli((msg) => this.outputChannel.appendLine(`[AgentManager] ${msg}`))
if (!cliDiscovery) {
this.outputChannel.appendLine("ERROR: kilocode CLI not found")
this.showCliNotFoundError()
onSetupFailed?.()
return false
}

const processStartTime = Date.now()
let apiConfiguration: ProviderSettings | undefined
try {
apiConfiguration = await this.getApiConfigurationForCli()
} catch (error) {
this.outputChannel.appendLine(
`[AgentManager] Failed to read provider settings for CLI: ${
error instanceof Error ? error.message : String(error)
}`,
)
}

this.processHandler.spawnProcess(
cliDiscovery.cliPath,
workspaceFolder,
prompt,
options,
this.processHandler,
{ ...options, apiConfiguration, shellPath: cliDiscovery.shellPath },
(sid, event) => {
if (result.processStartTime && !this.processStartTimes.has(sid)) {
this.processStartTimes.set(sid, result.processStartTime)
if (!this.processStartTimes.has(sid)) {
this.processStartTimes.set(sid, processStartTime)
}
this.handleCliEvent(sid, event)
},
() => {
this.showCliNotFoundError()
onSetupFailed?.()
},
)

return result.success
return true
}

/**
Expand Down Expand Up @@ -1138,7 +1167,7 @@ export class AgentManagerProvider implements vscode.Disposable {
this.processHandler.dispose()
this.sessionMessages.clear()
this.firstApiReqStarted.clear()
this.sessionLauncher.clearPrewarm()

this.panel?.dispose()
this.disposables.forEach((d) => d.dispose())
}
Expand Down
98 changes: 90 additions & 8 deletions src/core/kilocode/agent-manager/CliPathResolver.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import * as path from "node:path"
import * as fs from "node:fs"
import { execSync } from "node:child_process"
import { execSync, spawnSync } from "node:child_process"
import { fileExistsAtPath } from "../../../utils/fs"
import { getLocalCliPath } from "./CliInstaller"

/**
* Result of CLI discovery including optional shell environment.
* The shellPath is captured from login shell lookup on macOS/Linux to ensure
* the spawned CLI process has access to the same tools (like git) that were
* available when finding the CLI.
*/
export interface CliDiscoveryResult {
cliPath: string
/** PATH from login shell - use this when spawning CLI on macOS to ensure tools like git are available */
shellPath?: string
}

/**
* Case-insensitive lookup for environment variables.
* Windows environment variables can have inconsistent casing (PATH, Path, path).
Expand Down Expand Up @@ -111,8 +123,15 @@ export async function findExecutable(
* - Direct PATH might find stale system-wide installations (e.g., old homebrew version)
* - When we auto-update via `npm install -g`, it installs to the user's node (nvm etc.)
* - So we need to find the CLI in the same location where updates go
*
* @returns CliDiscoveryResult with cliPath and optional shellPath for spawning
*/
export async function findKilocodeCli(log?: (msg: string) => void): Promise<string | null> {
export async function findKilocodeCli(log?: (msg: string) => void): Promise<CliDiscoveryResult | null> {
// Capture shell PATH early for use when spawning (macOS/Linux only)
// This ensures the CLI has access to the same tools (git, etc.) that were available
// when finding it, even when the editor is launched from Finder/Spotlight
const shellEnv = process.platform !== "win32" ? getLoginShellPath(log) : undefined

// 1) Explicit override from settings
try {
// Lazy import avoids hard dep when running in non-extension contexts
Expand All @@ -122,7 +141,7 @@ export async function findKilocodeCli(log?: (msg: string) => void): Promise<stri
if (overridePath) {
log?.(`Using CLI path override from settings: ${overridePath}`)
if (await fileExistsAtPath(overridePath)) {
return overridePath
return { cliPath: overridePath, shellPath: shellEnv }
}
log?.(`WARNING: Override path does not exist: ${overridePath}`)
}
Expand All @@ -133,7 +152,7 @@ export async function findKilocodeCli(log?: (msg: string) => void): Promise<stri
const localCli = path.join(workspacePath, "cli", "dist", "index.js")
if (await fileExistsAtPath(localCli)) {
log?.(`Using workspace CLI: ${localCli}`)
return localCli
return { cliPath: localCli, shellPath: shellEnv }
}
}
} catch (error) {
Expand All @@ -144,18 +163,18 @@ export async function findKilocodeCli(log?: (msg: string) => void): Promise<stri
const localCliPath = getLocalCliPath()
if (await fileExistsAtPath(localCliPath)) {
log?.(`Found local CLI installation: ${localCliPath}`)
return localCliPath
return { cliPath: localCliPath, shellPath: shellEnv }
}

// 4) Try login shell FIRST to pick up user's shell environment (nvm, fnm, volta, asdf, etc.)
const loginShellResult = findViaLoginShell(log)
if (loginShellResult) return loginShellResult
if (loginShellResult) return { cliPath: loginShellResult, shellPath: shellEnv }

// 5) Use findExecutable to resolve CLI path (handles PATHEXT on Windows)
const executablePath = await findExecutable("kilocode")
if (executablePath) {
log?.(`Found CLI via PATH: ${executablePath}`)
return executablePath
return { cliPath: executablePath, shellPath: shellEnv }
}
log?.("kilocode not found in PATH lookup")

Expand All @@ -165,7 +184,7 @@ export async function findKilocodeCli(log?: (msg: string) => void): Promise<stri
try {
if (await fileExistsAtPath(candidate)) {
log?.(`Found CLI at: ${candidate}`)
return candidate
return { cliPath: candidate, shellPath: shellEnv }
}
} catch (error) {
log?.(`Error checking path ${candidate}: ${error}`)
Expand All @@ -176,6 +195,69 @@ export async function findKilocodeCli(log?: (msg: string) => void): Promise<stri
return null
}

/**
* Get the PATH from the user's login shell.
* This is essential on macOS when the editor is launched from Finder/Spotlight,
* as the extension host doesn't inherit the user's shell environment.
* The captured PATH ensures spawned CLI processes can access tools like git.
*
* Uses markers to reliably extract PATH even if shell startup scripts print
* banners, warnings, or other output that would otherwise pollute the result.
*/
function getLoginShellPath(log?: (msg: string) => void): string | undefined {
if (process.platform === "win32") {
return undefined
}

const userShell = process.env.SHELL || "/bin/bash"
const shellName = path.basename(userShell)

// Use -i -l (interactive + login) to source both .zprofile/.bash_profile AND .zshrc/.bashrc
// stdio: ['ignore', 'pipe', 'pipe'] prevents stdin from blocking
const shellArgs = shellName === "tcsh" || shellName === "csh" ? ["-ic"] : ["-i", "-l", "-c"]

// Use markers to reliably extract PATH even if shell prints banners/warnings
const startMarker = "__KILO_PATH_START__"
const endMarker = "__KILO_PATH_END__"
const command = `printf '${startMarker}%s${endMarker}\\n' "$PATH"`

try {
const result = spawnSync(userShell, [...shellArgs, command], {
encoding: "utf-8",
timeout: 10000,
env: { ...process.env, HOME: process.env.HOME },
stdio: ["ignore", "pipe", "pipe"], // stdin ignored, stdout/stderr captured
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
})

if (result.error) {
log?.(`Could not capture shell PATH: ${result.error}`)
return undefined
}

const output = result.stdout ?? ""

// Extract PATH from between markers
const startIdx = output.indexOf(startMarker)
const endIdx = output.indexOf(endMarker)

if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
log?.(`Could not find PATH markers in shell output`)
return undefined
}

const shellPath = output.slice(startIdx + startMarker.length, endIdx)

if (shellPath && shellPath !== process.env.PATH) {
log?.(`Captured shell PATH (${shellPath.split(":").length} entries)`)
return shellPath
}
} catch (error) {
log?.(`Could not capture shell PATH: ${error}`)
}

return undefined
}

/**
* Try to find kilocode by running `which` in a login shell.
* This sources the user's shell profile (~/.zshrc, ~/.bashrc, etc.)
Expand Down
Loading