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;