diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index d7022d365..4c0fe135a 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -236,12 +236,15 @@ jobs: const fs = require("node:fs"); const baseUrl = process.env.INSTALL_BASE_URL; if (!baseUrl) throw new Error("INSTALL_BASE_URL is required"); - const installer = fs.readFileSync("install.sh", "utf8"); - const renderInstaller = (channel) => installer + const renderInstaller = (source, channel) => source .replaceAll("__PRIME_AGENT_DOWNLOAD_BASE_URL__", baseUrl) .replaceAll("__PRIME_AGENT_DEFAULT_RELEASE_CHANNEL__", channel); - fs.writeFileSync("/tmp/prime-agent-install.sh", renderInstaller("stable")); - fs.writeFileSync("/tmp/prime-agent-install-beta.sh", renderInstaller("beta")); + const shellInstaller = fs.readFileSync("install.sh", "utf8"); + fs.writeFileSync("/tmp/prime-agent-install.sh", renderInstaller(shellInstaller, "stable")); + fs.writeFileSync("/tmp/prime-agent-install-beta.sh", renderInstaller(shellInstaller, "beta")); + const powershellInstaller = fs.readFileSync("install.ps1", "utf8"); + fs.writeFileSync("/tmp/prime-agent-install.ps1", renderInstaller(powershellInstaller, "stable")); + fs.writeFileSync("/tmp/prime-agent-install-beta.ps1", renderInstaller(powershellInstaller, "beta")); NODE - name: Extract production release notes @@ -298,6 +301,16 @@ jobs: --content-type text/x-shellscript \ --cache-control no-cache + aws s3 cp /tmp/prime-agent-install.ps1 "s3://${R2_BUCKET}/install.ps1" \ + --endpoint-url "$R2_ENDPOINT_URL" \ + --content-type text/plain \ + --cache-control no-cache + + aws s3 cp /tmp/prime-agent-install-beta.ps1 "s3://${R2_BUCKET}/install-beta.ps1" \ + --endpoint-url "$R2_ENDPOINT_URL" \ + --content-type text/plain \ + --cache-control no-cache + - name: Create production GitHub release if: env.PUBLISH_PRODUCTION == 'true' env: @@ -379,6 +392,16 @@ jobs: --content-type text/x-shellscript \ --cache-control no-cache + aws s3 cp /tmp/prime-agent-install.ps1 "s3://${R2_BUCKET}/install.ps1" \ + --endpoint-url "$R2_ENDPOINT_URL" \ + --content-type text/plain \ + --cache-control no-cache + + aws s3 cp /tmp/prime-agent-install-beta.ps1 "s3://${R2_BUCKET}/install-beta.ps1" \ + --endpoint-url "$R2_ENDPOINT_URL" \ + --content-type text/plain \ + --cache-control no-cache + printf 'Automated beta build from `%s` (`%s`).\n' "$DEFAULT_BRANCH" "$BUILD_REF" > /tmp/beta-release-notes.md if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/beta" >/dev/null 2>&1; then diff --git a/README.md b/README.md index 1d6f850c5..cc4241aa9 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,12 @@ Install the latest stable release on macOS or Linux: curl -fsSL https://app.primeintellect.ai/prime-agent/install.sh | sh ``` +On Windows, install from PowerShell (see [Windows setup](packages/coding-agent/docs/windows.md)): + +```powershell +irm https://app.primeintellect.ai/prime-agent/install.ps1 | iex +``` + 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. Start Prime Agent from the repository or directory you want it to work in: diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 000000000..bbbea3b64 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,479 @@ +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string] $ChannelOrVersion +) + +# The installer body runs inside this script block so `irm ... | iex` cannot leak +# its helpers, variables, strict mode, or preference changes into the caller's session. +& { + param([string] $ChannelOrVersion) + + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $ProgressPreference = 'SilentlyContinue' + + if ($PSVersionTable.PSVersion.Major -lt 6) { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } + + # Keep these sentinels split so release publishing only rewrites the configured + # values below; local or unpublished copies still need unreplaced values to compare. + $PrimeAgentUnconfiguredBaseUrl = '__PRIME_AGENT_DOWNLOAD_BASE' + '_URL__' + $PrimeAgentUnconfiguredDefaultReleaseChannel = '__PRIME_AGENT_DEFAULT_RELEASE_' + 'CHANNEL__' + $PrimeAgentBaseUrl = '__PRIME_AGENT_DOWNLOAD_BASE_URL__' + $PrimeAgentDefaultReleaseChannel = '__PRIME_AGENT_DEFAULT_RELEASE_CHANNEL__' + + if ($env:PRIME_AGENT_DOWNLOAD_BASE_URL) { + $PrimeAgentBaseUrl = $env:PRIME_AGENT_DOWNLOAD_BASE_URL + } + $PrimeAgentBaseUrl = $PrimeAgentBaseUrl.TrimEnd('/') + + if ($PrimeAgentDefaultReleaseChannel -eq $PrimeAgentUnconfiguredDefaultReleaseChannel) { + $PrimeAgentDefaultReleaseChannel = 'stable' + } + $PrimeAgentReleaseChannel = $PrimeAgentDefaultReleaseChannel + if ($env:PRIME_AGENT_RELEASE_CHANNEL) { + $PrimeAgentReleaseChannel = $env:PRIME_AGENT_RELEASE_CHANNEL + } + + $PrimeAgentPackage = 'prime-agent' + if ($env:PRIME_AGENT_PACKAGE) { + $PrimeAgentPackage = $env:PRIME_AGENT_PACKAGE + } + + $PrimeAgentCmd = 'prime-agent' + if ($env:PRIME_AGENT_CMD) { + $PrimeAgentCmd = $env:PRIME_AGENT_CMD + } + + $PrimeAgentMinimumNodeVersion = [Version]'20.6.0' + + function Write-PrimeAgentLine { + param([string] $Text = '') + + Write-Host $Text + } + + function Write-PrimeAgentStep { + param([string] $Text = '') + + Write-Host " $Text" + } + + function Write-PrimeAgentNote { + param([string] $Text = '') + + Write-Host " $Text" -ForegroundColor DarkGray + } + + function Write-PrimeAgentWarning { + param([string] $Text = '') + + Write-Host " $Text" -ForegroundColor Yellow + } + + function Write-PrimeAgentHeader { + Write-PrimeAgentLine + Write-Host ' Installing Prime Agent' -ForegroundColor Magenta + Write-PrimeAgentNote 'npm global install' + Write-PrimeAgentLine + } + + function Test-PrimeAgentCanPrompt { + try { + if (-not [Environment]::UserInteractive) { + return $false + } + if ([Console]::IsInputRedirected) { + return $false + } + } catch { + return $false + } + return $null -ne $Host.UI + } + + function Read-PrimeAgentYesNo { + param( + [Parameter(Mandatory = $true)][string] $Question, + [Parameter(Mandatory = $true)][string] $Detail + ) + + Write-PrimeAgentLine + Write-PrimeAgentNote $Detail + + if (-not (Test-PrimeAgentCanPrompt)) { + Write-PrimeAgentNote 'No terminal detected; continuing without confirmation.' + return $true + } + + $answer = $null + try { + $answer = Read-Host " $Question [Y/n]" + } catch { + Write-PrimeAgentNote 'No terminal detected; continuing without confirmation.' + return $true + } + + if ($null -eq $answer) { + $answer = '' + } + $answer = $answer.Trim().ToLowerInvariant() + return -not ($answer -eq 'n' -or $answer -eq 'no') + } + + function Get-PrimeAgentCommandPath { + param([Parameter(Mandatory = $true)][string] $Name) + + $command = Get-Command -Name $Name -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command -and $command.Source) { + return [string]$command.Source + } + return $null + } + + function Get-PrimeAgentNpmPath { + # npm ships as npm.cmd on Windows; the bare npm shim is a shell script that + # PowerShell cannot execute, and npm.ps1 depends on the execution policy. + foreach ($name in @('npm.cmd', 'npm.exe')) { + $command = Get-Command -Name $name -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command -and $command.Source) { + return [string]$command.Source + } + } + + $command = Get-Command -Name 'npm' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command -and $command.Source) { + return [string]$command.Source + } + + $command = Get-Command -Name 'npm' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command -and $command.Source) { + $sibling = Join-Path (Split-Path -Parent ([string]$command.Source)) 'npm.cmd' + if (Test-Path -LiteralPath $sibling) { + return $sibling + } + return [string]$command.Source + } + + return $null + } + + function Get-PrimeAgentNodeVersion { + $nodePath = Get-PrimeAgentCommandPath -Name 'node' + if (-not $nodePath) { + return $null + } + + $output = $null + try { + $output = & $nodePath --version 2>&1 | Select-Object -First 1 + } catch { + return $null + } + if (-not $output) { + return $null + } + + $match = [regex]::Match([string]$output, '(\d+)\.(\d+)\.(\d+)') + if (-not $match.Success) { + return $null + } + return [Version]::new([int]$match.Groups[1].Value, [int]$match.Groups[2].Value, [int]$match.Groups[3].Value) + } + + function Write-PrimeAgentNodeInstallHelp { + Write-PrimeAgentWarning 'Install Node.js 20.6.0 or newer, then run this installer again.' + Write-PrimeAgentLine + Write-PrimeAgentStep 'With winget:' + Write-PrimeAgentStep ' winget install OpenJS.NodeJS.LTS' + Write-PrimeAgentLine + Write-PrimeAgentStep 'Or download an installer from https://nodejs.org' + Write-PrimeAgentNote 'Open a new terminal after installing so PATH picks up node and npm.' + Write-PrimeAgentLine + } + + function Test-PrimeAgentPrerequisites { + $nodeVersion = Get-PrimeAgentNodeVersion + if (-not $nodeVersion) { + Write-PrimeAgentNodeInstallHelp + throw 'Node.js 20.6.0 or newer is required to install Prime Agent.' + } + if ($nodeVersion -lt $PrimeAgentMinimumNodeVersion) { + Write-PrimeAgentNodeInstallHelp + throw "Prime Agent requires Node.js 20.6.0 or newer. Found v$nodeVersion." + } + + if (-not (Get-PrimeAgentNpmPath)) { + Write-PrimeAgentNodeInstallHelp + throw 'npm is required to install Prime Agent.' + } + + Write-PrimeAgentStep "Node.js v$nodeVersion detected." + + $existing = Get-PrimeAgentCommandPath -Name $PrimeAgentCmd + if ($existing) { + Write-PrimeAgentWarning "Existing $($PrimeAgentCmd) found at: $existing" + } + } + + function New-PrimeAgentTempDirectory { + $path = Join-Path ([System.IO.Path]::GetTempPath()) "prime-agent-install-$([guid]::NewGuid().ToString('N'))" + $null = New-Item -ItemType Directory -Path $path -Force + return $path + } + + function Save-PrimeAgentFile { + param( + [Parameter(Mandatory = $true)][string] $Uri, + [Parameter(Mandatory = $true)][string] $Path + ) + + try { + Invoke-WebRequest -Uri $Uri -OutFile $Path -UseBasicParsing + } catch { + throw "could not download $Uri`: $($_.Exception.Message)" + } + } + + function ConvertTo-PrimeAgentVersion { + param([Parameter(Mandatory = $true)][AllowEmptyString()][string] $Version) + + $normalized = $Version.Trim() + if ($normalized.StartsWith('v')) { + $normalized = $normalized.Substring(1) + } + if (-not $normalized) { + throw 'empty Prime Agent version.' + } + if ($normalized -notmatch '^[0-9A-Za-z.-]+$') { + throw "invalid Prime Agent version: $Version" + } + return $normalized + } + + function Resolve-PrimeAgentVersion { + param([string] $ChannelOrVersion) + + $channel = $PrimeAgentReleaseChannel + if ($ChannelOrVersion) { + if ($ChannelOrVersion -eq 'stable' -or $ChannelOrVersion -eq 'beta') { + $channel = $ChannelOrVersion + } else { + return ConvertTo-PrimeAgentVersion -Version $ChannelOrVersion + } + } + + if ($env:PRIME_AGENT_VERSION) { + return ConvertTo-PrimeAgentVersion -Version $env:PRIME_AGENT_VERSION + } + + if ($channel -ne 'stable' -and $channel -ne 'beta') { + throw "invalid Prime Agent release channel: $channel" + } + + $channelUrl = "$($PrimeAgentBaseUrl)/$channel" + Write-PrimeAgentStep "Resolving the latest $channel release." + + $channelDir = New-PrimeAgentTempDirectory + try { + $channelPath = Join-Path $channelDir $channel + Save-PrimeAgentFile -Uri $channelUrl -Path $channelPath + $channelVersion = (Get-Content -LiteralPath $channelPath -Raw) -replace '\s', '' + } finally { + Remove-Item -LiteralPath $channelDir -Recurse -Force -ErrorAction SilentlyContinue + } + + if (-not $channelVersion) { + throw "could not resolve the latest Prime Agent version from $channelUrl" + } + return ConvertTo-PrimeAgentVersion -Version $channelVersion + } + + function Test-PrimeAgentChecksum { + param( + [Parameter(Mandatory = $true)][string] $ChecksumsPath, + [Parameter(Mandatory = $true)][string] $FilePath + ) + + $fileName = Split-Path -Leaf $FilePath + $expected = $null + foreach ($line in Get-Content -LiteralPath $ChecksumsPath) { + $match = [regex]::Match($line, '^([0-9a-fA-F]{64})\s+\*?(.+)$') + if ($match.Success -and $match.Groups[2].Value.Trim() -eq $fileName) { + $expected = $match.Groups[1].Value + break + } + } + + if (-not $expected) { + throw "checksum for $fileName was not found in $ChecksumsPath" + } + + $actual = (Get-FileHash -LiteralPath $FilePath -Algorithm SHA256).Hash + if ($actual -ne $expected.ToUpperInvariant()) { + throw "checksum mismatch for $fileName. Expected $($expected.ToLowerInvariant()), got $($actual.ToLowerInvariant())." + } + Write-PrimeAgentStep 'Verified the SHA-256 checksum.' + } + + function Get-PrimeAgentKernelChoice { + if ($env:PRIME_AGENT_BOOTSTRAP_KERNEL_ON_INSTALL -eq '1') { + return $true + } + if ($env:PRIME_AGENT_BOOTSTRAP_KERNEL_ON_INSTALL -eq '0') { + return $false + } + + $prepare = Read-PrimeAgentYesNo ` + -Question 'Prepare IPython runtime now?' ` + -Detail 'Installs uv, Python 3.11, ipykernel, and the Prime Agent runtime.' + if (-not $prepare) { + Write-PrimeAgentNote 'Skipping IPython runtime setup; it is prepared on first ipython use.' + } + return $prepare + } + + function Install-PrimeAgentPackage { + param( + [Parameter(Mandatory = $true)][string] $TarballPath, + [switch] $BootstrapKernel + ) + + $npm = Get-PrimeAgentNpmPath + if (-not $npm) { + throw 'npm is required to install Prime Agent.' + } + + $installEnv = @{ 'PRIME_AGENT_BOOTSTRAP_TOOLS_ON_INSTALL' = '1' } + if ($BootstrapKernel) { + $installEnv['PRIME_AGENT_BOOTSTRAP_KERNEL_ON_INSTALL'] = '1' + $installEnv['PRIME_AGENT_INSTALL_UV'] = '1' + } + + $previousEnv = @{} + foreach ($name in @($installEnv.Keys)) { + $previousEnv[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') + [Environment]::SetEnvironmentVariable($name, $installEnv[$name], 'Process') + } + + $npmArguments = @('install', '-g', '--no-fund', '--no-audit', '--loglevel=error', '--progress=false') + try { + $npmHelp = (& $npm install --help 2>$null | Out-String) + if ($npmHelp -match '--allow-scripts') { + $npmArguments += '--allow-scripts=prime-agent,zeromq,@google/genai,koffi,protobufjs' + } + } catch { + # Older npm versions do not support per-package lifecycle-script approvals. + } + $npmArguments += $TarballPath + + Write-PrimeAgentStep 'Installing Prime Agent with npm.' + $exitCode = 0 + try { + & $npm @npmArguments + $exitCode = $LASTEXITCODE + } finally { + foreach ($name in @($previousEnv.Keys)) { + [Environment]::SetEnvironmentVariable($name, $previousEnv[$name], 'Process') + } + } + + if ($exitCode -ne 0) { + throw "npm install -g failed with exit code $exitCode." + } + } + + function Get-PrimeAgentNpmPrefix { + $npm = Get-PrimeAgentNpmPath + if (-not $npm) { + return $null + } + + try { + $prefix = & $npm config get prefix 2>$null | Select-Object -First 1 + } catch { + return $null + } + if (-not $prefix) { + return $null + } + return ([string]$prefix).Trim() + } + + function Write-PrimeAgentCompletion { + Write-PrimeAgentLine + Write-PrimeAgentStep 'Prime Agent was installed successfully.' + + if (Get-PrimeAgentCommandPath -Name $PrimeAgentCmd) { + Write-PrimeAgentStep "Run it with: $($PrimeAgentCmd)" + Write-PrimeAgentLine + return + } + + $prefix = Get-PrimeAgentNpmPrefix + Write-PrimeAgentLine + Write-PrimeAgentWarning "The $($PrimeAgentCmd) command is not on your PATH yet." + if ($prefix) { + Write-PrimeAgentStep 'Add npm''s global bin directory to your PATH:' + Write-PrimeAgentStep " $prefix" + Write-PrimeAgentLine + Write-PrimeAgentStep 'For the current session:' + Write-PrimeAgentStep " `$env:Path = '$prefix;' + `$env:Path" + } else { + Write-PrimeAgentStep 'Find npm''s global bin directory with:' + Write-PrimeAgentStep ' npm config get prefix' + Write-PrimeAgentStep 'Then add that directory to your PATH.' + } + Write-PrimeAgentNote 'A new terminal also picks up PATH changes made by the Node.js installer.' + Write-PrimeAgentLine + } + + function Invoke-PrimeAgentInstall { + param([string] $ChannelOrVersion) + + if ($PrimeAgentBaseUrl -eq $PrimeAgentUnconfiguredBaseUrl) { + throw 'installer download URL is not configured. Set PRIME_AGENT_DOWNLOAD_BASE_URL or use the installer published by the release workflow.' + } + + Write-PrimeAgentHeader + Test-PrimeAgentPrerequisites + + $version = Resolve-PrimeAgentVersion -ChannelOrVersion $ChannelOrVersion + $tarballName = "$($PrimeAgentPackage)-$version.tgz" + $releaseUrl = "$($PrimeAgentBaseUrl)/releases/v$version" + $tarballUrl = "$releaseUrl/$tarballName" + + $install = Read-PrimeAgentYesNo ` + -Question "Install Prime Agent v$version globally with npm?" ` + -Detail "Downloads and verifies $tarballUrl, then runs npm install -g." + if (-not $install) { + Write-PrimeAgentLine + Write-PrimeAgentStep 'Installation cancelled. No changes were made.' + Write-PrimeAgentLine + return + } + + $bootstrapKernel = Get-PrimeAgentKernelChoice + + Write-PrimeAgentLine + $downloadDir = New-PrimeAgentTempDirectory + try { + $tarballPath = Join-Path $downloadDir $tarballName + $checksumsPath = Join-Path $downloadDir 'SHA256SUMS' + + Write-PrimeAgentStep "Downloading Prime Agent v$version." + Save-PrimeAgentFile -Uri "$releaseUrl/SHA256SUMS" -Path $checksumsPath + Save-PrimeAgentFile -Uri $tarballUrl -Path $tarballPath + Test-PrimeAgentChecksum -ChecksumsPath $checksumsPath -FilePath $tarballPath + Install-PrimeAgentPackage -TarballPath $tarballPath -BootstrapKernel:$bootstrapKernel + } finally { + Remove-Item -LiteralPath $downloadDir -Recurse -Force -ErrorAction SilentlyContinue + } + + Write-PrimeAgentCompletion + } + + Invoke-PrimeAgentInstall -ChannelOrVersion $ChannelOrVersion +} $ChannelOrVersion diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f7100b579..3479084dd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,11 @@ - Added privacy-safe pseudonymous product analytics for onboarding, command use, execution modes, run outcomes, TTFT, latency, usage, tools, retries, and compactions, with disclosure and opt-out controls ([ENG-4682](https://linear.app/primeintellect/issue/ENG-4682/add-privacy-safe-posthog-analytics-to-prime-agent)). - Changed sent agent messages in the IPython cell UI to show only the message text with a `╰─` gutter when expanded, matching received messages, and hid the raw `agent_message.send` receipt dictionary. +- Fixed Windows child processes opening transient console windows. - Fixed Homebrew installs attempting to self-update their versioned Cellar keg instead of directing users to `brew upgrade prime-agent` ([#844](https://github.com/PrimeIntellect-ai/prime-agent/issues/844)) +- Added a Windows PowerShell installer (`irm .../install.ps1 | iex`) that verifies the release checksum, installs with npm, and can prepare the IPython runtime. +- Added Windows support to the kernel bootstrap, installing uv with the official PowerShell installer and using the venv's `Scripts/python.exe` ([#663](https://github.com/PrimeIntellect-ai/prime-agent/pull/663) by [@skulitom](https://github.com/skulitom)). +- Fixed the Windows PowerShell installer skipping required lifecycle scripts with npm versions that require explicit approvals. ## [0.7.1] - 2026-08-07 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 565e7f742..ae76fcb7f 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -52,6 +52,12 @@ To install the beta built from the latest commit on `main`: curl -fsSL https://app.primeintellect.ai/prime-agent/install.sh | sh -s -- beta ``` +On Windows, install from PowerShell (see [Windows setup](docs/windows.md)): + +```powershell +irm https://app.primeintellect.ai/prime-agent/install.ps1 | iex +``` + Authenticate with an API key: ```bash diff --git a/packages/coding-agent/docs/index.md b/packages/coding-agent/docs/index.md index 847fd73b6..d19adf86e 100644 --- a/packages/coding-agent/docs/index.md +++ b/packages/coding-agent/docs/index.md @@ -10,6 +10,12 @@ Install the latest stable release on Linux or macOS: curl -fsSL https://app.primeintellect.ai/prime-agent/install.sh | sh ``` +On Windows, install from PowerShell (see [Windows](windows.md)): + +```powershell +irm https://app.primeintellect.ai/prime-agent/install.ps1 | iex +``` + Then run it in a project directory: ```bash diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index 95fd940ea..7c4edf1d8 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -16,7 +16,13 @@ To try the latest beta built from `main`: 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. +On Windows, install from PowerShell instead: + +```powershell +irm https://app.primeintellect.ai/prime-agent/install.ps1 | iex +``` + +These 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. See [Windows](windows.md) for requirements, PATH notes, and the bash shell Prime Agent needs on Windows. Then start Prime Agent in the project directory you want it to work on: diff --git a/packages/coding-agent/docs/rlm-runtime.md b/packages/coding-agent/docs/rlm-runtime.md index 4de095cf0..a3ceaf35d 100644 --- a/packages/coding-agent/docs/rlm-runtime.md +++ b/packages/coding-agent/docs/rlm-runtime.md @@ -76,7 +76,7 @@ The Python side does not call providers or implement an agent loop. The kernel is created lazily on first IPython use. Python resolution is: 1. `PRIME_AGENT_KERNEL_PYTHON`, when it can import `ipykernel`; -2. `~/.prime/agent/kernel-venv/bin/python`, bootstrapped with `uv`; or +2. `~/.prime/agent/kernel-venv/bin/python` (`Scripts\python.exe` on Windows), bootstrapped with `uv`; or 3. the XDG data location when `~/.prime` is not writable. The managed environment includes Python 3.11, `ipykernel`, and `prime-agent-runtime`. A bootstrap marker detects stale environments. diff --git a/packages/coding-agent/docs/windows.md b/packages/coding-agent/docs/windows.md index 3f7da6c61..cfa81fca7 100644 --- a/packages/coding-agent/docs/windows.md +++ b/packages/coding-agent/docs/windows.md @@ -1,17 +1,117 @@ # Windows Setup -Prime Agent requires a bash shell on Windows. Checked locations (in order): +Prime Agent installs and runs natively on Windows with PowerShell. WSL and Git Bash are not required for the install itself, but a bash shell is required at runtime (see [Bash requirement](#bash-requirement)). -1. Custom path from `~/.prime/agent/settings.json` -2. Git Bash (`C:\Program Files\Git\bin\bash.exe`) +## Install + +Run this in Windows PowerShell 5.1 or PowerShell 7+: + +```powershell +irm https://app.primeintellect.ai/prime-agent/install.ps1 | iex +``` + +To try the latest beta built from `main`: + +```powershell +irm https://app.primeintellect.ai/prime-agent/install-beta.ps1 | iex +``` + +The installer resolves the current release, downloads the release tarball and `SHA256SUMS`, verifies the SHA-256 checksum, and installs the `prime-agent` command with `npm install -g`. It asks before installing and before preparing the IPython runtime; both default to yes, and both are assumed when no terminal is attached. + +Then start Prime Agent in the directory you want it to work on: + +```powershell +cd C:\path\to\project +prime-agent +``` + +## Requirements + +- Windows PowerShell 5.1 or PowerShell 7+ +- Node.js 20.6.0 or newer and npm on PATH +- A bash shell for the agent's shell commands + +The installer does not install Node.js for you. If it is missing: + +```powershell +winget install OpenJS.NodeJS.LTS +``` + +Or download an installer from [nodejs.org](https://nodejs.org). Open a new terminal afterwards so `node` and `npm` are on PATH. + +## Installer Options + +Environment variables, set before piping the installer to `iex`: + +| Variable | Purpose | +| --- | --- | +| `PRIME_AGENT_RELEASE_CHANNEL` | `stable` or `beta` | +| `PRIME_AGENT_VERSION` | Install an explicit version instead of the channel's current release | +| `PRIME_AGENT_DOWNLOAD_BASE_URL` | Alternate release host | +| `PRIME_AGENT_BOOTSTRAP_KERNEL_ON_INSTALL` | `1` prepares the IPython runtime during install, `0` skips it | +| `PRIME_AGENT_PACKAGE`, `PRIME_AGENT_CMD` | Package and command name overrides | + +```powershell +$env:PRIME_AGENT_RELEASE_CHANNEL = 'beta' +$env:PRIME_AGENT_BOOTSTRAP_KERNEL_ON_INSTALL = '1' +irm https://app.primeintellect.ai/prime-agent/install.ps1 | iex +``` + +A downloaded copy of the installer also accepts a channel or version as its first argument: + +```powershell +Invoke-WebRequest https://app.primeintellect.ai/prime-agent/install.ps1 -OutFile install.ps1 +./install.ps1 beta +./install.ps1 0.7.1 +``` + +## PATH + +`npm install -g` puts `prime-agent.cmd` in npm's global prefix. If the command is not found after installing, print the prefix and add it to PATH: + +```powershell +npm config get prefix +``` + +For the current session only: + +```powershell +$env:Path = "$(npm config get prefix);" + $env:Path +``` + +Make it permanent through System Settings, `Environment Variables`, or `setx PATH`. A newly opened terminal also picks up PATH changes made by the Node.js installer. + +## Bash requirement + +The agent runs shell commands, `%%bash` cells, and some tools through bash. Checked locations (in order): + +1. Custom path from `shellPath` in `~/.prime/agent/settings.json` +2. Git Bash (`C:\Program Files\Git\bin\bash.exe`, then the 32-bit Program Files location) 3. `bash.exe` on PATH (Cygwin, MSYS2, WSL) For most users, [Git for Windows](https://git-scm.com/download/win) is sufficient. -## Custom Shell Path +### Custom Shell Path ```json { "shellPath": "C:\\cygwin64\\bin\\bash.exe" } ``` + +## IPython Runtime + +The first IPython use (or the install-time prompt) bootstraps the kernel runtime: + +- `uv` is installed with the official PowerShell installer to `%USERPROFILE%\.local\bin\uv.exe` when it is not already available +- `uv` installs Python 3.11, `ipykernel`, `prime-agent-runtime`, and the default Python packages +- The kernel virtual environment lives in `%USERPROFILE%\.prime\agent\kernel-venv` + +The uv installer only adds `%USERPROFILE%\.local\bin` to the persisted user PATH, so `uv` is not on PATH in the current terminal; Prime Agent looks for the binary in that directory directly. Set `PRIME_AGENT_KERNEL_PYTHON` to an existing Python with `ipykernel` and a current `prime-agent-runtime` to skip the bootstrap entirely. + +## Known Limitations + +- A bash shell must be installed for shell commands to work; Prime Agent does not fall back to `cmd.exe` or PowerShell. +- Image paste uses Alt+V instead of Ctrl+V. +- Windows Terminal is recommended; older console hosts render the TUI poorly. +- Running the Linux installer inside WSL is still supported and is the better option if a project needs a Linux toolchain. diff --git a/packages/coding-agent/postinstall.cjs b/packages/coding-agent/postinstall.cjs index 32c99b797..6eb97f41c 100644 --- a/packages/coding-agent/postinstall.cjs +++ b/packages/coding-agent/postinstall.cjs @@ -7,7 +7,7 @@ if (!existsSync(script)) { process.exit(0); } -const result = spawnSync(process.execPath, [script], { stdio: "inherit" }); +const result = spawnSync(process.execPath, [script], { windowsHide: true, stdio: "inherit" }); if (result.error) { console.error(`prime-agent: postinstall setup skipped: ${result.error.message}`); } diff --git a/packages/coding-agent/src/cli/daemon-command.ts b/packages/coding-agent/src/cli/daemon-command.ts index 9883707bd..f87bf7116 100644 --- a/packages/coding-agent/src/cli/daemon-command.ts +++ b/packages/coding-agent/src/cli/daemon-command.ts @@ -692,6 +692,7 @@ async function runStart(parsed: ParsedDaemonClientCommand): Promise { ...sessionArgs.daemonArgs.filter((arg) => arg !== "--background" && arg !== "-d"), ]; const child = spawn(process.execPath, daemonArgs, { + windowsHide: true, cwd: sessionArgs.config?.cwd ?? process.cwd(), detached: true, env: process.env, diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index 6de9285e9..0d554635d 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -372,6 +372,7 @@ async function ensureDaemonRunning(socketPath: string, spawnCwd?: string): Promi process.execPath, [...process.execArgv, entrypoint, "--mode", "daemon", "--daemon-socket", socketPath], { + windowsHide: true, cwd: spawnCwd ?? process.cwd(), detached: true, env, diff --git a/packages/coding-agent/src/cli/daemon-ps.ts b/packages/coding-agent/src/cli/daemon-ps.ts index 6f22f823e..4a9bd4416 100644 --- a/packages/coding-agent/src/cli/daemon-ps.ts +++ b/packages/coding-agent/src/cli/daemon-ps.ts @@ -178,18 +178,22 @@ function scanListeningDaemons(): DiscoveredDaemonProcess[] { if (process.platform === "win32") { return []; } - const ss = spawnSync("ss", ["-lxp"], { encoding: "utf8" }); + const ss = spawnSync("ss", ["-lxp"], { windowsHide: true, encoding: "utf8" }); if (!ss.error && ss.status === 0 && typeof ss.stdout === "string") { return enrichUptimes(parseSsListeners(ss.stdout, APP_NAME)); } - const lsof = spawnSync("lsof", ["-nP", "-F", "pn", "-U", "-a", "-c", APP_NAME], { encoding: "utf8" }); + const lsof = spawnSync("lsof", ["-nP", "-F", "pn", "-U", "-a", "-c", APP_NAME], { + windowsHide: true, + encoding: "utf8", + }); const byName = !lsof.error && typeof lsof.stdout === "string" ? parseLsofListeners(lsof.stdout) : []; let byPid: DiscoveredDaemonProcess[] = []; - const ps = spawnSync("ps", ["-axo", "pid=,comm=,args="], { encoding: "utf8" }); + const ps = spawnSync("ps", ["-axo", "pid=,comm=,args="], { windowsHide: true, encoding: "utf8" }); if (!ps.error && ps.status === 0 && typeof ps.stdout === "string") { const pids = parsePrimeAgentProcessIds(ps.stdout, APP_NAME); if (pids.length > 0) { const lsofByPid = spawnSync("lsof", ["-nP", "-F", "pn", "-U", "-a", "-p", pids.join(",")], { + windowsHide: true, encoding: "utf8", }); if (!lsofByPid.error && typeof lsofByPid.stdout === "string") { @@ -210,7 +214,7 @@ function enrichUptimes(daemons: DiscoveredDaemonProcess[]): DiscoveredDaemonProc if (pids.length === 0) { return daemons; } - const ps = spawnSync("ps", ["-o", "pid=,etimes=", "-p", pids.join(",")], { encoding: "utf8" }); + const ps = spawnSync("ps", ["-o", "pid=,etimes=", "-p", pids.join(",")], { windowsHide: true, encoding: "utf8" }); if (ps.error || typeof ps.stdout !== "string") { return daemons; } @@ -806,7 +810,10 @@ function recordResidualListenerFailures( } } function describeDaemonParent(pid: number): string { - const result = spawnSync("ps", ["-o", "ppid=,tty=,command=", "-p", String(pid)], { encoding: "utf8" }); + const result = spawnSync("ps", ["-o", "ppid=,tty=,command=", "-p", String(pid)], { + windowsHide: true, + encoding: "utf8", + }); if (result.error || result.status !== 0 || typeof result.stdout !== "string") { return ""; } diff --git a/packages/coding-agent/src/cli/daemon-update-restart.ts b/packages/coding-agent/src/cli/daemon-update-restart.ts index 7dc44ed57..8b007ee3d 100644 --- a/packages/coding-agent/src/cli/daemon-update-restart.ts +++ b/packages/coding-agent/src/cli/daemon-update-restart.ts @@ -548,6 +548,7 @@ export async function launchDaemonUpdateRestartCoordinator( ...(originActiveSessionId ? [DAEMON_UPDATE_RESTART_ORIGIN_FLAG, originActiveSessionId] : []), ]); const child = spawn(launch.command, launch.args, { + windowsHide: true, cwd: options.cwd ?? process.cwd(), detached: true, env: coordinatorEnvironment(agentDir), diff --git a/packages/coding-agent/src/cli/owned-session-worker.ts b/packages/coding-agent/src/cli/owned-session-worker.ts index f7ab37535..b8d148fc4 100644 --- a/packages/coding-agent/src/cli/owned-session-worker.ts +++ b/packages/coding-agent/src/cli/owned-session-worker.ts @@ -344,6 +344,7 @@ export async function runOwnedSessionWorkerFrontend( ? ["inherit", "inherit", "inherit", "ipc"] : [bridgeStdin ? "pipe" : "inherit", "pipe", "pipe", "ipc"]; const child = spawn(launch.command, launch.args, { + windowsHide: true, cwd: process.cwd(), detached: process.platform !== "win32", env: { diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index b709ab10e..40ae31043 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -207,6 +207,7 @@ function readCommandOutput( options: { requireSuccess?: boolean } = {}, ): string | undefined { const result = spawnSync(command, args, { + windowsHide: true, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], shell: shouldUseWindowsShell(command), diff --git a/packages/coding-agent/src/core/autonomous.ts b/packages/coding-agent/src/core/autonomous.ts index 75930d415..3215ebff1 100644 --- a/packages/coding-agent/src/core/autonomous.ts +++ b/packages/coding-agent/src/core/autonomous.ts @@ -492,6 +492,7 @@ function runChildProcess( options.signal?.throwIfAborted(); return new Promise((resolve) => { const child = spawn(command, args, { + windowsHide: true, cwd: options.cwd, detached: process.platform !== "win32", shell: options.shell === true, diff --git a/packages/coding-agent/src/core/exec.ts b/packages/coding-agent/src/core/exec.ts index bd62eb5a0..e2088ffcd 100644 --- a/packages/coding-agent/src/core/exec.ts +++ b/packages/coding-agent/src/core/exec.ts @@ -59,6 +59,7 @@ export async function execCommand( ): Promise { return new Promise((resolve) => { const proc = spawn(command, args, { + windowsHide: true, cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], diff --git a/packages/coding-agent/src/core/footer-data-provider.ts b/packages/coding-agent/src/core/footer-data-provider.ts index 2aff8ac32..f759dfe7d 100644 --- a/packages/coding-agent/src/core/footer-data-provider.ts +++ b/packages/coding-agent/src/core/footer-data-provider.ts @@ -7,6 +7,7 @@ import { findGitPaths, type GitPaths } from "../utils/git.js"; /** Ask git for the current branch. Returns null on detached HEAD or if git is unavailable. */ function resolveBranchWithGitSync(repoDir: string): string | null { const result = spawnSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], { + windowsHide: true, cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], @@ -22,6 +23,7 @@ function resolveBranchWithGitAsync(repoDir: string): Promise { "git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], { + windowsHide: true, cwd: repoDir, encoding: "utf8", }, diff --git a/packages/coding-agent/src/core/kernel/bootstrap.ts b/packages/coding-agent/src/core/kernel/bootstrap.ts index 9b12b4b41..18304ae8e 100644 --- a/packages/coding-agent/src/core/kernel/bootstrap.ts +++ b/packages/coding-agent/src/core/kernel/bootstrap.ts @@ -36,6 +36,8 @@ 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 -NoProfile -Command "irm https://astral.sh/uv/install.ps1 | iex"'; const REQUIRED_HARNESS_METHODS = [ "create_memory", "update_memory", @@ -119,6 +121,10 @@ function expandHome(filePath: string): string { return filePath; } +export function venvPython(venv: string): string { + return process.platform === "win32" ? path.join(venv, "Scripts", "python.exe") : path.join(venv, "bin", "python"); +} + function fileContentHash(filePath: string): string { try { return `sha256:${createHash("sha256").update(readFileSync(filePath)).digest("hex")}`; @@ -374,6 +380,7 @@ async function resolveWritableKernelVenvDir(): Promise { function run(command: string, args: string[], options: { stdio?: "ignore" | "inherit" } = {}): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { + windowsHide: true, env: process.env, stdio: options.stdio ?? "ignore", }); @@ -511,35 +518,77 @@ async function findExecutable(name: string): Promise { return null; } +function uvInstallCommand(): string { + return process.platform === "win32" ? UV_INSTALL_COMMAND_WINDOWS : UV_INSTALL_COMMAND; +} + +function uvInstaller(): { command: string; args: string[] } { + if (process.platform === "win32") { + return { + command: "powershell", + args: ["-ExecutionPolicy", "ByPass", "-NoProfile", "-Command", "irm https://astral.sh/uv/install.ps1 | iex"], + }; + } + return { command: "sh", args: ["-c", UV_INSTALL_COMMAND] }; +} + +// Both uv installers drop the binary into the first of XDG_BIN_HOME, +// XDG_DATA_HOME/../bin, or ~/.local/bin. On Windows the installer only adds that +// directory to the persisted user PATH, so this process never sees it. +function uvInstallLocations(): string[] { + if (process.platform !== "win32") { + return [path.join(os.homedir(), ".local", "bin", "uv")]; + } + const locations: string[] = []; + if (process.env.XDG_BIN_HOME) locations.push(path.join(process.env.XDG_BIN_HOME, "uv.exe")); + if (process.env.XDG_DATA_HOME) locations.push(path.join(process.env.XDG_DATA_HOME, "..", "bin", "uv.exe")); + locations.push(path.join(os.homedir(), ".local", "bin", "uv.exe")); + return locations; +} + +function uvInstallLocationHint(): string { + return process.platform === "win32" ? "%USERPROFILE%\\.local\\bin\\uv.exe" : "~/.local/bin/uv"; +} + +async function findInstalledUv(): Promise { + for (const location of uvInstallLocations()) { + if (await isExecutable(location)) return location; + } + return null; +} + async function ensureUv(options: EnsureKernelPythonOptions): Promise { const fromPath = await findExecutable("uv"); if (fromPath) return fromPath; - const localUv = path.join(os.homedir(), ".local", "bin", process.platform === "win32" ? "uv.exe" : "uv"); - if (await isExecutable(localUv)) return localUv; + const localUv = await findInstalledUv(); + if (localUv) return localUv; + const installCommand = uvInstallCommand(); const shouldInstallUv = 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: ${installCommand}, ` + "or set PRIME_AGENT_INSTALL_UV=1 to let prime-agent run that installer.", ); } reportProgress(options, "› installing uv (one-time)…"); + const installer = uvInstaller(); try { - await run("sh", ["-c", UV_INSTALL_COMMAND], { stdio: options.onProgress ? "ignore" : "inherit" }); + await run(installer.command, installer.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: ${installCommand}, then re-run prime-agent. ${errorMessage(error)}`, ); } - if (await isExecutable(localUv)) return localUv; + const installedUv = await findInstalledUv(); + if (installedUv) return installedUv; const installedFromPath = await findExecutable("uv"); if (installedFromPath) return installedFromPath; - throw new Error("uv install completed but binary not found at ~/.local/bin/uv"); + throw new Error(`uv install completed but binary not found at ${uvInstallLocationHint()}`); } async function confirmUvInstall(): Promise { @@ -725,7 +774,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 = venvPython(venv); const sourceDir = await resolveRuntimeSourceDir(); const runtimeRequirement = sourceDir ?? RUNTIME_REQUIREMENT; const runtimeIdentity = await resolveRuntimeIdentity(); @@ -886,7 +935,7 @@ async function ensureKernelPythonUncached( } const venv = await resolveWritableKernelVenvDir(); - const python = path.join(venv, "bin", "python"); + const python = venvPython(venv); const runtimeIdentity = await resolveRuntimeIdentity(); if (await kernelReady(python, venv, runtimeIdentity, pythonSkills)) return python; diff --git a/packages/coding-agent/src/core/kernel/fork-server.ts b/packages/coding-agent/src/core/kernel/fork-server.ts index d5b5423ac..efaa8778d 100644 --- a/packages/coding-agent/src/core/kernel/fork-server.ts +++ b/packages/coding-agent/src/core/kernel/fork-server.ts @@ -173,6 +173,7 @@ class ForkServer { // The template only imports; its own cwd/env are irrelevant since each // forked child applies the per-kernel cwd/env itself. Inherit the daemon's. const proc = spawn(this.params.python, ["-c", FORK_SERVER_SCRIPT, socketPath], { + windowsHide: true, env: this.launchEnv, stdio: ["ignore", "ignore", "pipe"], }); diff --git a/packages/coding-agent/src/core/kernel/index.ts b/packages/coding-agent/src/core/kernel/index.ts index b760a2e1e..86b7e9802 100644 --- a/packages/coding-agent/src/core/kernel/index.ts +++ b/packages/coding-agent/src/core/kernel/index.ts @@ -645,6 +645,7 @@ export class KernelManager { if (!forked) { const kernel = spawn(python, ["-m", "ipykernel_launcher", "-f", connection.path], { + windowsHide: true, cwd: this.options.cwd, env: this.options.env ? { ...process.env, ...this.options.env } : process.env, stdio: ["ignore", "pipe", "pipe"], diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index fd818595f..d9dbeee50 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -2379,6 +2379,7 @@ export class DefaultPackageManager implements PackageManager { private spawnCommand(command: string, args: string[], options?: { cwd?: string }): ChildProcess { return spawn(command, args, { + windowsHide: true, cwd: options?.cwd, stdio: isStdoutTakenOver() ? ["ignore", 2, 2] : "inherit", shell: shouldUseWindowsShell(command), @@ -2393,6 +2394,7 @@ export class DefaultPackageManager implements PackageManager { ): ChildProcessByStdio { const baseEnv = getEnv(); return spawn(command, args, { + windowsHide: true, cwd: options?.cwd, stdio: ["ignore", "pipe", "pipe"], shell: shouldUseWindowsShell(command), @@ -2460,6 +2462,7 @@ export class DefaultPackageManager implements PackageManager { private runCommandSync(command: string, args: string[]): string { const result = spawnSync(command, args, { + windowsHide: true, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", shell: shouldUseWindowsShell(command), diff --git a/packages/coding-agent/src/core/resolve-config-value.ts b/packages/coding-agent/src/core/resolve-config-value.ts index 646042e1c..32c00d6f0 100644 --- a/packages/coding-agent/src/core/resolve-config-value.ts +++ b/packages/coding-agent/src/core/resolve-config-value.ts @@ -55,6 +55,7 @@ function executeWithConfiguredShell(command: string): { executed: boolean; value function executeWithDefaultShell(command: string): string | undefined { try { const output = execSync(command, { + windowsHide: true, encoding: "utf-8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"], diff --git a/packages/coding-agent/src/core/session-file-actions.ts b/packages/coding-agent/src/core/session-file-actions.ts index adcec4c01..cdc6f1b0c 100644 --- a/packages/coding-agent/src/core/session-file-actions.ts +++ b/packages/coding-agent/src/core/session-file-actions.ts @@ -25,7 +25,7 @@ async function deleteSessionArtifacts(sessionPath: string): Promise { /** Remove the session `.jsonl`, trying the `trash` CLI first, then falling back to unlink. */ async function removeSessionFile(sessionPath: string): Promise { const trashArgs = sessionPath.startsWith("-") ? ["--", sessionPath] : [sessionPath]; - const trashResult = spawnSync("trash", trashArgs, { encoding: "utf-8" }); + const trashResult = spawnSync("trash", trashArgs, { windowsHide: true, encoding: "utf-8" }); const getTrashErrorHint = (): string | null => { const parts: string[] = []; diff --git a/packages/coding-agent/src/core/session-lease.ts b/packages/coding-agent/src/core/session-lease.ts index 6c4e2975c..763a6300a 100644 --- a/packages/coding-agent/src/core/session-lease.ts +++ b/packages/coding-agent/src/core/session-lease.ts @@ -114,6 +114,7 @@ type ProcessQuery = (command: string, args: string[]) => string; function runProcessQuery(command: string, args: string[]): string { return execFileSync(command, args, { + windowsHide: true, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index efc260eae..6b75c4051 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -73,6 +73,7 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas return; } const child = spawn(shell, [...args, command], { + windowsHide: true, cwd, detached: process.platform !== "win32", env: env ?? getShellEnv(), 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 c841b5b79..13ec7edb0 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -393,6 +393,7 @@ export class DaemonCatalogClient { private async spawnCatalog(): Promise { const launch = createCliSubprocessLaunchSpec(["--version"]); const child = spawn(launch.command, launch.args, { + windowsHide: true, cwd: process.cwd(), env: createCliSubprocessEnv({ ...process.env, [DAEMON_CATALOG_ROLE_ENV]: "1" }), stdio: ["ignore", "ignore", "ignore", "ipc"], diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index dfebcdf61..5a7a3328d 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -816,6 +816,7 @@ export class AgentDaemon { delete environment[SESSION_LEASES_ENABLED_ENV]; delete environment[SESSION_LEASE_OWNER_ID_ENV]; const child = spawn(launch.command, launch.args, { + windowsHide: true, cwd: this.options.defaultSessionConfig.cwd ?? process.cwd(), detached: true, env: environment, diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index b22701bbc..0abf81da3 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -2115,6 +2115,7 @@ export class DaemonSupervisor { const launch = createCliSubprocessLaunchSpec(["--mode", "daemon", "--daemon-socket", socketPath]); await this.assertRecoveryAllowed(); const child: ChildProcess = spawn(launch.command, launch.args, { + windowsHide: true, cwd: createCommand.config?.cwd ?? process.cwd(), detached: true, env: createCliSubprocessEnv({ @@ -4860,6 +4861,7 @@ export class DaemonSupervisor { delete environment[SESSION_LEASES_ENABLED_ENV]; delete environment[SESSION_LEASE_OWNER_ID_ENV]; const replacement = spawn(launch.command, launch.args, { + windowsHide: true, cwd: this.defaultSessionConfig.cwd ?? process.cwd(), detached: true, env: environment, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0ec1d8bcd..0169e8d5c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -8436,6 +8436,7 @@ export class InteractiveMode { process.execPath, [...process.execArgv, entrypoint, "update", ...updateChildArgs], { + windowsHide: true, stdio: "inherit", cwd: updateCwd, env: updateEnv, @@ -8487,6 +8488,7 @@ export class InteractiveMode { } } const relaunchResult = spawnSync(process.execPath, [...process.execArgv, entrypoint, ...relaunchArgs], { + windowsHide: true, stdio: "inherit", cwd: updateCwd, env: process.env, @@ -8703,7 +8705,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"], { windowsHide: true, encoding: "utf-8" }); if (authResult.status !== 0) { this.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); return; @@ -8752,7 +8754,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/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 7c3278816..5e14038b3 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -104,6 +104,7 @@ export class RpcClient { } this.process = spawn("node", [cliPath, ...args], { + windowsHide: true, cwd: this.options.cwd, env: { ...process.env, ...this.options.env }, stdio: ["pipe", "pipe", "pipe"], diff --git a/packages/coding-agent/src/modes/shared/startup-notices.ts b/packages/coding-agent/src/modes/shared/startup-notices.ts index 1fe9ea1ad..c16e0942d 100644 --- a/packages/coding-agent/src/modes/shared/startup-notices.ts +++ b/packages/coding-agent/src/modes/shared/startup-notices.ts @@ -67,6 +67,7 @@ export async function checkTmuxKeyboardSetup(): Promise { const runTmuxShow = (option: string): Promise => { return new Promise((resolve) => { const proc = spawn("tmux", ["show", "-gv", option], { + windowsHide: true, stdio: ["ignore", "pipe", "ignore"], }); let stdout = ""; diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index a9f4bd6cb..01e1ad044 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -464,6 +464,7 @@ async function runSelfUpdate(command: SelfUpdateCommand): Promise { await new Promise((resolve, reject) => { // Windows package managers are commonly .cmd shims. Use the shell so Node can execute them. const child = spawn(step.command, step.args, { + windowsHide: true, stdio: "inherit", shell: shouldUseWindowsShell(step.command), }); diff --git a/packages/coding-agent/src/utils/clipboard-image.ts b/packages/coding-agent/src/utils/clipboard-image.ts index 4cf44908f..1de85a275 100644 --- a/packages/coding-agent/src/utils/clipboard-image.ts +++ b/packages/coding-agent/src/utils/clipboard-image.ts @@ -95,6 +95,7 @@ function runCommand( const maxBufferBytes = options?.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; const result = spawnSync(command, args, { + windowsHide: true, timeout: timeoutMs, maxBuffer: maxBufferBytes, env: options?.env, diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index 84166e95e..e880b61bc 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -7,6 +7,7 @@ type NativeClipboardExecOptions = { input: string; timeout: number; stdio: ["pipe", "ignore", "ignore"]; + windowsHide: true; }; function copyToX11Clipboard(options: NativeClipboardExecOptions): void { @@ -61,7 +62,12 @@ export async function copyToClipboard(text: string): Promise { return; } - const options: NativeClipboardExecOptions = { input: text, timeout: 5000, stdio: ["pipe", "ignore", "ignore"] }; + const options: NativeClipboardExecOptions = { + input: text, + timeout: 5000, + stdio: ["pipe", "ignore", "ignore"], + windowsHide: true, + }; if (!copied) { try { @@ -89,9 +95,9 @@ export async function copyToClipboard(text: string): Promise { if (isWayland && hasWaylandDisplay) { try { // Verify wl-copy exists (spawn errors are async and won't be caught) - execSync("which wl-copy", { stdio: "ignore" }); + execSync("which wl-copy", { windowsHide: true, stdio: "ignore" }); // wl-copy with execSync hangs due to fork behavior; use spawn instead - const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] }); + const proc = spawn("wl-copy", [], { windowsHide: true, stdio: ["pipe", "ignore", "ignore"] }); proc.stdin.on("error", () => { // Ignore EPIPE errors if wl-copy exits early }); diff --git a/packages/coding-agent/src/utils/git.ts b/packages/coding-agent/src/utils/git.ts index b60d98a00..1ad87bf2f 100644 --- a/packages/coding-agent/src/utils/git.ts +++ b/packages/coding-agent/src/utils/git.ts @@ -250,6 +250,7 @@ export function gitContextsEqual(a: GitContext, b: GitContext): boolean { function runGit(cwd: string, args: string[]): string | null { const result = spawnSync("git", ["--no-optional-locks", ...args], { + windowsHide: true, cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index cbf289d86..5ebe01324 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -16,7 +16,7 @@ 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"], { windowsHide: true, encoding: "utf-8", timeout: 5000 }); if (result.status === 0 && result.stdout) { const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; if (firstMatch && existsSync(firstMatch)) { @@ -31,7 +31,7 @@ function findBashOnPath(): string | null { // Unix: Use 'which' and trust its output (handles Termux and special filesystems) try { - const result = spawnSync("which", ["bash"], { encoding: "utf-8", timeout: 5000 }); + const result = spawnSync("which", ["bash"], { windowsHide: true, encoding: "utf-8", timeout: 5000 }); if (result.status === 0 && result.stdout) { const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; if (firstMatch) { @@ -192,6 +192,7 @@ export function killProcessTree(pid: number): void { // Use taskkill on Windows to kill process tree try { spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { + windowsHide: true, stdio: "ignore", detached: true, }); diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index c3da7045e..50914fd34 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -100,7 +100,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"], { windowsHide: true, stdio: "pipe", timeout: COMMAND_TIMEOUT_MS }); return !result.error && result.status === 0; } catch { return false; @@ -224,7 +224,10 @@ 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], { + windowsHide: true, + stdio: "pipe", + }); 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}`); diff --git a/packages/coding-agent/test/clipboard.test.ts b/packages/coding-agent/test/clipboard.test.ts index ea231b572..a8c52bcf5 100644 --- a/packages/coding-agent/test/clipboard.test.ts +++ b/packages/coding-agent/test/clipboard.test.ts @@ -121,6 +121,7 @@ describe("copyToClipboard", () => { input: "hello", stdio: ["pipe", "ignore", "ignore"], timeout: 5000, + windowsHide: true, }); expect(osc52Writes()).toHaveLength(0); }); diff --git a/packages/coding-agent/test/ipython-bootstrap.test.ts b/packages/coding-agent/test/ipython-bootstrap.test.ts index 8aba99b46..fa5c23ba2 100644 --- a/packages/coding-agent/test/ipython-bootstrap.test.ts +++ b/packages/coding-agent/test/ipython-bootstrap.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, describe, expect, it } from "vitest"; +import { venvPython } from "../src/core/kernel/bootstrap.js"; import { KernelManager } from "../src/core/kernel/index.js"; import { buildRlmBootstrapCode } from "../src/core/tools/ipython.js"; @@ -44,7 +45,7 @@ describe("IPython RLM bootstrap", () => { function resolveKernelPython(): string | null { const candidates = [ process.env.PRIME_AGENT_KERNEL_PYTHON, - join(homedir(), ".prime", "agent", "kernel-venv", "bin", "python"), + venvPython(join(homedir(), ".prime", "agent", "kernel-venv")), ].filter((p): p is string => Boolean(p)); for (const python of candidates) { if (!existsSync(python)) continue; diff --git a/packages/coding-agent/test/kernel-bootstrap.test.ts b/packages/coding-agent/test/kernel-bootstrap.test.ts index eeb2fc0d1..bed16ba12 100644 --- a/packages/coding-agent/test/kernel-bootstrap.test.ts +++ b/packages/coding-agent/test/kernel-bootstrap.test.ts @@ -10,6 +10,7 @@ import { getKernelVenvDir, type KernelPythonSkill, resolveRuntimeIdentity, + venvPython, } from "../src/core/kernel/bootstrap.js"; let tempDir = ""; @@ -174,6 +175,21 @@ describe("kernel bootstrap", () => { expect(getKernelVenvDir()).toBe(venv); }); + it("resolves the venv interpreter using the host platform layout", () => { + const originalPlatform = process.platform; + const setPlatform = (value: NodeJS.Platform) => + Object.defineProperty(process, "platform", { value, configurable: true }); + try { + setPlatform("win32"); + expect(venvPython("C:\\venv")).toBe(join("C:\\venv", "Scripts", "python.exe")); + + setPlatform("linux"); + expect(venvPython("/venv")).toBe(join("/venv", "bin", "python")); + } finally { + setPlatform(originalPlatform); + } + }); + it("bootstraps a missing venv with uv, ipykernel, prime-agent-runtime, and default extra packages", async () => { const logPath = installFakeUv(); const venv = join(tempDir, "kernel-venv"); diff --git a/packages/coding-agent/test/kernel-state-roundtrip.test.ts b/packages/coding-agent/test/kernel-state-roundtrip.test.ts index b4d767c4a..0e64dbd21 100644 --- a/packages/coding-agent/test/kernel-state-roundtrip.test.ts +++ b/packages/coding-agent/test/kernel-state-roundtrip.test.ts @@ -3,13 +3,14 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { venvPython } from "../src/core/kernel/bootstrap.js"; import { KernelManager } from "../src/core/kernel/index.js"; /** Find a python that can launch an ipykernel and has dill, or null to skip. */ function resolveKernelPython(): string | null { const candidates = [ process.env.PRIME_AGENT_KERNEL_PYTHON, - join(homedir(), ".prime", "agent", "kernel-venv", "bin", "python"), + venvPython(join(homedir(), ".prime", "agent", "kernel-venv")), ].filter((p): p is string => Boolean(p)); for (const python of candidates) { if (!existsSync(python)) continue; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index abf78757a..a73c655a7 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Fixed Windows file autocomplete opening transient console windows. + ## [0.7.1] - 2026-08-07 ## [0.7.0] - 2026-08-05 diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index bc11f530e..985f5ba63 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -163,6 +163,7 @@ async function walkDirectoryWithFd( } const child = spawn(fdPath, args, { + windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; diff --git a/scripts/check-installer-render.mjs b/scripts/check-installer-render.mjs index 835f306bb..4fe8af51c 100644 --- a/scripts/check-installer-render.mjs +++ b/scripts/check-installer-render.mjs @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; const installerSource = readFileSync("install.sh", "utf-8"); +const powershellInstallerSource = readFileSync("install.ps1", "utf-8"); const mainCall = '\nmain "$@"'; const mainCallIndex = installerSource.lastIndexOf(mainCall); const ansiPattern = /\x1b\[[0-?]*[ -/]*[@-~]/g; @@ -156,6 +157,9 @@ try { rmSync(tempDir, { recursive: true, force: true }); } +checkInstallerRelease("install.sh", installerSource); +checkInstallerRelease("install.ps1", powershellInstallerSource); + if (failures.length > 0) { console.error(["Installer render check failed:", ...failures.map((failure) => `- ${failure}`)].join("\n")); process.exit(1); @@ -163,6 +167,32 @@ if (failures.length > 0) { console.log("Installer render check passed."); +// The release workflow rewrites the two configured sentinels in place. Unrendered +// copies must keep the split literals so they can still detect "not configured yet". +function checkInstallerRelease(name, source) { + const baseUrlSentinel = "__PRIME_AGENT_DOWNLOAD_BASE_URL__"; + const channelSentinel = "__PRIME_AGENT_DEFAULT_RELEASE_CHANNEL__"; + const baseUrl = "https://example.invalid/prime-agent"; + + check(source.includes(baseUrlSentinel), `${name}: expected a ${baseUrlSentinel} placeholder`); + check(source.includes(channelSentinel), `${name}: expected a ${channelSentinel} placeholder`); + + for (const channel of ["stable", "beta"]) { + const rendered = source.replaceAll(baseUrlSentinel, baseUrl).replaceAll(channelSentinel, channel); + check(!rendered.includes(baseUrlSentinel), `${name}: ${channel} render left a ${baseUrlSentinel} placeholder`); + check(!rendered.includes(channelSentinel), `${name}: ${channel} render left a ${channelSentinel} placeholder`); + check(rendered.includes(baseUrl), `${name}: ${channel} render did not apply the download base URL`); + check( + rendered.includes('__PRIME_AGENT_DOWNLOAD_BASE"') || rendered.includes("__PRIME_AGENT_DOWNLOAD_BASE'"), + `${name}: ${channel} render lost the split unconfigured base URL sentinel`, + ); + check( + rendered.includes('__PRIME_AGENT_DEFAULT_RELEASE_"') || rendered.includes("__PRIME_AGENT_DEFAULT_RELEASE_'"), + `${name}: ${channel} render lost the split unconfigured channel sentinel`, + ); + } +} + function runCase(name, initialCols, initialRows, resizedCols, resizedRows) { const result = spawnSync("sh", [harnessPath, String(initialCols), String(initialRows), String(resizedCols), String(resizedRows)], { detached: true,