From 2d0dddd417f641ac32826ad11fb4e75b7a947b72 Mon Sep 17 00:00:00 2001 From: Hypergrunge Date: Wed, 9 Sep 2026 02:58:53 +0500 Subject: [PATCH 1/2] the embedded Claude panel starts on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel never got as far as the CLI on Windows. `spawnSession` located `claude` with `/bin/sh -c "command -v claude"` and started it inside a `/bin/bash -c ` wrapper — neither shell exists there, so ConPTY answered «File not found» about bash, and the panel showed «не удалось запустить claude: Error: File not found:» whatever the state of the CLI. ### Locating the CLI `resolveCommand` replaces the shell lookup. A non-interactive sh reads no rc file, so its PATH was ours anyway; the walk is now done in node, along the SESSION's PATH (the one claude-env.json may extend — the header comment promised a custom PATH would work, and it did not reach the lookup), with PATHEXT on Windows. That is what finds every installer's launcher: npm's `claude.cmd` shim, the native installer's `claude.exe`, bun/pnpm/volta shims. CreateProcess on its own tries `.exe` and nothing else, and `where claude` lists npm's extensionless sh-script shim first — a file ConPTY cannot start. After PATH comes `~/.local/bin`, where the native installer puts the binary on every platform, for an editor launched from a desktop entry whose PATH has not caught up. A name with a separator is a path; `~` is expanded, as sh used to. A CLI that is not found is now reported by name, with the path of claude-env.json, instead of surfacing as the pty's error. ### Starting and stopping it On Windows the launcher is spawned directly, without the watchdog. The console is the watchdog there: the pseudoconsole is a handle of the Electron process, so a hard death takes the console host with it and every attached process receives CTRL_CLOSE_EVENT — verified with `taskkill /F` on the main process, claude.exe and its cmd.exe were gone within seconds. Closing the panel calls node-pty's ConPTY kill, which terminates the console's whole process list (claude and its MCP children); there are no process groups to signal. The Linux path — bash wrapper, SIGHUP then SIGKILL to the group — is unchanged. Environment overrides from claude-env.json are merged case-insensitively on Windows: a `PATH` next to the inherited `Path` would hand CreateProcess two spellings of one variable, and which one the child sees is not defined. One caveat, recorded in the code and in CLAUDE.md: a `.cmd` launcher is run by CreateProcess through cmd.exe, whose parser rewrites `%VAR%`, `^` and unquoted `& | < >`. The built-in arguments carry none of those — verified that the quotes and the non-ASCII arrows in the system hint reach claude.exe intact — and a user `args` override on Windows has to stay equally plain, or point `command` at the `.exe` the shim wraps. ### Verification On Windows 11, Electron 42, claude 2.1.265 from npm, over CDP: `claudeOpen` returns ok and the tree is cmd.exe → claude.exe with the arguments whole; `claudeClose` leaves no process behind; a hard kill of the Electron main process leaves no process behind; a bogus command name returns the new error text. `npm run typecheck` is clean. The Linux path was not exercised on this machine — the only change there is the lookup, and the e2e suites that set `command: "bash"` (e2e22–e2e34) cover it. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 14 ++++ electron/claude.ts | 159 +++++++++++++++++++++++++++++++++++++-------- 2 files changed, 146 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 05ebf84..419fe70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,20 @@ mixes audio and muxes/transcodes per preset. `userData/claude-env.json`, extra MCP servers via `userData/claude-mcp.json`; `sweepStaleSessions()` clears leftovers of hard-killed runs at startup. + THE CLI IS LOCATED IN NODE, NOT BY A SHELL: a bare name is walked along + the session's PATH (with PATHEXT on Windows — npm installs a `claude.cmd` + shim, the native installer a `claude.exe`, and CreateProcess alone would + find only the latter), then `~/.local/bin`; a missing CLI is reported by + name instead of as the pty's «File not found». On Windows the launcher is + spawned directly, without the bash watchdog: the pseudoconsole belongs to + the Electron process, so a hard death of Electron takes the console host + and every attached process with it (verified with taskkill /F on main: + claude.exe and its cmd.exe were gone within seconds), and node-pty's + ConPTY kill terminates the console's whole process list. One caveat: a + `.cmd` launcher runs through cmd.exe, whose parser rewrites `%VAR%`, `^` + and unquoted `& | < >` — the built-in args avoid those (verified: quotes + and non-ASCII reach claude.exe intact) and a user `args` override on + Windows has to as well. Open and close are SERIALIZED through one promise chain and carry a generation: spawning is async (config read, `which`, the node-pty import) while a close is instant, so a close that overtakes an in-flight open would diff --git a/electron/claude.ts b/electron/claude.ts index 77b7d45..bbd9c1e 100644 --- a/electron/claude.ts +++ b/electron/claude.ts @@ -5,12 +5,13 @@ import { app, BrowserWindow, ipcMain } from 'electron' import { createServer, type Server } from 'http' import { randomBytes } from 'crypto' -import { execFile } from 'child_process' -import { promises as fs } from 'fs' -import { join } from 'path' -import { tmpdir } from 'os' +import { constants as fsConstants, promises as fs } from 'fs' +import { delimiter, extname, join } from 'path' +import { homedir, tmpdir } from 'os' import type { IPty } from 'node-pty' +const WIN = process.platform === 'win32' + // The session inherits this process's environment MINUS the markers of any // Claude session that launched the editor (see SESSION_MARKERS). Anything extra the // user's claude needs (proxies, custom PATH…) plus command/args overrides @@ -185,12 +186,73 @@ function startBridge( }) } -function which(cmd: string): Promise { - return new Promise((resolve) => { - execFile('/bin/sh', ['-c', `command -v ${cmd}`], (err, stdout) => { - resolve(err ? null : stdout.trim() || null) - }) - }) +function expandHome(p: string): string { + return p === '~' || p.startsWith('~/') || (WIN && p.startsWith('~\\')) + ? join(homedir(), p.slice(2)) + : p +} + +/** the value of an environment variable, by Windows' rules if need be (names are case-insensitive there) */ +function envVar(env: Record, name: string): string | undefined { + if (!WIN) return env[name] + const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase()) + return key === undefined ? undefined : env[key] +} + +async function isFile(p: string): Promise { + try { + return (await fs.stat(p)).isFile() + } catch { + return false + } +} + +/** + * Where the CLI is. Resolved here rather than by asking a shell: there is no + * /bin/sh on Windows, and the shell never knew more than we do — a + * non-interactive sh reads no rc file, so its PATH was ours. + * + * A name with a separator is a path (`~` expanded). A bare name is walked + * along the session's PATH — the user may have extended it in claude-env.json + * precisely so that claude is found — with PATHEXT on Windows, which is how + * every installer's launcher turns up: npm's `claude.cmd` shim, the native + * installer's `claude.exe`, bun/pnpm/volta shims. (CreateProcess alone would + * try `.exe` and nothing else, and `where claude` lists npm's extensionless + * sh-script shim first — a file ConPTY cannot start.) Then ~/.local/bin, the + * native installer's directory on every platform, for an editor launched + * from a desktop entry whose PATH has not caught up with the install. + */ +async function resolveCommand( + cmd: string, + env: Record +): Promise { + const exts = WIN + ? (envVar(env, 'PATHEXT') ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) + : [] + const runnable = async (p: string): Promise => { + if (WIN) { + // a name with a known extension is taken as is; anything else gets one + const ownExt = extname(p).toUpperCase() + const tries = exts.some((e) => e.toUpperCase() === ownExt) ? [p] : exts.map((e) => p + e) + for (const t of tries) if (await isFile(t)) return t + return null + } + try { + await fs.access(p, fsConstants.X_OK) + return (await isFile(p)) ? p : null + } catch { + return null + } + } + const name = expandHome(cmd) + if (/[\\/]/.test(name)) return runnable(name) + const dirs = (envVar(env, 'PATH') ?? '').split(delimiter).filter(Boolean) + dirs.push(join(homedir(), '.local', 'bin')) + for (const dir of dirs) { + const hit = await runnable(join(dir, name)) + if (hit) return hit + } + return null } /** @@ -236,7 +298,17 @@ const SESSION_MARKERS = [ function sessionEnv(extra?: Record): Record { const env: Record = { ...process.env } as Record for (const key of SESSION_MARKERS) delete env[key] - return { ...env, ...extra } + for (const [key, value] of Object.entries(extra ?? {})) { + // Windows variable names are case-insensitive: a `PATH` override next to + // the inherited `Path` would hand CreateProcess two spellings of one + // variable, and which of them the child sees is not defined + if (WIN) { + const clash = Object.keys(env).find((k) => k !== key && k.toLowerCase() === key.toLowerCase()) + if (clash) delete env[clash] + } + env[key] = value + } + return env } interface ClaudeConfig { @@ -262,8 +334,17 @@ async function spawnSession( ): Promise<{ ok: boolean; port?: number; error?: string }> { killSession() // a reopen without a close in between const cfg = await userConfig() + const env = sessionEnv(cfg.env) const cmdName = process.env.KADR_CLAUDE_CMD || cfg.command || 'claude' - const bin = (await which(cmdName)) ?? cmdName + const bin = await resolveCommand(cmdName, env) + if (!bin) { + return { + ok: false, + error: + `"${cmdName}" not found on PATH (nor in ~/.local/bin) — install the Claude Code CLI, ` + + `or set "command" in ${join(app.getPath('userData'), 'claude-env.json')}` + } + } let bridge: { server: Server; port: number; token: string } try { @@ -315,13 +396,30 @@ async function spawnSession( const wrapper = `(while kill -0 ${process.pid} 2>/dev/null; do sleep 3; done; ` + `kill -HUP -$$ 2>/dev/null; sleep 2; kill -9 -$$ 2>/dev/null) & exec "$0" "$@"` - const p = pty.spawn('/bin/bash', ['-c', wrapper, bin, ...args], { - name: 'xterm-256color', - cols: Math.max(20, cols), - rows: Math.max(5, rows), - cwd: dir, - env: sessionEnv(cfg.env) - }) + // On Windows the console itself is the watchdog. The pseudoconsole is a + // handle of THIS process: when it dies, however hard, the console host + // goes with it and every process attached to that console receives + // CTRL_CLOSE_EVENT and is terminated — claude and the MCP children it + // spawned alike. So the launcher is started directly, no wrapper. + // Mind the launcher: a `.cmd` (npm's shim) is run by CreateProcess + // through cmd.exe, whose parser rewrites `%VAR%`, `^` and unquoted + // `& | < >` on the way. The built-in args carry none of those; keep it + // so, and on Windows keep any `args` override in claude-env.json equally + // plain — or point `command` at the `.exe` the shim wraps. + const p = WIN + ? pty.spawn(bin, args, { + cols: Math.max(20, cols), + rows: Math.max(5, rows), + cwd: dir, + env + }) + : pty.spawn('/bin/bash', ['-c', wrapper, bin, ...args], { + name: 'xterm-256color', + cols: Math.max(20, cols), + rows: Math.max(5, rows), + cwd: dir, + env + }) // publish BEFORE wiring the handlers: data emitted between spawn and the // assignment would otherwise be dropped by the identity guard below const mine: Session = { pty: p, server: bridge.server, port: bridge.port } @@ -351,20 +449,27 @@ function killSession() { if (!session) return const s = session session = null - // HUP the whole process group (claude + its MCP server children), then - // escalate: a busy tree that shrugs off SIGHUP must not outlive the panel - const pid = s.pty.pid - try { process.kill(-pid, 'SIGHUP') } catch { try { s.pty.kill() } catch { /* dead */ } } - setTimeout(() => { - try { process.kill(-pid, 'SIGKILL') } catch { /* already gone */ } - }, 1500) + if (WIN) { + // node-pty's ConPTY kill enumerates the console's process list and + // terminates every member (claude + its MCP server children), then + // closes the pseudoconsole; there are no process groups to signal + try { s.pty.kill() } catch { /* dead */ } + } else { + // HUP the whole process group (claude + its MCP server children), then + // escalate: a busy tree that shrugs off SIGHUP must not outlive the panel + const pid = s.pty.pid + try { process.kill(-pid, 'SIGHUP') } catch { try { s.pty.kill() } catch { /* dead */ } } + setTimeout(() => { + try { process.kill(-pid, 'SIGKILL') } catch { /* already gone */ } + }, 1500) + } s.server.close() } /** * Open and close run ONE AT A TIME, and every request takes a generation. * - * `spawnSession` is async (config read, `which`, the node-pty import) while a + * `spawnSession` is async (config read, `resolveCommand`, the node-pty import) while a * close is instant, so a close that overtook an in-flight open used to find * `session` still null, do nothing, and let the pending spawn install itself * afterwards — an orphaned claude nobody could reach or kill. React StrictMode From a723a3bd51fd4657f38b907d6a54f0642801990f Mon Sep 17 00:00:00 2001 From: Hypergrunge Date: Wed, 9 Sep 2026 19:43:45 +0500 Subject: [PATCH 2/2] Windows: start a .cmd launcher through cmd.exe explicitly, with PR #7 in view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #7 (simplaerai-sv, «Windows support for the embedded Claude session») solves the same failure on the v0.3 base. This compares the two and takes the one idea from it worth taking. ### Taken: the explicit cmd.exe route `winLaunch` now starts a `.cmd`/`.bat` launcher — npm's `claude.cmd` shim — through `%ComSpec%` itself instead of letting CreateProcess supply the interpreter. Two things come with that. `/d` skips the registry AutoRun (this machine has clink there; a conda hook is the other usual tenant), which otherwise runs inside the panel's cmd.exe and can print into it. And the quoting becomes ours: `/s` plus an outer pair of quotes around the whole line, which keeps a launcher path with a space in it whole. That last part is where PR #7's version breaks. It spawns `cmd.exe /c "" args...` bare, and cmd's parser strips the first and the last quote of its command when the first character is a quote — so `"C:\Users\Jane Doe\AppData\Roaming\npm\claude.cmd" --mcp-config ...` becomes `C:\Users\Jane Doe\...` and cmd stops at `C:\Users\Jane`. A user name with a space is not exotic. Verified with a shim in a directory with a space: bare `/c` fails as described, the outer-quoted form and the implicit CreateProcess form both deliver every argument intact, inner quotes and non-ASCII included. ### Not taken: `where.exe` PR #7 resolves the CLI with `where.exe` and prefers an `.exe`/`.cmd`/`.bat` hit, falling back to the first line — which is npm's extensionless sh shim, the file ConPTY cannot start. The PATH walk here uses PATHEXT, so that shim is never a candidate; it searches the SESSION's PATH, which claude-env.json may extend; and it checks `~/.local/bin`, the native installer's directory, for an editor launched from a desktop entry whose PATH has not caught up. It also spawns no helper process. Session teardown is the same in both: `pty.kill()` on Windows, node-pty's ConPTY kill enumerating the console's process list. ### Verification, on the real UI this time Windows 11, claude 2.1.265 from npm, the panel driven by hand: - open → one `cmd.exe /d /s /c "…claude.CMD …"` and one claude.exe; - close → nothing left; close-and-reopen in quick succession → exactly one tree; quit the editor with the panel open → nothing left; - `/mcp` inside the panel: kadr connected, 13 tools; the same 13 over a stdio client spawning mcp-bridge.cjs like claude does; kadr_state and kadr_eval answer; /eval refuses a request without the token and one carrying an Origin (403 both); - close/reopen regenerates kadr-mcp.json with a fresh bridge port; - the trust dialog that appears with no project open is the CLI's own: the panel starts in the home directory then, and the CLI does not persist trust for it even when run from a plain terminal. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 15 ++++++++++----- electron/claude.ts | 41 +++++++++++++++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 419fe70..5fe3ddf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,11 +90,16 @@ mixes audio and muxes/transcodes per preset. the Electron process, so a hard death of Electron takes the console host and every attached process with it (verified with taskkill /F on main: claude.exe and its cmd.exe were gone within seconds), and node-pty's - ConPTY kill terminates the console's whole process list. One caveat: a - `.cmd` launcher runs through cmd.exe, whose parser rewrites `%VAR%`, `^` - and unquoted `& | < >` — the built-in args avoid those (verified: quotes - and non-ASCII reach claude.exe intact) and a user `args` override on - Windows has to as well. + ConPTY kill terminates the console's whole process list. A `.cmd` + launcher (npm's shim) is started through `%ComSpec% /d /s /c ""` + by `winLaunch`: `/d` skips the registry AutoRun, and `/s` plus the outer + quotes keep a launcher path with a space in one piece — a bare + `cmd /c "shim" args` (PR #7's route) stops at `C:\Users\Jane` because + cmd strips the first and last quote of its command. Verified with a + shim in a directory with a space. The caveat that remains: cmd's parser + rewrites `%VAR%`, `^` and unquoted `& | < >` — the built-in args avoid + those (verified: quotes and non-ASCII reach claude.exe intact) and a + user `args` override on Windows has to as well. Open and close are SERIALIZED through one promise chain and carry a generation: spawning is async (config read, `which`, the node-pty import) while a close is instant, so a close that overtakes an in-flight open would diff --git a/electron/claude.ts b/electron/claude.ts index bbd9c1e..03aaadf 100644 --- a/electron/claude.ts +++ b/electron/claude.ts @@ -255,6 +255,35 @@ async function resolveCommand( return null } +/** One argument quoted by the MSVCRT rules every CreateProcess-started program parses by. */ +function quoteArg(arg: string): string { + if (arg !== '' && !/[\s"]/.test(arg)) return arg + return '"' + arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1') + '"' +} + +/** + * How the launcher is started on Windows. An `.exe` takes the arguments as + * they are. A `.cmd`/`.bat` — npm's shim — needs cmd.exe. CreateProcess + * would supply one by itself, but the explicit route is worth having: + * `/d` keeps the registry's AutoRun (clink, conda hooks) from running and + * printing into the panel, and the quoting is ours to get right. `/s` plus + * an outer pair of quotes is what keeps a launcher path with a space in it + * (`C:\Users\Jane Doe\AppData\Roaming\npm\claude.cmd`) in one piece: cmd + * strips the first and the last quote of its command when the first + * character is one, so a bare `/c "shim" args` loses the quotes around the + * path and stops at `C:\Users\Jane`. Verified with a shim in a directory + * with a space, both routes, inner quotes and non-ASCII arriving intact. + */ +function winLaunch( + bin: string, + args: string[], + env: Record +): [file: string, args: string | string[]] { + if (!/\.(cmd|bat)$/i.test(bin)) return [bin, args] + const line = [bin, ...args].map(quoteArg).join(' ') + return [envVar(env, 'ComSpec') ?? 'cmd.exe', `/d /s /c "${line}"`] +} + /** * Markers a Claude Code session puts in the environment of everything it * spawns. They have to go before the panel's own session starts. @@ -401,13 +430,13 @@ async function spawnSession( // goes with it and every process attached to that console receives // CTRL_CLOSE_EVENT and is terminated — claude and the MCP children it // spawned alike. So the launcher is started directly, no wrapper. - // Mind the launcher: a `.cmd` (npm's shim) is run by CreateProcess - // through cmd.exe, whose parser rewrites `%VAR%`, `^` and unquoted - // `& | < >` on the way. The built-in args carry none of those; keep it - // so, and on Windows keep any `args` override in claude-env.json equally - // plain — or point `command` at the `.exe` the shim wraps. + // Mind the launcher: a `.cmd` (npm's shim) goes through cmd.exe (see + // winLaunch), whose parser rewrites `%VAR%`, `^` and unquoted `& | < >` + // on the way. The built-in args carry none of those; keep it so, and on + // Windows keep any `args` override in claude-env.json equally plain — + // or point `command` at the `.exe` the shim wraps. const p = WIN - ? pty.spawn(bin, args, { + ? pty.spawn(...winLaunch(bin, args, env), { cols: Math.max(20, cols), rows: Math.max(5, rows), cwd: dir,