fix(core): resolve spawn completion on exit, not only close (Windows detached-child hang) - #29831
fix(core): resolve spawn completion on exit, not only close (Windows detached-child hang)#29831Hona wants to merge 13 commits into
Conversation
The shell tool hangs on Windows when a command spawns a detached child that inherits stdio (e.g. a backgrounded web server or playwright-cli). The cross-spawn spawner resolved process completion only on the child's `close` event, which fires after *all* stdio streams close. A detached child keeps the inherited pipe's write end open, so `close` is delayed indefinitely even though the main process already exited -- and exitCode (and thus the shell tool) never resolves. Resolve on whichever of `exit`/`close` fires first. `exit` returns promptly once the main process is gone (fixing the hang); `close` is kept as the required backstop for failed spawns, where cross-spawn emits spawn->error->close but never exit, so exit-only would hang that path forever. Closes anomalyco#24731
There was a problem hiding this comment.
Pull request overview
Fixes a Windows hang in the shell tool when a command spawns a detached child that inherits stdio (e.g. playwright-cli). The CrossSpawnSpawner previously completed only on the child's "close" event, which can be delayed indefinitely if a detached child keeps the parent's stdio pipes open. The fix completes the exit Deferred on whichever of "exit" or "close" fires first, while preserving the "close" listener as a backstop for the ENOENT path where cross-spawn never emits "exit".
Changes:
- Resolve the spawn completion Deferred in the
"exit"handler in addition to the existing"close"handler, withend/exitguards so the value is consistent regardless of order. - Added an explanatory comment documenting why both events are needed.
- Added a regression test (using
fx.live) that spawns a detached daemon inheriting stdio and assertsexitCoderesolves within 5s, reaping the daemon afterward.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| packages/core/src/cross-spawn-spawner.ts | Complete on first of exit/close; keep close as ENOENT backstop. |
| packages/core/test/effect/cross-spawn-spawner.test.ts | New regression test for detached child stdio inheritance (#24731); trailing blank lines added. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ed pipes Replace the fixed 100ms post-exit drain (which truncated output when a slow per-chunk consumer was still working through a backlog) with joining the output reader fiber. For a normal command this returns the instant the stdio streams reach EOF -- full output, no fixed delay, matching upstream's backpressure-synced behavior. A detached child can hold the pipe open forever with no further output, so an idle watcher that resets on every chunk bounds only that case; a command still producing output is never cut off.
|
Is it ready to merge? Will it be included in the next release? |
|
I tested this PR on Windows with PowerShell and confirmed that the issue has been fixed. Thank you for submitting this PR, and I hope it can be merged soon. |
|
hi hit the same issue shell is hanging forever when a command spawns a background process, can this be merged? |
|
This fix addresses a critical issue that affects agent-browser and any tool that spawns detached processes on Windows. Multiple users have confirmed it works. The approach of completing on exit instead of close is the right solution. Ready to merge? |
|
same lol this is annoying shit |
|
Is this ready to be merged? Really looking forward to this fix. |
|
gradlew processes also hangs frequently on windows with powershell, looking forward to the merge! |
|
? |
|
Would also love to see this merged ASAP! |
|
Looking forward to this fix ASAP please ! |
|
Hi, @Hona some merge checked fail, Could you please take a look? |
|
For anyone on Windows who is tired of waiting for this to land: here is a PowerShell script that builds opencode locally with this PR applied on top of latest Why not just build the PR branch directly? Its head is based on an older Usage git clone https://github.com/anomalyco/opencode.git
pwsh -File .\update-opencode.ps1 # CLI + Desktop
pwsh -File .\update-opencode.ps1 -Target cli # CLI only
pwsh -File .\update-opencode.ps1 -DryRun # port the PR, stop before buildingRequirements: PowerShell 7, Notes
Tested on Windows 11 / PowerShell 7 against update-opencode.ps1#Requires -Version 7
<#
.SYNOPSIS
Windows: rebuild + install OpenCode CLI and/or Desktop from latest origin/dev with PR #29831
(detached-child shell hang fix) ported on top. channel=prod by default.
.DESCRIPTION
Why this exists: PR #29831 fixes the Windows hang where a shell tool call never returns
because a detached child keeps the pipe open. The PR branch is based on an OLD dev, so
building the PR head directly downgrades you. This script instead checks out latest
origin/dev and 3-way merges only the PR's files onto it, then builds and installs locally.
Pipeline (same source / channel / PR for both targets):
1) fetch origin/dev (+ the PR ref)
2) port the PR onto dev via per-file 3-way merge (git merge-file) and commit
3) bun install
4) build + install the selected targets
Channel "prod" makes both targets use ~/.local/share/opencode/opencode.db (shared sessions).
Non-prod channels get opencode-<channel>.db and Desktop userData ai.opencode.desktop.<channel>.
CLI install uses Windows rename-while-running so the current session can stay up;
new terminals pick up the new binary. Desktop is stopped briefly to replace app.asar.
REQUIREMENTS
- PowerShell 7 (pwsh), git, bun
- Desktop target only: npx (Node) and rg (ripgrep, for the build-marker check)
- A clone of the repo: git clone https://github.com/anomalyco/opencode.git
- CLI target only: opencode installed once via npm, so the launcher shim exists:
npm i -g opencode-ai
- Desktop target only: the official OpenCode Desktop installed once.
PATHS
All paths auto-resolve for the current user; nothing is hardcoded to one machine.
repo : $env:OPENCODE_SOURCE, else <script dir>\opencode[-source], else ~\opencode[-source]
cli : %APPDATA%\npm\node_modules\opencode-ai, else `npm root -g`\opencode-ai
desktop : %LOCALAPPDATA%\Programs\@opencode-aidesktop (else any *opencode* app dir there)
work : %TEMP%\opencode
Override any of them with -RepoPath / -CliNpmDir / -DesktopInstall / -WorkRoot.
Everything it touches is reversible: the previous CLI exe is kept next to the new one as
opencode.exe.old-running-<stamp>, and the previous app.asar is backed up under %TEMP%\opencode.
.EXAMPLE
# Both CLI + Desktop (default)
pwsh -File .\update-opencode.ps1
.EXAMPLE
# CLI only
pwsh -File .\update-opencode.ps1 -Target cli
.EXAMPLE
# See what it would do (resolves paths, ports the PR, stops before build/install)
pwsh -File .\update-opencode.ps1 -DryRun
.EXAMPLE
# Repo lives somewhere else
pwsh -File .\update-opencode.ps1 -RepoPath D:\src\opencode
.EXAMPLE
# Plain latest dev, no PR port
pwsh -File .\update-opencode.ps1 -SkipPr
.EXAMPLE
# The current CLI process holds a lock on the exe; kill it and install anyway
pwsh -File .\update-opencode.ps1 -Target cli -ForceKillCli
#>
[CmdletBinding()]
param(
[ValidateSet("cli", "desktop", "both")]
[string]$Target = "both",
# Empty = auto-resolve for the current user (see Resolve-* below). Pass a path to override.
[string]$RepoPath = "",
[string]$DesktopInstall = "",
[string]$CliNpmDir = "",
[string]$WorkRoot = "",
[ValidateSet("prod", "beta", "dev")]
[string]$Channel = "prod",
# Empty = auto from packages/opencode/package.json on the branch (+ -prN if porting PR)
[string]$Version = "",
[int]$PrNumber = 29831,
[string]$BranchName = "",
[switch]$SkipPr,
[switch]$SkipFetch,
[switch]$SkipInstall,
[switch]$DryRun,
[switch]$KeepBranch,
[switch]$ForceKillCli
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$DoCli = $Target -in @("cli", "both")
$DoDesktop = $Target -in @("desktop", "both")
function Write-Step([string]$Message) {
Write-Host ""
Write-Host "==> $Message" -ForegroundColor Cyan
}
function Fail([string]$Message) {
# throw would collapse a multi-line message into one wrapped line; keep setup hints readable.
Write-Host ""
Write-Host $Message -ForegroundColor Red
Write-Host ""
exit 1
}
function Assert-Command {
param([string]$Name, [string]$Hint = "")
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
$msg = "Required command not found: $Name"
if ($Hint) { $msg += "`n $Hint" }
Fail $msg
}
}
# ---- path resolution (no machine-specific paths baked into this script) ----
function Resolve-RepoPath {
if ($env:OPENCODE_SOURCE) { return $env:OPENCODE_SOURCE }
$marker = "packages\opencode\package.json"
$candidates = @()
if ($PSScriptRoot) {
$candidates += (Join-Path $PSScriptRoot "opencode")
$candidates += (Join-Path $PSScriptRoot "opencode-source")
}
$candidates += (Join-Path $env:USERPROFILE "opencode")
$candidates += (Join-Path $env:USERPROFILE "opencode-source")
foreach ($c in $candidates) {
if (Test-Path -LiteralPath (Join-Path $c $marker)) { return $c }
}
# Not found: return the conventional location so Preflight can print a useful error.
return (Join-Path $env:USERPROFILE "opencode-source")
}
function Resolve-CliNpmDir {
$direct = Join-Path $env:APPDATA "npm\node_modules\opencode-ai"
if (Test-Path -LiteralPath $direct) { return $direct }
# nvm / fnm / volta put the global root elsewhere; ask npm.
$root = $null
try { $root = (& npm root -g 2>$null | Select-Object -First 1) } catch { $root = $null }
if ($root) {
$viaNpm = Join-Path $root.Trim() "opencode-ai"
if (Test-Path -LiteralPath $viaNpm) { return $viaNpm }
}
return $direct
}
function Resolve-DesktopInstall {
$direct = Join-Path $env:LOCALAPPDATA "Programs\@opencode-aidesktop"
if (Test-Path -LiteralPath (Join-Path $direct "resources\app.asar")) { return $direct }
$programs = Join-Path $env:LOCALAPPDATA "Programs"
if (Test-Path -LiteralPath $programs) {
$hit = Get-ChildItem -LiteralPath $programs -Directory -ErrorAction SilentlyContinue |
Where-Object {
$_.Name -match 'opencode' -and
(Test-Path -LiteralPath (Join-Path $_.FullName "resources\app.asar"))
} |
Select-Object -First 1
if ($hit) { return $hit.FullName }
}
return $direct
}
function Invoke-Bun {
param(
[string[]]$BunArgs,
[string]$Cwd,
[hashtable]$EnvExtra = @{}
)
Push-Location -LiteralPath $Cwd
$old = @{}
try {
foreach ($k in $EnvExtra.Keys) {
$old[$k] = [Environment]::GetEnvironmentVariable($k, "Process")
Set-Item -Path "Env:$k" -Value $EnvExtra[$k]
}
& bun @BunArgs
if ($LASTEXITCODE -ne 0) { throw "bun $($BunArgs -join ' ') failed (exit $LASTEXITCODE)" }
} finally {
foreach ($k in $old.Keys) {
if ($null -eq $old[$k]) { Remove-Item -Path "Env:$k" -ErrorAction SilentlyContinue }
else { Set-Item -Path "Env:$k" -Value $old[$k] }
}
Pop-Location
}
}
function Stop-DesktopOnly {
$killed = @()
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
$_.ExecutablePath -and (
$_.ExecutablePath -like "*\@opencode-aidesktop\*" -or
$_.ExecutablePath -like "*\Programs\@opencode-aidesktop\*"
)
} |
ForEach-Object {
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
$killed += $_.ProcessId
}
if ($killed.Count) {
Write-Host "Stopped desktop PIDs: $($killed -join ', ')"
Start-Sleep -Seconds 1
} else {
Write-Host "Desktop not running."
}
}
function Stop-NpmCliOnly {
$killed = @()
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
$_.ExecutablePath -and $_.ExecutablePath -like "*\npm\node_modules\opencode-ai\bin\opencode.exe"
} |
ForEach-Object {
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
$killed += $_.ProcessId
}
if ($killed.Count) {
Write-Host "Stopped npm CLI PIDs: $($killed -join ', ')"
Start-Sleep -Seconds 1
}
}
function Resolve-ConflictsPreferOursDescription {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return $false }
$text = Get-Content -LiteralPath $Path -Raw -Encoding utf8
if ($text -notmatch '<<<<<<<') { return $false }
$pattern = '(?s)<<<<<<<[^\n]*\r?\n(.*?)=======\r?\n(.*?)>>>>>>>[^\n]*\r?\n'
$resolved = [regex]::Replace($text, $pattern, {
param($m)
$ours = $m.Groups[1].Value
$theirs = $m.Groups[2].Value
if ($theirs -match 'Effect\.ensuring' -and $ours -notmatch 'Effect\.ensuring') {
return ($theirs -replace "(?m)^\s*description:\s*input\.description,?\r?\n", "")
}
return $ours
})
if ($resolved -match '<<<<<<<') {
throw "Unresolved conflict markers remain in $Path"
}
Set-Content -LiteralPath $Path -Value $resolved -Encoding utf8NoBOM -NoNewline
return $true
}
function Fix-ShellTestForLatest {
param([string]$TestPath)
if (-not (Test-Path -LiteralPath $TestPath)) { return }
$c = Get-Content -LiteralPath $TestPath -Raw -Encoding utf8
if ($c -notmatch 'from "effect/unstable/process"') {
if ($c -match 'import \{ FSUtil \} from "@opencode-ai/core/fs-util"') {
$c = $c -replace '(import \{ FSUtil \} from "@opencode-ai/core/fs-util"\r?\n)',
"`$1import { ChildProcessSpawner } from `"effect/unstable/process`"`r`n"
} else {
$c = $c -replace '(import \{ CrossSpawnSpawner \} from "@opencode-ai/core/cross-spawn-spawner"\r?\n)',
"`$1import { FSUtil } from `"@opencode-ai/core/fs-util`"`r`nimport { ChildProcessSpawner } from `"effect/unstable/process`"`r`n"
}
}
$c = $c -replace 'import \{ Cause, Effect, Exit, Layer \} from "effect"',
'import { Cause, Effect, Exit, Layer, Stream } from "effect"'
if ($c -notmatch 'import \{ LayerNode \}') {
$c = $c -replace '(import \{ describe, expect \} from "bun:test"\r?\n)',
"`$1import { LayerNode } from `"@opencode-ai/core/effect/layer-node`"`r`n"
}
$c = $c -replace 'import \{ AppFileSystem \} from "@opencode-ai/core/filesystem"\r?\n', ""
$c = $c -replace '(?s)const fsvc = yield\* AppFileSystem\.Service\s*const pid = Number\(yield\* fsvc\.readFileString\(',
'const pid = Number(yield* FSUtil.use.readFileString('
$c = $c -replace 'yield\* \(yield\* AppFileSystem\.Service\)\.readFileString',
'yield* FSUtil.use.readFileString'
$c = $c -replace 'AppFileSystem\.Service', 'FSUtil.Service'
$layerNodeStack = @(
'LayerNode.compile(',
' LayerNode.group([FSUtil.node, Plugin.node, Truncate.node, Config.node, Agent.node, RuntimeFlags.node]),',
' ),',
' testInstanceStoreLayer'
) -join "`n "
$c = $c -replace 'AppFileSystem\.defaultLayer,\r?\n\s*Plugin\.defaultLayer,\r?\n\s*Truncate\.defaultLayer,\r?\n\s*Config\.defaultLayer,\r?\n\s*Agent\.defaultLayer,\r?\n\s*RuntimeFlags\.defaultLayer',
$layerNodeStack
$c = $c -replace 'AppFileSystem\.defaultLayer', $layerNodeStack
if ($c -match '<<<<<<<|AppFileSystem') {
throw "shell.test.ts still has conflict markers or AppFileSystem after fixup: $TestPath"
}
Set-Content -LiteralPath $TestPath -Value $c -Encoding utf8NoBOM -NoNewline
}
function Sync-RepoAndPortPr {
Write-Step "Sync repo"
Push-Location -LiteralPath $RepoPath
try {
$dirty = git status --porcelain
if ($dirty) {
Write-Host "Working tree dirty; stashing..."
git stash push -u -m "update-opencode $script:Stamp"
if ($LASTEXITCODE -ne 0) { throw "git stash failed" }
Write-Host " your local changes are safe in the stash: git stash list / git stash pop" -ForegroundColor Yellow
}
if (-not $SkipFetch) {
git fetch origin dev
if ($LASTEXITCODE -ne 0) { throw "git fetch origin dev failed" }
if (-not $SkipPr) {
git fetch origin "pull/$PrNumber/head:refs/pr/$PrNumber"
if ($LASTEXITCODE -ne 0) { throw "git fetch PR #$PrNumber failed" }
}
}
git checkout -B $BranchName origin/dev
if ($LASTEXITCODE -ne 0) { throw "git checkout $BranchName failed" }
$head = (git rev-parse --short HEAD).Trim()
Write-Host "HEAD: $head (origin/dev)"
} finally {
Pop-Location
}
$prFiles = @(
"packages/core/src/cross-spawn-spawner.ts",
"packages/core/test/effect/cross-spawn-spawner.test.ts",
"packages/opencode/src/tool/shell.ts",
"packages/opencode/test/tool/shell.test.ts"
)
if ($SkipPr) {
Write-Step "Skip PR port (-SkipPr)"
return
}
Write-Step "Port PR #$PrNumber onto latest dev"
$shellPath = Join-Path $RepoPath "packages/opencode/src/tool/shell.ts"
$spawnPath = Join-Path $RepoPath "packages/core/src/cross-spawn-spawner.ts"
$already = (Select-String -LiteralPath $shellPath -Pattern "POST_EXIT_OUTPUT_IDLE_TIMEOUT" -Quiet) -and
(Select-String -LiteralPath $spawnPath -Pattern "const exited = Deferred" -Quiet)
if ($already) {
Write-Host "origin/dev already contains PR markers; skip port."
return
}
$mergeDir = Join-Path $WorkRoot "merge3-$script:Stamp"
New-Item -ItemType Directory -Force -Path $mergeDir | Out-Null
Push-Location -LiteralPath $RepoPath
try {
$prRef = "refs/pr/$PrNumber"
$prTip = (git rev-parse $prRef).Trim()
$ancestor = (git merge-base origin/dev $prTip).Trim()
Write-Host "PR tip: $prTip"
Write-Host "Ancestor: $ancestor"
foreach ($rel in $prFiles) {
$name = Split-Path $rel -Leaf
$anc = Join-Path $mergeDir "anc-$name"
$ours = Join-Path $mergeDir "ours-$name"
$theirs = Join-Path $mergeDir "theirs-$name"
$out = Join-Path $RepoPath $rel
git show "${ancestor}:$rel" | Set-Content -LiteralPath $anc -Encoding utf8
git show "HEAD:$rel" | Set-Content -LiteralPath $ours -Encoding utf8
git show "${prTip}:$rel" | Set-Content -LiteralPath $theirs -Encoding utf8
& git merge-file $ours $anc $theirs
$code = $LASTEXITCODE
if ($code -lt 0) { throw "git merge-file hard-failed on $rel (exit $code)" }
Copy-Item -LiteralPath $ours -Destination $out -Force
Write-Host (" {0}: merge-file exit={1}" -f $rel, $code)
if ($code -gt 0) {
$null = Resolve-ConflictsPreferOursDescription -Path $out
Write-Host " resolved conflict markers in $rel"
}
}
Fix-ShellTestForLatest -TestPath (Join-Path $RepoPath "packages/opencode/test/tool/shell.test.ts")
if (-not (Select-String -LiteralPath $shellPath -Pattern "POST_EXIT_OUTPUT_IDLE_TIMEOUT" -Quiet)) {
throw "PR marker missing in shell.ts after merge"
}
if (-not (Select-String -LiteralPath $spawnPath -Pattern "const exited = Deferred" -Quiet)) {
throw "PR marker missing in cross-spawn-spawner.ts after merge"
}
git add -- $prFiles
$pending = git diff --cached --name-only
if (-not $pending) {
Write-Host "No staged changes after port."
} else {
git commit -m "fix(core): port PR #$PrNumber detached-child hang fix onto latest dev"
if ($LASTEXITCODE -ne 0) { throw "git commit failed" }
}
} finally {
Pop-Location
}
}
function Install-CliBinary {
param([string]$BuiltExe)
Write-Step "Install CLI binary"
$binDir = Join-Path $CliNpmDir "bin"
$live = Join-Path $binDir "opencode.exe"
if (-not (Test-Path -LiteralPath $binDir)) {
throw "CLI npm bin dir missing: $binDir (install opencode-ai once via npm first)"
}
if (-not (Test-Path -LiteralPath $BuiltExe)) {
throw "Built CLI missing: $BuiltExe"
}
$sideDir = Join-Path $WorkRoot "cli-$Version"
New-Item -ItemType Directory -Force -Path $sideDir | Out-Null
$sideExe = Join-Path $sideDir "opencode.exe"
Copy-Item -LiteralPath $BuiltExe -Destination $sideExe -Force
Write-Host "Smoke side binary..."
$ver = & $sideExe --version
$db = & $sideExe db path
Write-Host " version: $ver"
Write-Host " db path: $db"
if ("$ver".Trim() -ne $Version) {
throw "CLI version mismatch: expected $Version, got $ver"
}
if ($Channel -eq "prod" -and "$db" -notmatch 'opencode\.db$') {
throw "Expected prod db opencode.db, got: $db"
}
if ($ForceKillCli) {
Stop-NpmCliOnly
}
# Windows: rename locked live exe, then drop new one in place
$oldRunning = Join-Path $binDir ("opencode.exe.old-running-" + $script:Stamp)
if (Test-Path -LiteralPath $live) {
if (Test-Path -LiteralPath $oldRunning) { Remove-Item -LiteralPath $oldRunning -Force -ErrorAction SilentlyContinue }
try {
Rename-Item -LiteralPath $live -NewName (Split-Path $oldRunning -Leaf) -Force
Write-Host "Renamed live -> $(Split-Path $oldRunning -Leaf)"
} catch {
if ($ForceKillCli) { throw }
Write-Host "Rename failed (CLI locked). Staged for next restart: $sideExe" -ForegroundColor Yellow
$pending = Join-Path $binDir ("opencode.exe.new-" + $Version)
Copy-Item -LiteralPath $BuiltExe -Destination $pending -Force
$script:CliPending = $pending
$script:CliSide = $sideExe
return
}
}
Copy-Item -LiteralPath $BuiltExe -Destination $live -Force
$pkgPath = Join-Path $CliNpmDir "package.json"
if (Test-Path -LiteralPath $pkgPath) {
$pkg = Get-Content -LiteralPath $pkgPath -Raw | ConvertFrom-Json
$pkg.version = $Version
($pkg | ConvertTo-Json -Depth 20) | Set-Content -LiteralPath $pkgPath -Encoding utf8
}
$check = & $live --version
$checkDb = & $live db path
Write-Host "Installed CLI: $check"
Write-Host "CLI db path: $checkDb"
$script:CliLive = $live
$script:CliOldRunning = $oldRunning
$script:CliSide = $sideExe
}
function Install-DesktopAsar {
param([string]$OutDir)
Write-Step "Pack and replace Desktop app.asar"
$asarPath = Join-Path $DesktopInstall "resources\app.asar"
if (-not (Test-Path -LiteralPath $asarPath)) { throw "Missing app.asar: $asarPath" }
if (-not (Test-Path -LiteralPath (Join-Path $OutDir "main\index.js"))) {
throw "Desktop out missing main/index.js"
}
Stop-DesktopOnly
$extract = Join-Path $WorkRoot "desktop-pack-$script:Stamp"
$origExtract = Join-Path $WorkRoot "desktop-orig-$script:Stamp"
if (Test-Path $extract) { Remove-Item $extract -Recurse -Force }
if (Test-Path $origExtract) { Remove-Item $origExtract -Recurse -Force }
Write-Host "Extracting current asar for package.json / native deps..."
& npx --yes asar extract $asarPath $origExtract
if ($LASTEXITCODE -ne 0) { throw "asar extract failed" }
New-Item -ItemType Directory -Force -Path $extract | Out-Null
$appPackagePath = Join-Path $extract "package.json"
Copy-Item -LiteralPath (Join-Path $origExtract "package.json") -Destination $appPackagePath -Force
$appPackage = Get-Content -LiteralPath $appPackagePath -Raw | ConvertFrom-Json
$appPackage.version = $script:DesktopAppVersion
($appPackage | ConvertTo-Json -Depth 100) | Set-Content -LiteralPath $appPackagePath -Encoding utf8
Write-Host "Desktop updater version: $($script:DesktopAppVersion)"
foreach ($name in @("resources", "node_modules")) {
$src = Join-Path $origExtract $name
if (Test-Path -LiteralPath $src) {
Copy-Item -LiteralPath $src -Destination (Join-Path $extract $name) -Recurse -Force
}
}
Copy-Item -LiteralPath $OutDir -Destination (Join-Path $extract "out") -Recurse -Force
$meta = @(
"target=desktop"
"channel=$Channel"
"version=$Version"
"branch=$BranchName"
"head=$(git -C $RepoPath rev-parse --short HEAD)"
"pr=$(if ($SkipPr) { 'none' } else { $PrNumber })"
"built=$(Get-Date -Format o)"
) -join "`n"
Set-Content -LiteralPath (Join-Path $extract "DESKTOP-BUILD.txt") -Value $meta -Encoding utf8
$backup = Join-Path $WorkRoot "app.asar.bak-$script:Stamp"
Copy-Item -LiteralPath $asarPath -Destination $backup -Force
Write-Host "Backup: $backup ($((Get-Item $backup).Length) bytes)"
& npx --yes asar pack $extract $asarPath
if ($LASTEXITCODE -ne 0) { throw "asar pack failed" }
Write-Host "Installed: $asarPath ($((Get-Item $asarPath).Length) bytes)"
$verify = Join-Path $WorkRoot "desktop-verify-$script:Stamp"
if (Test-Path $verify) { Remove-Item $verify -Recurse -Force }
& npx --yes asar extract $asarPath $verify
if ($LASTEXITCODE -ne 0) { throw "asar verify extract failed" }
$vIdx = Join-Path $verify "out\main\index.js"
$vRaw = Select-String -LiteralPath $vIdx -Pattern 'const raw = ' | Select-Object -First 1
Write-Host "Installed channel: $($vRaw.Line.Trim())"
if ($vRaw.Line -notmatch [regex]::Escape("`"$Channel`"")) {
throw "Installed asar channel mismatch"
}
if (-not $SkipPr) {
$vHit = rg -n "POST_EXIT_OUTPUT_IDLE_TIMEOUT" (Join-Path $verify "out\main\chunks") 2>$null
if (-not $vHit) { throw "Installed asar missing PR marker" }
}
$installedAppVersion = (Get-Content -LiteralPath (Join-Path $verify "package.json") -Raw | ConvertFrom-Json).version
Write-Host "Installed Desktop updater version: $installedAppVersion"
if ($installedAppVersion -ne $script:DesktopAppVersion) {
throw "Installed Desktop version mismatch: expected $($script:DesktopAppVersion), got $installedAppVersion"
}
$script:DesktopAsar = $asarPath
$script:DesktopBackup = $backup
}
# ---------- main ----------
# StrictMode: these are assigned only on some paths, but the final summary reads them all.
$script:CliLive = $null
$script:CliOldRunning = $null
$script:CliSide = $null
$script:CliPending = $null
$script:DesktopAsar = $null
$script:DesktopBackup = $null
$script:DesktopAppVersion = $null
Write-Step "Preflight"
Assert-Command git -Hint "https://git-scm.com/download/win"
Assert-Command bun -Hint "https://bun.sh (winget install Oven-sh.Bun)"
if ($DoDesktop) {
Assert-Command npx -Hint "Install Node.js (winget install OpenJS.NodeJS.LTS)"
Assert-Command rg -Hint "Install ripgrep (winget install BurntSushi.ripgrep.MSVC)"
}
if ($Channel -ne "prod") {
Write-Host "WARNING: channel=$Channel -> separate DB / Desktop userData; sessions won't match prod." -ForegroundColor Yellow
}
if (-not $RepoPath) { $RepoPath = Resolve-RepoPath }
if (-not $WorkRoot) { $WorkRoot = Join-Path ([IO.Path]::GetTempPath()) "opencode" }
if ($DoDesktop -and -not $DesktopInstall) { $DesktopInstall = Resolve-DesktopInstall }
if ($DoCli -and -not $CliNpmDir) { $CliNpmDir = Resolve-CliNpmDir }
# [IO.Path]::Combine, not Join-Path: a user-supplied path on a non-existent drive makes
# Join-Path throw before these explanatory messages ever print.
if (-not (Test-Path -LiteralPath ([IO.Path]::Combine($RepoPath, "packages\opencode\package.json")))) {
Fail @"
OpenCode source repo not found at: $RepoPath
Clone it first:
git clone https://github.com/anomalyco/opencode.git "$RepoPath"
Or point this script at an existing clone:
-RepoPath <path> (or set `$env:OPENCODE_SOURCE)
"@
}
if ($DoDesktop -and -not (Test-Path -LiteralPath ([IO.Path]::Combine($DesktopInstall, "resources\app.asar")))) {
Fail @"
OpenCode Desktop install not found at: $DesktopInstall
This script re-packs the app.asar of an existing Desktop install, so install the official
Desktop app once first, or pass -DesktopInstall <path>.
Use -Target cli to skip Desktop entirely.
"@
}
if ($DoCli -and -not (Test-Path -LiteralPath ([IO.Path]::Combine($CliNpmDir, "bin")))) {
Fail @"
opencode-ai npm package not found at: $CliNpmDir
The script overwrites the binary that the npm launcher shim runs, so install it once first:
npm i -g opencode-ai
Or pass -CliNpmDir <path>. Use -Target desktop to skip the CLI entirely.
"@
}
New-Item -ItemType Directory -Force -Path $WorkRoot | Out-Null
$script:Stamp = Get-Date -Format "yyyyMMdd-HHmmss"
if (-not $BranchName) {
$suffix = if ($SkipPr) { "latest" } else { "pr$PrNumber" }
$BranchName = "update-$Target-$suffix-$script:Stamp"
}
$versionOverride = $Version
Write-Host "Target: $Target"
Write-Host "Repo: $RepoPath"
Write-Host "Channel: $Channel"
Write-Host "Version: $(if ($versionOverride) { $versionOverride } else { '(auto from package.json after sync)' })"
Write-Host "PR: $(if ($SkipPr) { '(skipped)' } else { $PrNumber })"
Write-Host "Branch: $BranchName"
Write-Host "DryRun: $DryRun"
if ($DoDesktop) { Write-Host "Desktop: $DesktopInstall" }
if ($DoCli) { Write-Host "CLI npm: $CliNpmDir" }
Sync-RepoAndPortPr
# Version follows the tree we just checked out (origin/dev ± PR port).
if (-not $versionOverride) {
$pkgJson = Join-Path $RepoPath "packages\opencode\package.json"
if (-not (Test-Path -LiteralPath $pkgJson)) {
throw "Cannot auto-detect version: missing $pkgJson"
}
$baseVer = (Get-Content -LiteralPath $pkgJson -Raw | ConvertFrom-Json).version
if (-not $baseVer) { throw "packages/opencode/package.json has no version" }
# Strip any previous local suffix so re-runs stay clean: 1.19.0-pr29831 -> 1.19.0
$baseVer = ($baseVer -replace '-pr\d+$', '' -replace '-local.*$', '')
if ($SkipPr) {
$Version = $baseVer
} else {
$Version = "$baseVer-pr$PrNumber"
}
Write-Host "Auto version: $Version (from package.json $baseVer)"
} else {
$Version = $versionOverride
Write-Host "Version override: $Version"
}
if ($DoDesktop) {
# A local -pr prerelease sorts below the same official stable release, so it
# must not be used as Electron's app.getVersion() value. Keep the custom
# build identity in OPENCODE_VERSION / DESKTOP-BUILD.txt instead.
$script:DesktopAppVersion = if (-not $versionOverride -and -not $SkipPr) { $baseVer } else { $Version }
Write-Host "Desktop updater version: $($script:DesktopAppVersion)"
}
if ($DryRun) {
Write-Step "DryRun: stop before build/install"
Write-Host "Branch ready: $BranchName"
Write-Host "Resolved version: $Version"
Write-Host "Re-run without -DryRun to build and install."
return
}
if (-not $SkipInstall) {
Write-Step "bun install"
Invoke-Bun -BunArgs @("install") -Cwd $RepoPath
}
$envExtra = @{
OPENCODE_CHANNEL = $Channel
OPENCODE_VERSION = $Version
}
if ($DoDesktop) {
Write-Step "Build opencode node server (for desktop sidecar)"
Invoke-Bun -BunArgs @("script/build-node.ts") -Cwd (Join-Path $RepoPath "packages/opencode") -EnvExtra $envExtra
Write-Step "Build desktop (prebuild + electron-vite)"
$desktopPkg = Join-Path $RepoPath "packages/desktop"
Invoke-Bun -BunArgs @("run", "prebuild") -Cwd $desktopPkg -EnvExtra $envExtra
Invoke-Bun -BunArgs @("run", "build") -Cwd $desktopPkg -EnvExtra $envExtra
$outDir = Join-Path $desktopPkg "out"
$idx = Join-Path $outDir "main\index.js"
$rawLine = Select-String -LiteralPath $idx -Pattern 'const raw = ' | Select-Object -First 1
Write-Host "Desktop baked channel: $($rawLine.Line.Trim())"
if ($rawLine.Line -notmatch [regex]::Escape("`"$Channel`"")) {
throw "Expected OPENCODE_CHANNEL=$Channel in desktop index.js"
}
if (-not $SkipPr) {
$hit = rg -n "POST_EXIT_OUTPUT_IDLE_TIMEOUT" (Join-Path $outDir "main\chunks") 2>$null
if (-not $hit) { throw "Desktop chunks missing POST_EXIT_OUTPUT_IDLE_TIMEOUT" }
}
Install-DesktopAsar -OutDir $outDir
}
if ($DoCli) {
Write-Step "Build CLI binary (single platform, channel=$Channel)"
Invoke-Bun -BunArgs @("run", "script/build.ts", "--single", "--skip-install") `
-Cwd (Join-Path $RepoPath "packages/opencode") `
-EnvExtra $envExtra
$built = Join-Path $RepoPath "packages\opencode\dist\opencode-windows-x64\bin\opencode.exe"
if (-not (Test-Path -LiteralPath $built)) {
# fallback name patterns
$cand = Get-ChildItem (Join-Path $RepoPath "packages\opencode\dist") -Recurse -Filter "opencode.exe" |
Select-Object -First 1
if ($cand) { $built = $cand.FullName }
}
Install-CliBinary -BuiltExe $built
}
Write-Step "Done"
$head = (git -C $RepoPath rev-parse --short HEAD).Trim()
$lines = @(
"OpenCode update complete."
" target : $Target"
" channel : $Channel"
" version : $Version"
" branch : $BranchName"
" head : $head"
)
if ($DoDesktop -and $script:DesktopAsar) {
$lines += " desktop : $($script:DesktopAsar)"
$lines += " app ver : $($script:DesktopAppVersion) (Electron updater identity)"
$lines += " asar bak: $($script:DesktopBackup)"
$lines += " -> Fully quit and relaunch OpenCode Desktop."
}
if ($DoCli) {
if ($script:CliLive) {
$lines += " cli : $($script:CliLive)"
$lines += " cli old : $($script:CliOldRunning)"
}
if ($script:CliPending) {
$lines += " cli pend: $($script:CliPending) (rename-install failed; run with -ForceKillCli or close CLI then re-run)"
}
if ($script:CliSide) {
$lines += " cli side: $($script:CliSide)"
}
$lines += " -> Open a NEW terminal/session to use the new CLI (current session stays on old process)."
$lines += " -> With channel=prod, CLI uses opencode.db (same as Desktop)."
}
$lines += ""
$lines += "DB backups (manual): copy ~/.local/share/opencode/opencode*.db* first if unsure."
Write-Host ($lines -join "`n") -ForegroundColor Green |
|
This should be merged ASAP as its a deal breaker!!!!! @thdxr @rekram1-node |

What
Fix shell commands that hang after starting a background process.
Why
The command can finish while its child still keeps output open, leaving the agent waiting forever.
How
Return when the command exits, keep reading final output until it stays quiet across a 500ms check, and preserve cleanup for stuck children.
Tests cover detached children and delayed output.
Closes #24731
Closes #24784
Closes #20902
Closes #29822
Closes #25038
Closes #28697
Closes #25938