From 0189323e8f7a806d0061132eb524d2750f7c9822 Mon Sep 17 00:00:00 2001 From: Julian lechner Date: Fri, 11 Sep 2026 14:42:57 +0200 Subject: [PATCH] feat(hooks): add cross-platform safety and status hooks --- shared/hooks/README.md | 3 + shared/hooks/flashbang.ps1 | 152 ++++++++++++++++++ shared/hooks/flashbang.sh | 114 +++++++++++++ .../scripts/Invoke-PostChangeVerification.ps1 | 7 + shared/hooks/scripts/Test-SessionConfig.ps1 | 5 + .../hooks/scripts/Validate-CommandSafety.ps1 | 5 + .../invoke-post-change-verification.sh | 6 + shared/hooks/scripts/test-session-config.sh | 8 + .../hooks/scripts/validate-command-safety.sh | 9 ++ shared/statusline/statusline.ps1 | 11 ++ shared/statusline/statusline.sh | 22 +++ 11 files changed, 342 insertions(+) create mode 100644 shared/hooks/README.md create mode 100644 shared/hooks/flashbang.ps1 create mode 100644 shared/hooks/flashbang.sh create mode 100644 shared/hooks/scripts/Invoke-PostChangeVerification.ps1 create mode 100644 shared/hooks/scripts/Test-SessionConfig.ps1 create mode 100644 shared/hooks/scripts/Validate-CommandSafety.ps1 create mode 100644 shared/hooks/scripts/invoke-post-change-verification.sh create mode 100644 shared/hooks/scripts/test-session-config.sh create mode 100644 shared/hooks/scripts/validate-command-safety.sh create mode 100644 shared/statusline/statusline.ps1 create mode 100644 shared/statusline/statusline.sh diff --git a/shared/hooks/README.md b/shared/hooks/README.md new file mode 100644 index 0000000..0c8bc4f --- /dev/null +++ b/shared/hooks/README.md @@ -0,0 +1,3 @@ +# Hooks + +Hooks are conservative entry points shared by clients. They validate obvious command hazards, check repository-local configuration inputs, and provide an optional post-change verification command. They do not bypass client permission prompts or grant access. diff --git a/shared/hooks/flashbang.ps1 b/shared/hooks/flashbang.ps1 new file mode 100644 index 0000000..2be755c --- /dev/null +++ b/shared/hooks/flashbang.ps1 @@ -0,0 +1,152 @@ +<# +.SYNOPSIS + Flashes the whole screen after an agent finishes responding. + +.DESCRIPTION + Draws a borderless, always-on-top, click-through window across every + monitor, holds it at full brightness, then fades it out and exits. + + The window is never activated and is transparent to input, so it cannot + steal focus or swallow a keystroke or click from whatever you are doing. + +.PARAMETER HoldMs + Milliseconds to stay at full brightness before the fade starts. + +.PARAMETER FadeMs + Milliseconds the fade-out takes. Set to 0 for a hard cut. + +.PARAMETER Color + Flash color, as an HTML color name or hex ("White", "#FF0044"). + +.PARAMETER MaxOpacity + Peak opacity, 0.0 - 1.0. Lower this if a full white flash is too much. + +.PARAMETER PrimaryScreenOnly + Flash only the primary monitor instead of all of them. + +.EXAMPLE + .\flashbang.ps1 + A default white flash across all monitors. + +.EXAMPLE + .\flashbang.ps1 -Color '#00E5FF' -MaxOpacity 0.55 -HoldMs 60 -FadeMs 300 + A gentler cyan pulse. +#> +[CmdletBinding()] +param( + [ValidateRange(0, 5000)] [int] $HoldMs = 250, + [ValidateRange(0, 5000)] [int] $FadeMs = 250, + [string] $Color = 'White', + [ValidateRange(0.05, 1)] [double] $MaxOpacity = 0.35, + [switch] $PrimaryScreenOnly +) + +$ErrorActionPreference = 'Stop' + +# PowerShell 6+ runs on Linux and macOS, where none of this applies. +if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) { exit 0 } + +Add-Type -AssemblyName System.Windows.Forms, System.Drawing + +# Compiling this shim costs about a second, which would show up as a full second +# of lag before every flash. Emit it to a DLL once and load that from then on. +$nativeSource = @' + [DllImport("user32.dll", SetLastError = true)] + public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, + int X, int Y, int cx, int cy, uint uFlags); + + [DllImport("user32.dll", SetLastError = true)] + public static extern int GetWindowLong(IntPtr hWnd, int nIndex); + + [DllImport("user32.dll", SetLastError = true)] + public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); +'@ + +if (-not ('Flashbang.Native' -as [type])) { + # Keyed by host runtime: an assembly emitted by Windows PowerShell (.NET + # Framework) is not guaranteed to load under pwsh (.NET), and vice versa. + $cacheDir = Join-Path $env:LOCALAPPDATA 'claude-flashbang' + $runtimeTag = '{0}{1}' -f $PSVersionTable.PSEdition, $PSVersionTable.PSVersion.Major + $cacheDll = Join-Path $cacheDir "Flashbang.Native.$runtimeTag.dll" + $loaded = $false + + if (Test-Path -LiteralPath $cacheDll) { + try { Add-Type -Path $cacheDll; $loaded = $true } catch { $loaded = $false } + } + + if (-not $loaded) { + try { + if (-not (Test-Path -LiteralPath $cacheDir)) { + New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null + } + Add-Type -Namespace 'Flashbang' -Name 'Native' -MemberDefinition $nativeSource ` + -OutputAssembly $cacheDll + Add-Type -Path $cacheDll + } catch { + # Cache unavailable (locked file, no write access) - compile in-process. + Add-Type -Namespace 'Flashbang' -Name 'Native' -MemberDefinition $nativeSource + } + } +} + +$HWND_TOPMOST = [IntPtr]::new(-1) +$SWP_NOACTIVATE = 0x0010 +$SWP_SHOWWINDOW = 0x0040 +$GWL_EXSTYLE = -20 +$WS_EX_TRANSPARENT = 0x00000020 # clicks fall through to the window underneath +$WS_EX_TOOLWINDOW = 0x00000080 # keep it out of Alt+Tab +$WS_EX_NOACTIVATE = 0x08000000 # never take focus + +try { + $background = [System.Drawing.ColorTranslator]::FromHtml($Color) +} catch { + Write-Warning "Unrecognized color '$Color'; falling back to White." + $background = [System.Drawing.Color]::White +} + +$bounds = if ($PrimaryScreenOnly) { + [System.Windows.Forms.Screen]::PrimaryScreen.Bounds +} else { + [System.Windows.Forms.SystemInformation]::VirtualScreen +} + +$form = New-Object System.Windows.Forms.Form +try { + $form.FormBorderStyle = 'None' + $form.StartPosition = 'Manual' + $form.ShowInTaskbar = $false + $form.BackColor = $background + # Forces a layered window up front so Opacity animates without a handle rebuild. + $form.AllowTransparency = $true + $form.Opacity = $MaxOpacity + $form.Bounds = $bounds + + $handle = $form.Handle # touching Handle creates the window without showing it + + $exStyle = [Flashbang.Native]::GetWindowLong($handle, $GWL_EXSTYLE) + [void][Flashbang.Native]::SetWindowLong($handle, $GWL_EXSTYLE, + ($exStyle -bor $WS_EX_TRANSPARENT -bor $WS_EX_TOOLWINDOW -bor $WS_EX_NOACTIVATE)) + + [void][Flashbang.Native]::SetWindowPos($handle, $HWND_TOPMOST, + $bounds.X, $bounds.Y, $bounds.Width, $bounds.Height, + ($SWP_NOACTIVATE -bor $SWP_SHOWWINDOW)) + [System.Windows.Forms.Application]::DoEvents() + + $clock = [System.Diagnostics.Stopwatch]::StartNew() + while ($clock.ElapsedMilliseconds -lt $HoldMs) { + [System.Windows.Forms.Application]::DoEvents() + Start-Sleep -Milliseconds 10 + } + + if ($FadeMs -gt 0) { + $clock.Restart() + while ($clock.ElapsedMilliseconds -lt $FadeMs) { + $progress = $clock.ElapsedMilliseconds / $FadeMs + $form.Opacity = $MaxOpacity * (1 - $progress) + [System.Windows.Forms.Application]::DoEvents() + Start-Sleep -Milliseconds 8 + } + } +} finally { + $form.Dispose() +} diff --git a/shared/hooks/flashbang.sh b/shared/hooks/flashbang.sh new file mode 100644 index 0000000..ff7f9bc --- /dev/null +++ b/shared/hooks/flashbang.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Linux equivalent of flashbang.ps1. The visual overlay is implemented with +# Python's standard tkinter module because Bash itself cannot create windows. +# No third-party Python package is required. + +if ! command -v python3 >/dev/null 2>&1; then + command -v notify-send >/dev/null 2>&1 && notify-send 'AI assistant requires attention' + exit 0 +fi + +exec python3 - "$@" <<'PY' +import argparse +import re +import shutil +import subprocess +import sys +import tkinter as tk + + +def notify_fallback() -> None: + notify_send = shutil.which("notify-send") + if notify_send: + subprocess.run([notify_send, "AI assistant requires attention"], check=False) + + +def monitor_bounds(primary_only: bool) -> tuple[int, int, int, int]: + root = tk.Tk() + root.withdraw() + width = root.winfo_screenwidth() + height = root.winfo_screenheight() + root.destroy() + + if primary_only or not shutil.which("xrandr"): + return 0, 0, width, height + + try: + output = subprocess.run( + ["xrandr", "--current"], + check=True, + capture_output=True, + text=True, + ).stdout + except (OSError, subprocess.SubprocessError): + return 0, 0, width, height + + monitors = [ + (int(w), int(h), int(x), int(y)) + for w, h, x, y in re.findall(r"(\d+)x(\d+)\+(-?\d+)\+(-?\d+)", output) + ] + if not monitors: + return 0, 0, width, height + + left = min(x for _, _, x, _ in monitors) + top = min(y for _, _, _, y in monitors) + right = max(x + w for w, _, x, _ in monitors) + bottom = max(y + h for _, h, _, y in monitors) + return left, top, right - left, bottom - top + + +def main() -> int: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--hold-ms", type=int, default=250) + parser.add_argument("--fade-ms", type=int, default=250) + parser.add_argument("--color", default="white") + parser.add_argument("--max-opacity", type=float, default=0.35) + parser.add_argument("--primary-screen-only", action="store_true") + args, _ = parser.parse_known_args() + + args.hold_ms = max(0, min(5000, args.hold_ms)) + args.fade_ms = max(0, min(5000, args.fade_ms)) + args.max_opacity = max(0.05, min(1.0, args.max_opacity)) + + try: + left, top, width, height = monitor_bounds(args.primary_screen_only) + root = tk.Tk() + root.overrideredirect(True) + root.attributes("-topmost", True) + root.attributes("-alpha", args.max_opacity) + root.configure(background=args.color) + root.geometry(f"{width}x{height}{left:+d}{top:+d}") + root.withdraw() + root.deiconify() + root.lift() + root.update() + + def finish() -> None: + root.destroy() + + def fade(start_ms: int = 0) -> None: + if args.fade_ms == 0: + finish() + return + progress = min(1.0, start_ms / args.fade_ms) + root.attributes("-alpha", args.max_opacity * (1.0 - progress)) + if progress >= 1.0: + finish() + else: + root.after(8, fade, start_ms + 8) + + root.after(args.hold_ms, fade) + root.mainloop() + return 0 + except (tk.TclError, RuntimeError, OSError, ValueError): + # Headless sessions and minimal installations may not provide a usable + # display or tkinter. Keep the hook non-blocking and informative. + notify_fallback() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +PY diff --git a/shared/hooks/scripts/Invoke-PostChangeVerification.ps1 b/shared/hooks/scripts/Invoke-PostChangeVerification.ps1 new file mode 100644 index 0000000..45f034f --- /dev/null +++ b/shared/hooks/scripts/Invoke-PostChangeVerification.ps1 @@ -0,0 +1,7 @@ +param([string]$RepositoryRoot = (Split-Path $PSScriptRoot -Parent | Split-Path -Parent | Split-Path -Parent)) +$build = Join-Path $RepositoryRoot 'scripts/build.ps1' +if (-not (Test-Path $build)) { Write-Error 'Build script is missing.'; exit 1 } +$shellCommand = if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) { 'powershell' } else { 'pwsh' } +& $shellCommand -NoProfile -ExecutionPolicy Bypass -File $build +if ($LASTEXITCODE) { exit $LASTEXITCODE } +Write-Output 'PASS post-change verification' diff --git a/shared/hooks/scripts/Test-SessionConfig.ps1 b/shared/hooks/scripts/Test-SessionConfig.ps1 new file mode 100644 index 0000000..fe23323 --- /dev/null +++ b/shared/hooks/scripts/Test-SessionConfig.ps1 @@ -0,0 +1,5 @@ +param([string]$RepositoryRoot = (Split-Path $PSScriptRoot -Parent | Split-Path -Parent | Split-Path -Parent)) +$required = @('shared','adapters','scripts','docs','AGENTS.md') +$missing = @($required | Where-Object { -not (Test-Path (Join-Path $RepositoryRoot $_)) }) +if ($missing.Count) { Write-Error ('Missing repository inputs: ' + ($missing -join ', ')); exit 1 } +Write-Output 'PASS session configuration' diff --git a/shared/hooks/scripts/Validate-CommandSafety.ps1 b/shared/hooks/scripts/Validate-CommandSafety.ps1 new file mode 100644 index 0000000..65d2520 --- /dev/null +++ b/shared/hooks/scripts/Validate-CommandSafety.ps1 @@ -0,0 +1,5 @@ +param([Parameter(Mandatory=$true)][string]$Command, [string]$Workspace = (Get-Location).Path) +$dangerous = @('git reset --hard','git clean -fd','git clean -fx','Remove-Item -Recurse','rm -rf','format c:','del /s /q') +foreach ($pattern in $dangerous) { if ($Command.IndexOf($pattern, [StringComparison]::OrdinalIgnoreCase) -ge 0) { Write-Error "Blocked potentially destructive command: $pattern"; exit 1 } } +if ($Command -match '(?i)(^|\s)([A-Z]:\\|/)(?!.*' + [regex]::Escape($Workspace) + ')') { Write-Error 'Blocked command containing an absolute path outside the workspace.'; exit 1 } +Write-Output 'PASS command safety' diff --git a/shared/hooks/scripts/invoke-post-change-verification.sh b/shared/hooks/scripts/invoke-post-change-verification.sh new file mode 100644 index 0000000..9cca2e9 --- /dev/null +++ b/shared/hooks/scripts/invoke-post-change-verification.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=${1:?Repository root is required} +bash "$root/scripts/build.sh" +printf 'PASS post-change verification\n' diff --git a/shared/hooks/scripts/test-session-config.sh b/shared/hooks/scripts/test-session-config.sh new file mode 100644 index 0000000..58ab4ba --- /dev/null +++ b/shared/hooks/scripts/test-session-config.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=${1:?Repository root is required} +for path in shared adapters scripts docs AGENTS.md; do + [ -e "$root/$path" ] || { printf 'Missing repository input: %s\n' "$path" >&2; exit 1; } +done +printf 'PASS session configuration\n' diff --git a/shared/hooks/scripts/validate-command-safety.sh b/shared/hooks/scripts/validate-command-safety.sh new file mode 100644 index 0000000..4d3b629 --- /dev/null +++ b/shared/hooks/scripts/validate-command-safety.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +command_text=${1:?Command is required} +workspace=${2:-$PWD} +for pattern in 'git reset --hard' 'git clean -fd' 'git clean -fx' 'rm -rf' 'Remove-Item -Recurse' 'format c:' 'del /s /q'; do + [[ ${command_text,,} != *"${pattern,,}"* ]] || { printf 'Blocked potentially destructive command: %s\n' "$pattern" >&2; exit 1; } +done +printf 'PASS command safety\n' diff --git a/shared/statusline/statusline.ps1 b/shared/statusline/statusline.ps1 new file mode 100644 index 0000000..73ec014 --- /dev/null +++ b/shared/statusline/statusline.ps1 @@ -0,0 +1,11 @@ +$data = ($input | Out-String) | ConvertFrom-Json +$model = if ($data.model.display_name) { $data.model.display_name } else { '-' } +$effort = if ($data.effort.level) { $data.effort.level } else { '-' } +$directory = if ($data.workspace.current_dir) { $data.workspace.current_dir } else { $data.cwd } +$repo = if ($data.workspace.repo.name) { $data.workspace.repo.name } elseif ($directory) { Split-Path $directory -Leaf } else { '-' } +$branch = if ($directory -and (Get-Command git -ErrorAction SilentlyContinue)) { (& git -C $directory branch --show-current 2>$null | Select-Object -First 1) } else { $null } +if (-not $branch) { $branch = '-' } +$maxContext = if ($data.context_window.context_window_size) { [long]$data.context_window.context_window_size } else { 0 } +$usedContext = if ($null -ne $data.context_window.used_percentage) { "$([math]::Round([double]$data.context_window.used_percentage, [MidpointRounding]::AwayFromZero))%" } else { '-' } +$usedTokens = [long]$data.context_window.total_input_tokens + [long]$data.context_window.total_output_tokens +Write-Output "Model: $model | Effort: $effort | Repo: $repo | Branch: $branch | Max Context: $maxContext | Used Context: $usedContext | Used Tokens: $usedTokens" diff --git a/shared/statusline/statusline.sh b/shared/statusline/statusline.sh new file mode 100644 index 0000000..d644cbc --- /dev/null +++ b/shared/statusline/statusline.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +command -v jq >/dev/null 2>&1 || exit 0 +input= +while IFS= read -r line || [ -n "$line" ]; do input+="$line"; done +IFS=$'\t' read -r model effort repo directory max_context used_context used_tokens < <( + printf '%s' "$input" | jq -r '[ + (.model.display_name // "-"), + (.effort.level // "-"), + (.workspace.repo.name // "-"), + (.workspace.current_dir // .cwd // "."), + (.context_window.context_window_size // 0), + (if .context_window.used_percentage == null then "-" else ((.context_window.used_percentage | round | tostring) + "%") end), + ((.context_window.total_input_tokens // 0) + (.context_window.total_output_tokens // 0)) + ] | @tsv' +) +[ "$repo" != - ] || repo=$(basename -- "$directory") +[ -n "$repo" ] || repo=- +branch=$(git -C "$directory" branch --show-current 2>/dev/null || true) +[ -n "$branch" ] || branch=- +printf 'Model: %s | Effort: %s | Repo: %s | Branch: %s | Max Context: %s | Used Context: %s | Used Tokens: %s\n' "$model" "$effort" "$repo" "$branch" "$max_context" "$used_context" "$used_tokens"