Uh oh!
There was an error while loading. Please reload this page.
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. Closesanomalyco#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.
Uh oh!
There was an error while loading. Please reload this page.
…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.
jliu666
commented
May 30, 2026
Is it ready to merge? Will it be included in the next release? |
gnahz77
commented
Jun 1, 2026
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? |
JustSidus
commented
Jun 12, 2026
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? |
asdasd070511
commented
Jun 14, 2026
same lol this is annoying shit |
asdasd070511
commented
Jun 17, 2026
Is this ready to be merged? Really looking forward to this fix. |
myavkat
commented
Jun 21, 2026
gradlew processes also hangs frequently on windows with powershell, looking forward to the merge! |
frodrd00
commented
Jul 1, 2026
? |
frasdav
commented
Jul 6, 2026
Would also love to see this merged ASAP! |
will-santiago
commented
Jul 21, 2026
Looking forward to this fix ASAP please ! |
asdasd070511
commented
Jul 21, 2026
mcxinyu
commented
Jul 27, 2026
Hi, @Hona some merge checked fail, Could you please take a look? |
asdasd070511
commented
Jul 27, 2026
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 -Version7<#.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")
functionWrite-Step([string]$Message) {
Write-Host""Write-Host"==> $Message"-ForegroundColor Cyan
}
functionFail([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""exit1
}
functionAssert-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) ----functionResolve-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 ($cin$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")
}
functionResolve-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=$nulltry { $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
}
functionResolve-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 1if ($hit) { return$hit.FullName }
}
return$direct
}
functionInvoke-Bun {
param(
[string[]]$BunArgs,
[string]$Cwd,
[hashtable]$EnvExtra=@{}
)
Push-Location-LiteralPath $Cwd$old=@{}
try {
foreach ($kin$EnvExtra.Keys) {
$old[$k] = [Environment]::GetEnvironmentVariable($k,"Process")
Set-Item-Path "Env:$k"-Value $EnvExtra[$k]
}
& bun @BunArgsif ($LASTEXITCODE-ne0) { throw"bun $($BunArgs-join'') failed (exit $LASTEXITCODE)" }
} finally {
foreach ($kin$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
}
}
functionStop-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."
}
}
functionStop-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
}
}
functionResolve-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
}
functionFix-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',$layerNodeStackif ($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
}
functionSync-RepoAndPortPr {
Write-Step"Sync repo"Push-Location-LiteralPath $RepoPathtry {
$dirty= git status --porcelain
if ($dirty) {
Write-Host"Working tree dirty; stashing..."
git stash push -u -m "update-opencode $script:Stamp"if ($LASTEXITCODE-ne0) { 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-ne0) { throw"git fetch origin dev failed" }
if (-not$SkipPr) {
git fetch origin "pull/$PrNumber/head:refs/pr/$PrNumber"if ($LASTEXITCODE-ne0) { throw"git fetch PR #$PrNumber failed" }
}
}
git checkout -B $BranchName origin/dev
if ($LASTEXITCODE-ne0) { 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-NullPush-Location-LiteralPath $RepoPathtry {
$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 ($relin$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=$LASTEXITCODEif ($code-lt0) { 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-gt0) {
$null=Resolve-ConflictsPreferOursDescription-Path $outWrite-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-ne0) { throw"git commit failed" }
}
} finally {
Pop-Location
}
}
functionInstall-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=$sideExereturn
}
}
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
}
functionInstall-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$origExtractif ($LASTEXITCODE-ne0) { 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 ($namein@("resources","node_modules")) {
$src=Join-Path$origExtract$nameif (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$asarPathif ($LASTEXITCODE-ne0) { 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$verifyif ($LASTEXITCODE-ne0) { throw"asar verify extract failed" }
$vIdx=Join-Path$verify"out\main\index.js"$vRaw=Select-String-LiteralPath $vIdx-Pattern 'const raw = '|Select-Object-First 1Write-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>$nullif (-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=$nullWrite-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: $RepoPathClone 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: $DesktopInstallThis script re-packs the app.asar of an existing Desktop install, so install the officialDesktop 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: $CliNpmDirThe script overwrites the binary that the npm launcher shim runs, so install it once first: npm i -g opencode-aiOr 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=$VersionWrite-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=$versionOverrideWrite-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=$ChannelOPENCODE_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 $envExtraWrite-Step"Build desktop (prebuild + electron-vite)"$desktopPkg=Join-Path$RepoPath"packages/desktop"Invoke-Bun-BunArgs @("run","prebuild") -Cwd $desktopPkg-EnvExtra $envExtraInvoke-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 1Write-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>$nullif (-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 1if ($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 |
axsaenz
commented
Aug 4, 2026
Hi @Hona, thank you for working on this. I investigated the same failure in detail and reproduced it twice on Windows 11 with PowerShell 5.1 and opencode-ai 1.18.11. The second reproduction provides a useful additional data point because it removes
I also reproduced the original This confirms two related problems in the current implementation:
The four-file scope of this PR makes sense: two production files and their tests. However, the branch now appears to be about 1,753 commits behind
Would you be willing to refresh this PR against current Concrete code-level changesI reviewed the current implementation and believe the refresh should make the following specific changes. 1. |
mcxinyu
commented
Aug 4, 2026
Let me submit the usage report. @axsaenz, I adopt the approach specified at #29831 (comment), synchronize code from the main branch of https://github.com/anomalyco/opencode and apply corresponding modifications on a daily basis. The issue can no longer be reproduced in my environment (Windows 11, PowerShell 7.6.3, running Gradle build commands). |
QuXile
commented
Aug 9, 2026
Looking forward to having this PR merged as soon as possible in the next release😊 |
yyueniao
commented
Aug 15, 2026
Hii the bot brought me here, I made a simpler PR that only make sure Since it's very thin, so I hope we can at least successfully merge that first. |
Hona
commented
Aug 15, 2026
try using opencode2 this bug is gone |
yyueniao
commented
Aug 18, 2026
I think it's still reasonable to fix this small issue at v1 since the latest stable version still 1.18.x |

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