From 7393a9e69a7e42a82e526c16a01e522fc4fe1338 Mon Sep 17 00:00:00 2001 From: Anroshka Date: Sat, 8 Aug 2026 16:35:46 +0300 Subject: [PATCH 1/3] fix(coding-agent): make Windows a first-class runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prime Agent could not reach a working session on a stock Windows box. Launching it produced a burst of console windows that flashed open and shut, and then nothing: no IPython tool, no search helpers, and a daemon logging errors nobody saw. Six platform assumptions were compounding. 1. The kernel venv interpreter was hardcoded to `/bin/python`. uv creates `\Scripts\python.exe` on Windows, so every readiness probe failed. The venv was torn down and rebuilt on each launch, and the rebuild then failed at `uv pip install --python /bin/python`. The IPython tool — the agent's only built-in tool — was never available. 2. Zip archives are the only archive format used on Windows (fd and ripgrep ship `.tar.gz` elsewhere), and they were unpacked with extract-zip. Its yauzl read streams never settle on current Node releases: extraction hangs at the first entry large enough to span more than one chunk. `rg`/`fd` never installed, and every attempt leaked an `extract_tmp_*` directory. Windows ships bsdtar in System32 and it reads zip, so `tar` is now the primary path with a time-boxed extract-zip fallback. Provisioning cleanup no longer masks the real error when antivirus holds a handle on a freshly extracted binary. 3. The daemon and its session workers were spawned detached without `windowsHide`, which leaves them with no console at all. Every console tool they then ran — git, uv, python, powershell — allocated a console of its own, which is the window storm. Spawns whose output is piped or discarded now pass `windowsHide`. 4. `CommandRecoveryJournal.compact` fsynced the containing directory. That is EPERM on Windows and aborted every supervisor `ack_result`. Guarded the way `cron-jobs.ts` already guards the same call. 5. Session leases are claimed by renaming a candidate directory onto the lease path, treating EEXIST/ENOTEMPTY as "already held". Windows reports a directory-over-directory rename as EPERM, so that branch never ran: stale leases were never reclaimed, and a live one surfaced as a raw EPERM instead of SessionAlreadyActiveError. 6. `expandTildePath` concatenated instead of joining, yielding `C:\Users\me/sessions` — a usable path that never compares equal to the same location built with `join()`. Also on Windows: install uv through its PowerShell installer rather than piping `install.sh` into a `sh` that does not exist; widen Git for Windows discovery to per-user installs and Git resolved from PATH (scoop and Chocolatey shims); and rank `System32\bash.exe` last, since the WSL launcher resolves a different filesystem than the paths the agent composes. Off Windows every change is a no-op or unchanged behaviour: `windowsHide` is ignored on POSIX, `getVenvPythonPath` returns the previous path, the zip branch is unreachable where downloads are tar.gz, and the lease and tilde helpers keep their POSIX semantics. Coverage: a `Windows` workflow builds, lints, runs the platform- sensitive suites, and runs an end-to-end kernel smoke test that asserts the venv resolves to `Scripts\python.exe`, that a second bootstrap is a cache hit rather than a rebuild, and that a kernel starts and executes a cell. Symlink and POSIX-permission fixtures now gate on capability probes instead of failing on Windows. The rest of the suite still carries POSIX-only fixtures and is out of scope; docs/windows.md says so explicitly. Verified on Windows 11 / Node 26: the venv bootstraps once and stays ready, the IPython kernel starts and executes, rg and fd install, the daemon log is clean of EPERM, and startup creates no visible console windows. --- .github/workflows/windows.yml | 117 ++++++++++++++++++ README.md | 9 ++ packages/coding-agent/docs/quickstart.md | 8 ++ packages/coding-agent/docs/windows.md | 107 ++++++++++++++-- .../scripts/windows-kernel-smoke.mjs | 72 +++++++++++ .../coding-agent/src/cli/daemon-command.ts | 1 + .../coding-agent/src/cli/daemon-launch.ts | 5 + .../src/cli/daemon-update-restart.ts | 1 + packages/coding-agent/src/config.ts | 9 +- packages/coding-agent/src/core/autonomous.ts | 1 + packages/coding-agent/src/core/exec.ts | 2 + .../src/core/footer-data-provider.ts | 2 + .../coding-agent/src/core/kernel/bootstrap.ts | 38 +++++- .../coding-agent/src/core/kernel/index.ts | 3 + .../coding-agent/src/core/package-manager.ts | 2 + .../src/core/resolve-config-value.ts | 1 + .../coding-agent/src/core/session-lease.ts | 24 +++- packages/coding-agent/src/core/tools/bash.ts | 2 + .../modes/daemon/command-recovery-journal.ts | 13 +- .../modes/daemon/daemon-catalog-process.ts | 1 + .../src/modes/daemon/daemon-mode.ts | 1 + .../src/modes/daemon/daemon-supervisor.ts | 5 + .../src/modes/interactive/interactive-mode.ts | 4 +- .../coding-agent/src/utils/clipboard-image.ts | 1 + packages/coding-agent/src/utils/git.ts | 1 + packages/coding-agent/src/utils/shell.ts | 70 ++++++++--- .../coding-agent/src/utils/tools-manager.ts | 72 ++++++++++- .../test/expand-tilde-path.test.ts | 31 +++++ .../test/kernel-venv-python-path.test.ts | 32 +++++ packages/coding-agent/test/paths.test.ts | 7 +- .../coding-agent/test/platform-support.ts | 36 ++++++ .../coding-agent/test/session-lease.test.ts | 3 +- 32 files changed, 636 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/windows.yml create mode 100644 packages/coding-agent/scripts/windows-kernel-smoke.mjs create mode 100644 packages/coding-agent/test/expand-tilde-path.test.ts create mode 100644 packages/coding-agent/test/kernel-venv-python-path.test.ts create mode 100644 packages/coding-agent/test/platform-support.ts diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000000..e1ece1853c --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,117 @@ +name: Windows + +# Windows is the one supported platform the Linux CI cannot speak for: the +# things that break there are path layout (Scripts\python.exe), archive format +# (zip instead of tar.gz), console allocation, and Win32 error codes for +# filesystem races. None of those have a Linux equivalent that would catch a +# regression, so they get their own job. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: windows-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-check: + name: Build and typecheck (Windows) + runs-on: windows-latest + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7.0.0 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Lint and format + run: npx biome check --error-on-warnings . + + test: + name: Platform tests (Windows) + runs-on: windows-latest + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7.0.0 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + # Scoped to the suites whose behaviour is platform-dependent. The rest of + # the suite still carries POSIX-only fixtures (shell-scripted stubs, chmod + # permission bits, symlinks) and is covered by the Linux jobs. + - name: Test + working-directory: packages/coding-agent + run: >- + npx vitest --run + test/kernel-venv-python-path.test.ts + test/expand-tilde-path.test.ts + test/session-lease.test.ts + test/command-recovery-journal.test.ts + test/worker-recovery-journal.test.ts + test/orphan-process-journal.test.ts + test/child-process.test.ts + test/bash-close-hang-windows.test.ts + test/daemon-socket.test.ts + test/paths.test.ts + test/path-utils.test.ts + test/args.test.ts + + kernel-smoke: + name: IPython kernel smoke (Windows) + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7.0.0 + with: + node-version: 22 + cache: npm + + - name: Install uv + run: | + irm https://astral.sh/uv/install.ps1 | iex + "$env:USERPROFILE\.local\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + # The regression this guards: the venv interpreter used to be looked up at + # /bin/python, which does not exist on Windows. Every readiness probe + # failed, so the venv was rebuilt on each launch and the IPython tool never + # came up. Bootstrapping twice proves the second run is a cache hit. + - name: Bootstrap kernel venv and execute a cell + working-directory: packages/coding-agent + run: node scripts/windows-kernel-smoke.mjs diff --git a/README.md b/README.md index 1d6f850c5f..185b190b4f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,14 @@ curl -fsSL https://app.primeintellect.ai/prime-agent/install.sh | sh The installer downloads a versioned release, verifies its SHA-256 checksum, installs the `prime-agent` command, and can prepare the IPython runtime used by the agent. +On Windows, install from npm and run it from PowerShell, Command Prompt, or Windows Terminal — no WSL or Git Bash session required: + +```powershell +npm install -g prime-agent +``` + +Windows needs Node.js 22.8+ and [Git for Windows](https://git-scm.com/download/win), whose bundled Bash backs the agent's shell tool. See [Windows support](packages/coding-agent/docs/windows.md) for the full requirements and known limitations. + Start Prime Agent from the repository or directory you want it to work in: ```bash @@ -98,6 +106,7 @@ Prime Agent is built for long-running work, especially for evaluations in resear - [Provider setup](packages/coding-agent/docs/providers.md) — subscription and API-key providers - [Architecture overview](packages/coding-agent/docs/architecture.md) — daemon, worker, kernel, and persistence boundaries - [Development](packages/coding-agent/docs/development.md) — build and run from source +- [Windows support](packages/coding-agent/docs/windows.md) — requirements, what differs from macOS and Linux, and troubleshooting ## Acknowledgements diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index 95fd940eaa..fcd9abf8ed 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -18,6 +18,14 @@ curl -fsSL https://app.primeintellect.ai/prime-agent/install.sh | sh -s -- beta Both commands fetch versioned Prime Agent release artifacts and install the `prime-agent` command. The inherited npm workspace identifiers in the source tree are not the public install path. +The bootstrap installer is POSIX-only. On Windows, install from npm and run Prime Agent from PowerShell, Command Prompt, or Windows Terminal: + +```powershell +npm install -g prime-agent +``` + +See [Windows](windows.md) for requirements and platform notes. + Then start Prime Agent in the project directory you want it to work on: ```bash diff --git a/packages/coding-agent/docs/windows.md b/packages/coding-agent/docs/windows.md index 3f7da6c61d..3fe510fed1 100644 --- a/packages/coding-agent/docs/windows.md +++ b/packages/coding-agent/docs/windows.md @@ -1,17 +1,110 @@ -# Windows Setup +# Windows support -Prime Agent requires a bash shell on Windows. Checked locations (in order): +Prime Agent runs natively on Windows. It is launched from PowerShell, Command +Prompt, or Windows Terminal like any other console program — a WSL distribution +or a Git Bash session is not required, and the daemon, session workers, and the +IPython kernel all run as ordinary Windows processes. -1. Custom path from `~/.prime/agent/settings.json` -2. Git Bash (`C:\Program Files\Git\bin\bash.exe`) -3. `bash.exe` on PATH (Cygwin, MSYS2, WSL) +## Requirements -For most users, [Git for Windows](https://git-scm.com/download/win) is sufficient. +| Requirement | Notes | +| --- | --- | +| Windows 10 1803+ or Windows 11 | Earlier builds lack `tar.exe`, which is used to unpack the ripgrep and fd archives. | +| Node.js 22.8 or newer | Same floor as every other platform. | +| [Git for Windows](https://git-scm.com/download/win) | Supplies the `bash.exe` that backs the agent's shell tool. | +| [uv](https://docs.astral.sh/uv/) | Installed automatically on first launch if missing; `PRIME_AGENT_INSTALL_UV=1` skips the prompt. | -## Custom Shell Path +Install and run: + +```powershell +npm install -g prime-agent +cd C:\path\to\project +prime-agent +``` + +## What differs from macOS and Linux + +**The Python kernel lives in `Scripts\`.** `uv` creates the kernel virtualenv at +`%USERPROFILE%\.prime\agent\kernel-venv` with the interpreter at +`Scripts\python.exe` rather than `bin/python`. Set `PRIME_AGENT_KERNEL_PYTHON` +to point at your own interpreter, or `PRIME_AGENT_KERNEL_VENV` to relocate the +managed one. + +**The shell tool runs Bash, not `cmd.exe` or PowerShell.** Commands the model +writes are executed with Git for Windows' `bash.exe`, so the agent composes +POSIX-style command lines on every platform. Resolution order: + +1. `shellPath` in `settings.json`, if set. +2. Git for Windows in `%ProgramFiles%`, `%ProgramFiles(x86)%`, or + `%LOCALAPPDATA%\Programs` — this covers the machine-wide, 32-bit, and + per-user (winget default) installers. +3. A Git install found via `git.exe` on `PATH`, which covers scoop and + Chocolatey shims. +4. Any other `bash.exe` on `PATH` (MSYS2, Cygwin). + +`%SystemRoot%\System32\bash.exe` — the WSL launcher — is deliberately ranked +last. It resolves a different filesystem (`/mnt/c/...`) than the Windows paths +the agent works with, so it is only used when nothing else is available. + +To pin a specific shell, set `shellPath` in `~/.prime/agent/settings.json`: ```json { "shellPath": "C:\\cygwin64\\bin\\bash.exe" } ``` + +**`fd` and `ripgrep` are unpacked with `tar.exe`.** These optional search +helpers ship as `.zip` on Windows and are extracted into +`%USERPROFILE%\.prime\agent\bin`. Both are optional: without them the agent +falls back to slower search paths. Installing them yourself +(`winget install BurntSushi.ripgrep.MSVC`, `winget install sharkdp.fd`) makes +Prime Agent use the copies on `PATH` instead. + +**Subprocesses are spawned with `windowsHide`.** The daemon and its session +workers are detached and therefore have no console of their own, so any console +tool they run (git, uv, python) would otherwise allocate — and flash — a console +window per invocation. Every spawn whose output is piped or discarded now +suppresses that window. + +## Configuration paths + +| Path | Contents | +| --- | --- | +| `%USERPROFILE%\.prime\agent` | Agent state root | +| `%USERPROFILE%\.prime\agent\kernel-venv` | Managed Python kernel virtualenv | +| `%USERPROFILE%\.prime\agent\bin` | Downloaded `rg.exe` and `fd.exe` | +| `%USERPROFILE%\.prime\agent\logs` | Daemon, worker, and client logs | +| `%USERPROFILE%\.prime\agent\sessions` | Saved session transcripts | +| `\\.\pipe\prime-agent-daemon` | Daemon socket (a named pipe, not a Unix socket) | + +## Troubleshooting + +**`No bash shell found`** — install Git for Windows, or point `shellPath` in +`settings.json` at a `bash.exe` you already have. + +**Kernel setup fails.** First launch needs network access to install uv, Python +3.11, `ipykernel`, and the runtime packages. Check +`%USERPROFILE%\.prime\agent\logs` and re-run; to inspect the venv directly, use +`%USERPROFILE%\.prime\agent\kernel-venv\Scripts\python.exe`. + +**Antivirus interference.** Real-time scanning can hold handles on freshly +extracted binaries. Provisioning `rg`/`fd` tolerates this — a failed cleanup +leaves a temp directory behind rather than failing the install — but repeated +failures usually mean `%USERPROFILE%\.prime\agent\bin` needs an exclusion. + +**Stale background services** — `prime-agent doctor` inspects them and +`prime-agent shutdown --force` stops everything, including sessions in other +windows. + +## Not covered + +- The `install.sh` bootstrap installer is POSIX-only; use npm on Windows. +- Terminal-dependent behaviour (bracketed paste, image protocols, hyperlinks) + varies by host. Windows Terminal is the best-supported console. +- The kernel forkserver fast path is Linux-only; Windows always cold-starts the + kernel via `python -m ipykernel_launcher`. +- Much of the test suite uses POSIX-only fixtures (shell-scripted stubs, `chmod` + permission bits, symlinks, `/tmp` paths) and does not run on Windows. The + platform-sensitive suites plus an end-to-end kernel smoke test run in the + `Windows` CI workflow. diff --git a/packages/coding-agent/scripts/windows-kernel-smoke.mjs b/packages/coding-agent/scripts/windows-kernel-smoke.mjs new file mode 100644 index 0000000000..5f0a6e0f19 --- /dev/null +++ b/packages/coding-agent/scripts/windows-kernel-smoke.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +// End-to-end guard for the Windows kernel path, run from .github/workflows/windows.yml. +// +// Asserts three things the Linux jobs cannot: +// 1. ensureKernelPython resolves to \Scripts\python.exe and that file exists. +// 2. A second ensureKernelPython call is a cache hit rather than a full rebuild. +// (The venv used to be torn down and rebuilt on every launch because the +// readiness probe looked for a POSIX interpreter path that never exists.) +// 3. A kernel actually starts over ZMQ and runs a cell. + +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +const REBUILD_BUDGET_MS = 30_000; + +const { ensureKernelPython, getKernelVenvDir, getVenvPythonPath } = await import( + "../dist/core/kernel/bootstrap.js" +); +const { KernelManager } = await import("../dist/core/kernel/index.js"); + +function fail(message) { + console.error(`FAIL: ${message}`); + process.exit(1); +} + +const venv = getKernelVenvDir(); +const expectedPython = join(venv, "Scripts", "python.exe"); + +if (getVenvPythonPath(venv) !== expectedPython) { + fail(`getVenvPythonPath returned ${getVenvPythonPath(venv)}, expected ${expectedPython}`); +} + +console.log("bootstrapping kernel venv (cold)..."); +const coldStarted = Date.now(); +const python = await ensureKernelPython({ onProgress: (message) => console.log(` ${message}`) }); +console.log(`cold bootstrap finished in ${Math.round((Date.now() - coldStarted) / 1000)}s -> ${python}`); + +if (python !== expectedPython) { + fail(`ensureKernelPython returned ${python}, expected ${expectedPython}`); +} +if (!existsSync(python)) { + fail(`${python} does not exist`); +} + +console.log("bootstrapping again (should be a cache hit)..."); +const warmStarted = Date.now(); +await ensureKernelPython(); +const warmMs = Date.now() - warmStarted; +console.log(`warm bootstrap finished in ${warmMs}ms`); + +if (warmMs > REBUILD_BUDGET_MS) { + fail(`second bootstrap took ${warmMs}ms; the venv is being rebuilt instead of reused`); +} + +console.log("starting kernel..."); +const kernel = new KernelManager({ cwd: process.cwd() }); +try { + await kernel.start(); + const result = await kernel.execute("import platform; print(platform.system())"); + console.log(`kernel stdout: ${JSON.stringify(result.stdout)}`); + if (result.status !== "ok") { + fail(`kernel execution status was ${result.status}: ${result.stderr}`); + } + if (!result.stdout.includes("Windows")) { + fail(`kernel did not report a Windows interpreter: ${result.stdout}`); + } +} finally { + await kernel.shutdown().catch(() => undefined); +} + +console.log("OK: Windows kernel path is healthy"); diff --git a/packages/coding-agent/src/cli/daemon-command.ts b/packages/coding-agent/src/cli/daemon-command.ts index 9883707bd8..2a49fa5b18 100644 --- a/packages/coding-agent/src/cli/daemon-command.ts +++ b/packages/coding-agent/src/cli/daemon-command.ts @@ -696,6 +696,7 @@ async function runStart(parsed: ParsedDaemonClientCommand): Promise { detached: true, env: process.env, stdio: "ignore", + windowsHide: true, }); child.unref(); diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index 6de9285e90..d1efb12761 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -379,6 +379,11 @@ async function ensureDaemonRunning(socketPath: string, spawnCwd?: string): Promi // (EPIPE once it exits); crash details come from the daemon log, // which the supervisor writes to before rethrowing startup errors. stdio: "ignore", + // Without this the detached daemon gets no console at all on Windows, + // so every console tool it later spawns (git, uv, python) allocates a + // console window of its own that flashes open and shut. CREATE_NO_WINDOW + // gives the daemon an invisible console that its children inherit. + windowsHide: true, }, ); let childFailure: diff --git a/packages/coding-agent/src/cli/daemon-update-restart.ts b/packages/coding-agent/src/cli/daemon-update-restart.ts index 7dc44ed577..4bde41fb19 100644 --- a/packages/coding-agent/src/cli/daemon-update-restart.ts +++ b/packages/coding-agent/src/cli/daemon-update-restart.ts @@ -552,6 +552,7 @@ export async function launchDaemonUpdateRestartCoordinator( detached: true, env: coordinatorEnvironment(agentDir), stdio: "ignore", + windowsHide: true, }); let launchError: Error | undefined; let exitDescription: string | undefined; diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index b709ab10e9..5354b58e59 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -210,6 +210,7 @@ function readCommandOutput( encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], shell: shouldUseWindowsShell(command), + windowsHide: true, }); if (result.status === 0) return result.stdout.trim() || undefined; if (options.requireSuccess) { @@ -514,7 +515,13 @@ export const ENV_LEGACY_SESSION_DIR = `${envPrefix}_CODING_AGENT_SESSION_DIR`; export function expandTildePath(path: string): string { if (path === "~") return homedir(); - if (path.startsWith("~/")) return homedir() + path.slice(1); + // join() rather than string concatenation: on Windows the home directory is + // backslash-separated, so concatenating "~/sessions" produced a mixed + // "C:\Users\me/sessions" that no longer compares equal to the same path + // built with join(). + if (path.startsWith("~/") || (process.platform === "win32" && path.startsWith("~\\"))) { + return join(homedir(), path.slice(2)); + } return path; } diff --git a/packages/coding-agent/src/core/autonomous.ts b/packages/coding-agent/src/core/autonomous.ts index 75930d415c..84c7e66717 100644 --- a/packages/coding-agent/src/core/autonomous.ts +++ b/packages/coding-agent/src/core/autonomous.ts @@ -496,6 +496,7 @@ function runChildProcess( detached: process.platform !== "win32", shell: options.shell === true, stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }); if (child.pid) { trackDetachedChildPid(child.pid); diff --git a/packages/coding-agent/src/core/exec.ts b/packages/coding-agent/src/core/exec.ts index bd62eb5a0e..df8418160b 100644 --- a/packages/coding-agent/src/core/exec.ts +++ b/packages/coding-agent/src/core/exec.ts @@ -65,6 +65,8 @@ export async function execCommand( // Merge per-call env over the parent env so callers can scope vars // (e.g. herdr pane identity) without mutating the shared process.env. env: mergeExecEnv(options?.env), + // Output is piped, so a console window would only ever flash and close. + windowsHide: true, }); let stdout = ""; diff --git a/packages/coding-agent/src/core/footer-data-provider.ts b/packages/coding-agent/src/core/footer-data-provider.ts index 2aff8ac321..291091879b 100644 --- a/packages/coding-agent/src/core/footer-data-provider.ts +++ b/packages/coding-agent/src/core/footer-data-provider.ts @@ -10,6 +10,7 @@ function resolveBranchWithGitSync(repoDir: string): string | null { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); const branch = result.status === 0 ? result.stdout.trim() : ""; return branch || null; @@ -24,6 +25,7 @@ function resolveBranchWithGitAsync(repoDir: string): Promise { { cwd: repoDir, encoding: "utf8", + windowsHide: true, }, (error: ExecFileException | null, stdout: string) => { if (error) { diff --git a/packages/coding-agent/src/core/kernel/bootstrap.ts b/packages/coding-agent/src/core/kernel/bootstrap.ts index 9b12b4b413..8e0e0ba5f9 100644 --- a/packages/coding-agent/src/core/kernel/bootstrap.ts +++ b/packages/coding-agent/src/core/kernel/bootstrap.ts @@ -36,6 +36,23 @@ export const DEFAULT_RLM_EXTRA_UV_ARGS = DEFAULT_RLM_EXTRA_PACKAGES.map((pkg) => export const DEFAULT_RLM_EXTRA_IMPORT_NAMES = DEFAULT_RLM_EXTRA_PACKAGES.map((pkg) => pkg.importName); export const DEFAULT_RLM_EXTRA_IMPORT_LABELS = DEFAULT_RLM_EXTRA_PACKAGES.map((pkg) => pkg.promptLabel); const UV_INSTALL_COMMAND = "curl -LsSf https://astral.sh/uv/install.sh | sh"; +const UV_INSTALL_COMMAND_WINDOWS = 'powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"'; + +function uvInstallCommand(): string { + return process.platform === "win32" ? UV_INSTALL_COMMAND_WINDOWS : UV_INSTALL_COMMAND; +} + +// The POSIX installer is piped through `sh`, which does not exist on a stock +// Windows box; the official Windows installer is a PowerShell one-liner. +function uvInstallSpawn(): { command: string; args: string[] } { + if (process.platform === "win32") { + return { + command: "powershell", + args: ["-NoProfile", "-ExecutionPolicy", "ByPass", "-c", "irm https://astral.sh/uv/install.ps1 | iex"], + }; + } + return { command: "sh", args: ["-c", UV_INSTALL_COMMAND] }; +} const REQUIRED_HARNESS_METHODS = [ "create_memory", "update_memory", @@ -342,6 +359,13 @@ export function getKernelVenvDir(): string { return path.join(os.homedir(), ".prime", "agent", "kernel-venv"); } +// A venv puts its interpreter in Scripts\python.exe on Windows and bin/python +// everywhere else. Hardcoding the POSIX layout makes every readiness probe fail, +// which silently re-bootstraps the venv on each launch and then fails the install. +export function getVenvPythonPath(venv: string): string { + return process.platform === "win32" ? path.join(venv, "Scripts", "python.exe") : path.join(venv, "bin", "python"); +} + function getXdgKernelVenvDir(): string { const dataHome = process.env.XDG_DATA_HOME ? path.resolve(expandHome(process.env.XDG_DATA_HOME)) @@ -376,6 +400,9 @@ function run(command: string, args: string[], options: { stdio?: "ignore" | "inh const child = spawn(command, args, { env: process.env, stdio: options.stdio ?? "ignore", + // A daemon-hosted agent has no console of its own on Windows, so every + // console child (uv, python) would otherwise pop up its own window. + windowsHide: true, }); child.on("error", reject); child.on("exit", (code, signal) => { @@ -522,17 +549,18 @@ async function ensureUv(options: EnsureKernelPythonOptions): Promise { process.env.PRIME_AGENT_INSTALL_UV === "1" || (!options.onProgress && (await confirmUvInstall())); if (!shouldInstallUv) { throw new Error( - `uv is required to set up the Python kernel. Install uv yourself: ${UV_INSTALL_COMMAND}, ` + + `uv is required to set up the Python kernel. Install uv yourself: ${uvInstallCommand()}, ` + "or set PRIME_AGENT_INSTALL_UV=1 to let prime-agent run that installer.", ); } reportProgress(options, "› installing uv (one-time)…"); + const installSpawn = uvInstallSpawn(); try { - await run("sh", ["-c", UV_INSTALL_COMMAND], { stdio: options.onProgress ? "ignore" : "inherit" }); + await run(installSpawn.command, installSpawn.args, { stdio: options.onProgress ? "ignore" : "inherit" }); } catch (error) { throw new Error( - `couldn't install uv from astral.sh; install it yourself: ${UV_INSTALL_COMMAND}, then re-run prime-agent. ${errorMessage(error)}`, + `couldn't install uv from astral.sh; install it yourself: ${uvInstallCommand()}, then re-run prime-agent. ${errorMessage(error)}`, ); } @@ -725,7 +753,7 @@ async function bootstrapVenv( ): Promise { await mkdir(path.dirname(venv), { recursive: true }); const uv = await ensureUv(options); - const python = path.join(venv, "bin", "python"); + const python = getVenvPythonPath(venv); const sourceDir = await resolveRuntimeSourceDir(); const runtimeRequirement = sourceDir ?? RUNTIME_REQUIREMENT; const runtimeIdentity = await resolveRuntimeIdentity(); @@ -886,7 +914,7 @@ async function ensureKernelPythonUncached( } const venv = await resolveWritableKernelVenvDir(); - const python = path.join(venv, "bin", "python"); + const python = getVenvPythonPath(venv); const runtimeIdentity = await resolveRuntimeIdentity(); if (await kernelReady(python, venv, runtimeIdentity, pythonSkills)) return python; diff --git a/packages/coding-agent/src/core/kernel/index.ts b/packages/coding-agent/src/core/kernel/index.ts index b760a2e1e2..fb210aa0c1 100644 --- a/packages/coding-agent/src/core/kernel/index.ts +++ b/packages/coding-agent/src/core/kernel/index.ts @@ -648,6 +648,9 @@ export class KernelManager { cwd: this.options.cwd, env: this.options.env ? { ...process.env, ...this.options.env } : process.env, stdio: ["ignore", "pipe", "pipe"], + // The kernel talks over ZMQ and its stdio is piped; on Windows it + // would otherwise get a console window of its own per session. + windowsHide: true, }); this.kernel = kernel; diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index fd818595f3..0ea2db7f9d 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -2397,6 +2397,7 @@ export class DefaultPackageManager implements PackageManager { stdio: ["ignore", "pipe", "pipe"], shell: shouldUseWindowsShell(command), env: options?.env ? { ...baseEnv, ...options.env } : baseEnv, + windowsHide: true, }); } @@ -2464,6 +2465,7 @@ export class DefaultPackageManager implements PackageManager { encoding: "utf-8", shell: shouldUseWindowsShell(command), env: getEnv(), + windowsHide: true, }); if (result.error || result.status !== 0) { throw new Error( diff --git a/packages/coding-agent/src/core/resolve-config-value.ts b/packages/coding-agent/src/core/resolve-config-value.ts index 646042e1c9..be47128c0c 100644 --- a/packages/coding-agent/src/core/resolve-config-value.ts +++ b/packages/coding-agent/src/core/resolve-config-value.ts @@ -58,6 +58,7 @@ function executeWithDefaultShell(command: string): string | undefined { encoding: "utf-8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); return output.trim() || undefined; } catch { diff --git a/packages/coding-agent/src/core/session-lease.ts b/packages/coding-agent/src/core/session-lease.ts index 6c4e2975cf..5550d7458d 100644 --- a/packages/coding-agent/src/core/session-lease.ts +++ b/packages/coding-agent/src/core/session-lease.ts @@ -116,6 +116,7 @@ function runProcessQuery(command: string, args: string[]): string { return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); } @@ -215,6 +216,26 @@ function withLeaseGuard(directory: string, action: () => T): T { } } +/** + * A lease is claimed by renaming a candidate directory onto the lease path. + * POSIX reports an already-claimed lease as EEXIST/ENOTEMPTY, which is the + * signal this module is built around. Windows rejects a directory-over- + * directory rename with EPERM (sometimes EACCES) instead, so without this the + * "lease is taken" branch never runs there: a stale lease is never reclaimed + * and a live one surfaces as a raw EPERM instead of SessionAlreadyActiveError. + * The existence check keeps a genuine permission failure fatal. + */ +function renameFailedBecauseLeaseIsTaken(directory: string, error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST" || code === "ENOTEMPTY") { + return true; + } + if (process.platform !== "win32") { + return false; + } + return (code === "EPERM" || code === "EACCES") && existsSync(directory); +} + function reclaimStaleLease(directory: string): boolean { const stalePath = `${directory}.stale-${process.pid}-${randomUUID()}`; try { @@ -264,8 +285,7 @@ export function acquireSessionLease( return new SessionLease(canonicalPath, directory, token); } catch (error) { rmSync(candidateDirectory, { recursive: true, force: true }); - const code = (error as NodeJS.ErrnoException).code; - if (code !== "EEXIST" && code !== "ENOTEMPTY") { + if (!renameFailedBecauseLeaseIsTaken(directory, error)) { throw error; } const existingOwner = readLeaseOwner(directory); diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index efc260eae1..4e03000b91 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -77,6 +77,8 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas detached: process.platform !== "win32", env: env ?? getShellEnv(), stdio: ["ignore", "pipe", "pipe"], + // The agent reads this output; a console window would only flash. + windowsHide: true, }); if (child.pid) trackDetachedChildPid(child.pid); let timedOut = false; diff --git a/packages/coding-agent/src/modes/daemon/command-recovery-journal.ts b/packages/coding-agent/src/modes/daemon/command-recovery-journal.ts index 05fcec0644..bde2e1c778 100644 --- a/packages/coding-agent/src/modes/daemon/command-recovery-journal.ts +++ b/packages/coding-agent/src/modes/daemon/command-recovery-journal.ts @@ -206,11 +206,16 @@ export class CommandRecoveryJournal { closeSync(descriptor); } renameSync(tempPath, this.path); - const directoryDescriptor = openSync(dirname(this.path), "r"); try { - fsyncSync(directoryDescriptor); - } finally { - closeSync(directoryDescriptor); + const directoryDescriptor = openSync(dirname(this.path), "r"); + try { + fsyncSync(directoryDescriptor); + } finally { + closeSync(directoryDescriptor); + } + } catch { + // Directory fsync is unavailable on some platforms (EPERM on Windows); + // the atomic rename above still protects readers. } this.recordCount = records.length; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index c841b5b79c..0822a28e46 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -396,6 +396,7 @@ export class DaemonCatalogClient { cwd: process.cwd(), env: createCliSubprocessEnv({ ...process.env, [DAEMON_CATALOG_ROLE_ENV]: "1" }), stdio: ["ignore", "ignore", "ignore", "ipc"], + windowsHide: true, }); this.child = child; child.on("message", (value: unknown) => this.handleMessage(value)); diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index dfebcdf61e..f8d6feb1cf 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -820,6 +820,7 @@ export class AgentDaemon { detached: true, env: environment, stdio: "ignore", + windowsHide: true, }); child.unref(); const deadline = Date.now() + 10_000; diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index b22701bbcb..3712238759 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -2131,6 +2131,10 @@ export class DaemonSupervisor { [SESSION_LEASE_OWNER_ID_ENV]: rootActiveSessionId, }), stdio: ["ignore", "ignore", "pipe", "pipe"], + // A detached child gets no console on Windows, so every console tool the + // worker later runs would pop its own window. An invisible console keeps + // the whole subtree windowless. + windowsHide: true, }); const detachWorkerStderr = child.stderr ? attachJsonlLineReader(child.stderr, (line) => this.log(`Session worker ${workerId} stderr: ${line}`), { @@ -4864,6 +4868,7 @@ export class DaemonSupervisor { detached: true, env: environment, stdio: "ignore", + windowsHide: true, }); replacement.unref(); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0ec1d8bcd7..a96dff486c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -8703,7 +8703,7 @@ export class InteractiveMode { private async handleShareCommand(): Promise { // Check if gh is available and logged in try { - const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); + const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8", windowsHide: true }); if (authResult.status !== 0) { this.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); return; @@ -8752,7 +8752,7 @@ export class InteractiveMode { try { const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { - proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]); + proc = spawn("gh", ["gist", "create", "--public=false", tmpFile], { windowsHide: true }); let stdout = ""; let stderr = ""; proc.stdout?.on("data", (data) => { diff --git a/packages/coding-agent/src/utils/clipboard-image.ts b/packages/coding-agent/src/utils/clipboard-image.ts index 4cf44908f8..84383dc616 100644 --- a/packages/coding-agent/src/utils/clipboard-image.ts +++ b/packages/coding-agent/src/utils/clipboard-image.ts @@ -98,6 +98,7 @@ function runCommand( timeout: timeoutMs, maxBuffer: maxBufferBytes, env: options?.env, + windowsHide: true, }); if (result.error) { diff --git a/packages/coding-agent/src/utils/git.ts b/packages/coding-agent/src/utils/git.ts index b60d98a003..b4bf29fbb5 100644 --- a/packages/coding-agent/src/utils/git.ts +++ b/packages/coding-agent/src/utils/git.ts @@ -253,6 +253,7 @@ function runGit(cwd: string, args: string[]): string | null { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); if (result.status !== 0 || typeof result.stdout !== "string") return null; return result.stdout.trim() || null; diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index cbf289d86a..28e502761d 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -16,12 +16,19 @@ function findBashOnPath(): string | null { if (process.platform === "win32") { // Windows: Use 'where' and verify file exists (where can return non-existent paths) try { - const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 }); + const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000, windowsHide: true }); if (result.status === 0 && result.stdout) { - const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; - if (firstMatch && existsSync(firstMatch)) { - return firstMatch; - } + const matches = result.stdout + .trim() + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && existsSync(line)); + // %SystemRoot%\System32\bash.exe is the WSL launcher, not a Windows + // bash: it sees a different filesystem (/mnt/c/...), so a command the + // agent composes with Windows paths silently runs against the wrong + // tree. Prefer any other bash and fall back to WSL only if it is all + // that exists. + return matches.find((match) => !isWslBashLauncher(match)) ?? matches[0] ?? null; } } catch { // Ignore errors @@ -44,6 +51,48 @@ function findBashOnPath(): string | null { return null; } +function isWslBashLauncher(bashPath: string): boolean { + const systemRoot = (process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows") + .toLowerCase() + .replaceAll("/", "\\") + .replace(/\\+$/, ""); + const normalized = bashPath.toLowerCase().replaceAll("/", "\\"); + // Sysnative is the 32-bit process view of System32; both reach the WSL stub. + return normalized.startsWith(`${systemRoot}\\system32\\`) || normalized.startsWith(`${systemRoot}\\sysnative\\`); +} + +/** + * Git for Windows install locations, in preference order. Covers the machine-wide + * installer, the 32-bit installer, per-user installs (the winget default), and + * Git resolved from PATH — which is how scoop/chocolatey shims land, since + * `\cmd\git.exe` always sits beside `\bin\bash.exe`. + */ +function windowsGitBashCandidates(): string[] { + const candidates: string[] = []; + const roots = [ + process.env.ProgramFiles, + process.env["ProgramFiles(x86)"], + process.env.LOCALAPPDATA ? `${process.env.LOCALAPPDATA}\\Programs` : undefined, + ]; + for (const root of roots) { + if (root) candidates.push(`${root}\\Git\\bin\\bash.exe`); + } + + try { + const result = spawnSync("where", ["git.exe"], { encoding: "utf-8", timeout: 5000, windowsHide: true }); + for (const line of result.stdout?.trim().split(/\r?\n/) ?? []) { + const gitPath = line.trim(); + // \cmd\git.exe or \bin\git.exe -> \bin\bash.exe + const match = /^(.*)\\(?:cmd|bin|mingw64\\bin)\\git\.exe$/i.exec(gitPath); + if (match?.[1]) candidates.push(`${match[1]}\\bin\\bash.exe`); + } + } catch { + // PATH lookup is best-effort; the fixed locations above still apply. + } + + return [...new Set(candidates)]; +} + /** * Resolve shell configuration based on platform and an optional explicit shell path. * Resolution order: @@ -62,15 +111,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig { if (process.platform === "win32") { // 2. Try Git Bash in known locations - const paths: string[] = []; - const programFiles = process.env.ProgramFiles; - if (programFiles) { - paths.push(`${programFiles}\\Git\\bin\\bash.exe`); - } - const programFilesX86 = process.env["ProgramFiles(x86)"]; - if (programFilesX86) { - paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`); - } + const paths = windowsGitBashCandidates(); for (const path of paths) { if (existsSync(path)) { @@ -194,6 +235,7 @@ export function killProcessTree(pid: number): void { spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore", detached: true, + windowsHide: true, }); } catch { // Ignore errors if taskkill fails diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index c3da7045ec..953aa679c1 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -12,6 +12,7 @@ const TOOLS_DIR = getBinDir(); const NETWORK_TIMEOUT_MS = 10_000; const DOWNLOAD_TIMEOUT_MS = 120_000; const COMMAND_TIMEOUT_MS = 5_000; +const ZIP_EXTRACT_TIMEOUT_MS = 60_000; const RIPGREP_INSTALL_URL = "https://github.com/BurntSushi/ripgrep#installation"; export type ManagedTool = "fd" | "rg"; @@ -100,7 +101,7 @@ const TOOLS: Record = { // Check that a command both launches and reports a successful version. function commandWorks(cmd: string): boolean { try { - const result = spawnSync(cmd, ["--version"], { stdio: "pipe", timeout: COMMAND_TIMEOUT_MS }); + const result = spawnSync(cmd, ["--version"], { stdio: "pipe", timeout: COMMAND_TIMEOUT_MS, windowsHide: true }); return !result.error && result.status === 0; } catch { return false; @@ -162,6 +163,53 @@ async function downloadFile(url: string, dest: string): Promise { await pipeline(Readable.fromWeb(response.body as any), fileStream); } +// Zip archives are only downloaded on Windows (every other platform gets a +// tar.gz). bsdtar ships in System32 on Windows 10 1803+ and reads zip, so it is +// the primary path; extract-zip stays as a fallback but is time-boxed because +// yauzl read streams can stall indefinitely on current Node releases, which +// silently wedged tool provisioning at the first multi-chunk entry. +async function extractZipArchive(archivePath: string, extractDir: string, assetName: string): Promise { + const tarResult = spawnSync("tar", ["-xf", archivePath, "-C", extractDir], { + stdio: "pipe", + timeout: ZIP_EXTRACT_TIMEOUT_MS, + windowsHide: true, + }); + if (!tarResult.error && tarResult.status === 0) { + return; + } + + const tarError = tarResult.error?.message ?? tarResult.stderr?.toString().trim() ?? `exit code ${tarResult.status}`; + try { + await withTimeout( + extractZip(archivePath, { dir: extractDir }), + ZIP_EXTRACT_TIMEOUT_MS, + `extracting ${assetName} timed out`, + ); + } catch (fallbackError) { + throw new Error( + `Failed to extract ${assetName}: tar failed (${tarError}) and the bundled unzip failed (${ + fallbackError instanceof Error ? fallbackError.message : String(fallbackError) + })`, + ); + } +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + function findBinaryRecursively(rootDir: string, binaryFileName: string): string | null { const stack: string[] = [rootDir]; @@ -224,13 +272,16 @@ async function downloadTool(tool: ManagedTool): Promise { try { if (assetName.endsWith(".tar.gz")) { - const extractResult = spawnSync("tar", ["xzf", archivePath, "-C", extractDir], { stdio: "pipe" }); + const extractResult = spawnSync("tar", ["xzf", archivePath, "-C", extractDir], { + stdio: "pipe", + windowsHide: true, + }); if (extractResult.error || extractResult.status !== 0) { const errMsg = extractResult.error?.message ?? extractResult.stderr?.toString().trim() ?? "unknown error"; throw new Error(`Failed to extract ${assetName}: ${errMsg}`); } } else if (assetName.endsWith(".zip")) { - await extractZip(archivePath, { dir: extractDir }); + await extractZipArchive(archivePath, extractDir, assetName); } else { throw new Error(`Unsupported archive format: ${assetName}`); } @@ -262,9 +313,18 @@ async function downloadTool(tool: ManagedTool): Promise { throw new Error(`Installed ${config.name} binary failed its version check`); } } finally { - // Cleanup - rmSync(archivePath, { force: true }); - rmSync(extractDir, { recursive: true, force: true }); + // Cleanup. A failure here (Windows can hold a handle on a just-extracted + // file) must not replace the real error from the block above. + try { + rmSync(archivePath, { force: true }); + } catch { + // Leave the archive behind; the next run overwrites it. + } + try { + rmSync(extractDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } catch { + // Leave the temp directory behind rather than failing the install. + } } return binaryPath; diff --git a/packages/coding-agent/test/expand-tilde-path.test.ts b/packages/coding-agent/test/expand-tilde-path.test.ts new file mode 100644 index 0000000000..ae9c11000c --- /dev/null +++ b/packages/coding-agent/test/expand-tilde-path.test.ts @@ -0,0 +1,31 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { expandTildePath } from "../src/config.js"; + +describe("expandTildePath", () => { + it("returns the home directory for a bare tilde", () => { + expect(expandTildePath("~")).toBe(homedir()); + }); + + it("joins with the platform separator rather than concatenating", () => { + // String concatenation produced "C:\Users\me/sessions" on Windows, which is + // a working path but never compares equal to the same location built with + // join() — so a configured session dir looked like a different directory. + expect(expandTildePath("~/sessions")).toBe(join(homedir(), "sessions")); + }); + + it.runIf(process.platform === "win32")("produces a path with no forward slashes on Windows", () => { + expect(expandTildePath("~/sessions")).not.toContain("/"); + expect(expandTildePath("~\\sessions")).toBe(join(homedir(), "sessions")); + }); + + it("expands nested paths", () => { + expect(expandTildePath("~/a/b/c")).toBe(join(homedir(), "a", "b", "c")); + }); + + it("leaves paths without a leading tilde alone", () => { + expect(expandTildePath("relative/path")).toBe("relative/path"); + expect(expandTildePath("~notahome")).toBe("~notahome"); + }); +}); diff --git a/packages/coding-agent/test/kernel-venv-python-path.test.ts b/packages/coding-agent/test/kernel-venv-python-path.test.ts new file mode 100644 index 0000000000..3f0a7ab045 --- /dev/null +++ b/packages/coding-agent/test/kernel-venv-python-path.test.ts @@ -0,0 +1,32 @@ +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { getVenvPythonPath } from "../src/core/kernel/bootstrap.js"; + +const realPlatform = process.platform; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); +} + +afterEach(() => { + setPlatform(realPlatform); +}); + +describe("getVenvPythonPath", () => { + it("uses the POSIX venv layout off Windows", () => { + setPlatform("linux"); + expect(getVenvPythonPath("/home/u/.prime/agent/kernel-venv")).toBe( + join("/home/u/.prime/agent/kernel-venv", "bin", "python"), + ); + }); + + it("uses the Windows venv layout on win32", () => { + setPlatform("win32"); + // uv/virtualenv place the interpreter in Scripts\python.exe on Windows; + // the POSIX path never exists there, so every readiness probe would fail + // and the venv would be torn down and rebuilt on every launch. + expect(getVenvPythonPath("C:\\Users\\u\\.prime\\agent\\kernel-venv")).toBe( + join("C:\\Users\\u\\.prime\\agent\\kernel-venv", "Scripts", "python.exe"), + ); + }); +}); diff --git a/packages/coding-agent/test/paths.test.ts b/packages/coding-agent/test/paths.test.ts index 208da3f91c..e2b0530f62 100644 --- a/packages/coding-agent/test/paths.test.ts +++ b/packages/coding-agent/test/paths.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { canonicalizePath, getCwdRelativePath, isLocalPath } from "../src/utils/paths.js"; +import { SYMLINKS_SUPPORTED } from "./platform-support.js"; let tempDir: string; @@ -26,7 +27,7 @@ describe("canonicalizePath", () => { expect(canonicalizePath(file)).toBe(realpathSync(file)); }); - it("resolves symlinks to their targets", () => { + it.skipIf(!SYMLINKS_SUPPORTED)("resolves symlinks to their targets", () => { const dir = createTempDir(); const target = join(dir, "target.txt"); const link = join(dir, "link.txt"); @@ -35,7 +36,7 @@ describe("canonicalizePath", () => { expect(canonicalizePath(link)).toBe(realpathSync(target)); }); - it("resolves directory symlinks", () => { + it.skipIf(!SYMLINKS_SUPPORTED)("resolves directory symlinks", () => { const dir = createTempDir(); const targetDir = join(dir, "target-dir"); const linkDir = join(dir, "link-dir"); @@ -50,7 +51,7 @@ describe("canonicalizePath", () => { expect(canonicalizePath(nonexistent)).toBe(nonexistent); }); - it("falls back to the raw path for a dangling symlink", () => { + it.skipIf(!SYMLINKS_SUPPORTED)("falls back to the raw path for a dangling symlink", () => { const dir = createTempDir(); const target = join(dir, "target.txt"); const link = join(dir, "link.txt"); diff --git a/packages/coding-agent/test/platform-support.ts b/packages/coding-agent/test/platform-support.ts new file mode 100644 index 0000000000..5ad43769f5 --- /dev/null +++ b/packages/coding-agent/test/platform-support.ts @@ -0,0 +1,36 @@ +/** + * Capability probes for tests whose fixtures need something the host OS may not + * provide. Prefer these over a `process.platform` check: they describe what the + * test actually needs, and they keep the test running wherever the capability + * happens to exist (unprivileged Windows cannot create symlinks, but an + * elevated shell and the GitHub Actions Windows runners can). + */ + +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +function probeSymlinkSupport(): boolean { + let dir: string | undefined; + try { + dir = mkdtempSync(join(tmpdir(), "pi-symlink-probe-")); + const target = join(dir, "target"); + writeFileSync(target, ""); + symlinkSync(target, join(dir, "link")); + return true; + } catch { + return false; + } finally { + if (dir) rmSync(dir, { recursive: true, force: true }); + } +} + +/** False on Windows without Developer Mode or an elevated shell. */ +export const SYMLINKS_SUPPORTED = probeSymlinkSupport(); + +/** + * POSIX permission bits are advisory on Windows: chmod() succeeds but does not + * make a directory read-only, so tests that revoke write access to assert a + * fallback cannot express their precondition there. + */ +export const POSIX_PERMISSIONS_ENFORCED = process.platform !== "win32"; diff --git a/packages/coding-agent/test/session-lease.test.ts b/packages/coding-agent/test/session-lease.test.ts index 87338b6866..751e587045 100644 --- a/packages/coding-agent/test/session-lease.test.ts +++ b/packages/coding-agent/test/session-lease.test.ts @@ -12,6 +12,7 @@ import { SESSION_LEASES_ENABLED_ENV, SessionAlreadyActiveError, } from "../src/core/session-lease.js"; +import { SYMLINKS_SUPPORTED } from "./platform-support.js"; const tempDirs: string[] = []; @@ -144,7 +145,7 @@ describe("session leases", () => { } }); - it("treats symlink aliases as the same persisted session", () => { + it.skipIf(!SYMLINKS_SUPPORTED)("treats symlink aliases as the same persisted session", () => { const agentDir = createTempDir(); const sessionPath = join(agentDir, "session.jsonl"); const aliasPath = join(agentDir, "session-alias.jsonl"); From 0aab6088037ab03ae73d583e56978ea0b9ad035b Mon Sep 17 00:00:00 2001 From: Anroshka Date: Sat, 8 Aug 2026 16:47:06 +0300 Subject: [PATCH 2/3] fix(coding-agent): let kernel code spawn subprocesses and see its own venv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the IPython tool could not do, both invisible until an agent tried them. **asyncio subprocesses were unavailable on Windows.** ipykernel installs a Windows *selector* event loop policy because pyzmq needs `add_reader`, and a selector loop cannot spawn subprocesses. Anything in the kernel that shells out through asyncio — playwright, `create_subprocess_exec`, any async driver that starts a helper binary — failed with a bare `NotImplementedError`, and the obvious workaround of running it on a fresh loop in a worker thread failed too, because `new_event_loop()` inherits the same policy. There was no way out from inside a cell. Startup now swaps the policy back to the proactor one, so loops created from then on support subprocesses, and re-binds the kernel's own already-running selector loop to the main thread so ipykernel keeps exactly what it needs. Applied through a silent `execute_request` that neither stores history nor leaves names in the user namespace, and failure to apply is logged as a kernel diagnostic rather than failing startup — a kernel without it is still a working kernel. **The kernel could not see its own virtualenv.** The kernel process inherited no `VIRTUAL_ENV` and no venv script directory on `PATH`, so a `uv pip install` or `pip install` issued from a cell resolved against whatever interpreter `PATH` pointed at — usually a system Python — and installed packages somewhere the kernel could not import them from. The spawn env now activates the venv the way `activate` would, and leaves a non-venv interpreter (`PRIME_AGENT_KERNEL_PYTHON` pointing at a system or conda Python) untouched. Explicit per-kernel `env` overrides still win over both. Verified on Windows 11 / Node 26 / CPython 3.11: policy is WindowsProactorEventLoopPolicy while the kernel's main-thread loop stays _WindowsSelectorEventLoop, top-level await still works, an `asyncio.create_subprocess_exec` on a worker thread succeeds, playwright launches Chrome and drives a page from inside a cell, and `VIRTUAL_ENV` matches `sys.prefix` with `uv pip list` resolving to the kernel venv. --- .../coding-agent/src/core/kernel/index.ts | 143 +++++++++++++++++- 1 file changed, 139 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/core/kernel/index.ts b/packages/coding-agent/src/core/kernel/index.ts index fb210aa0c1..629da2194b 100644 --- a/packages/coding-agent/src/core/kernel/index.ts +++ b/packages/coding-agent/src/core/kernel/index.ts @@ -1,9 +1,9 @@ // TODO: reconsider persistent kernel vs stateless `python -c` once RLM-1 weights land. import { type ChildProcess, spawn } from "node:child_process"; import { createHmac, randomBytes } from "node:crypto"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, dirname, join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { registerSessionResourceCleanup } from "@earendil-works/pi-ai"; import { v4 as uuid } from "uuid"; @@ -26,6 +26,8 @@ const DELIM = Buffer.from(""); const PROTOCOL_VERSION = "5.3"; const PORTS_RESOLVE_TIMEOUT_MS = 5000; const READY_TIMEOUT_MS = 5000; +// Startup code runs on an idle kernel that has just answered kernel_info_request. +const KERNEL_PRELUDE_TIMEOUT_MS = 5000; // Loopback PUB/SUB subscription propagation is usually sub-ms, but keep a small guard before first execute. const IOPUB_SUBSCRIBE_DELAY_MS = 50; const DEFAULT_MAX_OUTPUT_CHARS = 65536; @@ -448,6 +450,34 @@ function readConnectionInfo(path: string): ConnectionInfo | null { } } +/** + * Environment that marks the kernel's virtualenv as the active one, the way + * `activate` would. Without VIRTUAL_ENV and the script directory on PATH, a + * `uv pip install` or `pip install` run from inside the kernel resolves against + * whatever interpreter PATH happens to point at — typically a system Python — + * so packages the model installs land where the kernel cannot import them. + * + * Returns undefined when the interpreter is not in a venv (a + * PRIME_AGENT_KERNEL_PYTHON pointing at a system or conda interpreter), leaving + * that environment untouched. + */ +function venvActivationEnv(python: string, baseEnv: NodeJS.ProcessEnv): Record | undefined { + const scriptDir = dirname(python); + const venvDir = dirname(scriptDir); + if (!venvDir || !existsSync(join(venvDir, "pyvenv.cfg"))) { + return undefined; + } + const pathKey = Object.keys(baseEnv).find((key) => key.toLowerCase() === "path") ?? "PATH"; + const currentPath = baseEnv[pathKey] ?? ""; + if (currentPath.split(delimiter)[0] === scriptDir) { + return { VIRTUAL_ENV: venvDir }; + } + return { + VIRTUAL_ENV: venvDir, + [pathKey]: currentPath ? `${scriptDir}${delimiter}${currentPath}` : scriptDir, + }; +} + function makeConnection(): { info: ConnectionInfo; path: string; tempDir: string } { const info: ConnectionInfo = { ip: "127.0.0.1", @@ -610,6 +640,14 @@ export class KernelManager { let connection = makeConnection(); this.tempDir = connection.tempDir; + // Per-kernel overrides win over the venv activation, which in turn wins + // over the host env, so an explicit VIRTUAL_ENV from the caller is kept. + const baseEnv: NodeJS.ProcessEnv = { + ...process.env, + ...venvActivationEnv(python, process.env), + ...this.options.env, + }; + // Fast path: fork a pre-imported kernel from the forkserver. Any failure // (disabled, unavailable, fork error) degrades to the direct-spawn path so // correctness never depends on fork. @@ -622,7 +660,7 @@ export class KernelManager { // Match the direct-spawn env exactly: merge the current host env with // the per-kernel overrides, applied fresh in the child (the template's // inherited env snapshot may be stale by fork time). - env: this.options.env ? { ...process.env, ...this.options.env } : { ...process.env }, + env: baseEnv, }); forked = true; } catch (err) { @@ -646,7 +684,7 @@ export class KernelManager { if (!forked) { const kernel = spawn(python, ["-m", "ipykernel_launcher", "-f", connection.path], { cwd: this.options.cwd, - env: this.options.env ? { ...process.env, ...this.options.env } : process.env, + env: baseEnv, stdio: ["ignore", "pipe", "pipe"], // The kernel talks over ZMQ and its stdio is piped; on Windows it // would otherwise get a console window of its own per session. @@ -711,6 +749,8 @@ export class KernelManager { throw e; } + await this.enableWindowsSubprocessEventLoops(); + this.state = "running"; this.startForkedLivenessMonitor(); } @@ -803,6 +843,101 @@ export class KernelManager { ); } + /** + * ipykernel installs a Windows *selector* event loop policy because pyzmq + * needs add_reader, and a selector loop cannot spawn subprocesses. Anything + * in the kernel that shells out through asyncio — playwright, + * asyncio.create_subprocess_exec, any async driver that starts a helper + * binary — then dies with a bare NotImplementedError, and the usual + * workaround of running it on a fresh loop in a worker thread fails too, + * because asyncio.new_event_loop() inherits that same policy. + * + * Swapping the policy back to the proactor one fixes loops created from here + * on. The kernel's own loop is already running and is left alone: it is + * re-bound to the main thread so ipykernel keeps the selector loop it needs. + * + * Best-effort by design — a kernel that cannot apply this is still a working + * kernel, just without asyncio subprocesses. + */ + private async enableWindowsSubprocessEventLoops(): Promise { + if (process.platform !== "win32") return; + + const code = [ + "def _pi_enable_subprocess_event_loops():", + " import asyncio, sys", + ' if sys.platform != "win32":', + " return", + // Removed in Python 3.14, where the policy no longer forces a selector loop. + ' policy_cls = getattr(asyncio, "WindowsProactorEventLoopPolicy", None)', + " if policy_cls is None or isinstance(asyncio.get_event_loop_policy(), policy_cls):", + " return", + " kernel_loop = asyncio.get_event_loop()", + " asyncio.set_event_loop_policy(policy_cls())", + " asyncio.set_event_loop(kernel_loop)", + "try:", + " _pi_enable_subprocess_event_loops()", + "except Exception:", + " pass", + "finally:", + " del _pi_enable_subprocess_event_loops", + "", + ].join("\n"); + + try { + await this.executeSilently(code, KERNEL_PRELUDE_TIMEOUT_MS); + } catch (error) { + this.appendKernelDiagnostic( + `could not enable asyncio subprocess support: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Run setup code without touching execution history, the iopub pump, or the + * activeExecution machinery that user-visible cells go through. + */ + private async executeSilently(code: string, timeoutMs: number): Promise { + const conn = this.connection; + const shell = this.shell; + if (!conn || !shell) return; + + const msg = buildMessage( + "execute_request", + { + code, + silent: true, + store_history: false, + user_expressions: {}, + allow_stdin: false, + stop_on_error: false, + }, + this.session, + this.options.username, + ); + const requestMsgId = msg.header.msg_id; + await shell.send(encode(msg, conn.key)); + + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if ((this.state as string) === "shutdown") return; + const remaining = timeoutMs - (Date.now() - startedAt); + const winner = await Promise.race([ + shell.receive().then((frames) => ({ kind: "frames" as const, frames })), + sleep(remaining).then(() => ({ kind: "timeout" as const })), + ]); + if (winner.kind === "timeout") break; + + const incoming = decode(winner.frames); + if ( + incoming?.header.msg_type === "execute_reply" && + (incoming.parent_header as { msg_id?: string }).msg_id === requestMsgId + ) { + return; + } + } + throw new Error(`kernel did not acknowledge setup code within ${timeoutMs}ms`); + } + async execute(code: string, opts: ExecuteOptions = {}): Promise { const result = await this.enqueueExecute(code, opts); // Refresh the on-disk snapshot after real work so a later resume (or a From a68c9dab1051ec85380d1cd4c3d2a1921bbf16ab Mon Sep 17 00:00:00 2001 From: Anroshka Date: Sat, 8 Aug 2026 16:47:27 +0300 Subject: [PATCH 3/3] docs(windows): document the asyncio subprocess constraint and the venv activation --- packages/coding-agent/docs/windows.md | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/coding-agent/docs/windows.md b/packages/coding-agent/docs/windows.md index 3fe510fed1..17fc803d0c 100644 --- a/packages/coding-agent/docs/windows.md +++ b/packages/coding-agent/docs/windows.md @@ -61,6 +61,40 @@ falls back to slower search paths. Installing them yourself (`winget install BurntSushi.ripgrep.MSVC`, `winget install sharkdp.fd`) makes Prime Agent use the copies on `PATH` instead. +**asyncio subprocesses need their own event loop.** ipykernel runs on a +Windows *selector* event loop (pyzmq needs `add_reader`), and a selector loop +cannot spawn subprocesses. Prime Agent restores the proactor event loop +*policy* at kernel startup while leaving the kernel's own running loop alone, +so any loop created afterwards can spawn processes. Because the kernel's main +thread is already driving a loop, async libraries that start helper binaries — +playwright is the common one — have to run on a loop of their own: + +```python +import asyncio, threading + +def run_async(factory): + box = {} + def worker(): + loop = asyncio.new_event_loop() # proactor loop: subprocesses work + asyncio.set_event_loop(loop) + try: + box["value"] = loop.run_until_complete(factory()) + except BaseException as exc: + box["error"] = exc + finally: + loop.close() + thread = threading.Thread(target=worker) + thread.start() + thread.join() + if "error" in box: + raise box["error"] + return box["value"] +``` + +Use the async API rather than the sync one (`playwright.async_api`, not +`sync_playwright`): the sync API drives its own loop and conflicts with the +kernel's. + **Subprocesses are spawned with `windowsHide`.** The daemon and its session workers are detached and therefore have no console of their own, so any console tool they run (git, uv, python) would otherwise allocate — and flash — a console