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:
parent
7aec53faca
commit
930aeafd0f
3 changed files with 258 additions and 2 deletions
|
|
@ -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
145
docs/notes/hook-design.md
Normal 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.
|
||||
109
docs/notes/terminal-probes.md
Normal file
109
docs/notes/terminal-probes.md
Normal 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 1–3 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.
|
||||
Loading…
Add table
Reference in a new issue