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;