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>
This commit is contained in:
jedarden 2026-07-02 13:48:36 -04:00
parent 7aec53faca
commit 930aeafd0f
3 changed files with 258 additions and 2 deletions

View file

@ -99,6 +99,9 @@ claude-print --timeout 30 "quick question"
| `--disallowedTools <LIST>` | | | Comma-separated list of disallowed tools |
| `--dangerously-skip-permissions` | | | Skip permission prompts (dangerous) |
| `--timeout <SECS>` | | `3600` | Wall-clock timeout in seconds |
| `--first-output-timeout <SECS>` | | `90` | First-output timeout in seconds (PTY output) |
| `--stream-json-timeout <SECS>` | | `90` | Stream-json first-output timeout in seconds |
| `--stop-hook-timeout <SECS>` | | `120` | Stop hook watchdog timeout in seconds |
| `--claude-binary <PATH>` | | PATH lookup | Path to claude binary |
| `--no-inherit-hooks` | | | Disable user hook inheritance |
| `--verbose` | | | Write timing traces to stderr |
@ -119,8 +122,7 @@ claude-print --timeout 30 "quick question"
| `0` | Success |
| `1` | Assistant error (`is_error: true` in transcript) |
| `2` | Internal error (PTY spawn, hook setup, parse failure) |
| `3` | Timeout exceeded |
| `4` | Bad input (missing prompt, unreadable file) |
| `124` | Timeout exceeded |
| `130` | Interrupted (SIGINT) |
## How it works

145
docs/notes/hook-design.md Normal file
View file

@ -0,0 +1,145 @@
# 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.

View file

@ -0,0 +1,109 @@
# Terminal Probes
Claude Code's TUI (built on Ink, a React/Yoga-based framework) sends DEC terminal queries at startup and hangs indefinitely if unanswered. The terminal emulator in `claude-print` scans PTY output for these probes and responds automatically.
## Probe Table
| Probe Bytes | Response Bytes | Name | Notes |
|-------------|---------------|------|-------|
| `ESC [ c` or `ESC [ 0 c` | `ESC [ ? 6 c` | DA1 (Device Attributes) | Primary terminal type query |
| `ESC [ > c` or `ESC [ > 0 c` | `ESC [ > 0 ; 0 ; 0 c` | DA2 (Device Attributes 2) | Secondary terminal type query |
| `ESC [ 6 n` | `ESC [ 1 ; 1 R` | DSR (Device Status Report) | Cursor position report |
| `ESC [ > q` or `ESC [ > 0 q` | `ESC P >\| claude-print ESC \` | XTVERSION (Terminal Identification) | DCS string with ST terminator |
| `ESC [ 1 8 t` | `ESC [ 8 ; <rows> ; <cols> t` | Window Size | Responds with configured dimensions |
### Response Details
- **DA1**: `ESC [ ? 6 c` — Indicates "VT102" compatibility level
- **DA2**: `ESC [ > 0 ; 0 ; 0 c` — Format: `> <version> ; <options> ; <rom-version>c`
- **DSR**: `ESC [ 1 ; 1 R` — Cursor at row 1, column 1
- **XTVERSION**: `ESC P >\| claude-print ESC \` — DCS string with identifier and ST (String Terminator = ESC + backslash)
- Note: The final two bytes are ESC (`0x1B`) + backslash (`0x5C`), not a backtick
- **Window Size**: `ESC [ 8 ; <rows> ; <cols> t` — Configured dimensions (default 220×50 from stty fallback)
## Implementation
The probe responder (`src/terminal.rs`) uses a byte-by-byte state machine to handle probes that may be split across chunk boundaries:
### State Machine
```
Empty → ESC received
Partial → accumulating CSI sequence
Complete → CSI sequence complete
Invalid → not a recognized probe
```
### CSI Format
A CSI (Control Sequence Introducer) sequence has the structure:
```
ESC [ <params> <final-byte>
```
- `<params>`: intermediate/parameter bytes in range `0x20-0x3F`
- `<final-byte>`: terminator in range `0x40-0x7E`
### Matching Logic
Probe identification:
```rust
// params = everything after ESC [ up to the final byte
if params == b"c" || params == b"0c" → DA1
else if params == b">c" || params == b">0c" → DA2
else if params == b"6n" → DSR
else if params == b">q" || params == b">0q" → XTVersion
else if params == b"18t" → WinSize
else → Unknown probe (silently ignored)
```
## Deduplication
Each probe type is answered at most once per session using a bitmask:
- Bit 0: DA1
- Bit 1: DA2
- Bit 2: DSR
- Bit 3: XTVersion
- Bit 4: WinSize
If the same probe is received again, no response is emitted.
## Unknown Sequences
Unknown escape sequences are **silently ignored** — they are never treated as an error. This ensures version-resilience: if Ink adds new probe types in future versions, `claude-print` will not hang; it simply won't respond to the unrecognized probes.
The startup sequencer has a fallback timeout (0.8 s idle after ≥ 200 bytes received) to cover cases where the terminal doesn't respond to all probes or emits unexpected output.
## Version Resilience
The probe responder is designed to survive Claude Code version changes:
1. **Unknown probes ignored**: New probe types won't crash the binary
2. **Split-chunk handling**: Probe bytes straddling chunk boundaries are correctly assembled
3. **Length cap**: Sequences exceeding `MAX_PROBE_LEN` (32 bytes) are discarded as invalid
4. **Lenient matching**: Both bare (`c`) and parameterized (`0c`) forms are recognized where applicable
## Window Size Fallback
Window size is probed in order:
1. `TIOCGWINSZ` on `STDOUT_FILENO`
2. `TIOCGWINSZ` on `STDIN_FILENO`
3. Open `/dev/tty` and `TIOCGWINSZ`
4. Fallback: `220 × 50`
In headless/NEEDLE mode, steps 13 fail and the fallback is always used.
## Integration with Event Loop
The terminal emulator runs on every chunk of PTY output in the event loop:
```
master_fd POLLIN → read chunk → feed to TerminalEmu → response bytes queued
next writable poll → write response bytes to master_fd
```
This ensures low-latency probe responses — Ink receives answers before its own timeouts.