feat(hooks): add cross-platform safety and status hooks

This commit is contained in:
Julian lechner
2026-09-11 14:42:57 +02:00
parent 54820797fa
commit 0189323e8f
11 changed files with 342 additions and 0 deletions
+3
View File
@@ -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.
+152
View File
@@ -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()
}
+114
View File
@@ -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
@@ -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'
@@ -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'
@@ -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'
@@ -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'
@@ -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'
@@ -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'