Autorun autorun-maintainer
Expertise in maintaining, debugging, and deploying the autorun hook system for Claude Code and Gemini CLI. Use when the user asks to "fix hooks", "deploy autorun", "debug hook errors", "update autorun version", or when troubleshooting "invisible failures" where safety guards appear inactive, piped commands are blocked, or work appears to have "reverted" after a session.
git clone https://github.com/ahundt/autorun
T=$(mktemp -d) && git clone --depth=1 https://github.com/ahundt/autorun "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/autorun/skills/autorun-maintainer" ~/.claude/skills/ahundt-autorun-autorun-maintainer && rm -rf "$T"
plugins/autorun/skills/autorun-maintainer/SKILL.mdAutorun Maintainer Skill: The Definitive Guide
You are a Senior QA and Release Engineer specialized in the autorun hook ecosystem. Your mission is to eliminate the "Zombie State" (code edited but hooks stale) and resolve "Invisible Failures" (UI masking the true cause).
1. The Debugging Philosophy: "Trust No UI"
Claude Code's "hook error" is a generic mask. Never trust the UI. You MUST follow the Diagnostic Hierarchy to find the root cause:
Step 1: Plumbing Check (~/.autorun/hook_entry_debug.log
)
~/.autorun/hook_entry_debug.log- Binary Selection: Verify
found the correct venv.get_autorun_bin() - Exit Codes: Did the CLI exit with
(Allow/Ask) or0
(Blocking Workaround)?2 - Raw Output: Check for non-JSON noise (UV warnings, logs) before or after the JSON block.
- Validation: Did
isolate exactly one valid block viaextract_json()
?json.loads
Step 2: Logic Check (~/.autorun/daemon.log
)
~/.autorun/daemon.log- FullPayload: Check
. Are expected keys present (e.g.,FullPayload
,_pid
)?_cwd - Timing: Check
. If duration > 9000ms, it will trigger a Claude timeout.DAEMON PROCESSING END - Piped Commands: If a command like
is blocked, verify thegit log | grep fix
predicate is registered in_not_in_pipe
.main.py:_PREDICATES
Step 3: Source Check (~/.autorun/daemon_startup.log
)
~/.autorun/daemon_startup.log- Stale Code: Is the daemon loading from
(STALE) or.../cache/...
(FRESH)?.../plugins/autorun/src/... - Identity: Confirm the Commit Hash and PID change on every restart.
2. Platform Schema Deep Dive (Claude v2.1.41)
Claude Code performs strict JSON validation. A single extra field in a lifecycle event causes a silent failure.
The "Hook Error" Matrix
| Symptom | Event Type | Cause | Resolution |
|---|---|---|---|
| "Invalid Input" | , | Sent or . | STRICT MODE: These events ONLY allow , , , and . |
| "Missing context" | , | Missing . | Map feedback to inside . |
| "JSON failed" | | Missing . | Must exist at top-level AND in . |
| "Double print" | All | printed noise. | Refactor to isolate and print exactly one JSON block. |
The "Ask" vs "Deny" Strategy
- The Conflict: Claude Code ignores
at exit 0.permissionDecision: "deny" - The Resolution:
- For AI-only feedback, use Exit 2 + Stderr (Bug #4669).
- For User-facing redirection (e.g., "Use trash instead of rm"), use
. This is the only way to ensure the redirection message is actually visible to the human.decision: "ask"
- Gemini Symmetry: Always map
->ask
for Gemini indeny
because Gemini respects JSONcore.py:respond()
and does not support thedeny
prompt.ask
3. Deployment & Synchronization Architecture
The "9-Location Bug" (Legacy)
Historically, fixes failed because the code was copied into 9 separate locations. We now use Symlink Architecture:
- UV Tool:
uv tool install --editable . - Gemini:
gemini extensions link /path/to/repo - Result: Edits in
reflect immediately in those binaries.src/
The "Stale Code Trap"
Source edits in
src/ are IGNORED by the persistent daemon until autorun --restart-daemon is run. NEVER assume code is active just because you saved the file.
The "One-Liner of Truth" (Mandatory)
uv run --project plugins/autorun python -m autorun --install --force && \ cd plugins/autorun && uv tool install --force --editable . && cd ../.. && \ autorun --restart-daemon
Critical Installer Fixes:
- Invisible Variable: For local marketplaces, Claude fails to substitute
.${CLAUDE_PLUGIN_ROOT}
MUST manually substitute this in theinstall.py
directory.~/.claude/plugins/cache/ - Path Doubling:
previously failed because it unconditionally appendedautorun --status
to the marketplace root. Discovery must be idempotent./plugins/autorun
4. Stability & Performance Insights
- 1GB Buffer Limit: Client and server must synchronize on a high buffer limit (e.g., 1GB). Large session transcripts (500MB+) will crash the hook with
if left at default (64KB).asyncio.LimitOverrunError - Session ID Fallback: If
is missing,CLAUDE_SESSION_ID
must use a PID-based fallback to preventcore.py
crashes during startup hooks.NoneType - Socket Polling:
must userestart_daemon.py
socket checks rather thanis_daemon_responding()
. Fragile sleeps lead to race conditions where the client tries to connect before the server is bound.time.sleep() - Plan Recovery:
uses a "Fresh Context" workaround (Option 1). It must track plan writes in a global database to recover them across session restarts.plan_export.py
5. UI/UX: Formatting & Anti-Duplication
- Avoid Double-Escaping: Never call
on strings that will be put into a dict. This causes literaljson.dumps
in the UI. Pass raw strings; let the final\n
handle encoding.print(json.dumps()) - Anti-Reversion Warning: Beware of context "compaction." If the AI summarizes the session, it may lose the "Fact" that a fix was applied and accidentally revert code via
. Always verify the disk state after compaction.git checkout
6. Official & Internal References
- Claude Hooks Reference: https://code.claude.com/docs/en/hooks
- Claude Schema Output: https://code.claude.com/docs/en/hooks#json-output
- Gemini Hooks Reference: https://geminicli.com/docs/hooks/reference/
- Claude Bug #4669 (Exit 2): https://claude.com/blog/how-to-configure-hooks
- Internal Path Ref:
notes/autorun_install_paths_reference.md - Lessons Learned:
notes/2026_02_11_lessons_learned_hook_failure_loop_prevention.md
7. Mandatory Verification Checklist
Before declaring a task "Complete," you MUST:
- Schema Test:
echo '{"hook_event_name":"PreToolUse", "tool_name":"Bash", "tool_input":{"command":"rm test"}}' | autorun - Metadata Test:
(Verify commit matches current git).autorun --version - Restart Test: Confirm PID in
has changed.~/.autorun/daemon.lock - Path Test: Verify
does NOT contain~/.claude/plugins/cache/autorun/autorun/0.10.1/hooks/hooks.json
.${CLAUDE_PLUGIN_ROOT} - Pipes Test:
(Should be ALLOWED).cargo build 2>&1 | head -50 - Status Test:
(Ensure paths aren't doubled).autorun --status
8. Detailed Architectural Inventory (The 9 Locations)
If synchronization fails, verify these locations for stale code:
- Git Source:
plugins/autorun/src/autorun/ - Dev Venv:
plugins/autorun/.venv/lib/python*/site-packages/autorun/ - Build Artifacts:
(DELETE THIS)plugins/autorun/build/ - Claude Cache:
~/.claude/plugins/cache/autorun/autorun/0.10.1/ - UV Tool:
(Must be editable)~/.local/share/uv/tools/autorun/ - Gemini Extension:
(Must be symlink)~/.gemini/extensions/ar/ - Gemini Venv:
~/.gemini/extensions/ar/.venv/ - Gemini Workspace:
~/.gemini/extensions/pdf-extractor/ - Gemini Build:
(DELETE THIS)~/.gemini/extensions/ar/build/
9. Loop Detection Checklist
You are in a "Failure Loop" if:
- Tests Pass, Hooks Fail: Unit tests use source directly; hooks use stale binaries.
- "Fixed" Code Reappears: Alternating additions/removals of the same lines in git history.
- Multiple Daemons:
> 1.pgrep -f "autorun.daemon" | wc -l - User Reports Broken rm: Safety guards appear inactive despite "Fix" commits.
10. Common Technical Pitfalls
- Stdin Consumption: Never read
insidesys.stdin
. Read it once at the entry point and pass it down, otherwise fallbacks will receive empty input.try_cli() - UV Warnings: Using deprecated fields like
intool.uv.default-extras
causes warnings onpyproject.toml
. Claude Code treats this as a hook error.stderr - PID Management: Always use
after changes. Stale processes bind the socket and prevent new code from running.pkill -f "autorun.daemon" - Bytecode Cache:
can persist stale logic. The restart script must purge these explicitly.__pycache__
11. Testing Strategy (Triple-Layer)
- Unit (integrations.py): Test predicate logic (e.g.,
)._not_in_pipe - Integration (main.py): Test
with real predicates.should_block_command() - E2E (hook_entry.py): Test the full subprocess execution path with fake JSON payloads.
Synthetic Verification Examples:
# SessionStart echo '{"hook_event_name":"SessionStart"}' | autorun # PreToolUse (rm block) echo '{"hook_event_name":"PreToolUse", "tool_name":"Bash", "tool_input":{"command":"rm test"}}' | autorun # Piped Command (Allow check) echo '{"hook_event_name":"PreToolUse", "tool_name":"Bash", "tool_input":{"command":"git log | grep fix"}}' | autorun
12. Daemon Architecture & Lifecycle
The daemon is the high-performance "Brain" of autorun. It minimizes hook latency to 1-5ms.
Core Components:
- Unix Domain Socket (
): High-speed communication path. Bypasses the overhead of TCP/IP.~/.autorun/daemon.sock - Shared Magic State (
): Persistent key-value store. Allows hooks to share state (e.g.,shelve
) across multiple independent subprocess invocations.autorun_stage - Watchdog Mechanism: The daemon monitors parent PIDs. If the spawning CLI (Claude/Gemini) dies, the daemon self-terminates after an idle timeout (30min) to prevent resource leakage.
- Tri-Layer Session Identity:
- Layer 1:
/CLAUDE_SESSION_ID
(Direct).GEMINI_SESSION_ID - Layer 2: Parent PID fallback (If env var is lost).
- Layer 3: Current Working Directory fallback.
- Layer 1:
Critical Daemon Gotchas:
- Socket Binding: If the
file exists but no process is running,.sock
will fail to connect. The restart script MUST clean up stale socket files.client.py - Zombie Daemons: Multiple daemons running from different code versions will cause non-deterministic hook behavior. One might allow
while another blocks it. Always audit withrm
.pgrep - Blocking vs. Non-Blocking IO: The daemon uses
. Any synchronousasyncio
or blocking subprocess call in a hook handler will freeze ALL hooks for ALL active sessions.time.sleep()
13. Full Hook Repair & Connectivity Guide
If hooks fail to connect or present errors, follow this repair guide.
Connectivity Failure Matrix
| Symptom | Probable Cause | Diagnostic Command | Repair Action |
|---|---|---|---|
| "Connection Refused" | Daemon not running or socket stale. | | Run . |
| "No such file" (Hook CLI) | missing. | | Run . |
| "ImportError" | Python deps missing in venv. | | Run . |
| "Hang" (Claude wait) | Daemon frozen or buffer full. | `ps aux | grep autorun.daemon` |
| "Hook Error" (UI) | Stderr noise or bad JSON. | | Check for double-printing or UV warnings. |
The "Silent Fail-Open" Trap
Claude Code fails OPEN. If a hook script crashes, the tool (e.g.,
rm) will execute without warning.
- Verification: If
doesn't block, checkrm
. If it's empty, the script didn't even start (path issue).hook_entry_debug.log
Connectivity Specs:
- Protocol: JSON-over-STDIN (In), JSON-over-STDOUT (Out).
- Socket Type:
(Unix Domain Socket).AF_UNIX - Default Timeout: 10 seconds (Claude), 5 seconds (Gemini).
- Buffer Limit: 1GB (Synchronized in
andclient.py
).core.py
Reference Guide for Repairs:
- Official Hook Specs: https://code.claude.com/docs/en/hooks
- Claude JSON Output Ref: https://code.claude.com/docs/en/hooks#json-output
- Gemini Hook Reference: https://geminicli.com/docs/hooks/reference/
- Asyncio Stream Ref: https://docs.python.org/3/library/asyncio-stream.html
14. Deep Dive: Solving the "Hook Error" Loop
The "Hook Error" was the most persistent failure mode. It manifests as a generic UI message but represents three distinct layers of failure.
Layer 1: The Schema Violation ("Invalid Input")
Claude Code's JSON validator is event-specific. A field valid for one event will crash another.
- Symptom:
Stop: hook error: JSON validation failed: - : Invalid input - The Trap: Sending
ordecision
in a lifecycle event.reason - The Schema Source of Truth:
- PreToolUse: MUST have
at root AND inpermissionDecision
. Top-levelhookSpecificOutput
must bedecision
or"approve"
."block" - UserPromptSubmit / PostToolUse: MUST have
inadditionalContext
.hookSpecificOutput - Stop / SessionStart: MUST NOT have
,decision
, orreason
.hookSpecificOutput
- PreToolUse: MUST have
- Solution: The
method invalidate_hook_response()
acts as a strict whitelist filter per event type.core.py
Layer 2: The Plumbing Noise ("Double-Printing")
Any non-JSON output on
stdout causes a parsing error.
- Symptom:
Hook JSON output validation failed: Unexpected token '{' at position 120 - The Trap:
- Double JSON:
prints JSON, thenclient.py
prints it again.hook_entry.py - UV Noise:
printing "warning: tool.uv.default-extras is deprecated".uv run - Logs: Stray
in the source code.print("Debug: ...")
- Double JSON:
- Solution:
- Refactor
to usehook_entry.py
which finds exactly oneextract_json()
block using{...}
validation.json.loads - Use
(file-only) instead oflogger.info
for all internal status messages.print
- Refactor
Layer 3: The Execution Gap ("No such file")
The hook script is registered but cannot be found or executed.
- Symptom:
Stop hook error: can't open file '${CLAUDE_PLUGIN_ROOT}/hooks/hook_entry.py': [Errno 2] No such file or directory - The Trap:
- Missing Substitution: Claude fails to replace
for local marketplaces.${CLAUDE_PLUGIN_ROOT} - Partial Install:
directory skipped duringhooks/
due to path logic.shutil.copytree
- Missing Substitution: Claude fails to replace
- Solution:
must manuallyinstall.py
-replace the variables insed
.~/.claude/plugins/cache/- Verify existence with:
.ls -l ~/.claude/plugins/cache/autorun/autorun/0.10.1/hooks/hook_entry.py
Layer 4: The Silent Ignore (Bug #4669)
The hook "succeeds" (exit 0) but the safety guard is ignored.
- Symptom:
command prompts for "remove file?" instead of being blocked.rm - The Trap: Claude Code ignores
if the process exits with code 0.permissionDecision: "deny" - Solution: The Exit 2 Workaround. You MUST print the reason to
andstderr
to trigger an actual block that the AI sees.sys.exit(2)
15. Stream Protocol & Stderr/Stdout Sensitivity
Claude Code interprets
stdout and stderr differently based on the exit code. Mismanaging these streams is the primary cause of "Hook Errors."
The stderr
Sensitivity Rules
stderr| Exit Code | Content | Claude Code Result |
|---|---|---|
| 0 (Success) | Any characters | FAILURE: Treated as "hook error". JSON is ignored. |
| 0 (Success) | Empty | SUCCESS: JSON is parsed and processed. |
| 2 (Block) | Reason string | SUCCESS: Tool blocked. Reason is fed to AI as feedback. |
| 2 (Block) | Empty | SUCCESS: Tool blocked. AI gets generic "Tool failed" message. |
Meta-Rule: NEVER use
print() for logging in hook paths. Use a file-only logger (e.g., logging_utils.py) to keep stdout/stderr pristine.
The "Exactly One JSON" Rule (stdout
)
stdoutClaude's parser is fragile. If
stdout contains anything other than a single valid JSON block, it fails.
- The Problem:
warnings, daemon status logs, or multipleuv run
calls.print(json.dumps()) - The Fix:
must use a robust extractor:hook_entry.py- Capture all
.stdout - Use a sliding window or regex to find the last
block.{...} - Validate with
.json.loads() - Print only that block and exit.
- Capture all
UI Clutter: The Triple-Print & Double-Escape
- Triple-Print: Claude displays three fields simultaneously:
,systemMessage
, andhookSpecificOutput.permissionDecisionReason
(at exit 2).stderr- Solution: For
decisions, empty the top-level fields indeny
to show only one clean message.core.py:respond()
- Solution: For
- Double-Escape: Occurs when you manually escape a string (e.g., replacing
with\n
) and then pass it to\\n
.json.dumps()- Result: User sees literal
text instead of newlines.\n - Solution: Always pass raw strings through the internal logic. Let the final
at the system boundary handle the encoding.json.dumps()
- Result: User sees literal