Fix spawn ENOENT for GUI-launched VS Code

GUI launches (Dock/Finder/Spotlight) inherit a minimal system PATH that omits Homebrew, Cargo, ~/.local/bin, etc., so the `sigit` agent failed to spawn with `spawn sigit ENOENT` even when installed. Resolve the agent command against an augmented PATH (process PATH + login-shell PATH + common install dirs) and spawn the absolute path. Show an actionable error with Open Settings / Install Guide actions when the binary is genuinely missing, and dispose the connection on spawn error so the chat no longer hangs on a never-resolved request. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

paydii committed Jun 26, 2026 at 00:01 UTC a32733cae8018780fe7eed5119da82c4f1ef8cab
5 files changed +251 -6
CHANGELOG.md
+14
index 1a4b052..379b1fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to the **siGit Code** extension are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.1] - 2026-06-25 + +### Fixed + +- Agent failed to launch with `spawn sigit ENOENT` when VS Code was started from + the GUI (Dock, Finder, Spotlight). GUI launches inherit a minimal system PATH + that omits common install dirs (Homebrew, Cargo, `~/.local/bin`, …), so the + `sigit` binary couldn't be found even when installed. The agent command is now + resolved against an augmented PATH that adds those dirs plus the user's + login-shell PATH. +- A missing agent binary now shows an actionable error with **Open Settings** and + **Install Guide** actions instead of a raw `ENOENT`, and no longer leaves the + chat stuck on a hanging request. + ## [1.0.0] - 2026-06-25 ### Added
package.json
+1 -1
index 5918e1c..7138008 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "sigit-code", "displayName": "siGit Code — On-device AI Coding Agent", "description": "Local-first AI coding agent for VS Code. Runs the on-device sigit agent, and other ACP-compatible agents, over stdio.", - "version": "1.0.0", + "version": "1.0.1", "publisher": "getsigit", "license": "MIT", "packageManager": "pnpm@10.33.0",
src/acp/client.ts
+34 -3
index 29ed55a..2201e5b 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -1,6 +1,21 @@ import { ChildProcessWithoutNullStreams, spawn } from "child_process"; import { EventEmitter } from "events"; +import { delimiter } from "path"; import { Connection } from "./connection"; +import { augmentedPath, resolveExecutable } from "./resolveCommand"; + +/** Thrown when the agent executable cannot be located on the (augmented) PATH. */ +export class AgentNotFoundError extends Error { + readonly code = "AGENT_NOT_FOUND"; + constructor(readonly command: string) { + super( + `Could not find the "${command}" executable. Install the agent from ` + + `https://code.sigit.si and make sure it is on your PATH, or set an ` + + `absolute "command" path in the "sigit.agents" setting.` + ); + this.name = "AgentNotFoundError"; + } +} /** * AcpClient — spawns an Agent Client Protocol agent over stdio and drives the @@ -62,14 +77,30 @@ export class AcpClient extends EventEmitter { /** Spawn the agent process and wire up the JSON-RPC connection. */ spawn(config: AgentSpawnConfig): void { - const env = { ...process.env, ...(config.env ?? {}) }; - const child = spawn(config.command, config.args ?? [], { + // GUI-launched VS Code inherits a minimal PATH; augment it so the agent + // (and any subprocess it spawns) can be found and can find its own tools. + const path = augmentedPath(); + const env = { ...process.env, ...(config.env ?? {}), PATH: path }; + if (config.env?.PATH) { + env.PATH = `${config.env.PATH}${delimiter}${path}`; + } + + const resolved = resolveExecutable(config.command, env.PATH); + if (!resolved) { + throw new AgentNotFoundError(config.command); + } + + const child = spawn(resolved, config.args ?? [], { cwd: config.cwd, env, stdio: ["pipe", "pipe", "pipe"] }); - child.on("error", (err) => this.emit("error", err)); + child.on("error", (err) => { + this.emit("error", err); + // Unblock any in-flight request (e.g. initialize) instead of hanging. + this.connection?.dispose(); + }); child.on("exit", (code, signal) => this.emit("exit", code, signal)); child.stderr.on("data", (chunk: Buffer) => this.emit("stderr", chunk.toString()));
src/acp/resolveCommand.ts
+169
new file mode 100644 index 0000000..f86c49f --- /dev/null +++ b/src/acp/resolveCommand.ts @@ -0,0 +1,169 @@ +import { execFileSync } from "child_process"; +import { accessSync, constants, statSync } from "fs"; +import { homedir } from "os"; +import { delimiter, isAbsolute, join } from "path"; + +/** + * Locating the agent binary across launch contexts. + * + * When VS Code is launched from a GUI (Dock, Finder, Spotlight) rather than a + * terminal, the process inherits a minimal system PATH that omits the dirs + * where developer tools are usually installed (Homebrew, Cargo, ~/.local/bin, + * etc.). A `command` like `sigit` then fails to spawn with ENOENT even though + * it runs fine from the user's terminal. We work around this by resolving the + * command against an *augmented* PATH that adds the common install locations + * and, on macOS/Linux, the user's real login-shell PATH. + */ + +let cachedLoginPath: string | null | undefined; + +/** Common install directories that GUI launches frequently miss. */ +function commonBinDirs(): string[] { + const home = homedir(); + if (process.platform === "win32") { + return []; + } + return [ + "/usr/local/bin", + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + join(home, ".local", "bin"), + join(home, "bin"), + join(home, ".cargo", "bin"), + join(home, ".bun", "bin"), + join(home, ".deno", "bin"), + join(home, "go", "bin"), + join(home, ".sigit", "bin") + ]; +} + +/** + * The PATH exported by the user's interactive login shell. GUI-launched apps on + * macOS don't inherit this, so we ask the shell directly. Cached after the + * first (best-effort, time-boxed) lookup. + */ +function loginShellPath(): string | undefined { + if (cachedLoginPath !== undefined) { + return cachedLoginPath ?? undefined; + } + cachedLoginPath = null; + if (process.platform === "win32") { + return undefined; + } + const shell = process.env.SHELL || "/bin/zsh"; + try { + // `-ilc` → interactive login shell running one command. We echo a sentinel + // around PATH so noisy rc-file output doesn't corrupt the value. + const out = execFileSync(shell, ["-ilc", 'printf "__SIGIT_PATH__%s__SIGIT_END__" "$PATH"'], { + encoding: "utf8", + timeout: 3000, + stdio: ["ignore", "pipe", "ignore"] + }); + const match = /__SIGIT_PATH__([\s\S]*?)__SIGIT_END__/.exec(out); + if (match && match[1]) { + cachedLoginPath = match[1]; + } + } catch { + // Shell missing, slow, or non-interactive — fall back to common dirs only. + } + return cachedLoginPath ?? undefined; +} + +/** + * A PATH string combining the current process PATH, the login-shell PATH, and + * the common install dirs — de-duplicated, original order preserved. + */ +export function augmentedPath(): string { + const parts: string[] = []; + const seen = new Set<string>(); + const push = (value: string | undefined) => { + if (!value) { + return; + } + for (const dir of value.split(delimiter)) { + if (dir && !seen.has(dir)) { + seen.add(dir); + parts.push(dir); + } + } + }; + push(process.env.PATH); + push(loginShellPath()); + for (const dir of commonBinDirs()) { + push(dir); + } + return parts.join(delimiter); +} + +function isExecutableFile(p: string): boolean { + try { + if (!statSync(p).isFile()) { + return false; + } + if (process.platform === "win32") { + return true; + } + accessSync(p, constants.X_OK); + return true; + } catch { + return false; + } +} + +/** On Windows, the suffixes that make a bare command name executable. */ +function windowsExts(): string[] { + const pathext = process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD"; + return pathext.split(";").filter(Boolean); +} + +/** + * Resolve `command` to an absolute executable path, searching `pathString` + * (defaults to the augmented PATH). Returns undefined when nothing matches. + * + * An absolute or path-qualified command is returned as-is when it points at an + * executable file, so explicit user configuration always wins. + */ +export function resolveExecutable(command: string, pathString = augmentedPath()): string | undefined { + if (!command) { + return undefined; + } + + const hasPathSep = command.includes("/") || command.includes("\\"); + if (isAbsolute(command) || hasPathSep) { + if (isExecutableFile(command)) { + return command; + } + if (process.platform === "win32") { + for (const ext of windowsExts()) { + const candidate = command + ext; + if (isExecutableFile(candidate)) { + return candidate; + } + } + } + return undefined; + } + + for (const dir of pathString.split(delimiter)) { + if (!dir) { + continue; + } + const base = join(dir, command); + if (isExecutableFile(base)) { + return base; + } + if (process.platform === "win32") { + for (const ext of windowsExts()) { + const candidate = base + ext; + if (isExecutableFile(candidate)) { + return candidate; + } + } + } + } + return undefined; +}
src/chatView.ts
+33 -2
index 09249d4..07ff582 100644 --- a/src/chatView.ts +++ b/src/chatView.ts @@ -99,7 +99,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { try { await this.ensureClient(); } catch (err) { - this.postError(`Failed to start agent: ${(err as Error).message}`); + this.reportAgentError(err as Error, resolveAgent(this.activeAgentKey).command); return; } if (!this.client) { @@ -145,7 +145,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { client.on("update", (update: SessionUpdate) => this.handleUpdate(update)); client.on("stderr", (chunk: string) => this.post({ type: "log", text: chunk })); - client.on("error", (err: Error) => this.postError(`Agent error: ${err.message}`)); + client.on("error", (err: Error) => this.reportAgentError(err, agent.command)); client.on("exit", (code: number | null) => { this.postStatus(`Agent exited${code === null ? "" : ` (code ${code})`}`); if (this.client === client) { @@ -295,6 +295,37 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this.post({ type: "error", text }); } + /** + * Surface an agent failure in the chat and, for a missing binary, raise a + * modal with one-click actions (open settings / install docs). ENOENT here + * almost always means the `command` isn't on the PATH the editor inherited. + */ + private reportAgentError(err: Error, command: string): void { + const code = (err as { code?: string }).code; + const notFound = code === "AGENT_NOT_FOUND" || code === "ENOENT"; + if (notFound) { + const message = + `Couldn't launch the "${command}" agent — the executable wasn't found. ` + + `Install it from code.sigit.si and ensure it's on your PATH, or set an ` + + `absolute path in the "sigit.agents" setting.`; + this.postError(message); + void this.offerAgentSetup(message); + return; + } + this.postError(`Agent error: ${err.message}`); + } + + private async offerAgentSetup(message: string): Promise<void> { + const openSettings = "Open Settings"; + const installGuide = "Install Guide"; + const choice = await vscode.window.showErrorMessage(message, openSettings, installGuide); + if (choice === openSettings) { + await vscode.commands.executeCommand("workbench.action.openSettings", "sigit.agents"); + } else if (choice === installGuide) { + await vscode.env.openExternal(vscode.Uri.parse("https://code.sigit.si")); + } + } + private disposeClient(): void { this.client?.dispose(); this.client = undefined;