# windows.ps1 -- repo-owned Windows Desktop update hand-off.
#
# WHY THIS EXISTS (the frozen-binary problem): the Desktop's Update button
# used to hand off exclusively to the staged Tauri binary
# (%HERMES_HOME%\hermes-setup.exe). That binary has no self-update path --
# copy_self_to_hermes_home deliberately no-ops during --update -- so every
# updater-side fix (cache refresh #67369, marker self-adopt #74782, straggler
# handling) only reaches users when a new installer is built, signed, and
# published. In practice binaries go months stale and users hit long-fixed
# bugs on every update (the 2026-08-09 incident chain).
#
# This script lives in the repo checkout, so EVERY `hermes update` refreshes
# the very code that drives the next update. The Desktop spawns it through a
# `cmd start` wrapper (see wrapHandoffForDetachedConsole in
# apps/desktop/electron/updater-process.ts -- a bare detached+hidden
# powershell dies before -File runs) and exits; only PowerShell itself -- an
# OS component -- is "frozen".
#
# CONTRACT (keep in sync with apps/desktop/electron/main.ts):
#   cmd /d /s /c start "" /min powershell -NoProfile -ExecutionPolicy Bypass
#     -File scripts\desktop-update\windows.ps1
#     -InstallRoot <path>   repo checkout (HERMES_HOME\hermes-agent)
#     -Branch <ref>         branch to update against
#     -DesktopPid <pid>     the Electron main process to wait out
#     [-RelaunchExe <path>] Hermes.exe to start when done (omit = no relaunch)
#     [-NoUi]               headless (tests); default shows a progress window
#     [-NoMarkerCleanup]    leave .hermes-update-in-progress in place (tests)
#
# SAFETY POSTURE: both preflight gates FAIL CLOSED. A Desktop that never
# exits, or a venv shim that never unlocks, aborts the hand-off without
# mutating the install -- a skipped update is recoverable, a half-updated
# venv is not. Every exit path (success, abort, crash) writes
# .hermes-update-result.json for the relaunched Desktop to surface, and
# relaunches the Desktop so the user is never left stranded.
#
# Marker: we claim HERMES_HOME\.hermes-update-in-progress with OUR pid as
# step 0 (the wrapper cmd.exe pid the Desktop saw is useless -- it exits
# immediately), retaining HERMES_UPDATE_STARTED_AT from the Desktop hand-off.
# hermes_cli/update_lock.py's ancestry rule lets our
# `hermes update` child adopt the claim; electron/update-marker.ts parks a
# relaunched Desktop on it. Cleanup only removes the marker while WE still
# own it (a handoff partner that rewrote it keeps its claim).

param(
    [string]$InstallRoot,
    [string]$Branch = "main",
    [int]$DesktopPid = 0,
    [string]$RelaunchExe = "",
    [switch]$NoUi,
    [switch]$NoMarkerCleanup,
    [switch]$SelfTestUi,
    [switch]$SelfTestPipeDrain,
    [switch]$SelfTestMarker
)

if (-not $SelfTestUi -and -not $SelfTestPipeDrain -and -not $InstallRoot) {
    # Mandatory in spirit; relaxed in the signature only so the self-test
    # switches can drive the UI / the pipe drain without a checkout.
    throw "-InstallRoot is required"
}

$ErrorActionPreference = "Continue"
# Foreground helpers: the script is spawned via `cmd start /min`, so its
# WinForms window comes up backgrounded unless we explicitly claim focus --
# and after the update we must hand focus TO the relaunched Desktop (a
# WMI-spawned process starts unfocused). AllowSetForegroundWindow lets us
# pass our foreground right on to the new Hermes.exe pid.
try {
    Add-Type -Namespace HermesHandoff -Name Win32 -MemberDefinition @'
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool AllowSetForegroundWindow(int dwProcessId);
[DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int nCmdShow);
'@ -ErrorAction Stop
    $script:Win32 = $true
} catch { $script:Win32 = $false }
# Render UTF-8 glyphs (checkmarks, arrows) correctly in our own console echo
# too; the legacy conhost default OEM codepage shows them as mojibake.
try {
    [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
    $OutputEncoding = [System.Text.Encoding]::UTF8
} catch {}
$TempDir = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() }
$HermesHome = if ($InstallRoot) { Split-Path -Parent $InstallRoot } else { $TempDir }
$MarkerPath = Join-Path $HermesHome ".hermes-update-in-progress"
$LogDir = Join-Path $HermesHome "logs"
$LogPath = Join-Path $LogDir "desktop-update-handoff.log"
$ResultPath = Join-Path $HermesHome ".hermes-update-result.json"
$script:Ui = $null
$script:UiStage = "Hermes will open once done."   # until the first gate; matches ui.html
$script:UiStopwatch = [System.Diagnostics.Stopwatch]::StartNew()

function Write-HandoffLog([string]$Message) {
    $line = "{0:yyyy-MM-ddTHH:mm:ssK} {1}" -f (Get-Date), $Message
    try { Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 } catch {}
    Write-Host $line
}

# ── The shim: repo-owned HTML in a chromeless default-browser app window ───
# The window is a veneer, not a participant: the update runs identically with
# or without it (default browser missing/failed degrades to the WinForms card below,
# then log-only). It never consumes child output; it polls /progress for the
# current hand-off stage or a terminal event and reacts. The loopback listener
# is not a web server in any meaningful sense; it exists because file:// pages
# cannot receive events from a detached process. Salvaged from the web-shell
# spike (Co-authored-by: teknium1), reshaped to the quiet update-surface
# contract (#75895/#83634): loader, one title, one line, no dashboard.
$script:UiState = [hashtable]::Synchronized(@{
    status     = "running"      # running | done | manual | error
    message    = $script:UiStage
    clock      = $script:UiStopwatch
})
$script:UiServer = $null     # @{ Listener; Runspace; PowerShell; Port; BrowserProc; Profile }

function Get-UiHtmlPath {
    # Lives next to this script in the checkout. Missing file = fall back to
    # WinForms (old checkouts mid-update, partial syncs).
    $p = Join-Path $PSScriptRoot "ui.html"
    if (Test-Path -LiteralPath $p) { return $p }
    return $null
}

function Get-DefaultBrowserExe {
    # The OS default browser, read from the UserChoice ProgId that the
    # Windows Settings app writes (https first, http as fallback). Only
    # Chromium-family browsers (ChromeHTML / MSEdgeHTM) support the
    # --app + --user-data-dir combo the shim relies on; any other
    # default browser returns $null and degrades to the WinForms card.
    $progId = $null
    foreach ($proto in @("https", "http")) {
        try {
            $progId = (Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\$proto\UserChoice" -Name ProgId -ErrorAction Stop).ProgId
        } catch { continue }
        if ($progId) { break }
    }
    if (-not $progId) { return $null }
    $family = switch ($progId) {
        "ChromeHTML" { "Google\Chrome\Application\chrome.exe" }
        "MSEdgeHTM"  { "Microsoft\Edge\Application\msedge.exe" }
        default      { $null }
    }
    if (-not $family) { return $null }
    # Exact path from the ProgId's open command first, then standard roots.
    try {
        $cmd = (Get-ItemProperty -Path "Registry::HKEY_CLASSES_ROOT\$progId\shell\open\command" -ErrorAction Stop).'(default)'
        if ($cmd -and $cmd -match '"([^"]+\.exe)"') {
            $exe = $Matches[1]
            if (Test-Path -LiteralPath $exe) { return $exe }
        }
    } catch {}
    foreach ($root in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:LOCALAPPDATA)) {
        if (-not $root) { continue }
        $p = Join-Path $root $family
        if (Test-Path -LiteralPath $p) { return $p }
    }
    return $null
}

function Start-UiServer([string]$HtmlPath) {
    # In-process HTTP on a loopback ephemeral port, served from a dedicated
    # runspace so the main thread never blocks on Accept. Plain TcpListener
    # instead of HttpListener: no URL ACL / netsh reservation semantics to
    # trip over, and two GET routes don't need more.
    try {
        $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
        $listener.Start()
        $port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port

        $rs = [runspacefactory]::CreateRunspace()
        $rs.Open()
        $rs.SessionStateProxy.SetVariable("Listener", $listener)
        $rs.SessionStateProxy.SetVariable("State", $script:UiState)
        $rs.SessionStateProxy.SetVariable("HtmlBytes", [System.IO.File]::ReadAllBytes($HtmlPath))

        $ps = [powershell]::Create()
        $ps.Runspace = $rs
        [void]$ps.AddScript({
            function Send-Response($Stream, [string]$Status, [string]$ContentType, [byte[]]$Body) {
                $head = "HTTP/1.1 $Status`r`nContent-Type: $ContentType`r`nContent-Length: $($Body.Length)`r`nCache-Control: no-store`r`nConnection: close`r`n`r`n"
                $headBytes = [System.Text.Encoding]::ASCII.GetBytes($head)
                $Stream.Write($headBytes, 0, $headBytes.Length)
                $Stream.Write($Body, 0, $Body.Length)
                $Stream.Flush()
            }
            while ($true) {
                try { $client = $Listener.AcceptTcpClient() } catch { break }  # Stop() ends the loop
                try {
                    $client.ReceiveTimeout = 2000
                    $stream = $client.GetStream()
                    $reader = [System.IO.StreamReader]::new($stream, [System.Text.Encoding]::ASCII, $false, 1024, $true)
                    $request = $reader.ReadLine()
                    # Drain headers so the client doesn't see a reset mid-send.
                    while ($true) { $h = $reader.ReadLine(); if ($null -eq $h -or $h -eq "") { break } }
                    if ($request -match "^GET /progress") {
                        $elapsed = [Math]::Floor($State.clock.Elapsed.TotalSeconds)
                        $snapshot = @{
                            status          = $State.status
                            message         = $State.message
                            elapsed_seconds = $elapsed
                        } | ConvertTo-Json -Compress
                        Send-Response $stream "200 OK" "application/json; charset=utf-8" ([System.Text.Encoding]::UTF8.GetBytes($snapshot))
                    } elseif ($request -match "^GET / ") {
                        Send-Response $stream "200 OK" "text/html; charset=utf-8" $HtmlBytes
                    } else {
                        Send-Response $stream "404 Not Found" "text/plain" ([System.Text.Encoding]::ASCII.GetBytes("not found"))
                    }
                } catch {
                    # Per-connection failure: drop it, keep serving.
                } finally {
                    try { $client.Close() } catch {}
                }
            }
        })
        [void]$ps.BeginInvoke()

        # Readiness handshake. BeginInvoke returns before the runspace has
        # opened its pipeline and JIT'd the script block — on a loaded machine
        # that is seconds, during which the kernel ACCEPTS connections into
        # the listener's backlog and nobody answers them. Anything that
        # trusted "listener bound" as "server serving" (the browser window
        # opening to a page that never loads; the -SelfTestUi URL that CI
        # polls) raced that gap. Prove one /progress round-trip before
        # handing the port out, so the URL means "serving", not "bound".
        $ready = $false
        $readyDeadline = [DateTime]::UtcNow.AddSeconds(15)
        while (-not $ready -and [DateTime]::UtcNow -lt $readyDeadline) {
            try {
                $probe = [System.Net.HttpWebRequest]::Create("http://127.0.0.1:$port/progress")
                $probe.Timeout = 1000
                $probe.ReadWriteTimeout = 1000
                $probe.KeepAlive = $false
                $resp = $probe.GetResponse()
                try { $ready = ([int]$resp.StatusCode -eq 200) } finally { $resp.Close() }
            } catch {
                Start-Sleep -Milliseconds 100
            }
        }
        if (-not $ready) {
            Write-HandoffLog "progress server did not answer /progress within 15s; continuing without UI"
            try { $listener.Stop() } catch {}
            try { $ps.Stop() } catch {}
            try { $rs.Close() } catch {}
            return $null
        }

        return @{ Listener = $listener; Runspace = $rs; PowerShell = $ps; Port = $port; BrowserProc = $null; Profile = $null }
    } catch {
        try { if ($listener) { $listener.Stop() } } catch {}
        return $null
    }
}

