diff --git a/README.md b/README.md index 4f5104f..0609bc7 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,9 @@ claude-print --timeout 30 "quick question" | `--disallowedTools ` | | | Comma-separated list of disallowed tools | | `--dangerously-skip-permissions` | | | Skip permission prompts (dangerous) | | `--timeout ` | | `3600` | Wall-clock timeout in seconds | +| `--first-output-timeout ` | | `90` | First-output timeout in seconds (PTY output) | +| `--stream-json-timeout ` | | `90` | Stream-json first-output timeout in seconds | +| `--stop-hook-timeout ` | | `120` | Stop hook watchdog timeout in seconds | | `--claude-binary ` | | 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 diff --git a/docs/notes/hook-design.md b/docs/notes/hook-design.md new file mode 100644 index 0000000..7d0ff4b --- /dev/null +++ b/docs/notes/hook-design.md @@ -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: + +``` +/claude-print--/ +├── 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 > '' 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 /settings.json` + +## settings.json + +The per-run settings file contains only the Stop relay hook: + +```json +{ + "hooks": { + "Stop": [{ + "hooks": [{"type": "command", "command": "/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`: + +``` +/.claude/projects//.jsonl +``` + +Where `` 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//.jsonl` +- Writes session entry to `~/.claude/sessions/.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 /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. diff --git a/docs/notes/terminal-probes.md b/docs/notes/terminal-probes.md new file mode 100644 index 0000000..bf443a9 --- /dev/null +++ b/docs/notes/terminal-probes.md @@ -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 ; ; t` | Window Size | Responds with configured dimensions | + +### Response Details + +- **DA1**: `ESC [ ? 6 c` — Indicates "VT102" compatibility level +- **DA2**: `ESC [ > 0 ; 0 ; 0 c` — Format: `> ; ; 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 ; ; 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 [ +``` + +- ``: intermediate/parameter bytes in range `0x20-0x3F` +- ``: 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.