com.github.copilot/hooks/scripts/Write-SessionClose.ps1
|
<#
.SYNOPSIS Stop hook that keeps the session clock the Post-flight elapsed line reads. .DESCRIPTION Reads the VS Code Stop hook payload from standard input and advances the turn counter on the clock the SessionStart hook wrote. Post-flight asks the agent to close with the elapsed chat duration, but a model has no clock: any timestamp it composes is a guess, and after a compaction it no longer knows when the session began. The agent therefore measures the line with Get-SessionElapsed.ps1, and this hook keeps the clock that reader depends on rather than reporting the duration a second time. The turn counter advances only when the payload does not say the agent is already continuing from a blocking Stop hook, so a resumed run stays one turn. .PARAMETER InputJson Hook payload as JSON. Defaults to reading standard input. Tests pass the payload directly so they do not depend on redirected input. .PARAMETER ClockRoot Directory holding the session clock files. Defaults to the per-user application data location. Tests override it to stay off the real profile. .NOTES Never blocks: no decision field is emitted and every failure path exits 0, so a missing or unreadable clock costs a warning and nothing else. #> [CmdletBinding()] param( [Parameter()] [AllowEmptyString()] [AllowNull()] [string]$InputJson, [Parameter()] [AllowEmptyString()] [AllowNull()] [string]$ClockRoot ) function Get-SessionClockPath { <# Resolves the clock file for a session. Duplicated verbatim in Add-SessionContext.ps1: VS Code launches each hook by its own path, so a shared helper would need the same fragile path probing that hooks.json already carries. Both sides must derive the same name from the same payload, so change them together. #> [CmdletBinding()] [OutputType([string])] param( [Parameter()] [AllowNull()] [AllowEmptyString()] [string]$SessionId, [Parameter()] [AllowNull()] [AllowEmptyString()] [string]$WorkingDirectory, [Parameter()] [AllowNull()] [AllowEmptyString()] [string]$Root ) if ([string]::IsNullOrWhiteSpace($Root)) { # Per-user by construction. The temp directory is world-writable on # Linux, where a predictable name invites another local account to # pre-create the path. $Root = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) if ([string]::IsNullOrWhiteSpace($Root)) { $Root = [IO.Path]::GetTempPath() } $Root = [IO.Path]::Combine($Root, 'CopilotAtelier', 'sessions') } # The payload supplies this value, so it becomes a path component only after # every character that could traverse a directory is gone. $key = ($SessionId -replace '[^A-Za-z0-9._-]', '') if ($key.Length -gt 64) { $key = $key.Substring(0, 64) } if ([string]::IsNullOrWhiteSpace($key)) { # No session id: fall back to the workspace so two concurrent windows do # not share one clock. $seed = if ([string]::IsNullOrWhiteSpace($WorkingDirectory)) { 'default' } else { $WorkingDirectory } $sha = [Security.Cryptography.SHA256]::Create() try { $digest = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($seed)) } finally { $sha.Dispose() } $key = 'cwd-' + [BitConverter]::ToString($digest[0..7]).Replace('-', '').ToLowerInvariant() } return [IO.Path]::Combine($Root, "session-$key.json") } if ([string]::IsNullOrEmpty($InputJson)) { # Decode explicitly: Windows PowerShell would otherwise use the console input # encoding, which mangles non-ASCII payloads that pwsh reads as UTF-8. $reader = [IO.StreamReader]::new([Console]::OpenStandardInput(), [Text.UTF8Encoding]::new($false)) try { $InputJson = $reader.ReadToEnd() } finally { $reader.Dispose() } } $payload = $null if (-not [string]::IsNullOrWhiteSpace($InputJson)) { try { $payload = $InputJson | ConvertFrom-Json -ErrorAction Stop } catch { $payload = $null } } $endedUtc = (Get-Date).ToUniversalTime() $clockAdvanced = $false $turn = 0 # A Stop that fires while the agent is already continuing from a blocking hook # closes the same turn it started, so it must not advance the counter. $isContinuation = $false if ($payload -and $null -ne $payload.stop_hook_active) { $isContinuation = [bool]$payload.stop_hook_active } try { $clockPath = Get-SessionClockPath ` -SessionId ([string]$payload.session_id) ` -WorkingDirectory ([string]$payload.cwd) ` -Root $ClockRoot if ([IO.File]::Exists($clockPath)) { $clock = [IO.File]::ReadAllText($clockPath) | ConvertFrom-Json -ErrorAction Stop $turn = [int]$clock.turns if (-not $isContinuation) { $turn++ } $updated = [ordered]@{ startedUtc = $clock.startedUtc workspace = [string]$clock.workspace turns = $turn lastTurnEndedUtc = $endedUtc.ToString('o') } | ConvertTo-Json -Depth 3 [IO.File]::WriteAllText($clockPath, $updated, [Text.UTF8Encoding]::new($false)) $clockAdvanced = $true } } catch { $clockAdvanced = $false } # No decision field: blocking here would restart the agent and bill another turn. $output = [ordered]@{ continue = $true } <# Silent on the happy path. The agent already closed its reply with a measured elapsed line from Get-SessionElapsed.ps1, so repeating the duration here would only add a second copy in a detached warning box. A broken clock is worth surfacing, because that is the one case where the agent's own line could not be measured either. #> if (-not $clockAdvanced) { $output.systemMessage = 'POST-FLIGHT clock - no readable session clock, so the elapsed line reads unavailable.' } $output | ConvertTo-Json -Depth 5 -Compress exit 0 |