Public/Stop-GreenroomSession.ps1

# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2026 Tyler Vigario

<#
.SYNOPSIS
  Stop an instance's session, supervisor and launcher, and leave them stopped.

.DESCRIPTION
  The gap this fills: every existing way to get a session to stop also brings it back.
  Restart-GreenroomSession stops it and starts the task again. Hide-GreenroomSession
  only moves the window -- the process keeps running and keeps its handles. Uninstall
  stops it by removing the instance, which is a different operation entirely. So the
  documented answer to "take this one down for a minute" was to stop the watchdog by
  hand and remember not to start anything, which is the procedure the module exists to
  replace.

  The case that makes it necessary is an upgrade of the Claude Code CLI itself. The
  session holds claude.exe open, and on a WinGet install every session runs through the
  same WinGet\Links\claude.exe symlink into one package file. MEASURED: with a session
  running, `winget upgrade --id Anthropic.ClaudeCode` fails with 0x8a150003 and
  `remove: Access is denied` against that package file. Hiding the window does not
  release it, and restarting only re-takes it.

  Order matters, and it is the same order Restart uses for the same reason. The watchdog
  goes first: it polls once a second and restarts a dead session, so killing the session
  first means racing a supervisor that is specifically designed to undo you.

  The task is stopped before any of it, so its trigger cannot start a replacement
  watchdog part-way through. That does NOT disable the task -- a stopped instance comes
  back at the next logon, or when Start-GreenroomSession is run, which is the intended
  behaviour for a supervised thing.
  Uninstall-GreenroomInstance is how you make it stay away.

  Everything is driven from the instance NAME, its scheduled task and config.json,
  never from session discovery. Discovery reads CommandLine, which is NULL across
  integrity levels, so a discovery-driven stop would be unusable from an unelevated
  shell against an elevated instance.

  REFUSES to stop the instance the calling shell is running inside. Killing an ancestor
  of this process mid-run means the settle check never executes, so the command cannot
  report the one thing it promises -- that the instance stayed down. Reaching for it
  from inside the session is the natural mistake, which is what makes the guard worth
  having.

.PARAMETER Name
  The instance to stop. Wildcards match every registered instance, so
  `Stop-GreenroomSession *` acts on all of them. If omitted and exactly one instance is
  registered, that one is used. Accepts pipeline input, including Greenroom.Instance objects.

.PARAMETER NoElevate
  Do not escalate when the instance runs elevated. Fails instead. Without it, an elevated
  instance is handed to an elevated copy through UAC -- ONE prompt for every elevated
  instance a pattern or pipeline matched, raised after the others have been stopped.

.PARAMETER SettleSeconds
  How long to watch for the session coming back before reporting. Default 5. A
  surviving watchdog restarts a session within about a second, so this is long enough
  to catch one; 0 skips the check.

.OUTPUTS
  Greenroom.StopResult -- what was actually stopped, as data rather than prose.

.EXAMPLE
  Stop-GreenroomSession laptop-admin

.EXAMPLE
  Stop-GreenroomSession laptop-admin -WhatIf
  Shows what would be stopped without touching anything.

.EXAMPLE
  $running = (Get-GreenroomInstance).Instance
  $running | Stop-GreenroomSession
  winget upgrade --id Anthropic.ClaudeCode
  $running | Start-GreenroomSession
  Taking every instance down to upgrade the CLI they all hold open. Capture the names
  FIRST: Get-GreenroomInstance lists running sessions only, so once they are stopped it
  returns nothing. Note that a Claude Code session you are typing in holds the same
  binary, so this frees it only if the shell running these commands is not itself inside
  one.
#>

