Assets/greenroom-watchdog.ps1
|
# SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 Tyler Vigario <# Supervisor for one greenroom instance. Requirement: the session is not allowed to be down. If the Windows Terminal window is closed accidentally instead of detached, the session must come back within a second or two, without any window ever appearing. Design: - This watchdog is itself launched hidden by greenroom-watchdog.vbs (wscript is GUI-subsystem, and Run(cmd,0,False) passes SW_HIDE at process creation). - It launches the session as a Windows Terminal window that is ALSO born hidden, via Start-Process -WindowStyle Hidden. WT honours it, and the hidden window can still be revealed later by greenroom.ps1. - It watches the claude.exe PID directly. Get-Process -Id is cheap enough to poll once a second; the expensive CIM query only runs on a restart. Windows Terminal is required rather than conhost: conhost does no font fallback, and no console-registerable font contains the glyphs the TUI draws. MULTI-INSTANCE: every process lookup is filtered on '--remote-control <name>' so several instances can be supervised on one host without stealing each other's sessions. A watchdog that matched bare '--remote-control' would adopt whichever session it saw first and then fight the other watchdog over it. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$Instance) $ErrorActionPreference = 'Continue' $stateDir = Join-Path $env:USERPROFILE ".claude\greenroom\$Instance" if (-not (Test-Path $stateDir)) { New-Item -ItemType Directory -Path $stateDir -Force | Out-Null } $log = Join-Path $stateDir 'watchdog.log' function Log($m) { "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff') $m" | Add-Content -Path $log -Encoding UTF8 # keep the log from growing without bound across months of uptime $item = Get-Item $log -ErrorAction SilentlyContinue if ($item -and $item.Length -gt 512KB) { $tail = Get-Content $log -Tail 500 Set-Content -Path $log -Value $tail -Encoding UTF8 } } # SINGLE-INSTANCE GUARD. # # Exactly one watchdog may supervise an instance. Two is not merely redundant: # when the session dies they both see it inside the same 1s poll and both call # Start-RcSession, producing two Windows Terminal windows carrying the SAME # --name. Resolution then finds two matches, refuses, and attach breaks # permanently with nothing on screen to explain it. # # This is easy to reach by accident. Stop-ScheduledTask is a no-op against this # architecture -- the task runs wscript.exe, which spawns the watchdog detached # and returns, so the task sits at Ready with nothing left to stop. A plain # Start-ScheduledTask therefore ADDS a supervisor. Measured on the reference # host: 1 watchdog, Stop-ScheduledTask, still 1, Start-ScheduledTask, 2. # # A named mutex rather than a process scan, deliberately. Scanning races: two # watchdogs starting together can both look, both see nothing, and both proceed. # The mutex is a kernel object, so the check and the claim are one atomic step. # It is also self-cleaning -- if the holder is killed, its handle closes and the # claim is released, so a crashed watchdog never locks the instance out. # # Local\ not Global\: instances run in the interactive user's session, and # Global\ would let one user's watchdog block another's on a shared machine. $mutexName = "Local\greenroom-watchdog-$Instance" $script:mutex = New-Object System.Threading.Mutex($false, $mutexName) $acquired = $false try { $acquired = $script:mutex.WaitOne(0) } catch [System.Threading.AbandonedMutexException] { # The previous holder died without releasing. That means we DID acquire it, # and the instance is genuinely unsupervised right now. $acquired = $true Log 'previous watchdog terminated without releasing its claim -- taking over' } if (-not $acquired) { Log "another watchdog already supervises '$Instance' -- this one (pid $PID) is exiting" exit 0 } $cfgPath = Join-Path $stateDir 'config.json' if (-not (Test-Path $cfgPath)) { Log "FATAL: no config at $cfgPath -- run Install-GreenroomInstance -Name $Instance" exit 1 } $cfg = Get-Content $cfgPath -Raw | ConvertFrom-Json $wt = $cfg.wt $shell = $cfg.shell $inner = Join-Path $PSScriptRoot 'greenroom-launch.ps1' $POLL_MS = 1000 # Anchored so 'admin' cannot match an instance called 'admin-2'. $cmdPattern = '--remote-control\s+"?' + [regex]::Escape($Instance) + '("|\s|$)' Log "=== watchdog start, pid $PID, instance '$Instance' ===" Log " cwd=$($cfg.workingDirectory) claude=$($cfg.claudeExe)" function Get-RcClaudePid { $p = Get-CimInstance Win32_Process -Filter "Name='claude.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -match $cmdPattern } | Select-Object -First 1 if ($p) { $p.ProcessId } else { $null } } # Windows Terminal keeps a window alive when the process hosting its tab dies # abruptly rather than exiting. The window stays enumerable, frozen at whatever # title it had at death. Killing claude.exe does NOT do this -- the launcher then # exits normally and the window closes with it -- but killing the launcher shell # does, and so does any abnormal termination of it. # # For greenroom that corpse is not cosmetic. It holds "<glyph> <instance>", which # is exactly the title the replacement session will have, so greenroom.ps1 finds # two windows matching one name, refuses to choose, and attach breaks permanently # with nothing on screen to explain it. # # This runs only from Start-RcSession, which is reached only after Get-RcClaudePid # has confirmed no live session for this instance. Any window still bearing the # instance name at that moment is therefore stale by definition. Combined with the # single-instance mutex, no other watchdog can be launching a replacement # concurrently, so there is nothing live to mistake for a corpse. # # PostMessage, not SendMessage: PostMessage returns immediately, so an unresponsive # window cannot block the supervisor. Closing is best-effort by design. if (-not ('GreenroomWd.Win1' -as [type])) { Add-Type -Namespace GreenroomWd -Name Win1 -MemberDefinition @' [DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc cb, IntPtr p); public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); [DllImport("user32.dll", CharSet=CharSet.Auto)] public static extern int GetClassName(IntPtr h, System.Text.StringBuilder s, int n); [DllImport("user32.dll", CharSet=CharSet.Auto)] public static extern int GetWindowText(IntPtr h, System.Text.StringBuilder s, int n); [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint msg, IntPtr w, IntPtr l); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); '@ } # Every CASCADIA_HOSTING window currently on the desktop, as a set of handle values. function Get-CascadiaHandleSet { # $l is the lParam of Win32's EnumWindowsProc, BOOL CALLBACK(HWND, LPARAM). The # delegate requires it, we pass IntPtr.Zero, and dropping it stops the scriptblock # matching the delegate. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] [CmdletBinding()] param() $script:capHits = @() $cb = [GreenroomWd.Win1+EnumWindowsProc] { param($h, $l) $sb = New-Object System.Text.StringBuilder 256 [GreenroomWd.Win1]::GetClassName($h, $sb, 256) | Out-Null if ($sb.ToString() -match 'CASCADIA_HOSTING') { $script:capHits += [int64]$h } return $true } [GreenroomWd.Win1]::EnumWindows($cb, [IntPtr]::Zero) | Out-Null return , @($script:capHits) } # Record which window belongs to this session, so attach never has to guess. # # Windows Terminal hosts every window in ONE process, and Microsoft declined to # expose any mapping from a hosted process to its window (microsoft/terminal#5694, # closed Won't-Fix). GetWindowThreadProcessId therefore returns the same pid for # every window WT owns, and the only thing that differs between them is the title # -- which the hosted application owns and rewrites. A session sitting at a trust # dialog or a login prompt never applies --name at all, so title matching fails # exactly when attaching matters most. # # The handle is knowable at creation instead. Two independent filters are applied # so a concurrent start cannot be misattributed: # 1. the handle must be NEW since immediately before wt.exe was launched # 2. it must belong to the WindowsTerminal process in this session's ancestry # Exactly one handle satisfying both is recorded; anything else records nothing and # leaves the title fallback in place rather than storing a guess. function Save-SessionWindow { param([int]$ClaudePid, [int64[]]$Before) $wtPid = $null $cur = Get-CimInstance Win32_Process -Filter "ProcessId=$ClaudePid" -ErrorAction SilentlyContinue for ($i = 0; $i -lt 6 -and $cur; $i++) { $par = Get-CimInstance Win32_Process -Filter "ProcessId=$($cur.ParentProcessId)" -ErrorAction SilentlyContinue if (-not $par) { break } if ($par.Name -eq 'WindowsTerminal.exe') { $wtPid = [int]$par.ProcessId; break } $cur = $par } if (-not $wtPid) { Log 'window capture: no WindowsTerminal in ancestry -- leaving title fallback'; return } $now = Get-CascadiaHandleSet $new = @($now | Where-Object { $Before -notcontains $_ }) $owned = @($new | Where-Object { $wp = 0 [GreenroomWd.Win1]::GetWindowThreadProcessId([IntPtr]$_, [ref]$wp) | Out-Null [int]$wp -eq $wtPid }) if ($owned.Count -ne 1) { Log "window capture: $($owned.Count) candidate windows (new=$($new.Count), wt pid $wtPid) -- leaving title fallback" return } $state = @{ handle = $owned[0] claudePid = $ClaudePid terminalPid = $wtPid capturedUtc = (Get-Date).ToUniversalTime().ToString('o') } $path = Join-Path $stateDir 'session.json' $state | ConvertTo-Json | Set-Content -Path $path -Encoding UTF8 Log "window capture: handle $($owned[0]) on WindowsTerminal pid $wtPid" } $titlePattern = [regex]::Escape($Instance) + '$' function Close-StaleWindows { # Same EnumWindowsProc lParam as above. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] [CmdletBinding()] param() $script:staleHits = @() $script:stalePattern = $titlePattern $cb = [GreenroomWd.Win1+EnumWindowsProc] { param($h, $l) $sb = New-Object System.Text.StringBuilder 256 [GreenroomWd.Win1]::GetClassName($h, $sb, 256) | Out-Null if ($sb.ToString() -match 'CASCADIA_HOSTING') { $tb = New-Object System.Text.StringBuilder 512 [GreenroomWd.Win1]::GetWindowText($h, $tb, 512) | Out-Null if ($tb.ToString() -match $script:stalePattern) { $script:staleHits += [PSCustomObject]@{ Handle = $h; Title = $tb.ToString() } } } return $true } [GreenroomWd.Win1]::EnumWindows($cb, [IntPtr]::Zero) | Out-Null foreach ($w in $script:staleHits) { [GreenroomWd.Win1]::PostMessage($w.Handle, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null Log "closed stale window $($w.Handle) '$($w.Title)' -- its host died without releasing it" } if ($script:staleHits.Count -gt 0) { Start-Sleep -Milliseconds 500 } } function Start-RcSession { # No ShouldProcess: this is the watchdog's internal launch step, private to a script # that runs unattended from a scheduled task. There is no interactive caller for # -WhatIf or -Confirm to serve. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] param() Close-StaleWindows # Snapshot the desktop's console windows immediately before launching, so the # one this call creates can be identified by difference rather than by title. # Deliberately taken AFTER Close-StaleWindows, or a corpse still being torn # down could appear as "new" in the comparison. $script:windowsBefore = Get-CascadiaHandleSet # -w new forces its own window instead of a tab in an existing terminal. $args_ = @('-w', 'new', $shell, '-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $inner, '-Instance', $Instance) Start-Process -FilePath $wt -ArgumentList $args_ -WindowStyle Hidden Log 'launched WT session (hidden)' } $sessionPid = $null $restarts = @() # timestamps, for backoff $backoffUntil = [datetime]::MinValue while ($true) { try { $alive = $false if ($sessionPid) { $alive = [bool](Get-Process -Id $sessionPid -ErrorAction SilentlyContinue) } if (-not $alive) { # confirm via command line before declaring it dead -- it may have been # restarted by something else, or our recorded pid may be stale. $found = Get-RcClaudePid if ($found) { if ($found -ne $sessionPid) { Log "adopted existing session, claude pid $found" } $sessionPid = $found } elseif ((Get-Date) -lt $backoffUntil) { # in backoff, do nothing this tick } else { if ($sessionPid) { Log "session pid $sessionPid is gone -- restarting" } else { Log 'no session running -- starting' } Start-RcSession # wait for it to come up and record the new pid $deadline = (Get-Date).AddSeconds(45) while ((Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 500 $found = Get-RcClaudePid if ($found) { $sessionPid = $found; Log "session up, claude pid $found" # Capture now, while it is unambiguous which window was # just created. Best-effort: a failure here costs the # deterministic path, not the session. try { Save-SessionWindow -ClaudePid $found -Before $script:windowsBefore } catch { Log "window capture failed: $_" } break } } if (-not $found) { Log 'session did NOT come up within 45s'; $sessionPid = $null } # crash-loop guard: >5 restarts in 5 minutes -> back off 2 minutes $now = Get-Date $restarts = @($restarts | Where-Object { $_ -gt $now.AddMinutes(-5) }) $restarts += $now if ($restarts.Count -gt 5) { $backoffUntil = $now.AddMinutes(2) Log "crash loop detected ($($restarts.Count) restarts in 5 min) -- backing off until $backoffUntil" $restarts = @() } } } } catch { Log "watchdog error: $_" } Start-Sleep -Milliseconds $POLL_MS } |