@sigit / sigit-vscode / commits / a32733c

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
@@ -5,6 +5,20 @@ All notable changes to the **siGit Code** extension are documented here.
5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
8 +## [1.0.1] - 2026-06-25
9 +
10 +### Fixed
11 +
12 +- Agent failed to launch with `spawn sigit ENOENT` when VS Code was started from
13 + the GUI (Dock, Finder, Spotlight). GUI launches inherit a minimal system PATH
14 + that omits common install dirs (Homebrew, Cargo, `~/.local/bin`, …), so the
15 + `sigit` binary couldn't be found even when installed. The agent command is now
16 + resolved against an augmented PATH that adds those dirs plus the user's
17 + login-shell PATH.
18 +- A missing agent binary now shows an actionable error with **Open Settings** and
19 + **Install Guide** actions instead of a raw `ENOENT`, and no longer leaves the
20 + chat stuck on a hanging request.
21 +
22 ## [1.0.0] - 2026-06-25
23
24 ### Added
package.json
+1 -1
@@ -2,7 +2,7 @@
2 "name": "sigit-code",
3 "displayName": "siGit Code — On-device AI Coding Agent",
4 "description": "Local-first AI coding agent for VS Code. Runs the on-device sigit agent, and other ACP-compatible agents, over stdio.",
5 - "version": "1.0.0",
5 + "version": "1.0.1",
6 "publisher": "getsigit",
7 "license": "MIT",
8 "packageManager": "pnpm@10.33.0",
src/acp/client.ts
+34 -3
@@ -1,6 +1,21 @@
1 import { ChildProcessWithoutNullStreams, spawn } from "child_process";
2 import { EventEmitter } from "events";
3 +import { delimiter } from "path";
4 import { Connection } from "./connection";
5 +import { augmentedPath, resolveExecutable } from "./resolveCommand";
6 +
7 +/** Thrown when the agent executable cannot be located on the (augmented) PATH. */
8 +export class AgentNotFoundError extends Error {
9 + readonly code = "AGENT_NOT_FOUND";
10 + constructor(readonly command: string) {
11 + super(
12 + `Could not find the "${command}" executable. Install the agent from ` +
13 + `https://code.sigit.si and make sure it is on your PATH, or set an ` +
14 + `absolute "command" path in the "sigit.agents" setting.`
15 + );
16 + this.name = "AgentNotFoundError";
17 + }
18 +}
19
20 /**
21 * AcpClient — spawns an Agent Client Protocol agent over stdio and drives the
@@ -62,14 +77,30 @@ export class AcpClient extends EventEmitter {
77
78 /** Spawn the agent process and wire up the JSON-RPC connection. */
79 spawn(config: AgentSpawnConfig): void {
65 - const env = { ...process.env, ...(config.env ?? {}) };
66 - const child = spawn(config.command, config.args ?? [], {
80 + // GUI-launched VS Code inherits a minimal PATH; augment it so the agent
81 + // (and any subprocess it spawns) can be found and can find its own tools.
82 + const path = augmentedPath();
83 + const env = { ...process.env, ...(config.env ?? {}), PATH: path };
84 + if (config.env?.PATH) {
85 + env.PATH = `${config.env.PATH}${delimiter}${path}`;
86 + }
87 +
88 + const resolved = resolveExecutable(config.command, env.PATH);
89 + if (!resolved) {
90 + throw new AgentNotFoundError(config.command);
91 + }
92 +
93 + const child = spawn(resolved, config.args ?? [], {
94 cwd: config.cwd,
95 env,
96 stdio: ["pipe", "pipe", "pipe"]
97 });
98
72 - child.on("error", (err) => this.emit("error", err));
99 + child.on("error", (err) => {
100 + this.emit("error", err);
101 + // Unblock any in-flight request (e.g. initialize) instead of hanging.
102 + this.connection?.dispose();
103 + });
104 child.on("exit", (code, signal) => this.emit("exit", code, signal));
105 child.stderr.on("data", (chunk: Buffer) => this.emit("stderr", chunk.toString()));
106
src/acp/resolveCommand.ts new
+169
@@ -0,0 +1,169 @@
1 +import { execFileSync } from "child_process";
2 +import { accessSync, constants, statSync } from "fs";
3 +import { homedir } from "os";
4 +import { delimiter, isAbsolute, join } from "path";
5 +
6 +/**
7 + * Locating the agent binary across launch contexts.
8 + *
9 + * When VS Code is launched from a GUI (Dock, Finder, Spotlight) rather than a
10 + * terminal, the process inherits a minimal system PATH that omits the dirs
11 + * where developer tools are usually installed (Homebrew, Cargo, ~/.local/bin,
12 + * etc.). A `command` like `sigit` then fails to spawn with ENOENT even though
13 + * it runs fine from the user's terminal. We work around this by resolving the
14 + * command against an *augmented* PATH that adds the common install locations
15 + * and, on macOS/Linux, the user's real login-shell PATH.
16 + */
17 +
18 +let cachedLoginPath: string | null | undefined;
19 +
20 +/** Common install directories that GUI launches frequently miss. */
21 +function commonBinDirs(): string[] {
22 + const home = homedir();
23 + if (process.platform === "win32") {
24 + return [];
25 + }
26 + return [
27 + "/usr/local/bin",
28 + "/opt/homebrew/bin",
29 + "/opt/homebrew/sbin",
30 + "/usr/bin",
31 + "/bin",
32 + "/usr/sbin",
33 + "/sbin",
34 + join(home, ".local", "bin"),
35 + join(home, "bin"),
36 + join(home, ".cargo", "bin"),
37 + join(home, ".bun", "bin"),
38 + join(home, ".deno", "bin"),
39 + join(home, "go", "bin"),
40 + join(home, ".sigit", "bin")
41 + ];
42 +}
43 +
44 +/**
45 + * The PATH exported by the user's interactive login shell. GUI-launched apps on
46 + * macOS don't inherit this, so we ask the shell directly. Cached after the
47 + * first (best-effort, time-boxed) lookup.
48 + */
49 +function loginShellPath(): string | undefined {
50 + if (cachedLoginPath !== undefined) {
51 + return cachedLoginPath ?? undefined;
52 + }
53 + cachedLoginPath = null;
54 + if (process.platform === "win32") {
55 + return undefined;
56 + }
57 + const shell = process.env.SHELL || "/bin/zsh";
58 + try {
59 + // `-ilc` → interactive login shell running one command. We echo a sentinel
60 + // around PATH so noisy rc-file output doesn't corrupt the value.
61 + const out = execFileSync(shell, ["-ilc", 'printf "__SIGIT_PATH__%s__SIGIT_END__" "$PATH"'], {
62 + encoding: "utf8",
63 + timeout: 3000,
64 + stdio: ["ignore", "pipe", "ignore"]
65 + });
66 + const match = /__SIGIT_PATH__([\s\S]*?)__SIGIT_END__/.exec(out);
67 + if (match && match[1]) {
68 + cachedLoginPath = match[1];
69 + }
70 + } catch {
71 + // Shell missing, slow, or non-interactive — fall back to common dirs only.
72 + }
73 + return cachedLoginPath ?? undefined;
74 +}
75 +
76 +/**
77 + * A PATH string combining the current process PATH, the login-shell PATH, and
78 + * the common install dirs — de-duplicated, original order preserved.
79 + */
80 +export function augmentedPath(): string {
81 + const parts: string[] = [];
82 + const seen = new Set<string>();
83 + const push = (value: string | undefined) => {
84 + if (!value) {
85 + return;
86 + }
87 + for (const dir of value.split(delimiter)) {
88 + if (dir && !seen.has(dir)) {
89 + seen.add(dir);
90 + parts.push(dir);
91 + }
92 + }
93 + };
94 + push(process.env.PATH);
95 + push(loginShellPath());
96 + for (const dir of commonBinDirs()) {
97 + push(dir);
98 + }
99 + return parts.join(delimiter);
100 +}
101 +
102 +function isExecutableFile(p: string): boolean {
103 + try {
104 + if (!statSync(p).isFile()) {
105 + return false;
106 + }
107 + if (process.platform === "win32") {
108 + return true;
109 + }
110 + accessSync(p, constants.X_OK);
111 + return true;
112 + } catch {
113 + return false;
114 + }
115 +}
116 +
117 +/** On Windows, the suffixes that make a bare command name executable. */
118 +function windowsExts(): string[] {
119 + const pathext = process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD";
120 + return pathext.split(";").filter(Boolean);
121 +}
122 +
123 +/**
124 + * Resolve `command` to an absolute executable path, searching `pathString`
125 + * (defaults to the augmented PATH). Returns undefined when nothing matches.
126 + *
127 + * An absolute or path-qualified command is returned as-is when it points at an
128 + * executable file, so explicit user configuration always wins.
129 + */
130 +export function resolveExecutable(command: string, pathString = augmentedPath()): string | undefined {
131 + if (!command) {
132 + return undefined;
133 + }
134 +
135 + const hasPathSep = command.includes("/") || command.includes("\\");
136 + if (isAbsolute(command) || hasPathSep) {
137 + if (isExecutableFile(command)) {
138 + return command;
139 + }
140 + if (process.platform === "win32") {
141 + for (const ext of windowsExts()) {
142 + const candidate = command + ext;
143 + if (isExecutableFile(candidate)) {
144 + return candidate;
145 + }
146 + }
147 + }
148 + return undefined;
149 + }
150 +
151 + for (const dir of pathString.split(delimiter)) {
152 + if (!dir) {
153 + continue;
154 + }
155 + const base = join(dir, command);
156 + if (isExecutableFile(base)) {
157 + return base;
158 + }
159 + if (process.platform === "win32") {
160 + for (const ext of windowsExts()) {
161 + const candidate = base + ext;
162 + if (isExecutableFile(candidate)) {
163 + return candidate;
164 + }
165 + }
166 + }
167 + }
168 + return undefined;
169 +}
src/chatView.ts
+33 -2
@@ -99,7 +99,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
99 try {
100 await this.ensureClient();
101 } catch (err) {
102 - this.postError(`Failed to start agent: ${(err as Error).message}`);
102 + this.reportAgentError(err as Error, resolveAgent(this.activeAgentKey).command);
103 return;
104 }
105 if (!this.client) {
@@ -145,7 +145,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
145
146 client.on("update", (update: SessionUpdate) => this.handleUpdate(update));
147 client.on("stderr", (chunk: string) => this.post({ type: "log", text: chunk }));
148 - client.on("error", (err: Error) => this.postError(`Agent error: ${err.message}`));
148 + client.on("error", (err: Error) => this.reportAgentError(err, agent.command));
149 client.on("exit", (code: number | null) => {
150 this.postStatus(`Agent exited${code === null ? "" : ` (code ${code})`}`);
151 if (this.client === client) {
@@ -295,6 +295,37 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
295 this.post({ type: "error", text });
296 }
297
298 + /**
299 + * Surface an agent failure in the chat and, for a missing binary, raise a
300 + * modal with one-click actions (open settings / install docs). ENOENT here
301 + * almost always means the `command` isn't on the PATH the editor inherited.
302 + */
303 + private reportAgentError(err: Error, command: string): void {
304 + const code = (err as { code?: string }).code;
305 + const notFound = code === "AGENT_NOT_FOUND" || code === "ENOENT";
306 + if (notFound) {
307 + const message =
308 + `Couldn't launch the "${command}" agent — the executable wasn't found. ` +
309 + `Install it from code.sigit.si and ensure it's on your PATH, or set an ` +
310 + `absolute path in the "sigit.agents" setting.`;
311 + this.postError(message);
312 + void this.offerAgentSetup(message);
313 + return;
314 + }
315 + this.postError(`Agent error: ${err.message}`);
316 + }
317 +
318 + private async offerAgentSetup(message: string): Promise<void> {
319 + const openSettings = "Open Settings";
320 + const installGuide = "Install Guide";
321 + const choice = await vscode.window.showErrorMessage(message, openSettings, installGuide);
322 + if (choice === openSettings) {
323 + await vscode.commands.executeCommand("workbench.action.openSettings", "sigit.agents");
324 + } else if (choice === installGuide) {
325 + await vscode.env.openExternal(vscode.Uri.parse("https://code.sigit.si"));
326 + }
327 + }
328 +
329 private disposeClient(): void {
330 this.client?.dispose();
331 this.client = undefined;