function Stop-UiServer([switch]$LeaveWindow) {
    if (-not $script:UiServer) { return }
    try { $script:UiServer.Listener.Stop() } catch {}
    try { $script:UiServer.PowerShell.Stop() } catch {}
    try { $script:UiServer.Runspace.Close() } catch {}
    # On success the window closes itself out from under the user (the whole
    # point); on error we LEAVE it — the page holds the failure state and the
    # user closes it when they've read it.
    if (-not $LeaveWindow) {
        try {
            if ($script:UiServer.BrowserProc -and -not $script:UiServer.BrowserProc.HasExited) {
                $script:UiServer.BrowserProc.CloseMainWindow() | Out-Null
            }
        } catch {}
    }
    # Best-effort removal of the dedicated browser profile dirs: this run's
    # profile plus any stale hermes-update-ui-* leftovers from interrupted
    # past runs. A browser that is still shutting down may hold the lock, in
    # which case the delete silently no-ops. Safe to sweep by prefix: the
    # update marker (.hermes-update-in-progress) serialises hand-offs, so no
    # other run's profile can be in active use here.
    try {
        $profileDirs = @()
        if ($script:UiServer.Profile) { $profileDirs += $script:UiServer.Profile }
        Get-ChildItem -LiteralPath $TempDir -Directory -Filter "hermes-update-ui-*" -ErrorAction SilentlyContinue |
            ForEach-Object { $profileDirs += $_.FullName }
        foreach ($dir in ($profileDirs | Select-Object -Unique)) {
            Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
        }
    } catch {}
    $script:UiServer = $null
}

function Publish-UiEvent([string]$Status, [string]$Message) {
    # The event the shim listens for. One beat of poll latency (400ms) before
    # teardown so the page actually renders the terminal state.
    $script:UiState.message = $Message
    $script:UiState.status = $Status
    if ($script:UiServer) { Start-Sleep -Milliseconds 900 }
}

function Get-UiElapsedText {
    $elapsed = [Math]::Floor($script:UiStopwatch.Elapsed.TotalSeconds)
    if ($elapsed -lt 60) { return "${elapsed}s elapsed" }
    $minutes = [Math]::Floor($elapsed / 60)
    $seconds = $elapsed % 60
    return "${minutes}m ${seconds}s elapsed"
}

function Get-UiProgressLine {
    return "$script:UiStage`r`n$(Get-UiElapsedText)"
}

function Publish-UiProgress([string]$Message) {
    # Stages come from the orchestrator's own control flow. Child stdout and
    # stderr remain asynchronously drained in Invoke-HermesStep and are never
    # read or parsed for UI updates.
    $script:UiStage = $Message
    $script:UiState.message = $Message
    $script:UiState.status = "running"
    if ($script:Ui) {
        try {
            $script:Ui.Sub.Text = Get-UiProgressLine
            [System.Windows.Forms.Application]::DoEvents()
        } catch {}
    }
}

# ── Fallback card (no Edge / no HTML): same shape in WinForms ──────────────
# Matches the shim pixel-for-pixel in spirit -- loader, one title, one live
# stage/elapsed line, OS light/dark -- so degrading is invisible to the user.
function Get-AppsUseLightTheme {
    try {
        $v = Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" -Name AppsUseLightTheme -ErrorAction Stop
        return [int]$v.AppsUseLightTheme -ne 0
    } catch { return $true }
}

function Show-ProgressWindow {
    if ($NoUi) { return }

    # ── Primary: the HTML shim in a chromeless default-browser app window ──
    # Same footprint as the card (280x320), spawned as a normal window: it
    # claims attention once by appearing, then competes with nothing.
    $htmlPath = Get-UiHtmlPath
    $browser = Get-DefaultBrowserExe
    if ($htmlPath -and $browser) {
        $server = Start-UiServer $htmlPath
        if ($server) {
            try {
                # Dedicated tiny profile dir: guarantees a NEW WINDOW + process
                # we own (a default-profile launch delegates to an existing
                # browser and returns instantly, leaving nothing to close), and
                # avoids touching the user's real browser profile.
                $browserProfile = Join-Path $TempDir ("hermes-update-ui-{0}" -f $PID)
                $browserArgs = @(
                    "--app=http://127.0.0.1:$($server.Port)/",
                    "--user-data-dir=$browserProfile",
                    "--no-first-run", "--no-default-browser-check",
                    "--disable-features=msImplicitSignin",
                    "--window-size=280,320"
                )
                $server.BrowserProc = Start-Process -FilePath $browser -ArgumentList $browserArgs -PassThru
                $server.Profile = $browserProfile
                $script:UiServer = $server
                Write-HandoffLog "shim: default-browser app window on 127.0.0.1:$($server.Port)"
                return
            } catch {
                try { $server.Listener.Stop() } catch {}
                # fall through to WinForms
            }
        }
    }

    try {
        Add-Type -AssemblyName System.Windows.Forms | Out-Null
        Add-Type -AssemblyName System.Drawing | Out-Null
        $light = Get-AppsUseLightTheme
        # Dark seeds are the settled installer palette: neutral charcoal,
        # never brand blue.
        if ($light) {
            $back = [System.Drawing.Color]::White
            $fore = [System.Drawing.ColorTranslator]::FromHtml("#1A1A1A")
            $mute = [System.Drawing.ColorTranslator]::FromHtml("#6B6B6B")
        } else {
            $back = [System.Drawing.ColorTranslator]::FromHtml("#232323")
            $fore = [System.Drawing.ColorTranslator]::FromHtml("#F5F5F5")
            $mute = [System.Drawing.ColorTranslator]::FromHtml("#A8A8A8")
        }
        $form = New-Object System.Windows.Forms.Form
        $form.Text = "Hermes"
        $form.FormBorderStyle = "FixedSingle"
        $form.MaximizeBox = $false
        $form.MinimizeBox = $false
        $form.ControlBox = $false
        $form.ClientSize = New-Object System.Drawing.Size(280, 320)
        $form.StartPosition = "CenterScreen"
        $form.BackColor = $back

        $bar = New-Object System.Windows.Forms.ProgressBar
        $bar.Style = "Marquee"
        $bar.MarqueeAnimationSpeed = 30
        $bar.SetBounds(60, 128, 160, 8)
        $title = New-Object System.Windows.Forms.Label
        $title.Text = "Updating Hermes"
        $title.Font = New-Object System.Drawing.Font("Segoe UI Semibold", 12)
        $title.ForeColor = $fore
        $title.TextAlign = "MiddleCenter"
        $title.SetBounds(16, 156, 248, 28)
        $sub = New-Object System.Windows.Forms.Label
        $sub.Text = Get-UiProgressLine
        $sub.Font = New-Object System.Drawing.Font("Segoe UI", 9)
        $sub.ForeColor = $mute
        $sub.TextAlign = "TopCenter"
        $sub.SetBounds(24, 190, 232, 48)
        $form.Controls.Add($bar)
        $form.Controls.Add($title)
        $form.Controls.Add($sub)
        $form.Show()
        # `cmd start /min` spawned us backgrounded, so the card comes up
        # behind everything without one explicit activation. Claim it ONCE
        # (so the user knows the update started), then never again — the
        # window is decoration and competes with nothing (no TopMost).
        try {
            $form.Activate()
            if ($script:Win32) { [HermesHandoff.Win32]::SetForegroundWindow($form.Handle) | Out-Null }
        } catch {}
        [System.Windows.Forms.Application]::DoEvents()
        $script:Ui = [pscustomobject]@{ Form = $form; Bar = $bar; Title = $title; Sub = $sub; Timer = $null }
        $timer = New-Object System.Windows.Forms.Timer
        $timer.Interval = 1000
        $timer.Add_Tick({
            if ($script:Ui -and $script:Ui.Sub) {
                $script:Ui.Sub.Text = Get-UiProgressLine
            }
        })
        $script:Ui.Timer = $timer
        $timer.Start()
    } catch {
        # Headless session / WinForms unavailable: degrade to log-only.
        $script:Ui = $null
    }
}