function Stop-GreenroomSession {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType('Greenroom.StopResult')]
    param(
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('Instance')]
        [SupportsWildcards()]
        [string]$Name,

        [switch]$NoElevate,

        [ValidateRange(0, 60)]
        [int]$SettleSeconds = 5
    )

    begin {
        # Stopped instances are collected and settled together in `end`, once. Settling
        # inside the loop slept a full SettleSeconds after EACH instance, so `Stop *` over
        # six instances blocked for thirty seconds of pure sleep to learn what one pass
        # learns in five.
        $stopped = [System.Collections.Generic.List[object]]::new()

        # Elevated instances this shell cannot act on, escalated together in `end` so that
        # one UAC prompt covers all of them -- whether they came from one pattern or were
        # piped in one at a time.
        $deferred = [System.Collections.Generic.List[string]]::new()
    }

    process {
        # ONE INVOCATION, looping, as Start-GreenroomSession does. This replaced a fan-out
        # that re-invoked the command once per matched instance, and that was wrong in
        # three ways: -Confirm's "Yes to All" and "No to All" reset for every nested call,
        # since each had its own ShouldProcess state; a THROW in one nested call -- a
        # declined UAC prompt -- unwound through the loop and abandoned every instance after
        # it; and each nested call resolved its name all over again.
        foreach ($n in @(Resolve-InstanceName -Name $Name -Cmdlet $PSCmdlet)) {
            if (Test-SelfIsInstance -Name $n) {
                Write-Error -Category InvalidOperation -Message (
                    "'$n' is the session this shell is running inside. Stopping it from here would kill " +
                    'this process part-way through, so the settle check would never run and this could not ' +
                    'report whether it stayed down. Run it from a shell outside the session.')
                continue
            }

            # Gated before escalation, for the same reason Restart-GreenroomSession gates
            # there: a new elevated process starts with its own $WhatIfPreference and
            # $ConfirmPreference, so -WhatIf must stop here rather than be re-decided against
            # defaults over there, and -Confirm must prompt in the shell the operator typed in.
            if (-not $PSCmdlet.ShouldProcess($n, 'Stop-GreenroomSession')) { continue }

            if (-not (Assert-CanActOnInstance -Name $n -Command 'Stop-GreenroomSession' -NoElevate:$NoElevate -Defer $deferred)) {
                continue
            }

            # Before the kills, so the trigger cannot launch a replacement watchdog while
            # they run. This only ends a task currently executing; it does not disable it.
            Stop-ScheduledTask -TaskName "greenroom-$n" -ErrorAction SilentlyContinue

            $esc    = [regex]::Escape($n)
            $shells = 'pwsh.exe', 'powershell.exe'

            $watchdog = Stop-VerifiedProcess -ProcessName $shells      -Pattern ('greenroom-watchdog.*-Instance\s+"?' + $esc + '("|\s|$)') -Label 'watchdog'
            $session  = Stop-VerifiedProcess -ProcessName 'claude.exe' -Pattern ('--remote-control\s+"?' + $esc + '("|\s|$)')                 -Label 'session'
            $launcher = Stop-VerifiedProcess -ProcessName $shells      -Pattern ('greenroom-launch.*-Instance\s+"?' + $esc + '("|\s|$)')   -Label 'launcher'

            if (($watchdog + $session + $launcher) -eq 0) { Write-Verbose "nothing was running for '$n'" }

            $stopped.Add([PSCustomObject]@{ Instance = $n; Watchdog = $watchdog; Session = $session; Launcher = $launcher })
        }
    }

    end {
        # The elevated copy stops and settles its own instances, and its results do not
        # cross back -- only whether it succeeded does -- so they are reported here as an
        # error or not at all, as they were when each was escalated on its own.
        if ($deferred.Count) { Invoke-DeferredElevation -Command 'Stop-GreenroomSession' -Name $deferred -Cmdlet $PSCmdlet }

        # Confirm by observation, not by the kills returning. "Stopped" is only meaningful
        # if it is still stopped a moment later: a watchdog that was missed -- one belonging
        # to a stale asset version, say, whose command line does not match the pattern --
        # puts the session straight back, and the counts would still look like success.
        #
        # Counted iterations rather than a wall-clock deadline. A deadline loop with the
        # sleep inside it degenerates into a busy-wait the moment the sleep does not
        # actually sleep -- which is exactly what happens under test.
        $cameBack = @{}
        if ($SettleSeconds -gt 0 -and $stopped.Count -gt 0) {
            for ($i = 0; $i -lt $SettleSeconds * 2; $i++) {
                Start-Sleep -Milliseconds 500
                foreach ($s in $stopped) {
                    if ($cameBack.ContainsKey($s.Instance)) { continue }
                    $back = @(Get-GreenroomInstance -Name $s.Instance -WarningAction SilentlyContinue -ErrorAction SilentlyContinue)
                    if ($back.Count -gt 0 -and -not $back[0].Opaque) {
                        $cameBack[$s.Instance] = $true
                        Write-Warning ("'$($s.Instance)' came back up while settling -- something is still supervising it. " +
                                       "Check for a watchdog from a different module version: Get-CimInstance Win32_Process " +
                                       "-Filter `"Name='pwsh.exe'`" | Where-Object CommandLine -match 'greenroom-watchdog'")
                    }
                }
                if ($cameBack.Count -eq $stopped.Count) { break }
            }
        }

        foreach ($s in $stopped) {
            $stayedDown = -not $cameBack.ContainsKey($s.Instance)

            # An unelevated shell cannot read an elevated session's command line, so absence
            # is not evidence here. Saying so beats reporting a confirmation never made.
            $confirmed = $stayedDown
            if ((Test-InstanceElevated -Name $s.Instance) -and -not (Test-SelfElevated)) {
                $confirmed = $false
                Write-Warning ("cannot confirm '$($s.Instance)' stopped from an unelevated shell: an elevated " +
                               'session is unreadable here. Re-check with Get-GreenroomInstance from an elevated shell.')
            }

            # Returned rather than printed, matching Uninstall-GreenroomInstance: the facts
            # about what stopped are the ones an operator needs, and as data they can be
            # asserted on instead of read.
            [PSCustomObject]@{
                PSTypeName      = 'Greenroom.StopResult'
                Instance        = $s.Instance
                WatchdogStopped = $s.Watchdog
                SessionStopped  = $s.Session
                LauncherStopped = $s.Launcher
                StayedDown      = $stayedDown
                Confirmed       = $confirmed
            }
        }
    }
}