claude-print/docs/notes/hook-design.md
jedarden 930aeafd0f docs(bf-1v8): fix README exit codes and sync flags table, add docs/notes stubs
- Fix exit-code table: change incorrect codes 3/4 to correct values 124 (timeout) and 130 (interrupted), matching src/error.rs implementation
- Add missing timeout flags to flags table: --first-output-timeout (90s), --stream-json-timeout (90s), --stop-hook-timeout (120s), matching src/cli.rs defaults
- Add docs/notes/hook-design.md covering relay hook mechanics, FIFO protocol, and keeper fd pattern (from src/hook.rs, src/poller.rs)
- Add docs/notes/terminal-probes.md covering Ink probe table and response bytes (from src/terminal.rs)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 13:57:00 -04:00

145 lines
5 KiB
Markdown

# Hook Design
The Stop hook is the IPC mechanism between Claude Code and `claude-print`. When the AI completes a turn, Claude Code fires the Stop hook with a JSON payload containing session metadata; `claude-print` reads this from a FIFO to know when the response is ready.
## Temp Directory Structure
Each `claude-print` invocation creates a per-run temp directory:
```
<TMPDIR>/claude-print-<pid>-<rand>/
├── settings.json # Relay Stop hook configuration
├── hook.sh # Hook script executed by Claude Code
└── stop.fifo # Named pipe for IPC
```
The temp dir is created with mode `0700` (owner-only) to prevent local users from reading the Stop payload (session ID, prompt text).
## Relay Hook
The relay hook is a minimal Claude Code Stop hook that writes the Stop payload to the FIFO:
```sh
#!/bin/sh
cat > '<fifo-path>' 2>/dev/null || true
```
Key points:
- The FIFO path is embedded as a shell single-quoted string — no variable expansion at execution time
- If FIFO write fails, the hook exits cleanly (`|| true`); Claude Code does not wait beyond the 10s timeout
- The hook is executed by Claude Code via `--settings <temp>/settings.json`
## settings.json
The per-run settings file contains only the Stop relay hook:
```json
{
"hooks": {
"Stop": [{
"hooks": [{"type": "command", "command": "<temp>/hook.sh", "timeout": 10}]
}]
}
}
```
Claude Code merges this with any user hooks from `~/.claude/settings.json`, so both user hooks and the relay hook fire.
## FIFO Protocol
The FIFO is a POSIX named pipe created with `mkfifo(path, mode=0600)`:
- **Writer**: Claude Code executes `hook.sh`, which opens the FIFO for writing and writes the JSON payload
- **Reader**: `claude-print` opens the FIFO for reading and blocks until data is available
### Stop Hook Payload
All fields are optional for forward compatibility:
```json
{
"hook_event_name": "Stop",
"session_id": "abc123",
"transcript_path": "/home/user/.claude/projects/home-user-myproject/abc123.jsonl",
"last_assistant_message": "Response text...",
"cwd": "/home/user/myproject"
}
```
### Transcript Path Derivation
If `transcript_path` is absent from the payload, it is derived from `session_id` and `cwd`:
```
<HOME>/.claude/projects/<slug>/<session_id>.jsonl
```
Where `<slug>` is the `cwd` with leading `/` stripped and remaining `/` replaced with `-`:
```
/home/user/myproject → home-user-myproject
/tmp → tmp
```
## Keeper FD Pattern
Linux FIFO semantics: `open(O_WRONLY|O_NONBLOCK)` returns `ENXIO` if no reader is present. To prevent the hook's `cat > fifo` from blocking or failing, `claude-print` uses a **keeper write-end fd**:
1. Open FIFO read-end `O_RDONLY|O_NONBLOCK` — always succeeds immediately
2. Open keeper write-end `O_WRONLY|O_NONBLOCK` — succeeds because read-end is now open
3. Hold keeper write-end open until Stop fires
4. When Stop fires, read the payload, then close the keeper write-end
5. Claude Code's `cat > fifo` opens its own write-end (simultaneous write-ends are valid)
### Cleanup on Non-Stop Exit Paths
On exit paths where Stop never fires (SIGINT, timeout, child exit), the keeper write-end **must be explicitly closed** before `waitpid`:
- Closing the keeper causes any pending `cat > fifo` in `hook.sh` to receive `EPIPE`/`ENXIO` and exit
- Without this, the hook runner would hang indefinitely waiting for a reader that will never come
## FIFO Poller States
```
UNOPENED
│ opened O_NONBLOCK at TRUST_DISMISSED → PROMPT_INJECTED transition
OPEN_WAITING
│ FIFO becomes readable (Stop hook wrote payload)
PAYLOAD_READ → DONE
```
The FIFO read-end is opened **before** the bracketed paste is injected (at the `TRUST_DISMISSED → PROMPT_INJECTED` transition), so Stop cannot fire before the reader is ready.
## Hook Inheritance
### Default Mode (inherit hooks)
By default, `claude-print` does not redirect `CLAUDE_CONFIG_DIR`. The inner `claude` process:
- Writes transcripts to `~/.claude/projects/<cwd-slug>/<session-id>.jsonl`
- Writes session entry to `~/.claude/sessions/<pid>.json`
- Appends to `~/.claude/history.jsonl`
- Fires all hooks in `~/.claude/settings.json` alongside the relay hook
### Isolation Mode (--no-inherit-hooks)
When `--no-inherit-hooks` is passed:
- `--setting-sources=` (empty) is forwarded to claude — suppresses loading of standard settings sources
- Only `--settings <temp>/settings.json` is active — contains solely the relay hook
- User hooks (SessionStart, Stop, PreToolUse, ccdash, trail-boss, etc.) do not fire
Use this mode for NEEDLE workers to prevent hook noise, or when user hooks have side effects.
## Cleanup
The temp directory is cleaned up on all exit paths via `tempfile::TempDir` drop:
- Normal exit: Stop fires, payload read, cleanup completes
- Timeout: keeper write-end closed, child SIGTERM'd, cleanup runs
- SIGINT: interrupted flag set, poll loop breaks, cleanup runs
- Panic: `TempDir` destructor runs during unwind
Cleanup is idempotent — can be called multiple times safely. The FIFO is removed before the directory to avoid permission issues.