feat(core,tooling): Windows support — shell selection, install.ps1, win-x64 release package, Windows CI (#79)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-27 22:17:35 +08:00
committed by GitHub
parent c23f0bc2f0
commit c369e089a7
46 changed files with 1315 additions and 217 deletions
@@ -4,6 +4,8 @@
export { CommandSessionManager } from "./session-manager.js";
export { ManagedSession, resultForExit } from "./session.js";
export type { ProcessExit, SpawnOptions } from "./session.js";
export { resolveShell, sessionShell } from "./shell.js";
export type { ShellInvocation, ResolveShellOptions } from "./shell.js";
export {
DEFAULT_EXEC_YIELD_MS,
DEFAULT_WRITE_YIELD_MS,
@@ -1,11 +1,14 @@
/**
* ManagedSession — runtime state and collection logic for a single command session.
*
* Spawns the process with `bash -lc <cmd>`, with stdout/stderr going through plain pipes (no
* Spawns the process with `bash -lc <cmd>` (on Windows, the shell picked by `sessionShell()`
* — see shell.ts), with stdout/stderr going through plain pipes (no
* native dependency, clean output; an interactive program that detects no TTY falls back to
* non-interactive mode, which parses more cleanly for the Agent anyway). `detached` makes the
* child process the process-group leader, so both Ctrl-C and killing the whole group rely on
* **process-group signals** (sending a signal to `-pid` also reaches background child processes).
* Windows has neither process groups nor real signals: every "signal" degrades to a hard
* TerminateProcess, and tree-wide cleanup goes through `taskkill /t` instead (see signalGroup).
*
* Key semantics:
* - **Termination is determined by the foreground process exiting (the exit event, waitpid
@@ -22,9 +25,10 @@
* - `kill()` sends SIGTERM to the process group, then SIGKILL after a grace period, reaping any
* leftover background child processes; idempotent.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import type { ToolResult } from "../types.js";
import { CappedTextBuffer, WakeSignal } from "../background/index.js";
import { sessionShell } from "./shell.js";
/** Process-group semantics are available on POSIX; Windows falls back to signaling the child process directly. */
const SUPPORTS_PROCESS_GROUP = process.platform !== "win32";
@@ -48,7 +52,7 @@ export interface ProcessExit {
/** Arguments required to start a command. */
export interface SpawnOptions {
/** Command string handed to `bash -lc`. */
/** Command string handed to the session shell (`bash -lc` on POSIX; see shell.ts for Windows). */
cmd: string;
/** Working directory (absolute path). */
cwd: string;
@@ -71,11 +75,13 @@ export class ManagedSession {
private readonly wakeSignal = new WakeSignal();
constructor(opts: SpawnOptions) {
this.child = spawn("bash", ["-lc", opts.cmd], {
const shell = sessionShell();
this.child = spawn(shell.command, [...shell.args, opts.cmd], {
cwd: opts.cwd,
env: opts.env,
detached: SUPPORTS_PROCESS_GROUP, // Become the process-group leader, so the whole group can be signaled
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true, // No flashing console window on Windows (ignored elsewhere)
});
this.child.stdout?.setEncoding("utf8");
this.child.stderr?.setEncoding("utf8");
@@ -92,11 +98,26 @@ export class ManagedSession {
this.child.on("error", (err) => this.handleError(err));
}
/** Signals the process group; ignores the case where the process/group has already exited (ESRCH). */
private signalGroup(sig: NodeJS.Signals): void {
/**
* Signals the process group; ignores the case where the process/group has already exited (ESRCH).
*
* Windows has no signals: `child.kill()` is an unconditional TerminateProcess of the direct
* child only, which would orphan grandchildren (a `node server.js` started by the shell).
* Every signal therefore becomes a hard kill of the whole tree via `taskkill /t /f` — including
* SIGINT: without a shared console there is no way to deliver a real Ctrl-C to a piped child,
* so input_command's Ctrl-C degrades to this hard kill on Windows. `sync` uses spawnSync for
* the process-'exit' fallback, where the event loop is no longer running.
*/
private signalGroup(sig: NodeJS.Signals, sync = false): void {
try {
if (SUPPORTS_PROCESS_GROUP && typeof this.child.pid === "number" && this.child.pid > 0) {
process.kill(-this.child.pid, sig); // Negative pid = the whole process group
} else if (
process.platform === "win32" &&
typeof this.child.pid === "number" &&
this.child.pid > 0
) {
this.killTreeWindows(this.child.pid, sync);
} else {
this.child.kill(sig);
}
@@ -105,6 +126,26 @@ export class ManagedSession {
}
}
/** Hard-kills the whole process tree on Windows; falls back to child.kill() if taskkill itself fails. */
private killTreeWindows(pid: number, sync: boolean): void {
const args = ["/pid", String(pid), "/t", "/f"];
if (sync) {
const res = spawnSync("taskkill", args, { stdio: "ignore", windowsHide: true });
if (res.error || res.status !== 0) this.child.kill();
return;
}
try {
const killer = spawn("taskkill", args, { stdio: "ignore", windowsHide: true });
// taskkill missing or failing (e.g. restricted environment): still terminate the direct child.
killer.on("error", () => this.child.kill());
killer.on("exit", (code) => {
if (code !== 0 && !this.exited) this.child.kill();
});
} catch {
this.child.kill();
}
}
private handleData(chunk: string): void {
this.buffer.append(chunk);
this.wakeSignal.notify();
@@ -191,6 +232,7 @@ export class ManagedSession {
// stdin may already be closed, ignored.
}
}
/** Ctrl-C. POSIX: SIGINT to the process group; Windows: degrades to a hard tree kill (see signalGroup). */
interrupt(): void {
this.lastUsed = Date.now();
this.signalGroup("SIGINT");
@@ -214,7 +256,7 @@ export class ManagedSession {
clearTimeout(this.killTimer);
this.killTimer = null;
}
this.signalGroup("SIGKILL");
this.signalGroup("SIGKILL", true);
}
}
@@ -0,0 +1,121 @@
/**
* Shell selection for command sessions.
*
* POSIX behavior is unchanged: commands run via `bash -lc <cmd>`. On Windows there is no
* bash by default, so the resolver picks the best available shell once per process:
*
* 1. `PENGUIN_SHELL` (explicit executable name or path) always wins, on every platform;
* the argument shape is inferred from its basename (see below).
* 2. Otherwise, non-Windows uses `bash -lc` (today's behavior, bit for bit).
* 3. On Windows, probe PATH for `bash` (Git for Windows — best compatibility with the
* skill/prompt ecosystem, which is written for a POSIX shell), then `pwsh`
* (PowerShell 7+), then fall back to `powershell` (Windows PowerShell 5.1, always
* present). A `bash` that resolves into the Windows system directory is ignored: that
* is the WSL launcher, which runs commands inside a Linux distro with a different
* filesystem view (and fails outright when no distro is configured).
*
* Argument shapes by basename (also applied to `PENGUIN_SHELL` values):
* - `pwsh` / `powershell` -> `-NoLogo -NoProfile -Command <cmd>`
* - `cmd` -> `/d /s /c <cmd>`
* - anything else -> `-lc <cmd>` (bash/zsh/sh-style login shell)
*
* The resolved shell's name is surfaced to the model via the session environment (the
* `Shell:` line in the system prompt), so it knows which syntax the exec tool speaks.
*/
import { spawnSync } from "node:child_process";
import path from "node:path";
/** A resolved shell invocation: `spawn(command, [...args, cmd])` runs `cmd` in that shell. */
export interface ShellInvocation {
/** Executable name or absolute path handed to spawn(). */
command: string;
/** Fixed argument prefix placed before the command string. */
args: string[];
/** Short lowercase name (basename without extension), shown to the model (e.g. "bash", "pwsh"). */
name: string;
}
/** Injection points for unit tests; production callers use the defaults. */
export interface ResolveShellOptions {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
/** Returns the PATH resolutions of an executable name, best match first ([] when not found). */
whichAll?: (cmd: string) => string[];
/** The Windows system root (to recognize the WSL bash launcher); default `env.SystemRoot` or C:\Windows. */
systemRoot?: string;
}
/** Basename without a trailing .exe/.cmd/.bat/.ps1 extension, lowercased ("C:\...\pwsh.EXE" -> "pwsh"). */
function shellBasename(command: string): string {
// path.win32 handles both separators, so PENGUIN_SHELL=/usr/bin/zsh still yields "zsh".
return path.win32
.basename(command)
.replace(/\.(exe|cmd|bat|ps1)$/i, "")
.toLowerCase();
}
/** Argument prefix for a shell, chosen by its basename (PowerShell-style vs cmd vs POSIX-style). */
function argsForShell(name: string): string[] {
if (name === "pwsh" || name === "powershell") return ["-NoLogo", "-NoProfile", "-Command"];
if (name === "cmd") return ["/d", "/s", "/c"];
return ["-lc"];
}
/** Default PATH probe: `where` lists every match line by line, in PATH order (win32 only). */
function defaultWhichAll(cmd: string): string[] {
try {
const res = spawnSync("where", [cmd], {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
});
if (res.status !== 0 || !res.stdout) return [];
return res.stdout
.toString("utf8")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
} catch {
return [];
}
}
/**
* Resolves the shell for command sessions (pure given its options; see the module comment
* for the order). Exported for unit tests; runtime code uses the cached `sessionShell()`.
*/
export function resolveShell(opts: ResolveShellOptions = {}): ShellInvocation {
const platform = opts.platform ?? process.platform;
const env = opts.env ?? process.env;
const explicit = env.PENGUIN_SHELL?.trim();
if (explicit) {
const name = shellBasename(explicit);
return { command: explicit, args: argsForShell(name), name };
}
if (platform !== "win32") {
return { command: "bash", args: ["-lc"], name: "bash" };
}
const whichAll = opts.whichAll ?? defaultWhichAll;
const systemRoot = opts.systemRoot ?? env.SystemRoot ?? "C:\\Windows";
// The WSL launcher lives in <SystemRoot>\System32 (or Sysnative under WOW64); a Git for
// Windows bash lives under the Git install dir. Only the first PATH match counts — that
// is the one spawn("bash") would run.
const bash = whichAll("bash")[0];
if (bash && !bash.toLowerCase().startsWith(systemRoot.toLowerCase() + path.win32.sep)) {
return { command: "bash", args: ["-lc"], name: "bash" };
}
if (whichAll("pwsh").length > 0) {
return { command: "pwsh", args: argsForShell("pwsh"), name: "pwsh" };
}
return { command: "powershell", args: argsForShell("powershell"), name: "powershell" };
}
let cached: ShellInvocation | null = null;
/** The process-wide shell for command sessions; resolved once (probing PATH costs a subprocess on Windows). */
export function sessionShell(): ShellInvocation {
if (!cached) cached = resolveShell();
return cached;
}
@@ -9,6 +9,7 @@ import path from "node:path";
import { randomBytes, randomUUID } from "node:crypto";
import { formatLocalDate } from "./dates.js";
import { sessionShell } from "../environment/tools/command/shell.js";
import type { SessionEnvironmentValues } from "../state/agent-state.js";
import { workspacesDir } from "../state/index.js";
import { userText } from "../omnimessage/index.js";
@@ -47,6 +48,9 @@ export function sessionEnvironment(
modelId: ids.modelId,
platform: process.platform,
osVersion: getOsVersion(),
// The shell exec_command actually runs (bash on POSIX; resolved on Windows): the model
// must know whether to write bash or PowerShell syntax.
shell: sessionShell().name,
date: formatLocalDate(date),
};
}
+47 -2
View File
@@ -36,6 +36,7 @@ import {
PLATFORM_PLACEHOLDER,
PROJECT_DIR_PLACEHOLDER,
SESSION_ID_PLACEHOLDER,
SHELL_PLACEHOLDER,
type SystemConfig,
} from "./default-config.js";
import { builtinProjectAgentPresets, type AgentPreset } from "./builtin-agents.js";
@@ -92,6 +93,8 @@ export interface SessionEnvironmentValues {
modelId: string;
platform: string;
osVersion: string;
/** The shell command sessions run in (system Prompt placeholder {{SHELL}}; e.g. "bash", "pwsh") — tells the model which command syntax exec_command speaks. */
shell: string;
date: string;
}
@@ -383,6 +386,43 @@ export function skillMetadataSection(skills: SkillMetadata[]): string {
.join("\n");
}
/**
* Windows compatibility fallback for pre-`{{SHELL}}` system prompt templates.
*
* `system_config.yaml` is baked at Agent creation and never auto-upgraded, so an Agent created
* before the `{{SHELL}}` placeholder existed never tells its model which shell `exec_command`
* speaks — and on Windows (where the shell may be PowerShell, not bash) the model then keeps
* emitting bash syntax into the wrong shell. When the platform is win32 and the template carries
* no `{{SHELL}}` placeholder, inject a `- Shell: <shell>` line into the assembled prompt at
* render time: right after the Environment section heading when one exists, else appended as a
* minimal final line. In-memory only — the stored template is never rewritten. On POSIX the
* assembled prompt stays byte-identical (bash was always implied there), and a prompt that
* already carries the exact line is left untouched (idempotent).
*
* Retirement condition: remove this fallback once pre-`{{SHELL}}` Agent configs (created before
* the placeholder shipped in PR #79) are no longer expected in the wild.
*/
function withShellLineFallback(
assembled: string,
template: string,
sessionEnvironment?: SessionEnvironmentValues,
): string {
if (sessionEnvironment?.platform !== "win32") return assembled;
if (!sessionEnvironment.shell) return assembled;
if (template.includes(SHELL_PLACEHOLDER)) return assembled; // The template already renders the line itself.
// Any existing `- Shell:` line means the model is already told a shell — including a custom
// template hardcoding a different value on purpose; never add a second, contradicting line.
if (/^- Shell: /m.test(assembled)) return assembled;
const line = `- Shell: ${sessionEnvironment.shell}`;
// The default templates head the section with `# Environment`; accept any heading level.
const heading = /^#+ Environment[ \t]*$/m.exec(assembled);
if (heading) {
const insertAt = heading.index + heading[0].length;
return `${assembled.slice(0, insertAt)}\n${line}${assembled.slice(insertAt)}`;
}
return `${assembled}\n${line}`;
}
/**
* Renders the complete runtime system Prompt: substitutes `AGENTS.md`, vault key names, Skill
* metadata, and the concrete Session runtime environment placeholders into the system Prompt
@@ -390,7 +430,8 @@ export function skillMetadataSection(skills: SkillMetadata[]): string {
* wrapper text such as `[developer_instructions]` and the # Vault / # Skills statements are
* written directly into the system Prompt template itself (the Prompt is fully
* transparent and editable via `system_config.yaml`). Other files in Agent State / Workspace are
* never auto-injected.
* never auto-injected. Sole exception: on win32 a template without `{{SHELL}}` gets a `- Shell:`
* line injected at render time (see `withShellLineFallback`).
*
* `{{VAULT_KEYS}}` is replaced with the vault key-name list (an empty string if empty/not
* provided): this lets the model know which APIs requiring a key it can call; values are never
@@ -406,7 +447,8 @@ export function assembleSystemPrompt(
vaultKeys?: string[],
skillMetadata?: SkillMetadata[],
): string {
return state.systemConfig.system_prompt
const template = state.systemConfig.system_prompt;
const assembled = template
.split(AGENTS_MD_PLACEHOLDER)
.join(state.agentsMd.trim())
.split(VAULT_KEYS_PLACEHOLDER)
@@ -429,9 +471,12 @@ export function assembleSystemPrompt(
.join(sessionEnvironment?.platform ?? "")
.split(OS_VERSION_PLACEHOLDER)
.join(sessionEnvironment?.osVersion ?? "")
.split(SHELL_PLACEHOLDER)
.join(sessionEnvironment?.shell ?? "")
.split(DATE_PLACEHOLDER)
.join(sessionEnvironment?.date ?? "")
.trim();
return withShellLineFallback(assembled, template, sessionEnvironment);
}
/**
@@ -33,6 +33,8 @@ export const PROVIDER_PLACEHOLDER = "{{PROVIDER}}";
export const MODEL_ID_PLACEHOLDER = "{{MODEL_ID}}";
export const PLATFORM_PLACEHOLDER = "{{PLATFORM}}";
export const OS_VERSION_PLACEHOLDER = "{{OS_VERSION}}";
/** The shell exec_command runs (`bash` on POSIX; on Windows whatever shell.ts resolved), so the model knows which command syntax to write. */
export const SHELL_PLACEHOLDER = "{{SHELL}}";
export const DATE_PLACEHOLDER = "{{DATE}}";
/**
@@ -147,6 +149,7 @@ Skills are reusable instruction packages stored under <app_data_dir>/agents/<age
# Environment
- Platform: {{PLATFORM}}
- OS Version: {{OS_VERSION}}
- Shell: {{SHELL}}
- Date: {{DATE}}
- App Data Dir: {{PROJECT_DIR}}
- Agent ID: {{AGENT_ID}}