# meathook session reaper, Windows edition — ends sessions whose terminal is # gone (spec §11). # # Hooks cannot see a closed terminal. SessionEnd fires on a clean exit; it does # not fire when the window is closed, the process is killed, or an SSH # connection drops. The status function deliberately will not paper over that: # `blocked`, `attention` and `idle` all outrank the deadman timers (§5.1, §5.2) # because a quiet session is usually a waiting one, so a ghost sits on the # board forever. Only ground truth that the process is gone can clear it. # # So this job asks the harness which sessions are actually running, and sends # `session_end` for the ones this machine still has open state for. # # EVERYTHING it can send (spec §8 allowlist): event type, event_id, # occurred_at, session_key, machine and project labels, harness name, and the # literal reason "reaped". It reads no transcript, no prompt, no output — only # the session ids of running processes and the state files the hook wrote. # # It FAILS CLOSED. If the harness cannot be asked — not installed, not on PATH, # a listing it does not recognise — it sends nothing at all. A reaper that # reads "cannot tell" as "nothing is running" ends every session on the # machine, so the only safe default is to do nothing. # # This is the line-for-line counterpart of meathook-reap.sh, and a test pins # that the two emit the same wire body for the same listing (§11). # # Usage: # meathook-reap.ps1 [-DryRun] report or send # meathook-reap.ps1 -Install run it every minute from now on # meathook-reap.ps1 -Uninstall stop running it param( [switch]$DryRun, [switch]$Install, [switch]$Uninstall ) $ErrorActionPreference = 'Stop' # 5.1 negotiates TLS 1.0 by default on older builds, which meathook.ai refuses. if ($PSVersionTable.PSEdition -eq 'Desktop') { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } $HomeDir = $env:USERPROFILE if (-not $HomeDir) { $HomeDir = $HOME } $StateDir = Join-Path $HomeDir '.meathook\state' $OnWindows = ($PSVersionTable.PSEdition -eq 'Desktop') -or $IsWindows # --- scheduling ------------------------------------------------------------- # Periodic, not a resident daemon: the whole job is one listing and a diff, and # a crashed daemon is another thing that fails silently. Once a minute is the # floor both schedulers offer, and it is affordable: the listing costs ~0.2 s # and one short-lived process, which is why it can be this frequent without a # resident daemon to amortise it. $Self = $MyInvocation.MyCommand.Path $TaskName = 'meathook-reap' if ($Install) { if (-not $OnWindows) { [Console]::Error.WriteLine('use meathook-reap.sh --install here'); exit 2 } # Non-interactive on purpose: an interactive task flashes a console window # every minute, which is a good way to get telemetry uninstalled. $cmd = "powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$Self`"" & schtasks.exe /Create /TN $TaskName /SC MINUTE /MO 1 /TR $cmd /F | Out-Null if ($LASTEXITCODE -ne 0) { [Console]::Error.WriteLine('schtasks failed'); exit 1 } Write-Host "Installed: scheduled task $TaskName, every minute." Write-Host "Check it with: powershell -NoProfile -File `"$Self`" -DryRun" exit 0 } if ($Uninstall) { if (-not $OnWindows) { [Console]::Error.WriteLine('use meathook-reap.sh --uninstall here'); exit 2 } & schtasks.exe /Delete /TN $TaskName /F | Out-Null Write-Host 'Removed. Sessions whose terminal closes will stay on the board again.' exit 0 } # --- config ----------------------------------------------------------------- # No per-worktree file: this runs from a scheduler, not inside a project. function Read-Conf([string]$Path) { if (-not (Test-Path -LiteralPath $Path)) { return $null } try { return (Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json) } catch { return $null } } $UserConf = Read-Conf (Join-Path $HomeDir '.meathook\config.json') function Cfg([string]$Key) { if ($UserConf -and $UserConf.PSObject.Properties.Name -contains $Key) { $v = [string]$UserConf.$Key if ($v) { return $v } } return '' } $Url = $env:MEATHOOK_URL; if (-not $Url) { $Url = Cfg 'url' } $Token = $env:MEATHOOK_TOKEN; if (-not $Token) { $Token = Cfg 'token' } if (-not $Url -or -not $Token) { exit 0 } $Machine = Cfg 'machine' if (-not $Machine) { $Machine = $env:COMPUTERNAME } if (-not $Machine) { $Machine = [System.Net.Dns]::GetHostName() } if (-not (Test-Path -LiteralPath $StateDir)) { exit 0 } # One run at a time. A scheduler that stacks runs on top of a hung listing # would keep launching processes; creating the directory is the atomic # test-and-set. $Lock = Join-Path $StateDir '.reap.lock' $got = $false try { New-Item -ItemType Directory -Path $Lock -ErrorAction Stop | Out-Null; $got = $true } catch { } if (-not $got) { # Ten minutes without finishing means the holder died without cleaning up. $held = Get-Item -LiteralPath $Lock -ErrorAction SilentlyContinue if (-not $held -or $held.LastWriteTime -gt (Get-Date).AddMinutes(-10)) { exit 0 } Remove-Item -LiteralPath $Lock -Recurse -Force -ErrorAction SilentlyContinue try { New-Item -ItemType Directory -Path $Lock -ErrorAction Stop | Out-Null } catch { exit 0 } } try { # --- who is actually alive ---------------------------------------------- $Claude = $env:MEATHOOK_CLAUDE if (-not $Claude) { $cmd = Get-Command claude -ErrorAction SilentlyContinue if ($cmd) { $Claude = $cmd.Source } } if (-not $Claude) { foreach ($c in @( (Join-Path $HomeDir '.local\bin\claude'), (Join-Path $HomeDir '.claude\local\claude'), '/opt/homebrew/bin/claude', '/usr/local/bin/claude')) { if (Test-Path -LiteralPath $c) { $Claude = $c; break } } } # A named CLI that is not there is "cannot tell", not "look elsewhere": # falling back to a discovered binary would answer with a harness the # operator did not name, and reporting it as a failed listing hides which # of the two broke. if (-not $Claude -or -not (Test-Path -LiteralPath $Claude)) { if ($DryRun) { Write-Host 'claude CLI not found - nothing sent' } exit 0 } # The listing is filtered by process liveness on the harness side: an entry # whose pid is gone is not returned, which is exactly the fact needed here. $Listing = '' try { $Listing = ((& $Claude agents --json 2>$null) -join "`n").Trim() } catch { } if ($LASTEXITCODE -ne 0 -or -not $Listing) { if ($DryRun) { Write-Host 'listing failed - nothing sent' } exit 0 } # Not a JSON array: an old CLI, an error page, a login prompt. Unknown is # not empty — see the fail-closed note at the top. if (-not ($Listing.StartsWith('[') -and $Listing.EndsWith(']'))) { if ($DryRun) { Write-Host 'unrecognised listing - nothing sent' } exit 0 } $Live = @([regex]::Matches($Listing, '"sessionId"\s*:\s*"([^"]*)"') | ForEach-Object { $_.Groups[1].Value }) # --- reap --------------------------------------------------------------- function Clean([string]$s) { return ($s -replace '["\\]', '') } function Field([string]$Name, [string]$Value) { if ($Value) { return '"' + $Name + '":"' + (Clean $Value) + '",' } return '' } foreach ($open in @(Get-ChildItem -LiteralPath $StateDir -Filter '*.open' -File -ErrorAction SilentlyContinue)) { $sid = [IO.Path]::GetFileNameWithoutExtension($open.Name) if ($Live -contains $sid) { continue } $lines = @(Get-Content -LiteralPath $open.FullName -ErrorAction SilentlyContinue) $sessionKey = ''; if ($lines.Count -ge 1) { $sessionKey = ([string]$lines[0]).Trim() } $project = ''; if ($lines.Count -ge 2) { $project = ([string]$lines[1]).Trim() } $state = Join-Path $StateDir $sid if (-not $sessionKey) { Remove-Item -LiteralPath $open.FullName -Force -ErrorAction SilentlyContinue; continue } if ($DryRun) { $label = $project; if (-not $label) { $label = '?' } Write-Host "would end $sid ($label)" continue } $body = '{"v":1,' + '"event_id":"' + [guid]::NewGuid().ToString() + '",' + '"session_key":"' + $sessionKey + '",' + '"occurred_at":"' + (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + '",' + (Field 'machine' $Machine) + (Field 'project' $project) + '"harness":"claude-code","type":"session_end","payload":{"reason":"reaped"}}' $sent = $false try { Invoke-RestMethod -Method Post -Uri "$Url/v1/events" -TimeoutSec 5 ` -Headers @{ Authorization = "Bearer $Token" } ` -ContentType 'application/json' -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null $sent = $true } catch { } # a board that is down must never break the agent # Forget the session only once the board has it. A board that is down # must cost a later reap, not a lost one — the state file is the only # record that this session was ever open. if ($sent) { foreach ($suffix in '.open', '.blocked', '.last', '.count', '.summary') { Remove-Item -LiteralPath "$state$suffix" -Force -ErrorAction SilentlyContinue } } } } finally { Remove-Item -LiteralPath $Lock -Recurse -Force -ErrorAction SilentlyContinue } exit 0