function Show-ErrorFinale([string]$Message) {
    # Terse by design: a title + the debug-share pointer. No error text, no
    # log tail -- `hermes debug share` uploads the real evidence and the
    # relaunched Desktop surfaces the result message.
    if ($script:UiServer) {
        # The shim renders the error state itself; leave the window up for
        # the user to read and close. Nothing to hold for — the page keeps
        # the state after the listener dies.
        Publish-UiEvent "error" $Message
        Stop-UiServer -LeaveWindow
        return
    }
    if (-not $script:Ui) { return }
    try {
        $ui = $script:Ui
        if ($ui.Timer) { $ui.Timer.Stop() }
        $ui.Bar.Visible = $false
        $ui.Title.Text = "Failed to update"
        $ui.Sub.Text = "Run `"hermes debug share`" in a terminal to send a report."
        $close = New-Object System.Windows.Forms.Button
        $close.Text = "Close"
        $close.SetBounds(100, 252, 80, 28)
        $close.FlatStyle = "Flat"
        $close.ForeColor = $ui.Title.ForeColor
        $script:ErrorDismissed = $false
        $close.Add_Click({ $script:ErrorDismissed = $true })
        $ui.Form.Controls.Add($close)
        $ui.Form.AcceptButton = $close
        try {
            $ui.Form.Activate()
            if ($script:Win32) { [HermesHandoff.Win32]::SetForegroundWindow($ui.Form.Handle) | Out-Null }
        } catch {}
        # Hold for dismissal so the failure is actually seen, but never park
        # forever -- the marker is already cleaned up and the relaunched
        # Desktop re-surfaces the failure, so walking away costs nothing.
        $deadline = (Get-Date).AddMinutes(5)
        while (-not $script:ErrorDismissed -and (Get-Date) -lt $deadline -and $ui.Form.Visible) {
            [System.Windows.Forms.Application]::DoEvents()
            Start-Sleep -Milliseconds 100
        }
    } catch {}
}

function Show-ManualFinale([string]$Message) {
    # Update landed but the Desktop did not verifiably come back. Same terse
    # shape as the error finale, success glyph semantics: the shim renders
    # `manual` itself; the WinForms card swaps its copy. Held so the user
    # actually sees the instruction — this window is the only surface until
    # they reopen Hermes themselves.
    if ($script:UiServer) {
        Publish-UiEvent "manual" $Message
        Stop-UiServer -LeaveWindow
        return
    }
    if (-not $script:Ui) { return }
    try {
        $ui = $script:Ui
        if ($ui.Timer) { $ui.Timer.Stop() }
        $ui.Bar.Visible = $false
        $ui.Title.Text = "Update complete"
        $ui.Sub.Text = $Message
        $close = New-Object System.Windows.Forms.Button
        $close.Text = "Close"
        $close.SetBounds(100, 252, 80, 28)
        $close.FlatStyle = "Flat"
        $close.ForeColor = $ui.Title.ForeColor
        $script:ErrorDismissed = $false
        $close.Add_Click({ $script:ErrorDismissed = $true })
        $ui.Form.Controls.Add($close)
        $ui.Form.AcceptButton = $close
        try {
            $ui.Form.Activate()
            if ($script:Win32) { [HermesHandoff.Win32]::SetForegroundWindow($ui.Form.Handle) | Out-Null }
        } catch {}
        $deadline = (Get-Date).AddMinutes(5)
        while (-not $script:ErrorDismissed -and (Get-Date) -lt $deadline -and $ui.Form.Visible) {
            [System.Windows.Forms.Application]::DoEvents()
            Start-Sleep -Milliseconds 100
        }
    } catch {}
}

function Close-ProgressWindow {
    if ($script:UiServer) {
        # Success event: the shim flips to the checkmark, then the window
        # closes out from under the user as the Desktop comes back.
        Publish-UiEvent "done" ""
        Stop-UiServer
    }
    if ($script:Ui) {
        try {
            if ($script:Ui.Timer) {
                $script:Ui.Timer.Stop()
                $script:Ui.Timer.Dispose()
            }
            $script:Ui.Form.Close()
        } catch {}
        $script:Ui = $null
    }
}

function Write-Result([bool]$Ok, [int]$Code, [string]$Message, [bool]$ManualAction = $false) {
    # Consumed (read + deleted) by the relaunched Desktop on boot so the
    # user actually SEES how a detached update ended. $ManualAction marks an
    # ok result the user still must act on -- the Desktop surfaces those in
    # a dialog, not just the log (same protocol as posix.sh).
    try {
        $obj = @{
            ok         = $Ok
            exit_code  = $Code
            manual     = $ManualAction
            message    = $Message
            branch     = $Branch
            finished_at = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
        } | ConvertTo-Json -Compress
        [System.IO.File]::WriteAllText($ResultPath, $obj)
    } catch {}
}

function Remove-MarkerIfOwned {
    if ($NoMarkerCleanup) { return }
    try {
        if (Test-Path -LiteralPath $MarkerPath) {
            $firstLine = (Get-Content -LiteralPath $MarkerPath -TotalCount 1 -ErrorAction SilentlyContinue)
            if ("$firstLine".Trim() -eq "$PID") {
                Remove-Item -LiteralPath $MarkerPath -Force -ErrorAction SilentlyContinue
                Write-HandoffLog "removed update marker (owned)"
            } else {
                Write-HandoffLog "leaving update marker: owned by pid '$firstLine', not us ($PID)"
            }
        }
    } catch {}
}

function Start-DesktopRelaunch {
    # Returns $true only when a launch VERIFIABLY happened (WMI accepted and
    # the pid exists, or the fallback spawn returned a live process). The
    # finally block downgrades the on-screen/on-disk outcome when it didn't
    # — the sibling truth contract to posix.sh's launch acceptance.
    if (-not $RelaunchExe) { return $false }
    # electron-builder replaces win-unpacked in place. After a successful
    # update it can remove the old Hermes.exe before writing the replacement,
    # so a one-shot existence check races the rebuild and strands the user.
    $relaunchDeadline = (Get-Date).AddSeconds(120)
    while (-not (Test-Path -LiteralPath $RelaunchExe)) {
        if ((Get-Date) -ge $relaunchDeadline) {
            Write-HandoffLog "WARNING: desktop relaunch executable did not reappear within 120s: $RelaunchExe"
            return $false
        }
        Start-Sleep -Milliseconds 500
        if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() }
    }
    Write-HandoffLog "relaunching desktop: $RelaunchExe"
    # DO NOT spawn Hermes.exe as our child: Electron/Chromium calls
    # AttachConsole(ATTACH_PARENT_PROCESS) at boot, so a Desktop launched
    # directly from this console PowerShell latches onto OUR console --
    # the console window then outlives the script (it can't close while
    # an attached process lives), and closing it kills the freshly
    # relaunched GUI with it. Create the process via WMI instead: the
    # parent becomes WmiPrvSE.exe and there is no console to inherit or
    # attach -- same detachment explorer.exe gives a normal launch.
    $spawned = $false
    try {
        $workDir = Split-Path -Parent $RelaunchExe
        $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
            CommandLine      = ('"{0}"' -f $RelaunchExe)
            CurrentDirectory = $workDir
        } -ErrorAction Stop
        if ($r -and $r.ReturnValue -eq 0) {
            Write-HandoffLog "desktop relaunched detached (pid $($r.ProcessId))"
            $spawned = $true
            # Hand our foreground rights to the new Desktop and focus its
            # main window once it exists. A WMI-spawned process starts
            # unfocused, and Windows only lets the CURRENT foreground
            # owner (us, while the progress window is up / just closed)
            # delegate that right. Poll briefly for the window: Electron
            # takes a couple seconds to create it.
            try {
                if ($script:Win32) {
                    [HermesHandoff.Win32]::AllowSetForegroundWindow([int]$r.ProcessId) | Out-Null
                    $deadline = (Get-Date).AddSeconds(20)
                    while ((Get-Date) -lt $deadline) {
                        $hwnd = [System.IntPtr]::Zero
                        try {
                            $p = Get-Process -Id $r.ProcessId -ErrorAction Stop
                            $hwnd = $p.MainWindowHandle
                        } catch {
                            # Process died before showing a window — that is a
                            # failed launch, not merely an unfocused one.
                            Write-HandoffLog "WARNING: relaunched desktop exited before its window appeared"
                            $spawned = $false
                            break
                        }
                        if ($hwnd -ne [System.IntPtr]::Zero) {
                            [HermesHandoff.Win32]::ShowWindow($hwnd, 9) | Out-Null  # SW_RESTORE
                            [HermesHandoff.Win32]::SetForegroundWindow($hwnd) | Out-Null
                            Write-HandoffLog "focused relaunched desktop window"
                            break
                        }
                        Start-Sleep -Milliseconds 400
                    }
                }
            } catch {
                Write-HandoffLog "WARNING: could not focus relaunched desktop: $($_.Exception.Message)"
            }
        } else {
            Write-HandoffLog "WARNING: WMI relaunch returned $($r.ReturnValue); falling back"
        }
    } catch {
        Write-HandoffLog "WARNING: WMI relaunch failed: $($_.Exception.Message); falling back"
    }
    if (-not $spawned) {
        # Middle rung: explorer.exe-mediated launch. On some machines
        # Win32_Process.Create fails outright (observed ReturnValue 8,
        # "unknown failure"), and the tethered fallback below re-attaches the
        # Desktop to this console — its stdout then floods the console and the
        # window can't close while the app lives. Explorer re-parents the
        # target exactly like a normal shell launch, giving the same
        # no-console detachment WMI would have. Explorer returns no pid, so
        # verify by watching for a fresh Hermes process.
        try {
            $exeName = [System.IO.Path]::GetFileNameWithoutExtension($RelaunchExe)
            $before = @(Get-Process -Name $exeName -ErrorAction SilentlyContinue | ForEach-Object { $_.Id })
            Start-Process -FilePath 'explorer.exe' -ArgumentList ('"{0}"' -f $RelaunchExe) | Out-Null
            $explorerDeadline = (Get-Date).AddSeconds(15)
            while ((Get-Date) -lt $explorerDeadline) {
                $fresh = @(Get-Process -Name $exeName -ErrorAction SilentlyContinue | Where-Object { $before -notcontains $_.Id })
                if ($fresh.Count -gt 0) {
                    Write-HandoffLog "desktop relaunched detached via explorer (pid $($fresh[0].Id))"
                    $spawned = $true
                    # Same foreground hand-off as the WMI rung: the new process
                    # starts unfocused and only the current foreground owner
                    # (us) can delegate that right.
                    try {
                        if ($script:Win32) {
                            [HermesHandoff.Win32]::AllowSetForegroundWindow([int]$fresh[0].Id) | Out-Null
                            $focusDeadline = (Get-Date).AddSeconds(20)
                            while ((Get-Date) -lt $focusDeadline) {
                                $hwnd = [System.IntPtr]::Zero
                                try { $hwnd = (Get-Process -Id $fresh[0].Id -ErrorAction Stop).MainWindowHandle } catch { break }
                                if ($hwnd -ne [System.IntPtr]::Zero) {
                                    [HermesHandoff.Win32]::ShowWindow($hwnd, 9) | Out-Null  # SW_RESTORE
                                    [HermesHandoff.Win32]::SetForegroundWindow($hwnd) | Out-Null
                                    Write-HandoffLog "focused relaunched desktop window"
                                    break
                                }
                                Start-Sleep -Milliseconds 400
                            }
                        }
                    } catch {
                        Write-HandoffLog "WARNING: could not focus relaunched desktop: $($_.Exception.Message)"
                    }
                    break
                }
                Start-Sleep -Milliseconds 400
                if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() }
            }
            if (-not $spawned) {
                Write-HandoffLog "WARNING: explorer relaunch did not produce a $exeName process; falling back"
            }
        } catch {
            Write-HandoffLog "WARNING: explorer relaunch failed: $($_.Exception.Message); falling back"
        }
    }
    if (-not $spawned) {
        try {
            # Fallback keeps the old behavior (console tie-in and all) --
            # a tethered Desktop beats no Desktop.
            $p = Start-Process -FilePath $RelaunchExe -WorkingDirectory (Split-Path -Parent $RelaunchExe) -PassThru
            Start-Sleep -Milliseconds 1500
            if ($p -and -not $p.HasExited) { $spawned = $true }
            elseif ($p) { Write-HandoffLog "WARNING: fallback relaunch exited immediately" }
        } catch {
            Write-HandoffLog "WARNING: desktop relaunch failed: $($_.Exception.Message)"
        }
    }
    return $spawned
}

# How long a step's pipes get to reach EOF AFTER the step process itself has
# exited (#90455). This is not a step timeout -- the step is already gone by
# the time the clock starts, and everything it wrote is sitting in the pipe
# buffer ready to read, so the grace only has to cover the final drain.
#
# It exists because pipe EOF is not the child's to give. Windows hands the
# write end of a redirected pipe to the child as an INHERITABLE handle, so
# every descendant that is spawned without its own redirection gets a
# duplicate -- and the read side does not see EOF until the last of them
# closes it. `hermes update` deliberately runs its build steps with stdout
# inherited (hermes_cli/main.py, the tee-stderr runner), so the tree under a
# step is arbitrarily deep and not something this script can enumerate. When
# one of those descendants is a resident gateway, the pipe stays open for the
# life of the gateway, i.e. forever.
#
# Overridable so the pipe-drain self-test does not have to sit out the real
# grace; not documented as a user knob.
$script:StepDrainGraceSeconds = 20
if ($env:HERMES_UPDATE_PIPE_DRAIN_SECONDS) {
    $parsedGrace = 0
    if ([int]::TryParse($env:HERMES_UPDATE_PIPE_DRAIN_SECONDS, [ref]$parsedGrace) -and $parsedGrace -ge 0) {
        $script:StepDrainGraceSeconds = $parsedGrace
    }
}

# A live step also needs a ceiling. The pipe-drain bound above only starts
# after the child exits, so it cannot recover a child that completed its visible
# work and then parks forever inside finalization (#95589). Silence is only the
# cancellation trigger, never evidence that the process tree is safe to overlap:
# every step is assigned to a private, non-breakaway Windows job and a timed-out
# step is retryable only after that job reports zero active processes.
$script:StepIdleTimeoutSeconds = 600
if ($env:HERMES_UPDATE_STEP_IDLE_SECONDS) {
    $parsedIdle = 0
    if ([int]::TryParse($env:HERMES_UPDATE_STEP_IDLE_SECONDS, [ref]$parsedIdle) -and $parsedIdle -gt 0) {
        $script:StepIdleTimeoutSeconds = $parsedIdle
    }
}

# Silence on the pipes is NOT silence in the update. `hermes update` captures
# the (very loud) Electron/vite build into logs/update.log instead of its own
# stdout (hermes_cli/update_cmd.py, the update-log tee), so a real update is
# routinely stdout-silent for 40+ minutes while demonstrably progressing. An
# idle ceiling that watched only stdout/stderr would cancel every healthy
# large update at StepIdleTimeoutSeconds. The drain therefore also counts
# growth of this file (size or mtime) as progress before declaring a stall.
# Overridable so the pipe-drain self-test can point it at its own file; not
# documented as a user knob.
$script:StepProgressLogPath = Join-Path $LogDir "update.log"
if ($env:HERMES_UPDATE_PROGRESS_LOG) {
    $script:StepProgressLogPath = $env:HERMES_UPDATE_PROGRESS_LOG
}

function Get-StepProgressLogStamp {
    # Size + mtime fingerprint of the update log; $null when absent or
    # unreadable. Comparing fingerprints between passes is how the idle
    # watchdog sees a build that streams to update.log instead of stdout.
    try {
        $fi = New-Object System.IO.FileInfo($script:StepProgressLogPath)
        if (-not $fi.Exists) { return $null }
        return ('{0}:{1}' -f $fi.Length, $fi.LastWriteTimeUtc.Ticks)
    } catch {
        return $null
    }
}

if (-not ("HermesUpdateJob" -as [type])) {
    Add-Type -TypeDefinition @'
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using Microsoft.Win32.SafeHandles;

public static class HermesUpdateJob {
    public sealed class StartedProcess {
        public Process Process;
        public StreamReader StandardOutput;
        public StreamReader StandardError;
        public IntPtr Job;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct SecurityAttributes {
        public int Length;
        public IntPtr SecurityDescriptor;
        public bool InheritHandle;
    }

    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    private struct StartupInfo {
        public int Size;
        public string Reserved;
        public string Desktop;
        public string Title;
        public int X;
        public int Y;
        public int XSize;
        public int YSize;
        public int XCountChars;
        public int YCountChars;
        public int FillAttribute;
        public int Flags;
        public short ShowWindow;
        public short Reserved2;
        public IntPtr Reserved2Ptr;
        public IntPtr StdInput;
        public IntPtr StdOutput;
        public IntPtr StdError;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct ProcessInformation {
        public IntPtr Process;
        public IntPtr Thread;
        public int ProcessId;
        public int ThreadId;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct BasicAccountingInformation {
        public long TotalUserTime;
        public long TotalKernelTime;
        public long ThisPeriodTotalUserTime;
        public long ThisPeriodTotalKernelTime;
        public uint TotalPageFaultCount;
        public uint TotalProcesses;
        public uint ActiveProcesses;
        public uint TotalTerminatedProcesses;
    }

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern IntPtr CreateJobObject(IntPtr attributes, string name);

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool CreatePipe(out IntPtr read, out IntPtr write, ref SecurityAttributes attributes, int size);

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool SetHandleInformation(IntPtr handle, int mask, int flags);

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern bool CreateProcess(
        string applicationName, StringBuilder commandLine,
        IntPtr processAttributes, IntPtr threadAttributes, bool inheritHandles,
        int creationFlags, IntPtr environment, string currentDirectory,
        ref StartupInfo startupInfo, out ProcessInformation processInformation
    );

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern uint ResumeThread(IntPtr thread);

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool TerminateProcess(IntPtr process, uint exitCode);

    [DllImport("kernel32.dll")]
    private static extern IntPtr GetStdHandle(int standardHandle);

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool TerminateJobObject(IntPtr job, uint exitCode);

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool QueryInformationJobObject(
        IntPtr job,
        int informationClass,
        out BasicAccountingInformation information,
        uint informationLength,
        IntPtr returnLength
    );

    [DllImport("kernel32.dll")]
    private static extern bool CloseHandle(IntPtr handle);

    public static StartedProcess StartAssigned(string executable, string arguments) {
        IntPtr job = IntPtr.Zero;
        IntPtr outRead = IntPtr.Zero, outWrite = IntPtr.Zero;
        IntPtr errRead = IntPtr.Zero, errWrite = IntPtr.Zero;
        ProcessInformation pi = new ProcessInformation();
        try {
            job = CreateJobObject(IntPtr.Zero, null);
            if (job == IntPtr.Zero) throw new InvalidOperationException("CreateJobObject failed");
            SecurityAttributes sa = new SecurityAttributes();
            sa.Length = Marshal.SizeOf(typeof(SecurityAttributes));
            sa.InheritHandle = true;
            if (!CreatePipe(out outRead, out outWrite, ref sa, 0) ||
                !CreatePipe(out errRead, out errWrite, ref sa, 0))
                throw new InvalidOperationException("CreatePipe failed");
            if (!SetHandleInformation(outRead, 1, 0) || !SetHandleInformation(errRead, 1, 0))
                throw new InvalidOperationException("SetHandleInformation failed");

            StartupInfo si = new StartupInfo();
            si.Size = Marshal.SizeOf(typeof(StartupInfo));
            si.Flags = 0x00000100; // STARTF_USESTDHANDLES
            si.StdInput = GetStdHandle(-10);
            si.StdOutput = outWrite;
            si.StdError = errWrite;
            StringBuilder commandLine = new StringBuilder("\"" + executable + "\" " + arguments);
            if (!CreateProcess(executable, commandLine, IntPtr.Zero, IntPtr.Zero, true,
                    0x00000004 | 0x08000000, IntPtr.Zero, null, ref si, out pi))
                throw new InvalidOperationException("CreateProcess failed");
            if (!AssignProcessToJobObject(job, pi.Process)) {
                TerminateProcess(pi.Process, 1);
                throw new InvalidOperationException("AssignProcessToJobObject failed");
            }

            Process process = Process.GetProcessById(pi.ProcessId);
            // Force Process to open its own stable query handle before the raw
            // CreateProcess handle is closed; PS 5.1 otherwise reports a null
            // ExitCode after fast children have already disappeared.
            IntPtr stableProcessHandle = process.Handle;
            StreamReader stdout = new StreamReader(new FileStream(
                new SafeFileHandle(outRead, true), FileAccess.Read, 4096, false), Encoding.UTF8);
            StreamReader stderr = new StreamReader(new FileStream(
                new SafeFileHandle(errRead, true), FileAccess.Read, 4096, false), Encoding.UTF8);
            outRead = IntPtr.Zero;
            errRead = IntPtr.Zero;
            CloseHandle(outWrite); outWrite = IntPtr.Zero;
            CloseHandle(errWrite); errWrite = IntPtr.Zero;
            if (ResumeThread(pi.Thread) == 0xffffffff)
                throw new InvalidOperationException("ResumeThread failed");
            return new StartedProcess { Process = process, StandardOutput = stdout, StandardError = stderr, Job = job };
        } catch {
            if (pi.Process != IntPtr.Zero) TerminateProcess(pi.Process, 1);
            if (job != IntPtr.Zero) CloseHandle(job);
            throw;
        } finally {
            if (pi.Thread != IntPtr.Zero) CloseHandle(pi.Thread);
            if (pi.Process != IntPtr.Zero) CloseHandle(pi.Process);
            if (outRead != IntPtr.Zero) CloseHandle(outRead);
            if (outWrite != IntPtr.Zero) CloseHandle(outWrite);
            if (errRead != IntPtr.Zero) CloseHandle(errRead);
            if (errWrite != IntPtr.Zero) CloseHandle(errWrite);
        }
    }

    public static bool TerminateAndWait(IntPtr job, uint exitCode, int timeoutMs) {
        if (job == IntPtr.Zero || !TerminateJobObject(job, exitCode)) return false;
        Stopwatch clock = Stopwatch.StartNew();
        BasicAccountingInformation information;
        do {
            if (!QueryInformationJobObject(
                    job, 1, out information,
                    (uint)Marshal.SizeOf(typeof(BasicAccountingInformation)),
                    IntPtr.Zero)) return false;
            if (information.ActiveProcesses == 0) return true;
            Thread.Sleep(50);
        } while (clock.ElapsedMilliseconds < timeoutMs);
        return false;
    }

    public static void Close(IntPtr job) {
        if (job != IntPtr.Zero) CloseHandle(job);
    }
}
'@
}

function Step-PipeDrain($Reader, [ref]$Task, $Buffer, $Sink, [ref]$Moved) {
    # Advance one redirected pipe by whatever has already arrived, without
    # ever blocking. Returns $true once the pipe has reached EOF (or its read
    # faulted), $false while more may still come. Sets $Moved when this call
    # actually consumed bytes, so the caller can tell a busy pipe from a quiet
    # one and skip its idle wait.
    #
    # The chunked ReadAsync loop is the point: ReadToEndAsync().Result cannot
    # hand back a partial read, so abandoning it loses the whole step's output.
    # Draining into a StringBuilder means an abandoned pipe still yields every
    # byte that arrived before we gave up.
    if ($null -eq $Task.Value) { return $true }
    if (-not $Task.Value.IsCompleted) { return $false }
    $count = 0
    try {
        $count = $Task.Value.Result
    } catch {
        # Faulted/cancelled read: treat as EOF rather than retrying forever.
        $Task.Value = $null
        return $true
    }
    if ($count -le 0) { $Task.Value = $null; return $true }
    [void]$Sink.Append($Buffer, 0, $count)
    $Moved.Value = $true
    $Task.Value = $Reader.ReadAsync($Buffer, 0, $Buffer.Length)
    return $false
}

function Invoke-HermesStep([string]$Exe, [string[]]$HermesArgs, [string]$Tag) {
    # The window does not stream child output, so no line-pump: both pipes
    # drain asynchronously (no deadlock however chatty the child) while a small
    # DoEvents loop keeps the marquee animating through long silent
    # stretches (pip installs) -- the old EndOfStream pump blocked on quiet
    # children and froze it. Full output still lands in the hand-off log
    # afterwards, where `hermes debug share` picks it up.
    #
    # The drain is bounded once the step exits (#90455). Waiting for pipe EOF
    # is waiting on the step's whole surviving descendant tree, and this
    # function sits upstream of every terminal obligation the hand-off has --
    # .hermes-update-result.json, clearing .hermes-update-in-progress,
    # relaunching the Desktop. One resident grandchild holding an inherited
    # handle used to strand all three and leave the Desktop on "Updating
    # Hermes" until the user killed something by hand. Losing the tail of a
    # log is the strictly better failure.
    # System.Diagnostics.Process directly: Start-Process's .ExitCode is
    # unreliably $null under PS 5.1 even with the Handle-touch workaround.
    # CREATE_SUSPENDED closes the startup race: no updater instruction can run
    # before the process is assigned to its private job and resumed.
    $arguments = ($HermesArgs | ForEach-Object { '"{0}"' -f ($_ -replace '"', '\"') }) -join ' '
    # CreateProcess inherits this process's environment. Set Python's encoding
    # and buffering only for the atomic launch, then restore the hand-off host.
    $savedPythonIoEncoding = $env:PYTHONIOENCODING
    $savedPythonUtf8 = $env:PYTHONUTF8
    $savedPythonUnbuffered = $env:PYTHONUNBUFFERED
    try {
        $env:PYTHONIOENCODING = "utf-8"
        $env:PYTHONUTF8 = "1"
        $env:PYTHONUNBUFFERED = "1"
        $started = [HermesUpdateJob]::StartAssigned($Exe, $arguments)
    } finally {
        if ($null -eq $savedPythonIoEncoding) { Remove-Item Env:PYTHONIOENCODING -ErrorAction SilentlyContinue } else { $env:PYTHONIOENCODING = $savedPythonIoEncoding }
        if ($null -eq $savedPythonUtf8) { Remove-Item Env:PYTHONUTF8 -ErrorAction SilentlyContinue } else { $env:PYTHONUTF8 = $savedPythonUtf8 }
        if ($null -eq $savedPythonUnbuffered) { Remove-Item Env:PYTHONUNBUFFERED -ErrorAction SilentlyContinue } else { $env:PYTHONUNBUFFERED = $savedPythonUnbuffered }
    }
    $proc = $started.Process
    $stdoutReader = $started.StandardOutput
    $stderrReader = $started.StandardError
    $job = $started.Job
    # A job gives cancellation a kernel-enforced tree boundary. We deliberately
    # do NOT set KILL_ON_JOB_CLOSE: successful updates may start detached
    # services that are meant to outlive this pipe reader. Descendants cannot
    # break away from a default job, but survive when its handle is closed after
    # a normal step.

    $outSink = New-Object System.Text.StringBuilder
    $errSink = New-Object System.Text.StringBuilder
    $outBuffer = New-Object char[] 16384
    $errBuffer = New-Object char[] 16384
    $outTask = $stdoutReader.ReadAsync($outBuffer, 0, $outBuffer.Length)
    $errTask = $stderrReader.ReadAsync($errBuffer, 0, $errBuffer.Length)
    $abandonAt = $null
    $abandoned = $false
    $lastProgressAt = Get-Date
    $progressLogStamp = Get-StepProgressLogStamp
    $stalled = $false
    while ($true) {
        $moved = $false
        $outDone = Step-PipeDrain $stdoutReader ([ref]$outTask) $outBuffer $outSink ([ref]$moved)
        $errDone = Step-PipeDrain $stderrReader ([ref]$errTask) $errBuffer $errSink ([ref]$moved)
        if ($moved) { $lastProgressAt = Get-Date }
        if ($proc.HasExited) {
            if ($outDone -and $errDone) { break }
            # Clock starts at the step's exit, not at its start: a slow step is
            # not a stuck one, and only a pipe outliving its process is.
            if ($null -eq $abandonAt) {
                $abandonAt = (Get-Date).AddSeconds($script:StepDrainGraceSeconds)
            } elseif ((Get-Date) -ge $abandonAt) {
                $abandoned = $true
                break
            }
        } elseif (-not $stalled -and $job -ne [IntPtr]::Zero -and ((Get-Date) - $lastProgressAt).TotalSeconds -ge $script:StepIdleTimeoutSeconds) {
            # Quiet pipes are how a healthy `hermes update` looks for 40+
            # minutes: its build output streams to logs/update.log, not the
            # child's stdout. Growth of that file is progress -- reset the
            # clock instead of cancelling. Stat'd only once the ceiling is
            # otherwise reached (at most once per 150ms pass after that), so
            # the hot drain path never touches the filesystem.
            $currentLogStamp = Get-StepProgressLogStamp
            if ($currentLogStamp -ne $progressLogStamp) {
                $progressLogStamp = $currentLogStamp
                $lastProgressAt = Get-Date
            } else {
                # The child is alive but has produced no observable progress
                # -- neither on its pipes nor in the update log -- for the
                # whole bound. Terminate the job, not just its direct process:
                # retrying while a descendant still mutates the checkout,
                # venv, or release tree can overlap two installers and
                # corrupt the install.
                Write-HandoffLog ("{0}!| step stalled: no stdout/stderr for {1}s and no update.log growth while pid {2} remained alive; cancelling its process tree." -f $Tag, $script:StepIdleTimeoutSeconds, $proc.Id)
                $stalled = [HermesUpdateJob]::TerminateAndWait($job, 124, 10000)
                if (-not $stalled) {
                    Write-HandoffLog ("{0}!| process-tree cancellation could not prove quiescence; refusing the timeout retry." -f $Tag)
                    $script:TreeSafeToFinalize = $false
                    [HermesUpdateJob]::Close($job)
                    throw "Unable to quiesce stalled update process tree"
                }
            }
        }
        # Only idle when both pipes came up empty this pass, and idle on the
        # reads themselves rather than on the clock.
        #
        # Sleeping after a chunk that DID arrive meters the drain at one buffer
        # per tick (16 KiB / 150ms ~ 107 KB/s), and because the pipe then backs
        # up that is backpressure on the running step, not just a slow read --
        # a chatty step blocks on write() waiting for us. Waiting for EOF and
        # trickling toward it are two ways to make a fast step slow, and this
        # function is upstream of the hand-off's obligations either way.
        #
        # A flat sleep is not enough on its own: a freshly issued ReadAsync is
        # rarely complete by the very next pass, so the loop would sleep 150ms
        # between chunks anyway. WaitAny returns the instant either pipe has
        # something (and immediately if one already does), and expires on its
        # own so a silent step still animates the marquee and still advances
        # the abandon deadline.
        if (-not $moved) {
            $live = @($outTask, $errTask) | Where-Object { $null -ne $_ }
            if ($live.Count -gt 0) {
                [void][System.Threading.Tasks.Task]::WaitAny([System.Threading.Tasks.Task[]]$live, 150)
            } else {
                Start-Sleep -Milliseconds 150
            }
        }
        if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() }
    }
    # Bounded overload deliberately: the argument-less overload also waits on
    # redirected streams, which is the very wait we just bounded. HasExited is
    # already true here, so this call only settles ExitCode.
    [void]$proc.WaitForExit(5000)
    if ($abandoned) {
        Write-HandoffLog ("{0}!| pipe drain abandoned after {1}s: '{0}' exited but a surviving descendant still holds its stdout/stderr handles. Continuing the hand-off with the output captured so far (#90455)." -f $Tag, $script:StepDrainGraceSeconds)
    }
    $outText = $outSink.ToString()
    $errText = $errSink.ToString()
    foreach ($ln in ($outText -split "`r?`n")) {
        if ($ln.Trim()) { Write-HandoffLog ("{0}| {1}" -f $Tag, $ln) }
    }
    foreach ($ln in ($errText -split "`r?`n")) {
        if ($ln.Trim()) { Write-HandoffLog ("{0}!| {1}" -f $Tag, $ln) }
    }
    $all = $outText
    if ($errText) { $all += "`n" + $errText }
    $code = if ($stalled) { 124 } else { $proc.ExitCode }
    [HermesUpdateJob]::Close($job)
    return @{ Code = $code; Output = $all; TreeQuiesced = (-not $stalled -or $proc.HasExited); StartedAfterJobAssignment = $true }
}

$finalCode = 1
$finalMsg = "update did not complete"
$script:TreeSafeToFinalize = $true

# ── -SelfTestUi: drive the shim to both terminal states, no update ─────────
# Manual QA for the Edge shell without a checkout or a real update. Exits
# before the marker/desktop/venv machinery — touches nothing. Off Windows
# (or without Edge) the loopback server still starts and the URL prints, so
# the page can be QA'd in any browser; HERMES_SELFTEST_FAIL=1 exercises the
# error state, HERMES_SELFTEST_HOLD_SECONDS delays the terminal event.
if ($SelfTestUi) {
    New-Item -ItemType Directory -Path $LogDir -Force -ErrorAction SilentlyContinue | Out-Null
    Show-ProgressWindow
    if (-not $script:UiServer) {
        $htmlPath = Get-UiHtmlPath
        if ($htmlPath) {
            $script:UiServer = Start-UiServer $htmlPath
        }
    }
    if ($script:UiServer) {
        Write-Host "SELF-TEST: shim at http://127.0.0.1:$($script:UiServer.Port)/"
    }
    Write-HandoffLog "SELF-TEST: shim simulation (no update will run)"
    $hold = 6
    if ($env:HERMES_SELFTEST_HOLD_SECONDS) { $hold = [int]$env:HERMES_SELFTEST_HOLD_SECONDS }
    Publish-UiProgress "Testing quiet update"
    Start-Sleep -Seconds $hold
    if ($env:HERMES_SELFTEST_FAIL) {
        Show-ErrorFinale "self-test error state"
    } else {
        Close-ProgressWindow
    }
    exit 0
}

# -SelfTestPipeDrain: prove Invoke-HermesStep survives a leaked pipe ------
# The #90455 deadlock needs no update, no checkout and no Hermes install to
# reproduce -- only a step whose grandchild outlives it holding the inherited
# write end of the redirected pipe. That is exactly what this builds, so the
# fix has an executable proof on Windows instead of a source-grep. Exits
# before any marker/desktop machinery, same as -SelfTestUi; touches nothing
# but its own temp files.
#
# Three arms cover the independent wait modes:
#
#   leak  -- a step whose grandchild outlives it. Guards the #90455 deadlock:
#            the drain must abandon rather than wait out the descendant.
#   flood -- a chatty step that leaks nothing. Guards the other cliff: a drain
#            that idles after every chunk it reads is metered at one buffer per
#            tick, which backpressures the running step. Waiting for EOF and
#            trickling toward it are both ways to make a fast step slow.
#   stall -- a step that remains alive after its visible work and emits no more
#            output. Guards #95589: the hand-off must terminate it and reach its
#            retry/finally recovery rather than strand the Desktop.
#   logstall -- a step that is silent on its pipes but keeps growing the
#            update log, the shape of every real `hermes update` build (output
#            goes to logs/update.log, not stdout, for 40+ minutes). Guards the
#            watchdog's other cliff: the idle ceiling must count update.log
#            growth as progress and must NOT kill the healthy step.
if ($SelfTestPipeDrain) {
    New-Item -ItemType Directory -Path $LogDir -Force -ErrorAction SilentlyContinue | Out-Null
    $hold = 60
    if ($env:HERMES_SELFTEST_HOLD_SECONDS) { $hold = [int]$env:HERMES_SELFTEST_HOLD_SECONDS }
    $floodKb = 8192
    if ($env:HERMES_SELFTEST_FLOOD_KB) { $floodKb = [int]$env:HERMES_SELFTEST_FLOOD_KB }
    # $PSHOME is this interpreter's own directory -- no hardcoded system path.
    $powershell = Join-Path $PSHOME "powershell.exe"
    $stamp = [Guid]::NewGuid().ToString("N")
    $childPs1 = Join-Path $TempDir "hermes-pipe-drain-$stamp.ps1"
    $floodPs1 = Join-Path $TempDir "hermes-pipe-flood-$stamp.ps1"
    $pidFile = Join-Path $TempDir "hermes-pipe-drain-$stamp.pid"
    $stallPs1 = Join-Path $TempDir "hermes-step-stall-$stamp.ps1"
    $stallPidFile = Join-Path $TempDir "hermes-step-stall-$stamp.pid"
    $stallGrandchildPidFile = Join-Path $TempDir "hermes-step-stall-grandchild-$stamp.pid"
    $logStallPs1 = Join-Path $TempDir "hermes-step-logstall-$stamp.ps1"
    $logStallProgress = Join-Path $TempDir "hermes-step-logstall-$stamp.update.log"
    # UseShellExecute=$false with no redirection is what makes the grandchild
    # inherit our stdout/stderr -- the whole point of the fixture. Anything
    # that redirects (Start-Process, subprocess with stdout=DEVNULL) would
    # close the handle and the deadlock would not reproduce.
    $childSource = @'
param([int]$Hold, [string]$PidFile)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = Join-Path $PSHOME "powershell.exe"
$psi.Arguments = "-NoProfile -Command Start-Sleep -Seconds $Hold"
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$grandchild = [System.Diagnostics.Process]::Start($psi)
[System.IO.File]::WriteAllText($PidFile, [string]$grandchild.Id)
Write-Output "pipe-drain step output"
[Console]::Out.Flush()
exit 7
'@
    # Writes straight to the console stream, holding nothing: a step that is
    # merely loud. `hermes update` is this shape -- the Electron/vite build
    # alone is megabytes. Few large lines rather than many small ones on
    # purpose: Write-HandoffLog is one Add-Content per line and runs inside the
    # measured window, so line-heavy output would time the logger instead of
    # the drain.
    $floodSource = @'
param([int]$Kb)
$chunk = "x" * (131072 - 1)
for ($i = 0; $i -lt [Math]::Ceiling($Kb / 128); $i++) { [Console]::Out.Write($chunk + "`n") }
[Console]::Out.Flush()
exit 5
'@
    $stallSource = @'
param([int]$Hold, [string]$PidFile, [string]$GrandchildPidFile)
[System.IO.File]::WriteAllText($PidFile, [string]$PID)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = Join-Path $PSHOME "powershell.exe"
$psi.Arguments = "-NoProfile -Command Start-Sleep -Seconds $Hold"
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$grandchild = [System.Diagnostics.Process]::Start($psi)
[System.IO.File]::WriteAllText($GrandchildPidFile, [string]$grandchild.Id)
Write-Output "step entered silent finalization"
[Console]::Out.Flush()
Start-Sleep -Seconds $Hold
exit 0
'@
    # Pipe-silent but log-writing: one stdout line, then only Add-Content to
    # the progress log every second. With Hold far above the idle ceiling,
    # surviving to exit 3 proves the watchdog counted the log growth.
    $logStallSource = @'
param([int]$Hold, [string]$ProgressLog)
Write-Output "silent but logging"
[Console]::Out.Flush()
for ($i = 0; $i -lt $Hold; $i++) { Add-Content -LiteralPath $ProgressLog -Value ("build tick {0}" -f $i); Start-Sleep -Seconds 1 }
exit 3
'@
    [System.IO.File]::WriteAllText($childPs1, $childSource)
    [System.IO.File]::WriteAllText($floodPs1, $floodSource)
    [System.IO.File]::WriteAllText($stallPs1, $stallSource)
    [System.IO.File]::WriteAllText($logStallPs1, $logStallSource)
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    $res = Invoke-HermesStep $powershell @(
        "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $childPs1,
        "-Hold", [string]$hold, "-PidFile", $pidFile
    ) "pipedrain"
    $sw.Stop()
    $elapsed = [Math]::Round($sw.Elapsed.TotalSeconds, 2)

    $leakPid = 0
    if (Test-Path -LiteralPath $pidFile) {
        [void][int]::TryParse((Get-Content -LiteralPath $pidFile -Raw).Trim(), [ref]$leakPid)
    }
    $leakAlive = $false
    if ($leakPid -gt 0) {
        $leakAlive = [bool](Get-Process -Id $leakPid -ErrorAction SilentlyContinue)
        Stop-Process -Id $leakPid -Force -ErrorAction SilentlyContinue
    }

    $floodSw = [System.Diagnostics.Stopwatch]::StartNew()
    $flood = Invoke-HermesStep $powershell @(
        "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $floodPs1,
        "-Kb", [string]$floodKb
    ) "pipeflood"
    $floodSw.Stop()
    $floodElapsed = [Math]::Round($floodSw.Elapsed.TotalSeconds, 2)
    $floodBytes = $flood.Output.Length

    $stallSw = [System.Diagnostics.Stopwatch]::StartNew()
    $stall = Invoke-HermesStep $powershell @(
        "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $stallPs1,
        "-Hold", [string]$hold, "-PidFile", $stallPidFile,
        "-GrandchildPidFile", $stallGrandchildPidFile
    ) "stepstall"
    $stallSw.Stop()
    $stallElapsed = [Math]::Round($stallSw.Elapsed.TotalSeconds, 2)
    $stallPid = 0
    if (Test-Path -LiteralPath $stallPidFile) {
        [void][int]::TryParse((Get-Content -LiteralPath $stallPidFile -Raw).Trim(), [ref]$stallPid)
    }
    $stallAlive = $stallPid -gt 0 -and [bool](Get-Process -Id $stallPid -ErrorAction SilentlyContinue)
    if ($stallAlive) { Stop-Process -Id $stallPid -Force -ErrorAction SilentlyContinue }
    $stallGrandchildPid = 0
    if (Test-Path -LiteralPath $stallGrandchildPidFile) {
        [void][int]::TryParse((Get-Content -LiteralPath $stallGrandchildPidFile -Raw).Trim(), [ref]$stallGrandchildPid)
    }
    $stallGrandchildAlive = $stallGrandchildPid -gt 0 -and [bool](Get-Process -Id $stallGrandchildPid -ErrorAction SilentlyContinue)
    if ($stallGrandchildAlive) { Stop-Process -Id $stallGrandchildPid -Force -ErrorAction SilentlyContinue }

    # logstall arm: point the watchdog's progress log at the fixture's file
    # for exactly this step, restore afterwards so the other arms' contract
    # (no update.log in play) is untouched.
    $savedProgressLogPath = $script:StepProgressLogPath
    $script:StepProgressLogPath = $logStallProgress
    $logStallSw = [System.Diagnostics.Stopwatch]::StartNew()
    try {
        $logstall = Invoke-HermesStep $powershell @(
            "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $logStallPs1,
            "-Hold", [string]$hold, "-ProgressLog", $logStallProgress
        ) "logstall"
    } finally {
        $script:StepProgressLogPath = $savedProgressLogPath
    }
    $logStallSw.Stop()
    $logStallElapsed = [Math]::Round($logStallSw.Elapsed.TotalSeconds, 2)

    Remove-Item -LiteralPath $childPs1, $floodPs1, $stallPs1, $logStallPs1, $pidFile, $stallPidFile, $stallGrandchildPidFile, $logStallProgress -Force -ErrorAction SilentlyContinue

    # The grandchild still being alive at return is what makes this a proof
    # rather than a timing coincidence: the pipe was demonstrably still open.
    $budget = $script:StepDrainGraceSeconds + 30
    # A sleep-per-chunk drain moves 16 KiB/150ms ~ 107 KB/s, so 8 MiB takes
    # ~76s. Generous enough for a loaded CI runner, far under the trickle.
    $floodBudget = 25
    $problems = @()
    if (-not $leakAlive) { $problems += "handle-holding grandchild was not alive on return (fixture did not reproduce the leak)" }
    if ($elapsed -ge $budget) { $problems += "leak arm returned in ${elapsed}s, over the ${budget}s budget" }
    if ($res.Code -ne 7) { $problems += "leak arm exit code $($res.Code), expected 7" }
    if ($res.Output -notmatch "pipe-drain step output") { $problems += "leak arm step output was lost" }
    if ($floodElapsed -ge $floodBudget) { $problems += "flood arm returned in ${floodElapsed}s, over the ${floodBudget}s budget -- the drain is metering itself, which backpressures the step" }
    if ($flood.Code -ne 5) { $problems += "flood arm exit code $($flood.Code), expected 5" }
    if ($floodBytes -lt ($floodKb * 1024)) { $problems += "flood arm captured $floodBytes bytes of $($floodKb * 1024)" }
    $stallBudget = $script:StepIdleTimeoutSeconds + 30
    if ($stallElapsed -ge $stallBudget) { $problems += "stall arm returned in ${stallElapsed}s, over the ${stallBudget}s budget" }
    if ($stall.Code -ne 124) { $problems += "stall arm exit code $($stall.Code), expected 124" }
    if ($stall.Output -notmatch "step entered silent finalization") { $problems += "stall arm step output was lost" }
    if ($stallAlive) { $problems += "stalled child pid $stallPid remained alive after Invoke-HermesStep returned" }
    if ($stallGrandchildAlive) { $problems += "stalled descendant pid $stallGrandchildPid remained alive after Invoke-HermesStep returned" }
    if (-not $stall.TreeQuiesced) { $problems += "stall arm returned without proving its process tree quiescent" }
    if (-not $stall.StartedAfterJobAssignment) { $problems += "stall arm started before cancellation-job assignment" }
    $logStallBudget = $hold + 60
    if ($logstall.Code -ne 3) { $problems += "logstall arm exit code $($logstall.Code), expected 3 -- the idle watchdog killed a pipe-silent step whose progress was visible as update.log growth (the shape of every real 40+ min build)" }
    if ($logstall.Output -notmatch "silent but logging") { $problems += "logstall arm step output was lost" }
    if ($logStallElapsed -ge $logStallBudget) { $problems += "logstall arm returned in ${logStallElapsed}s, over the ${logStallBudget}s budget" }

    $detail = "leak: elapsed=${elapsed}s budget=${budget}s code=$($res.Code) grandchildAlive=$leakAlive | flood: ${floodKb}KB in ${floodElapsed}s budget=${floodBudget}s bytes=$floodBytes code=$($flood.Code) | stall: elapsed=${stallElapsed}s budget=${stallBudget}s code=$($stall.Code) childAlive=$stallAlive descendantAlive=$stallGrandchildAlive quiesced=$($stall.TreeQuiesced) | logstall: elapsed=${logStallElapsed}s budget=${logStallBudget}s code=$($logstall.Code)"
    if ($problems.Count -gt 0) {
        Write-Host "PIPE-DRAIN SELF-TEST: FAIL $detail -- $($problems -join '; ')"
        exit 1
    }
    Write-Host "PIPE-DRAIN SELF-TEST: PASS $detail"
    exit 0
}

try {
    New-Item -ItemType Directory -Path $LogDir -Force -ErrorAction SilentlyContinue | Out-Null
    Remove-Item -LiteralPath $ResultPath -Force -ErrorAction SilentlyContinue
    Show-ProgressWindow
    Write-HandoffLog "hand-off start: root=$InstallRoot branch=$Branch desktopPid=$DesktopPid pid=$PID"

    # -- 0. Claim the update marker with OUR pid ---------------------------
    try {
        $epoch = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
        $startedAt = 0L
        $hasStartedAt = [int64]::TryParse($env:HERMES_UPDATE_STARTED_AT, [ref]$startedAt)
        if (-not $hasStartedAt -or $startedAt -gt $epoch -or ($epoch - $startedAt) -gt 1200) {
            $startedAt = $epoch
        }
        # WriteAllText for byte-exact LF framing: Set-Content emits CRLF and
        # the marker contract (Rust/TS/Python readers) is "<pid>\n<ts>\n".
        [System.IO.File]::WriteAllText($MarkerPath, "$PID`n$startedAt`n")
        Write-HandoffLog "claimed update marker (pid $PID)"
    } catch {
        Write-HandoffLog "WARNING: could not write update marker: $($_.Exception.Message)"
    }

    if ($SelfTestMarker) {
        $finalCode = 0
        $finalMsg = "marker self-test complete"
        exit 0
    }

    # -- 1. Wait for the Desktop to exit (FAIL CLOSED) ----------------------
    Publish-UiProgress "Waiting for Hermes to close"
    if ($DesktopPid -gt 0) {
        $deadline = (Get-Date).AddSeconds(30)
        while ((Get-Date) -lt $deadline) {
            $proc = Get-Process -Id $DesktopPid -ErrorAction SilentlyContinue
            if (-not $proc) { break }
            Start-Sleep -Milliseconds 300
            if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() }
        }
        if (Get-Process -Id $DesktopPid -ErrorAction SilentlyContinue) {
            # A live Desktop means a live backend re-locking the venv at any
            # moment. Updating under it is how installs brick. Abort.
            $finalCode = 4
            $finalMsg = "Update aborted: the Hermes window (pid $DesktopPid) did not exit within 30s. Nothing was changed. Close Hermes fully and try again."
            Write-HandoffLog $finalMsg
            exit $finalCode
        }
        Write-HandoffLog "desktop exited"
    }

    # -- 2. Wait for the venv shim to unlock (FAIL CLOSED) ------------------
    Publish-UiProgress "Preparing Hermes files"
    $shim = Join-Path $InstallRoot "venv\Scripts\hermes.exe"
    if (Test-Path -LiteralPath $shim) {
        $unlocked = $false
        $deadline = (Get-Date).AddSeconds(20)
        while ((Get-Date) -lt $deadline) {
            try {
                $fs = [System.IO.File]::Open($shim, 'Open', 'ReadWrite', 'None')
                $fs.Close()
                $unlocked = $true
                break
            } catch {
                Start-Sleep -Milliseconds 400
                if ($script:Ui) { [System.Windows.Forms.Application]::DoEvents() }
            }
        }
        if (-not $unlocked) {
            # Something still maps the venv. --force-ing past it guarantees a
            # half-updated venv (the exact 2026-08-09 Access-denied brick).
            $finalCode = 5
            $finalMsg = "Update aborted: another process is still holding the Hermes install open (venv\Scripts\hermes.exe locked after 20s). Nothing was changed. Close other Hermes windows/terminals and try again."
            Write-HandoffLog $finalMsg
            exit $finalCode
        }
        Write-HandoffLog "venv shim unlocked"
    }

    # -- 3. Run the update from the CURRENT checkout ------------------------
    # --force skips only the hermes.exe shim guard, which step 2 just PROVED
    # is unlocked; the venv-python holder guard (orphan reap included) stays
    # active. Our marker claim is adopted by the child via update_lock.py's
    # process-ancestry rule.
    #
    # DRIVE THE UPDATE THROUGH venv\Scripts\python.exe, NOT venv\Scripts\hermes.exe.
    # `uv pip install -e .` has to replace the console-script shims, so
    # _quarantine_running_hermes_exe must first rename the running hermes.exe
    # out of the way. On Windows that rename fails whenever ANY child process
    # spawned from that hermes.exe is still alive: a child inherits a handle on
    # the parent image, and the resulting sharing violation is indistinguishable
    # from a user leaving a second Hermes window open. It is the inherited
    # handle, not the trampoline itself, that pins the file -- killing the child
    # makes the same rename succeed immediately, and the shim flavour (uv
    # trampoline vs distlib launcher) makes no difference.
    #
    # The updater reliably spawns such children itself (npx cache warm, memory
    # provider refresh -- hindsight-api runs as a daemon with --idle-timeout
    # 300 and outlives the step that started it), so this is a race, not a
    # deterministic failure: the same hand-off succeeds on one run and dies on
    # the next. Step 2's preflight cannot catch it, because the shim genuinely
    # IS unlocked at that moment.
    #
    # When the rename loses that race there is no recovery: `uv pip install -e .`
    # exits 2 and the ZIP fallback repeats the identical sequence, so the desktop
    # build stage is never reached and apps/desktop/release is left missing -- an
    # install whose Start Menu shortcut points at a Hermes.exe that no longer
    # exists. (A reboot-deferred rename was the old last resort here; it needed
    # elevation a Desktop-driven update does not have, and freed nothing for the
    # install already in flight.)
    #
    # Running the same code as `python.exe -m hermes_cli.main update` puts the
    # inherited handles on python.exe, which uv never has to replace.
    #
    # posix.sh is deliberately left alone: unlinking a running executable is
    # legal there, so the equivalent call is harmless.
    $pythonExe = Join-Path $InstallRoot "venv\Scripts\python.exe"
    if (-not (Test-Path -LiteralPath $pythonExe)) {
        $finalCode = 3
        $finalMsg = "Update aborted: $pythonExe is missing. The install needs repair (run the Hermes installer or `hermes doctor`)."
        Write-HandoffLog $finalMsg
        exit $finalCode
    }
    $updateArgs = @("-m", "hermes_cli.main", "update", "--yes", "--gateway", "--force", "--branch", $Branch)
    # --keep-stash: never re-apply local source edits after the update (they
    # stay parked in git stash). Probe --help first: the flag ships with newer
    # backends and an unknown flag would abort argparse with exit 2, which
    # collides with the "close all Hermes windows" sentinel.
    try {
        $updateHelp = & $pythonExe -m hermes_cli.main update --help 2>$null | Out-String
        if ($updateHelp -match "--keep-stash") {
            $updateArgs += "--keep-stash"
        } else {
            Write-HandoffLog "installed hermes predates --keep-stash; running without it"
        }
    } catch {
        Write-HandoffLog "could not probe update --help; running without --keep-stash"
    }
    Write-HandoffLog ("running: python " + ($updateArgs -join " "))
    Publish-UiProgress "Updating code and dependencies"
    $res = Invoke-HermesStep $pythonExe $updateArgs "update"
    Write-HandoffLog "hermes update exit code: $($res.Code)"

    $retryPolicyPath = Join-Path $PSScriptRoot "retry-policy.ps1"
    if (Test-Path -LiteralPath $retryPolicyPath) {
        . $retryPolicyPath
        $shouldRetry = Test-HermesUpdateShouldRetry -ExitCode $res.Code -InstallRoot $InstallRoot
    } else {
        # The child may have swapped to a checkout without the companion policy
        # while this older script is still running in memory. Preserve the
        # previous fail-closed behavior instead of calling an undefined function.
        Write-HandoffLog "retry policy is unavailable after checkout swap; using legacy retry rules"
        $shouldRetry = $res.Code -ne 0 -and $res.Code -ne 2
    }
    if ($shouldRetry) {
        # One retry for update-boundary failures. Most exit-2 safety refusals
        # remain terminal, but self-lock deferral also uses exit 2 and writes
        # .update-incomplete after the code swap. That marker is only a retry
        # signal here: the fresh process's early-recovery pass finishes core
        # dependency sync before native modules load, then `update` continues
        # the remaining Desktop/skills stages of the full pipeline.
        Write-HandoffLog "first attempt left retryable update state; retrying once in a fresh process"
        Publish-UiProgress "Retrying update"
        $res = Invoke-HermesStep $pythonExe $updateArgs "update"
        Write-HandoffLog "retry exit code: $($res.Code)"
    }

    # -- 4. Truthful completion: don't trust exit 0 -------------------------
    # `hermes update` treats a Desktop GUI build failure as NON-fatal (prints
    # a one-line warning, exits 0). For a Desktop-DRIVEN update that warning
    # is fatal: we would relaunch the old exe and call it success. Detect it,
    # retry the build once, and propagate honestly.
    $desktopBuildFailed = $false
    if ($res.Code -eq 0 -and $res.Output -match "Desktop build failed") {
        Write-HandoffLog "hermes update reported a desktop build failure (non-fatal there, fatal here); retrying build"
        Publish-UiProgress "Rebuilding Desktop"
        $rebuild = Invoke-HermesStep $pythonExe @("-m", "hermes_cli.main", "desktop", "--force-build", "--build-only") "rebuild"
        Write-HandoffLog "desktop rebuild exit code: $($rebuild.Code)"
        if ($rebuild.Code -ne 0) { $desktopBuildFailed = $true }
    }

    if ($res.Code -eq 0 -and -not $desktopBuildFailed) {
        $finalCode = 0
        $finalMsg = "Update complete."
    } elseif ($desktopBuildFailed) {
        $finalCode = 6
        $finalMsg = "Code and dependencies updated, but the Desktop app REBUILD FAILED - you are running the previous build. Run `hermes desktop --force-build` from a terminal to retry."
    } else {
        $finalCode = $res.Code
        $finalMsg = "Update failed (exit $($res.Code)). Run `hermes debug share` in a terminal to send a report."
    }
    exit $finalCode
} finally {
    # Truth ordering (sibling contract to posix.sh finish()):
    #   1. durable result + marker removal (the relaunched Desktop consumes
    #      the result on boot and must not park on our marker);
    #   2. attempt the relaunch and require ACCEPTANCE;
    #   3. only then the terminal UI state — done means "Hermes is back",
    #      manual means "it is not, reopen it", error is error (and still
    #      tries to bring the app back after showing itself).
    if (-not $script:TreeSafeToFinalize) {
        # A failed job termination means a mutating descendant may still own
        # checkout/install files. Preserve the marker and do not relaunch into
        # that unknown state. This is intentionally fail-closed; the marker's
        # dead-owner recovery remains the next-start escape hatch.
        $finalCode = 7
        $finalMsg = "Update recovery could not stop every updater process. Hermes was not restarted to avoid overlapping the active install. Wait for it to finish or restart Windows, then reopen Hermes."
        Write-Result $false $finalCode $finalMsg
        Write-HandoffLog $finalMsg
        Show-ErrorFinale $finalMsg
        Close-ProgressWindow
    } else {
        Write-Result ($finalCode -eq 0) $finalCode $finalMsg
        Remove-MarkerIfOwned
        if ($finalCode -ne 0) {
            Show-ErrorFinale $finalMsg
            Close-ProgressWindow
            [void](Start-DesktopRelaunch)
        } else {
            Publish-UiProgress "Opening Hermes"
            $cameBack = Start-DesktopRelaunch
            if (-not $cameBack -and $RelaunchExe) {
                # Launch was due and did not verifiably land: truthful result
                # for the next boot, manual state held on screen now.
                $finalMsg = "Update complete. Reopen Hermes to finish (it could not restart itself)."
                Write-Result $true 0 $finalMsg $true
                Show-ManualFinale $finalMsg
            }
            Close-ProgressWindow
        }
    }
}
