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>
This commit is contained in:
itMrBoy
2026-06-14 19:48:42 +08:00
committed by GitHub
co-authored by danniel Mohamed Boudra
parent bf8f2510c8
commit f352072dac
22 changed files with 385 additions and 55 deletions
@@ -9,11 +9,33 @@ import type {
WorkerTerminalInfo,
} from "./terminal-worker-protocol.js";
type TerminalCreateRequest = Extract<TerminalWorkerRequest, { type: "createTerminal" }>;
const manager = createTerminalManager();
const unsubscribeByTerminalId = new Map<string, Array<() => void>>();
const outputCoalescerByTerminalId = new Map<string, TerminalOutputCoalescer>();
let ipcClosing = false;
interface InFlightTerminalCreateRequest {
requestId: string;
errorReported: boolean;
}
let inFlightTerminalCreateRequest: InFlightTerminalCreateRequest | null = null;
// The conpty failure signal is process-scoped, not request-scoped. Serializing
// creates keeps an async spawn failure attributable to exactly one request.
let createTerminalQueue: Promise<void> = Promise.resolve();
// node-pty completes its Windows conpty spawn asynchronously on a separate
// conout worker thread. When that spawn fails (bad cwd, missing command, etc.)
// it throws an exception there that cannot be caught at the call site and would
// otherwise crash this worker process and sever every existing terminal.
process.on("uncaughtException", (error) => {
console.error("Terminal worker uncaught exception (kept alive):", error);
reportInFlightTerminalCreateFailure(error);
});
function sendToParent(message: TerminalWorkerToParentMessage): void {
if (ipcClosing || !process.connected || !process.send) {
return;
@@ -48,6 +70,23 @@ function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo {
};
}
function terminalWorkerErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Terminal worker request failed";
}
function reportInFlightTerminalCreateFailure(error: unknown): void {
if (!inFlightTerminalCreateRequest || inFlightTerminalCreateRequest.errorReported) {
return;
}
inFlightTerminalCreateRequest.errorReported = true;
sendToParent({
type: "response",
requestId: inFlightTerminalCreateRequest.requestId,
ok: false,
error: terminalWorkerErrorMessage(error),
});
}
function clearTerminalSubscriptions(terminalId: string): void {
const subscriptions = unsubscribeByTerminalId.get(terminalId);
if (subscriptions) {
@@ -146,6 +185,49 @@ manager.subscribeTerminalsChanged((event) => {
});
});
function enqueueCreateTerminalRequest(message: TerminalCreateRequest): Promise<void> {
const nextRequest = createTerminalQueue.then(() => handleCreateTerminalRequest(message));
createTerminalQueue = nextRequest.catch(() => {});
return nextRequest;
}
async function handleCreateTerminalRequest(message: TerminalCreateRequest): Promise<void> {
const request: InFlightTerminalCreateRequest = {
requestId: message.requestId,
errorReported: false,
};
inFlightTerminalCreateRequest = request;
try {
const session = await manager.createTerminal(message.options);
if (request.errorReported) {
session.kill();
return;
}
watchTerminal(session);
const initialSnapshot = session.getStateSnapshot();
sendToParent({
type: "terminalCreated",
terminal: toTerminalInfo(session),
state: initialSnapshot.state,
});
sendToParent({
type: "response",
requestId: message.requestId,
ok: true,
result: {
terminal: toTerminalInfo(session),
state: initialSnapshot.state,
},
});
} catch (error) {
reportInFlightTerminalCreateFailure(error);
} finally {
if (inFlightTerminalCreateRequest === request) {
inFlightTerminalCreateRequest = null;
}
}
}
async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
switch (message.type) {
case "getTerminals": {
@@ -160,23 +242,7 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
}
case "createTerminal": {
const session = await manager.createTerminal(message.options);
watchTerminal(session);
const initialSnapshot = session.getStateSnapshot();
sendToParent({
type: "terminalCreated",
terminal: toTerminalInfo(session),
state: initialSnapshot.state,
});
sendToParent({
type: "response",
requestId: message.requestId,
ok: true,
result: {
terminal: toTerminalInfo(session),
state: initialSnapshot.state,
},
});
await enqueueCreateTerminalRequest(message);
return;
}
@@ -285,7 +351,7 @@ process.on("message", (message: TerminalWorkerRequest) => {
type: "response",
requestId: message.requestId,
ok: false,
error: error instanceof Error ? error.message : "Terminal worker request failed",
error: terminalWorkerErrorMessage(error),
});
});
});
@@ -5,6 +5,7 @@ import {
createTerminal,
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
resolveDefaultTerminalShell,
resolveTerminalSpawnCommand,
humanizeProcessTitle,
normalizeProcessTitle,
resolveZshShellIntegrationDir,
@@ -203,6 +204,50 @@ describe("createTerminal", () => {
).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
});
it("passes profile commands through untouched on non-Windows", async () => {
const resolveExecutable = vi.fn(async () => "/usr/local/bin/claude");
const resolved = await resolveTerminalSpawnCommand("claude", ["--foo"], {
platform: "linux",
resolveExecutable,
});
expect(resolved).toEqual({ command: "claude", args: ["--foo"] });
// Non-Windows must not pay the executable-resolution cost.
expect(resolveExecutable).not.toHaveBeenCalled();
});
it("keeps the original command when it cannot be resolved on Windows", async () => {
const resolved = await resolveTerminalSpawnCommand("claude", [], {
platform: "win32",
resolveExecutable: async () => null,
});
// Falls back to the bare command so the terminal surfaces the error itself.
expect(resolved).toEqual({ command: "claude", args: [] });
});
it("routes resolved .cmd shims through cmd.exe on Windows", async () => {
const resolved = await resolveTerminalSpawnCommand("claude", ["--foo"], {
platform: "win32",
env: { ComSpec: "C:\\Windows\\System32\\cmd.exe" },
resolveExecutable: async () => "C:\\npm\\claude.cmd",
});
expect(resolved).toEqual({
command: "C:\\Windows\\System32\\cmd.exe",
args: ["/c", "C:\\npm\\claude.cmd", "--foo"],
});
});
it("uses the resolved .exe path directly on Windows", async () => {
const resolved = await resolveTerminalSpawnCommand("claude", ["--foo"], {
platform: "win32",
resolveExecutable: async () => "C:\\tools\\claude.exe",
});
expect(resolved).toEqual({ command: "C:\\tools\\claude.exe", args: ["--foo"] });
});
it("creates a terminal session with an id, name, and cwd", async () => {
const session = trackSession(
await createTerminal({
+63 -3
View File
@@ -3,11 +3,12 @@ import xterm, { type Terminal as TerminalType } from "@xterm/headless";
import { randomUUID } from "crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
import { tmpdir, userInfo } from "node:os";
import { basename, dirname, join } from "node:path";
import { basename, dirname, extname, join } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import { writePrivateFileAtomicSync } from "../server/private-files.js";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
@@ -204,6 +205,64 @@ export function resolveDefaultTerminalShell(
return env.SHELL || "/bin/sh";
}
export interface ResolvedTerminalCommand {
command: string;
args: string[];
}
export interface ResolveTerminalSpawnCommandOptions {
platform?: NodeJS.Platform;
env?: Record<string, string | undefined>;
resolveExecutable?: (name: string) => Promise<string | null>;
}
/**
* Resolve a terminal profile command (e.g. `claude`) into something node-pty's
* conpty backend can actually launch on Windows.
*
* On Windows, conpty's underlying `CreateProcess` does not apply `PATHEXT`, so a
* bare `claude` (installed by npm as `claude.cmd`) fails with `error code: 2`
* (`ERROR_FILE_NOT_FOUND`). Worse, conpty completes the spawn asynchronously on
* its own conout worker thread, so that failure surfaces as an uncaught
* exception that takes down the whole terminal worker process. Resolving the
* real path up front — and routing `.cmd`/`.bat` shims through `cmd.exe /c`
* (node-pty has no `shell` option) — keeps the profile launchable.
*
* Non-Windows and the default-shell path (no explicit command) are unchanged.
*/
export async function resolveTerminalSpawnCommand(
command: string,
args: string[],
options: ResolveTerminalSpawnCommandOptions = {},
): Promise<ResolvedTerminalCommand> {
const platform = options.platform ?? process.platform;
if (platform !== "win32") {
return { command, args };
}
const resolveExecutable = options.resolveExecutable ?? findExecutable;
const resolved = await resolveExecutable(command);
if (!resolved) {
// Leave the command as-is so the terminal itself surfaces the "not found"
// error to the user instead of silently doing nothing.
return { command, args };
}
// `.cmd`/`.bat` shims are batch scripts that conpty's CreateProcess cannot
// launch directly; they must run through cmd.exe (node-pty has no `shell`
// option, so build the `cmd /c` invocation ourselves). Checked by extension
// rather than isWindowsCommandScript() because that helper gates on the live
// process.platform, which is wrong once we're already on the win32 branch.
const extension = extname(resolved).toLowerCase();
if (extension === ".cmd" || extension === ".bat") {
const env = options.env ?? process.env;
const comSpec = env.ComSpec || env.COMSPEC || "C:\\Windows\\System32\\cmd.exe";
return { command: comSpec, args: ["/c", resolved, ...args] };
}
return { command: resolved, args };
}
export function resolveZshShellIntegrationDir(): string {
return fileURLToPath(new URL("./shell-integration/zsh", import.meta.url));
}
@@ -636,8 +695,9 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
// Create PTY
const spawnCommand = command ?? resolvedShell;
const spawnArgs = command ? args : [];
const { command: spawnCommand, args: spawnArgs } = command
? await resolveTerminalSpawnCommand(command, args)
: { command: resolvedShell, args: [] as string[] };
const ptyProcess = pty.spawn(spawnCommand, spawnArgs, {
name: "xterm-256color",
cols,