com.github.copilot/hooks/scripts/Add-SessionContext.ps1
|
<#
.SYNOPSIS SessionStart hook that injects the deterministic part of Pre-flight. .DESCRIPTION Reads the VS Code SessionStart hook payload from standard input, resolves the session working directory, and returns a short block of additional context on standard output: the current UTC timestamp and an authoritative statement of whether a Memory Bank exists in the workspace. The workspace summary supplied at session start omits dotfile folders, which is the recurring cause of agents concluding that no Memory Bank exists. This hook probes the filesystem instead of relying on the model to remember to. It also starts the session clock: the same timestamp is written to a small per-session file under LocalApplicationData, which the Stop hook reads to report the elapsed chat duration at the end of every turn. A model cannot read a clock, so neither number may be left to it. .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 Emits the SessionStart output contract shared by VS Code, Copilot CLI, and Claude Code. Always exits 0 so a probe failure never blocks a session. #> [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 Write-SessionClose.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 } } $workingDirectory = if ($payload) { [string]$payload.cwd } else { $null } if ([string]::IsNullOrWhiteSpace($workingDirectory)) { $workingDirectory = (Get-Location).Path } # The path is interpolated into the instruction channel, so strip control # characters that a hostile workspace name could use to inject extra lines. $workingDirectory = $workingDirectory -replace '[\p{Cc}]', ' ' <# The payload supplies this path, so it is untrusted and may name a drive or a shape this host cannot resolve. Combine and probe it through .NET rather than the PowerShell provider: a provider that cannot resolve the path writes to standard error, and the caller merges the streams, which would corrupt the JSON contract on standard output. [System.IO.File]::Exists returns false for any path it cannot read and never throws. #> $memoryBankExists = $false $memoryBankIndex = $workingDirectory try { $memoryBankIndex = [System.IO.Path]::Combine($workingDirectory, '.memory-bank', 'index.md') $memoryBankExists = [System.IO.File]::Exists($memoryBankIndex) } catch { $memoryBankExists = $false } if ($memoryBankExists) { $memoryBankState = "A Memory Bank exists at $memoryBankIndex. This probe is authoritative: " + 'do not conclude that the Memory Bank is absent. Read the index and apply its routing table ' + 'before the first tool call.' } else { $memoryBankState = "No Memory Bank exists under $workingDirectory. This probe is authoritative. " + 'Create one only before a durable repository write, using the memory-bank Skill.' } $startedUtc = (Get-Date).ToUniversalTime() <# Start the session clock. The Stop hook reads this file at the end of every turn to report the elapsed chat duration, and it has to survive compaction, which is why it goes to disk rather than into the injected context alone. A clock failure must never cost the caller its Memory Bank probe. #> try { $clockPath = Get-SessionClockPath ` -SessionId ([string]$payload.session_id) ` -WorkingDirectory $workingDirectory ` -Root $ClockRoot $clockDirectory = [IO.Path]::GetDirectoryName($clockPath) if (-not [IO.Directory]::Exists($clockDirectory)) { [IO.Directory]::CreateDirectory($clockDirectory) | Out-Null } $clock = [ordered]@{ startedUtc = $startedUtc.ToString('o') workspace = $workingDirectory turns = 0 } | ConvertTo-Json -Depth 3 [IO.File]::WriteAllText($clockPath, $clock, [Text.UTF8Encoding]::new($false)) } catch { # A missing clock costs the closing duration line, nothing else. Reporting on # any other stream would corrupt the JSON contract on standard output. Write-Debug -Message "Session clock not started: $($_.Exception.Message)" } <# Hand the agent the absolute path of the clock reader. Post-flight closes with a measured duration, and the agent cannot resolve the reader itself: it sits under ~/.copilot when deployed and under the plugin root when installed as a plugin, which is the probe hooks.json already carries. #> $elapsedReader = [IO.Path]::Combine($PSScriptRoot, 'Get-SessionElapsed.ps1') $line = @( "Session started at $($startedUtc.ToString('yyyy-MM-dd HH:mm')) UTC." $memoryBankState 'Open the reply with that UTC timestamp and a one-line PRE-FLIGHT acknowledgment.' "Close every reply by running & '$elapsedReader' and copying its single line verbatim as the last line of the POST-FLIGHT block." 'Never push or otherwise mutate a git remote unless the user asks in the current turn.' ) $output = [ordered]@{ continue = $true hookSpecificOutput = [ordered]@{ hookEventName = 'SessionStart' additionalContext = ($line -join ' ') } } $output | ConvertTo-Json -Depth 5 -Compress exit 0 |