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, 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. If omitted and exactly one instance is installed, that one is
  used. Accepts pipeline input, including Greenroom.Instance objects.

.PARAMETER NoElevate
  Do not escalate when the instance runs elevated. Fails instead.

.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
  Get-GreenroomInstance | Stop-GreenroomSession
  winget upgrade --id Anthropic.ClaudeCode
  Get-GreenroomInstance | Restart-GreenroomSession
  Taking every instance down to upgrade the CLI they all hold open. 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')]
        [string]$Name,

        [switch]$NoElevate,

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

    process {
        if (-not $Name) {
            $known = @(Get-ChildItem (Get-GreenroomStateRoot) -Directory -ErrorAction SilentlyContinue)
            if ($known.Count -eq 1) { $Name = $known[0].Name }
            else {
                Write-Error -Category InvalidArgument -Message (
                    "an instance name is required. Installed: $($known.Name -join ', ')")
                return
            }
        }

        $task = "greenroom-$Name"
        if (-not (Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue)) {
            Write-Error -Category ObjectNotFound -Message "no scheduled task '$task' -- is '$Name' installed?"
            return
        }

        if (Test-SelfIsInstance -Name $Name) {
            Write-Error -Category InvalidOperation -Message (
                "'$Name' 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.')
            return
        }

        # 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($Name, 'Stop-GreenroomSession')) { return }

        if (-not (Assert-CanActOnInstance -Name $Name -Command 'Stop-GreenroomSession' -NoElevate:$NoElevate)) {
            return
        }

        # 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 $task -ErrorAction SilentlyContinue

        $esc    = [regex]::Escape($Name)
        $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 '$Name'" }

        # 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 above 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, where it spun for
        # the full settle period burning CPU instead of taking ten cheap turns.
        $stayedDown = $true
        if ($SettleSeconds -gt 0) {
            $polls = $SettleSeconds * 2
            for ($i = 0; $i -lt $polls; $i++) {
                Start-Sleep -Milliseconds 500
                $back = @(Get-GreenroomInstance -Name $Name -WarningAction SilentlyContinue -ErrorAction SilentlyContinue)
                if ($back.Count -gt 0 -and -not $back[0].Opaque) {
                    $stayedDown = $false
                    Write-Warning ("'$Name' 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'")
                    break
                }
            }
        }

        # An unelevated shell cannot read an elevated session's command line, so absence
        # is not evidence here. Saying so beats reporting a confirmation that was never made.
        $confirmed = $stayedDown
        if ((Test-InstanceElevated -Name $Name) -and -not (Test-SelfElevated)) {
            $confirmed = $false
            Write-Warning ("cannot confirm '$Name' 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        = $Name
            WatchdogStopped = $watchdog
            SessionStopped  = $session
            LauncherStopped = $launcher
            StayedDown      = $stayedDown
            Confirmed       = $confirmed
        }
    }
}