Private/Resolve-SessionWindow.ps1
|
# SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 Tyler Vigario <# Every CASCADIA_HOSTING window owned by one WindowsTerminal.exe process. Renamed from Get-CascadiaWindows for PSUseSingularNouns, as with Get-SessionProcess. #> function Get-CascadiaWindow { # The `$l` below is the lParam of the Win32 EnumWindowsProc signature, # BOOL CALLBACK(HWND, LPARAM). The delegate requires both parameters, we pass # IntPtr.Zero, and the callback correctly ignores it -- but it cannot be dropped # without the scriptblock ceasing to match the delegate. Suppressed here rather # than excluded repo-wide so a genuinely unused parameter elsewhere still fails. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '')] [CmdletBinding()] param([Parameter(Mandatory)][int]$HostPid) # The delegate executes in its own scope -- it can see neither this function's # locals nor its parameters. Both the input and the accumulator have to live in a # scope the callback can reach, which inside a module is the module scope. $script:grHits = @() $script:grPid = $HostPid $cb = [Greenroom.Win1+EnumWindowsProc] { param($h, $l) $wpid = 0 [Greenroom.Win1]::GetWindowThreadProcessId($h, [ref]$wpid) | Out-Null if ([int]$wpid -eq $script:grPid) { $sb = New-Object System.Text.StringBuilder 256 [Greenroom.Win1]::GetClassName($h, $sb, 256) | Out-Null if ($sb.ToString() -match 'CASCADIA_HOSTING') { $tb = New-Object System.Text.StringBuilder 512 [Greenroom.Win1]::GetWindowText($h, $tb, 512) | Out-Null $script:grHits += [PSCustomObject]@{ Handle = $h; Title = $tb.ToString() } } } return $true } [Greenroom.Win1]::EnumWindows($cb, [IntPtr]::Zero) | Out-Null $script:grHits } <# Resolve an instance's window from the handle the watchdog recorded when it created that window. That record is the ONLY source; there is deliberately no fallback. Windows Terminal hosts every window in one process and exposes no supported way to map a hosted process to its window (microsoft/terminal#5694, Won't Fix), so anything other than the record is inference from the window title -- and the title belongs to Claude Code, not to greenroom. A session sitting at a trust prompt or a /login screen has not applied --name yet and has no matching title at all, which is exactly when the operator needs to attach. A title fallback would paper over that, and would also hide the failure that matters: capture silently not working looks healthy right up until the day it resolves someone else's window. The record is validated, never trusted. Windows reuses handles after a window closes, so a stale record can name a live window belonging to something else. Returns the handle, or $null with the reason on the Verbose stream. It does not write to the host: a resolver that prints is unusable from Get-GreenroomInstance, which needs to report "unresolved" for one instance without spraying advice about it. #> function Resolve-SessionWindow { [CmdletBinding()] [OutputType([IntPtr])] param( [Parameter(Mandatory)][int]$HostPid, [Parameter(Mandatory)][int]$ClaudePid, [Parameter(Mandatory)][string]$Name ) $sf = Join-Path (Get-GreenroomStateRoot) "$Name\session.json" if (-not (Test-Path $sf)) { Write-Verbose "no window record for '$Name' -- restart the instance to create one" return $null } try { $rec = Get-Content $sf -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop # An empty file parses to $null rather than throwing, which would otherwise # fall through and report a mismatch against blank pids. if ($null -eq $rec) { throw 'file is empty' } } catch { Write-Verbose "window record for '$Name' is unreadable or malformed: $($_.Exception.Message)" return $null } # One invariant: this record was written for the session being acted on. Both pids # come from the live session the caller already resolved, so a record from any # earlier session fails here regardless of what it says. if ([int]$rec.claudePid -ne $ClaudePid -or [int]$rec.terminalPid -ne $HostPid) { Write-Verbose "record for '$Name' is for claude pid $($rec.claudePid) under terminal $($rec.terminalPid); this session is $ClaudePid under $HostPid" return $null } # Enumerate rather than trust the number: Windows reuses handles after a window # closes, so the record must still name a live CASCADIA window under this host. $match = @(Get-CascadiaWindow -HostPid $HostPid | Where-Object { [int64]$_.Handle -eq [int64]$rec.handle }) if ($match.Count -ne 1) { Write-Verbose "recorded handle $($rec.handle) for '$Name' is not a live console window under pid $HostPid" return $null } return $match[0].Handle } |