From 13eefe3309b1461095de1af036ffa184068d67a0 Mon Sep 17 00:00:00 2001 From: Julian lechner Date: Fri, 18 Sep 2026 16:17:21 +0200 Subject: [PATCH] fix(hooks): ignore optional hook failures --- docs/hooks.md | 2 +- scripts/commands/doctor.ps1 | 2 +- scripts/tests/test-session-state.py | 30 +++++++++++++++++++ shared/hooks/flashbang.ps1 | 4 +++ shared/hooks/flashbang.sh | 4 +-- shared/hooks/scripts/Record-Compact.ps1 | 23 +++++++------- .../scripts/Show-SessionStatePointer.ps1 | 7 +++-- shared/hooks/scripts/record-compact.sh | 4 +-- .../scripts/show-session-state-pointer.sh | 4 +-- 9 files changed, 60 insertions(+), 20 deletions(-) diff --git a/docs/hooks.md b/docs/hooks.md index e3c46b7..fdca55e 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -12,7 +12,7 @@ The adapters register these events: The main agent alone maintains canonical task state. Hooks never write or summarize it, print its contents, or infer completed verification. They use the hook event's `cwd`, falling back to the process working directory when no workspace is supplied. Malformed event data does not fall back to another project. An absent state file is a successful no-op. Legacy state under the installed configuration root is not automatically adopted; confirm its task before migrating it. The scripts do not grant permissions, bypass approvals, or replace client permission systems. -When enabled during installation or update, Flashbang runs on `Stop`, after the main agent finishes responding. The default flash lasts about 500 milliseconds. The PowerShell command omits `-WindowStyle Hidden`, which can hide or minimize the shared terminal. The PowerShell hook creates a low-opacity click-through overlay without activating another window when its native GUI APIs are available; unsupported runtimes exit safely. The Bash hook uses Python's `tkinter`, uses `xrandr` for multi-monitor bounds when available, and falls back to `notify-send` when the overlay is unavailable. +When enabled during installation or update, Flashbang runs on `Stop`, after the main agent finishes responding. The default flash lasts about 500 milliseconds. The PowerShell command omits `-WindowStyle Hidden`, which can hide or minimize the shared terminal. The PowerShell hook creates a low-opacity click-through overlay without activating another window when its native GUI APIs are available; unsupported runtimes exit safely. The Bash hook uses Python's `tkinter`, uses `xrandr` for multi-monitor bounds when available, and falls back to `notify-send` when the overlay is unavailable. Registered hooks are best effort: errors do not fail a Claude session. `Validate-CommandSafety`/`validate-command-safety.sh` and `Invoke-PostChangeVerification`/`invoke-post-change-verification.sh` are unregistered utilities. The former checks command text for selected dangerous patterns and outside-workspace paths; it is not a complete security boundary. The latter explicitly runs the repository build when invoked; it is not an automatic requirement after each change. diff --git a/scripts/commands/doctor.ps1 b/scripts/commands/doctor.ps1 index 5815049..abe3cf1 100644 --- a/scripts/commands/doctor.ps1 +++ b/scripts/commands/doctor.ps1 @@ -83,7 +83,7 @@ foreach ($path in @('shared/rules','shared/skills','shared/agents','shared/hooks foreach ($client in @('codex','claude')) { $config = if ($client -eq 'codex') { Join-Path $root 'adapters/codex/config/config.toml' } else { Join-Path $root 'adapters/claude/config/settings.json' } - $registered = if ($client -eq 'codex') { (Get-Content $config -Raw) -match '\[\[hooks\.Stop\]\]' } else { ((Get-Content $config -Raw | ConvertFrom-Json).hooks.PSObject.Properties.Name -contains 'Stop') } + $registered = if ($client -eq 'codex') { (Get-Content $config -Raw) -match '\[\[hooks\.Stop\]\]' } else { (Get-Content $config -Raw) -match '"Stop"\s*:' } if ($registered) { Result 'PASS' "$client Stop hook is registered" } else { Result 'WARN' "$client ships hook utilities, but no Stop hook is registered" } } diff --git a/scripts/tests/test-session-state.py b/scripts/tests/test-session-state.py index 61c80dd..0b07db6 100644 --- a/scripts/tests/test-session-state.py +++ b/scripts/tests/test-session-state.py @@ -186,6 +186,36 @@ class SessionStateHookTests(unittest.TestCase): self.assertEqual(self.run_hook(shell, executable, hook, config, workspace, ''), '') self.assertEqual(list(workspace.iterdir()), []) + def test_hooks_ignore_missing_helper(self): + for shell, executable in SHELLS: + if not executable or not Path(executable).exists(): + continue + with self.subTest(shell=shell), tempfile.TemporaryDirectory() as directory: + base = Path(directory) + config = base / 'config' + shutil.copytree(ROOT / 'shared/hooks/scripts', config / 'hooks/scripts') + (config / 'hooks/scripts/session-state.py').unlink() + workspace = base / 'workspace' + workspace.mkdir() + for hook in ('pointer', 'compact'): + self.assertEqual(self.run_hook(shell, executable, hook, config, workspace, ''), '') + + def test_flashbang_ignores_unavailable_runtime(self): + for shell, executable in SHELLS: + if not executable or not Path(executable).exists(): + continue + with self.subTest(shell=shell): + environment = dict(os.environ) + if shell == 'powershell': + environment['LOCALAPPDATA'] = '' + command = [executable, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', + str(ROOT / 'shared/hooks/flashbang.ps1')] + else: + environment['PATH'] = '' + command = [executable, str(ROOT / 'shared/hooks/flashbang.sh')] + result = subprocess.run(command, env=environment, capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stderr) + if __name__ == '__main__': unittest.main() diff --git a/shared/hooks/flashbang.ps1 b/shared/hooks/flashbang.ps1 index 2be755c..e600453 100644 --- a/shared/hooks/flashbang.ps1 +++ b/shared/hooks/flashbang.ps1 @@ -43,6 +43,7 @@ param( $ErrorActionPreference = 'Stop' +try { # PowerShell 6+ runs on Linux and macOS, where none of this applies. if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) { exit 0 } @@ -150,3 +151,6 @@ try { } finally { $form.Dispose() } +} catch { + exit 0 +} diff --git a/shared/hooks/flashbang.sh b/shared/hooks/flashbang.sh index cf29136..82170ac 100755 --- a/shared/hooks/flashbang.sh +++ b/shared/hooks/flashbang.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -set -euo pipefail +set -uo pipefail # Bash equivalent of flashbang.ps1. The visual overlay is implemented with # Python's standard tkinter module because Bash itself cannot create windows. @@ -10,7 +10,7 @@ if ! command -v python3 >/dev/null 2>&1; then exit 0 fi -exec python3 - "$@" <<'PY' +python3 - "$@" 2>/dev/null <<'PY' || exit 0 import argparse import re import shutil diff --git a/shared/hooks/scripts/Record-Compact.ps1 b/shared/hooks/scripts/Record-Compact.ps1 index ffb5f9b..25e0d7e 100644 --- a/shared/hooks/scripts/Record-Compact.ps1 +++ b/shared/hooks/scripts/Record-Compact.ps1 @@ -1,12 +1,15 @@ [CmdletBinding()] param() -$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent -$stateDir = Join-Path $root '.ai-session' -if ($env:AI_CONFIG_TELEMETRY -ne '0') { - New-Item $stateDir -ItemType Directory -Force | Out-Null - $path = Join-Path $stateDir 'telemetry.jsonl' - ('{"event":"compact","timestamp":"' + (Get-Date).ToUniversalTime().ToString('o') + '"}') | Add-Content $path - if ((Get-Item $path).Length -gt 1048576) { Move-Item $path ($path + '.1') -Force } -} -& python (Join-Path $PSScriptRoot 'session-state.py') --hook -exit $LASTEXITCODE +try { + $ErrorActionPreference = 'Stop' + $root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $stateDir = Join-Path $root '.ai-session' + if ($env:AI_CONFIG_TELEMETRY -ne '0') { + New-Item $stateDir -ItemType Directory -Force | Out-Null + $path = Join-Path $stateDir 'telemetry.jsonl' + ('{"event":"compact","timestamp":"' + (Get-Date).ToUniversalTime().ToString('o') + '"}') | Add-Content $path + if ((Get-Item $path).Length -gt 1048576) { Move-Item $path ($path + '.1') -Force } + } + & python (Join-Path $PSScriptRoot 'session-state.py') --hook 2>$null +} catch {} +exit 0 diff --git a/shared/hooks/scripts/Show-SessionStatePointer.ps1 b/shared/hooks/scripts/Show-SessionStatePointer.ps1 index 67e01c3..8406184 100644 --- a/shared/hooks/scripts/Show-SessionStatePointer.ps1 +++ b/shared/hooks/scripts/Show-SessionStatePointer.ps1 @@ -1,4 +1,7 @@ [CmdletBinding()] param() -& python (Join-Path $PSScriptRoot 'session-state.py') --hook -exit $LASTEXITCODE +try { + $ErrorActionPreference = 'Stop' + & python (Join-Path $PSScriptRoot 'session-state.py') --hook 2>$null +} catch {} +exit 0 diff --git a/shared/hooks/scripts/record-compact.sh b/shared/hooks/scripts/record-compact.sh index ace9734..3e7590e 100644 --- a/shared/hooks/scripts/record-compact.sh +++ b/shared/hooks/scripts/record-compact.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -set -euo pipefail +set -uo pipefail root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) [ "${AI_CONFIG_TELEMETRY:-1}" = 0 ] || { mkdir -p "$root/.ai-session" && printf '{"event":"compact","timestamp":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$root/.ai-session/telemetry.jsonl"; [ "$(wc -c < "$root/.ai-session/telemetry.jsonl")" -le 1048576 ] || mv -f "$root/.ai-session/telemetry.jsonl" "$root/.ai-session/telemetry.jsonl.1"; } -python3 "$root/hooks/scripts/session-state.py" --hook +python3 "$root/hooks/scripts/session-state.py" --hook 2>/dev/null || true diff --git a/shared/hooks/scripts/show-session-state-pointer.sh b/shared/hooks/scripts/show-session-state-pointer.sh index b2cf60e..87df00f 100644 --- a/shared/hooks/scripts/show-session-state-pointer.sh +++ b/shared/hooks/scripts/show-session-state-pointer.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash -set -euo pipefail +set -uo pipefail script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -python3 "$script_dir/session-state.py" --hook +python3 "$script_dir/session-state.py" --hook 2>/dev/null || true