feat(hooks): add cross-platform safety and status hooks
This commit is contained in:
@@ -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.
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user