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
}