Files
paseo/packages/server/src/executable-resolution/executable-resolution.ts
itMrBoy f352072dac Fix coding-agent terminal shortcuts not working on Windows (#1509)
* Fix coding-agent terminal shortcuts not working on Windows

On Windows, opening a Terminal profile (Claude Code / Codex / OpenCode) did
nothing, and afterwards even a plain New Terminal stopped working until restart.

Three Windows-specific problems combined:

- A failed node-pty conpty spawn completes asynchronously on its conout worker
  thread; the uncaught exception escaped the per-request try/catch and crashed
  the whole terminal worker, severing every terminal. Add uncaughtException /
  unhandledRejection guards so a single bad spawn keeps the worker alive.
- Profile commands were passed to conpty unresolved. conpty's CreateProcess
  ignores PATHEXT, so bare codex (npm codex.cmd) wasn't found and .cmd/.bat
  shims can't run directly. Add resolveTerminalSpawnCommand() to resolve the
  real path and route .cmd/.bat through cmd.exe /c on Windows.
- winget-installed CLIs (e.g. Claude Code) live under the winget Packages dir
  and aren't on PATH. findExecutable() now falls back to those known install
  locations, so all providers and terminal profiles benefit.

Also surface a failed terminal create to the user via a toast instead of
silently dropping the error.

* Address review: simplify winget scan, narrow worker exception guard

- Drop the single-element WINGET_PACKAGE_BIN_SUBDIRS loop in executable.ts and
  map package dirs directly to the root <name>.exe candidate.
- Remove the unhandledRejection handler in the terminal worker (only the
  uncaughtException path is exercised by conpty's async spawn failure) and
  expand the comment to explain the keep-alive trade-off and the absence of a
  worker restart path.

* Refactor executable resolution out of utils

* Serialize terminal create requests in worker

---------

Co-authored-by: danniel <liminfhu@gmail.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-06-14 19:48:42 +08:00

145 lines
3.8 KiB
TypeScript

import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import { execCommand } from "../utils/spawn.js";
import { isWindowsCommandScript } from "../utils/windows-command.js";
import { windowsExecutableResolution } from "./windows.js";
export { quoteWindowsArgument, quoteWindowsCommand } from "../utils/windows-command.js";
type Which = (command: string, options: { all: true }) => Promise<string[]>;
const require = createRequire(import.meta.url);
const which = require("which") as Which;
const PROBE_TIMEOUT_MS = 2000;
function hasPathSeparator(value: string): boolean {
return value.includes("/") || value.includes("\\");
}
async function enumerateCandidates(name: string): Promise<string[]> {
if (process.platform !== "win32" && existsSync("/usr/bin/which")) {
return enumerateCandidatesViaSystemWhich(name);
}
return enumerateCandidatesViaLibrary(name);
}
async function enumerateCandidatesViaSystemWhich(name: string): Promise<string[]> {
try {
const { stdout } = await execCommand("/usr/bin/which", ["-a", name], {
timeout: 3000,
killSignal: "SIGKILL",
});
return Array.from(new Set(stdout.trim().split("\n").filter(Boolean)));
} catch {
return [];
}
}
async function enumerateCandidatesViaLibrary(name: string): Promise<string[]> {
let candidates: string[];
try {
candidates = await which(name, { all: true });
} catch (error) {
// `which` throws ENOENT when the command is absent from PATH.
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
const seen = new Set<string>();
return candidates.filter((candidate) => {
if (seen.has(candidate)) {
return false;
}
seen.add(candidate);
return true;
});
}
export async function probeExecutable(
executablePath: string,
timeoutMs = PROBE_TIMEOUT_MS,
): Promise<boolean> {
try {
await execCommand(executablePath, ["--version"], {
timeout: timeoutMs,
killSignal: "SIGKILL",
maxBuffer: 64 * 1024,
shell: isWindowsCommandScript(executablePath),
});
return true;
} catch (error) {
return classifyProbeError(error);
}
}
function classifyProbeError(error: unknown): boolean {
const err = error as NodeJS.ErrnoException & {
killed?: boolean;
};
if (err.killed) {
return true;
}
if (typeof err.code === "number") {
return true;
}
if (
err.code === "ENOENT" ||
err.code === "EACCES" ||
err.code === "ENOEXEC" ||
err.code === "UNKNOWN"
) {
return false;
}
return false;
}
/**
* Check a literal executable path. PATH search is handled by findExecutable().
*/
export function executableExists(
executablePath: string,
exists: typeof existsSync = existsSync,
): string | null {
if (process.platform === "win32") {
return windowsExecutableResolution.exists(executablePath, { exists });
}
return exists(executablePath) ? executablePath : null;
}
export async function findExecutable(
name: string,
probeTimeoutMs = PROBE_TIMEOUT_MS,
): Promise<string | null> {
const trimmed = name.trim();
if (!trimmed) {
return null;
}
if (process.platform === "win32") {
return windowsExecutableResolution.find(trimmed, {
enumeratePathCandidates: enumerateCandidates,
probeExecutable,
exists: existsSync,
probeTimeoutMs,
});
}
if (hasPathSeparator(trimmed)) {
return (await probeExecutable(trimmed, probeTimeoutMs)) ? trimmed : null;
}
const candidates = await enumerateCandidates(trimmed);
for (const candidate of candidates) {
if (await probeExecutable(candidate, probeTimeoutMs)) {
return candidate;
}
}
return null;
}
export async function isCommandAvailable(command: string): Promise<boolean> {
return (await findExecutable(command)) !== null;
}