Commit graph

35 commits

Author SHA1 Message Date
jedarden
e97a8413b5 feat(bf-2f5): watchdog timeout implementation complete
Implement comprehensive watchdog timeout mechanism to prevent indefinite hangs
when child process wedges. All four timeout types are now enforced:

Timeout Types:
- PTY first-output timeout (default 90s): Detects if child produces no PTY output
- Stream-json first-output timeout (default 90s): Detects if child emits no stream-json events
- Overall timeout (default 3600s): Maximum session duration
- Stop hook timeout (default 120s): Detects if Stop hook doesn't fire after prompt injection

Timeout Behavior:
- Sends SIGTERM to child process
- Signals event loop via self-pipe to wake up
- Calls kill_child() which waits 2s then SIGKILL if needed
- Writes clear diagnostic to stderr
- Tears down temp resources via CleanupGuard
- Exits with code 124 (GNU timeout convention)

CLI Arguments:
- --timeout <seconds>: Overall timeout
- --first-output-timeout <seconds>: PTY first-output timeout
- --stream-json-timeout <seconds>: Stream-json first-output timeout
- --stop-hook-timeout <seconds>: Stop hook watchdog timeout

Testing:
- All 90 unit tests pass (6 watchdog-specific tests)
- Integration tests verify end-to-end timeout behavior

This ensures marathon loop/NEEDLE can retry cleanly instead of hanging indefinitely.
2026-06-25 13:39:29 -04:00
jedarden
a19e2b0aed chore(bf-2w7): verify cleanup implementation is complete and remove unused imports
- Confirm comprehensive cleanup on all exit paths:
  - Startup orphan sweep via cleanup_orphans()
  - RAII cleanup guard (CleanupGuard)
  - process::exit cleanup via exit_with_cleanup()
  - Signal safety via self-pipe pattern
  - Watchdog timeout cleanup via self-pipe signaling
  - Panic safety via catch_unwind

- Remove unused imports from watchdog.rs and session.rs

All cleanup paths verified:
✓ Normal exit → CleanupGuard drop
✓ Error return → CleanupGuard drop
✓ Timeout → Self-pipe → Event loop exit → CleanupGuard drop
✓ Signal → Handler writes to self-pipe → Event loop exit → CleanupGuard drop
✓ Panic → catch_unwind → CleanupGuard drop
2026-06-25 09:32:01 -04:00
jedarden
ea162c09a3 fix(bf-2f5): correct timeout exit code from 3 to 124
The watchdog mechanism was complete but had an inconsistency:
main.rs used exit code 3 for timeout errors while ClaudePrintError::Timeout.exit_code()
returned 124 (GNU timeout convention). Now uses the proper exit code from the error type.

This ensures timeout errors exit with the standard code 124, matching GNU timeout
behavior and making error handling consistent for callers (marathon loop/NEEDLE).
2026-06-25 08:33:00 -04:00
jedarden
07013f8009 feat(bf-2w7): add self-pipe signaling to watchdog timeout mechanism
This improvement ensures that when a watchdog timeout occurs, the event
loop wakes up immediately (via self-pipe write) rather than waiting for
the poll timeout. This allows for faster and more responsive cleanup on
timeout, ensuring temp dirs and FIFOs are removed promptly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 08:18:34 -04:00
jedarden
6676dc483b feat(bf-2w7): add comprehensive cleanup on all exit paths
- Add cleanup_performed flag to HookInstaller for idempotent cleanup
- Add Drop implementation to HookInstaller for automatic cleanup
- Enhance cleanup() to explicitly remove both FIFO and temp directory
- Ensure temp dirs are cleaned up on normal exit, error, timeout, signals, and panic
- cleanup_orphans() already called at startup to sweep stale temp dirs

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 07:42:17 -04:00
jedarden
7d40c937fb feat(bf-2f5): add comprehensive watchdog timeout mechanism
Implement a complete watchdog timeout system that ensures hung child
processes are terminated cleanly with proper diagnostics and cleanup.

Features:
- PTY first-output timeout (default 90s): detects if child produces no PTY output
- Stream-json first-output timeout (default 90s): detects if child produces no stream-json events
- Overall session timeout (default 3600s): prevents indefinite hangs
- Stop hook watchdog timeout (default 120s): detects if Stop hook doesn't fire after prompt injection

Timeout handling:
- Sends SIGTERM to child process when timeout fires
- kill_child() ensures SIGTERM → SIGKILL sequence (2s grace period)
- Writes clear diagnostic to stderr indicating timeout type
- Emits stream-json error event for downstream consumers
- CleanupGuard ensures temp dir/FIFO cleanup on all exit paths
- Returns Error::Timeout and exits non-zero (code 3) for retry loop

Fixes:
- Pass temp_dir_path to Watchdog so stream-json monitoring works correctly
- Remove unused constants (duplicates of watchdog module defaults)
- Improve mock-claude binary path resolution for workspace builds

This prevents the indefinite hang that occurs when Claude Code wedges
during session initialization or tool use, ensuring marathon loops and
NEEDLE can retry cleanly instead of blocking forever.

Bead-Id: bf-2f5
2026-06-25 07:42:17 -04:00
jedarden
54834e5070 feat(bf-2f5): add comprehensive watchdog timeout mechanism
- Add Watchdog module with 4 timeout types:
  * PTY first-output timeout (90s default)
  * Stream-json first-output timeout (90s default)
  * Overall session timeout (3600s default)
  * Stop hook watchdog timeout (120s default)
- Timeout thread monitors child and sends SIGTERM on deadline
- Main thread detects timeout, kills child (SIGTERM→SIGKILL), exits non-zero (code 3)
- Clear diagnostics to stderr with specific timeout descriptions
- CleanupGuard ensures temp dir/FIFO removal on all exit paths
- Add CLI flags: --timeout, --first-output-timeout, --stream-json-timeout, --stop-hook-timeout
- Integration tests verify timeout fires and cleanup succeeds

This prevents indefinite hangs regardless of why child wedges.

Bead-Id: bf-2f5
2026-06-25 06:59:23 -04:00
jedarden
93ced10afd fix(bf-2u1): prevent global settings inheritance to avoid startup hang
Root cause: Child claude hangs at startup when global settings containing
hooks (SessionStart, SessionEnd, etc.) are inherited despite creating a
temp settings.json with only a Stop hook.

When --settings=<temp_path> is passed without --setting-sources=, Claude Code
merges temp settings with global settings. Global hooks fire and may hang,
causing the child to never produce output and the first-output timeout to fire.

Fix: Always pass --setting-sources= to child claude (src/session.rs:127-129)
to prevent global settings inheritance. This ensures ONLY the temp settings.json
is loaded, preventing any global hooks from causing hangs.

Evidence: Documented in notes/bf-2u1-findings.md and notes/bf-2u1-investigation.md

Related beads:
- bf-2w7: temp dir and FIFO cleanup
- bf-3ag: session implementation
2026-06-25 06:00:56 -04:00
jedarden
6d3841e67f fix(bf-2w7): fix Session::run call sites after signature change
Update all call sites to include the new first_output_timeout_secs parameter:
- src/main.rs: pass None for default first-output timeout
- tests/watchdog.rs: pass None in both watchdog tests

The prior commit added the 5th parameter but missed updating the callers,
causing compilation errors.

Co-Authored-By: Claude <noreply@anthropic.com>
Bead-Id: bf-2w7
2026-06-25 00:59:49 -04:00
jedarden
7ddbf68e54 fix(bf-2w7): ensure temp dir and FIFO cleanup on all exit paths
- Add cleanup_orphans() to HookInstaller: sweeps stale claude-print-* dirs on startup
- Add cleanup() method to HookInstaller: explicitly removes FIFO and temp dir artifacts
- Add CleanupGuard struct in session.rs: ensures cleanup via Drop on all exit paths
- Call cleanup_orphans() in HookInstaller::new() on each invocation

This prevents orphaned temp directories from accumulating after crashes,
timeouts, or signal interruptions.

Co-Authored-By: Claude <noreply@anthropic.com>
Bead-Id: bf-2w7
2026-06-25 00:25:17 -04:00
jedarden
b76fa15479 feat: forward --dangerously-skip-permissions, --allowedTools, --disallowedTools to child; bump v0.2.0
These flags were parsed by the CLI but never added to claude_args,
so headless callers (e.g. marathon-coding) would hang on interactive
tool-use approval prompts. Also forward allowedTools/disallowedTools
for completeness.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 18:11:53 -04:00
jedarden
d344e9553c fix: five PTY→FIFO pipeline bugs that prevented end-to-end operation
Bug 1 (event_loop.rs): poll(-1) blocked forever when TUI went silent;
startup timer never fired so prompt was never injected. Fixed: poll(50ms)
+ empty-slice tick so poll_timers() runs on every iteration.

Bug 2 (session.rs): read_fd from open_fifo_nonblock() was dropped
immediately after as_raw_fd(), closing the fd the event loop was polling.
Fixed: store both (_fifo_read, _fifo_keeper) to keep both alive.

Bug 3 (pty.rs): child inherited CLAUDE_CODE_SESSION_ID from parent, so
it wrote events into the parent transcript and skipped Stop hook dispatch.
Fixed: unsetenv(CLAUDE_CODE_SESSION_ID) in child after fork; preserve
CLAUDECODE=1 and CLAUDE_CODE_ENTRYPOINT=cli.

Bug 4 (session.rs): empty-slice timer ticks were fed to startup.feed()
which reset last_output_at, preventing idle timer from ever firing.
Fixed: guard startup.feed() and terminal.feed() from empty slices.

Bug 5 (session.rs): handle.join() blocked main thread for up to
cli.timeout (default 3600s) on any early exit, because the timeout thread
sleeps for the full duration. Also, waitpid blocked forever if child
ignored SIGTERM. Fixed: drop(timeout_thread) to detach; add kill_child()
helper (SIGTERM → 2s wait → SIGKILL) used on all cleanup paths.

All five confirmed fixed: claude-print "what is 2+2?" → "4", exit 0,
cc_entrypoint=cli in session JSONL (subscription billing verified).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 23:32:50 -04:00
jedarden
d942572870 feat(main): wire prompt resolution, session dispatch, and emit
Replace the 'not yet implemented' stub with full execution path:

- Prompt resolution (precedence: --input-file, positional, stdin)
- Build claude_args to forward flags to child process
- Call session::run() and match results
- Emit success/error outputs per format (text/json/stream-json)
- Handle AS-5 (binary not found) with human-readable error
- Exit codes: 0=success, 2=setup/child errors, 3=timeout, 4=input errors, 130=interrupted

Completes bead bf-4aw.

Co-Authored-By: Claude <noreply@anthropic.com>
Bead-Id: bf-4aw
2026-06-13 23:32:50 -04:00
jedarden
508ba576d9 test(session): fix version resolution test and add struct validation test
- Fix test_version_resolution_with_mock_binary to properly mock claude --version
- Add test_session_result_struct_has_required_fields to verify SessionResult struct
- Remove unused std::env import
- Ensure all TranscriptResult fields are properly initialized

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-13 14:20:58 -04:00
jedarden
d3b7281ed5 feat(session): implement Session struct and version resolution
- Add SessionResult struct with transcript, claude_version, duration_ms
- Implement Session::run() with full signature and PTY session orchestration
- Add resolve_claude_version() using Command::new(claude_bin).arg("--version").output()
- Add pub mod session to lib.rs
- Include unit tests for version resolution with mock binaries

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-13 13:41:32 -04:00
jedarden
6b29283141 feat(error): implement comprehensive Error enum and Result alias
Complete implementation of error.rs with:
- Full Error enum covering all error types (PTY spawn failures, hook
  setup, parse failures, timeout, interruption, binary resolution,
  version checks, terminal errors, and child process errors)
- Proper error propagation with user-friendly messages
- ClaudePrintError for user-facing errors with exit codes and JSON
  subtypes
- 18 unit tests covering all error variants and conversions

Fixes bead bf-46v
2026-06-11 09:11:49 -04:00
jedarden
949c741a47 feat(pty): implement SIGINT forwarding to child process
- Add SIGINT_RECEIVED static flag and sigint_handler signal handler
- Install SIGINT handler in relay() to catch Ctrl-C presses
- Forward SIGINT to child process in relay loop using kill()
- Restore default SIGINT handler on exit
- Update error types to use specific PTY errors (OpenptyFailed, ForkFailed, SignalHandlerFailed, WaitpidFailed)

This implements key invariant #3 from AGENTS.md: pressing Ctrl-C must reach
the claude child process, not just terminate claude-print.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-11 08:49:51 -04:00
jedarden
d751a8baea feat(config): implement config file loading with model resolution
Add comprehensive config loading for ~/.claude/claude-print.toml:
- default_path() returns the config file path
- load_or_default() gracefully handles missing/invalid files
- resolve_model() prioritizes CLI flag > config > hardcoded default
- Add 11 tests covering all scenarios

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-11 08:29:05 -04:00
jedarden
7176ef2939 Add bf-5nr validation notes: claude-print-ci WorkflowTemplate YAML is valid
YAML parses cleanly and kubectl dry-run returns no errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 02:11:37 -04:00
jedarden
50b213285a Add Phase 9: NEEDLE integration — install.sh, claude-print.yaml, --check subcommand
- claude-print.yaml: NEEDLE agent config with stdin input_method, needle-transform-claude
  output_transform, and invoke_template for subscription-billed claude-print runs
- install.sh: download release binary from GitHub, backup existing, install mock_claude,
  install NEEDLE config if present, run --check to verify, print --version
- src/check.rs: --check doctor subcommand with openpty probe, mkfifo probe, and optional
  mock_claude PTY round-trip (skipped if mock_claude not in PATH)
- src/main.rs + src/lib.rs: wire up check::run() for --check flag
- README.md: add Install, Usage, Flags table (matches --help exactly), Exit codes,
  and NEEDLE integration sections
- test-fixtures/mock-claude: extend with all MOCK_* env var controls needed for
  integration tests (MOCK_SILENT, MOCK_EXIT_BEFORE_STOP, MOCK_TRUST_DIALOG, etc.)
- tests/cli.rs, tests/hooks.rs, tests/version_compat.rs: Phase 10 unit test stubs

claude-print --check passes: openpty PASS, mkfifo PASS, mock_claude PTY PASS
bash -n install.sh: syntax OK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:36:28 -04:00
jedarden
bfb50da40c Add Phase 8: Emitter — text/json/stream-json output formats
Adds emitter.rs with three output format handlers and stream-json reader thread,
ClaudePrintError enum with exit codes and JSON subtypes to error.rs,
and 13 unit tests in tests/emitter.rs covering all plan requirements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:57:30 -04:00
jedarden
c6241e37b7 Add Phase 7: transcript reader with retry loop and dedup
Implements src/transcript.rs: lenient JSONL parsing, message.id dedup
with usage-fingerprint fallback, text extraction from ContentBlock arrays,
40×50ms retry loop for Stop-before-JSONL races (PO-5), and last_assistant_message
fallback. All 18 tests in tests/transcript.rs pass; AS-6 verified with
MOCK_DELAY_JSONL=100.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:51:59 -04:00
jedarden
ab1e9d6d64 Fix clippy warnings: upper_case_acronyms and manual_range_contains
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:10:43 -04:00
jedarden
59e170ed03 Implement Phase 6: Stop Poller (bf-64s)
Add src/poller.rs with FIFO O_NONBLOCK open (read-end + keeper write-end),
Stop hook JSON payload parsing, transcript path derivation via cwd slug,
and StopInfo resolution. Wire poller into EventLoop via add_fifo_fd() which
was already present in event_loop.rs from Phase 3.

Update mock-claude to emit proper JSON Stop payloads (with and without
transcript_path via MOCK_OMIT_TRANSCRIPT_PATH=1) and update the pty_integration
assertion to match.

Tests test_stop_hook_fires and test_missing_transcript_path_derived both pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:05:14 -04:00
jedarden
71a4cb9f96 Implement large-prompt file relay in startup.rs (bf-1cx)
Prompts exceeding INLINE_PROMPT_MAX (32 KB) are written to a NamedTempFile
and a shell $(< path) read command is injected via bracketed paste instead
of the raw bytes, avoiding PTY pipe-buffer saturation.

Adds four unit tests: threshold boundary (inline), threshold+1 (file relay),
temp-file content verification, and end-to-end state-machine coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:26:16 -04:00
jedarden
adaf26e523 Add idle-gap detection to startup.rs (bf-54m)
After trust-dismiss fires, the post-dismiss wait is now an idle-gap:
injection fires only after idle_gap_ms of uninterrupted silence.
Any PTY output chunk resets the gap via last_output_at, preventing
premature injection during TUI redraws after the dismiss CR.

- Rename POST_DISMISS_IDLE_MS → DEFAULT_POST_DISMISS_IDLE_MS (pub const)
- Add idle_gap_ms field to StartupSeq; expose via with_idle_gap() ctor
- TrustDismissed timer now uses last_output_at instead of trust_dismiss_at
- Reset last_output_at when entering TrustDismissed via idle-fallback path
- Add three unit tests: resets on output, fires after silence, no double-fire

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:10:46 -04:00
jedarden
edd9470038 Add startup.rs: trust-keyword scanner with test_trust_dialog_* integration tests (Phase 5)
Implements StartupSeq with scan_line() that detects 2+ trust keywords
("trust", "Allow", "continue", "folder", "permission", "proceed") on a
PTY output line and returns CR to dismiss the dialog.  Includes idle
fallback (0.8 s after 200+ bytes) and hard timeout (45 s / <200 bytes →
HardTimeout).  Phase 2 injects the prompt via bracketed paste after a
2 s post-dismiss idle.

11 test_trust_dialog_* integration tests cover keyword match, threshold,
case sensitivity, chunk-boundary assembly, one-shot dismiss, and CR- vs
LF-terminated lines.  12 unit tests in startup::tests cover scan_line
and feed() in isolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:05:27 -04:00
jedarden
7b64f5b340 Add terminal.rs: probe scanner with response table, dedup bitmask, unknown-probe passthrough (Phase 4)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:25:54 -04:00
jedarden
78b06ed27f Add event_loop.rs: poll() loop with dynamic fd set and EIO detection (Phase 3)
Implements EventLoop with master_fd + self_pipe_read as the initial 2-fd
pollfd Vec. add_fifo_fd() pushes the stop FIFO fd at PROMPT_INJECTED. run()
dispatches PTY output chunks to a callback and returns ExitReason on child
exit (EIO), FIFO payload, or self-pipe interrupt. Both required tests pass:
test_event_loop_reads_pty_output and test_event_loop_detects_child_exit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:18:54 -04:00
jedarden
17c35f40a7 Add mock-claude fixture, test_pty_spawns_tty integration test, and hook module export
- test-fixtures/mock-claude: implement mock binary that writes 'stop' to FIFO then checks isatty(stdin), exiting 0 on TTY
- tests/pty_integration.rs: test_pty_spawns_tty uses HookInstaller + PtySpawner to verify controlling TTY is established
- src/lib.rs: expose hook module publicly
- Cargo.lock: add libc dependency for mock-claude

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 08:56:36 -04:00
jedarden
a1e74b59fa Add signal forwarding, I/O relay, and wait to PtySpawner
- Install a SIGWINCH handler that sets a static AtomicBool; the relay loop
  re-issues TIOCSWINSZ on the master fd each time it fires.
- relay() poll-loops over the master fd and stdin, copying master output to
  stdout and stdin input to the master fd, with 100 ms timeout so SIGWINCH
  is handled promptly on EINTR.
- Loop exits on EIO/EOF/POLLHUP from the master fd (slave side closed).
- waitpid() at the end of relay() surfaces the child exit code (or
  128+signum for signal termination).
- New tests: master_fd_carries_child_stdout (acceptance criterion),
  relay_echo_exits_zero_and_produces_output, relay_surfaces_nonzero_exit_code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:47:09 -04:00
jedarden
ffbe0f298e Add execvp and TIOCSWINSZ to PtySpawner::spawn
Change spawn() to spawn(cmd, args): on the parent side set the PTY
window size via TIOCSWINSZ mirroring stdin or defaulting to 80x24; on
the child side call execvp with the given command after login_tty.
Update the test to exercise /bin/true through the full exec path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:42:00 -04:00
jedarden
1ff77156db Add hook.rs and update bead state for Phase 2 completion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:38:45 -04:00
jedarden
4a38e8f6a3 Phase 2: implement PTY open and fork in pty.rs
Adds PtySpawner struct that calls openpty() for master/slave fds, forks,
and runs login_tty on the child side before it exits. Fixes nix feature
flag (pty module is gated by `term`, not a `pty` feature in nix 0.29).
Adds mock-claude workspace stub so the workspace resolves cleanly.

Unit test: fork_and_login_tty_does_not_panic passes (child exits 0).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:38:14 -04:00
jedarden
6f67cd5b00 Phase 1: crate scaffold with CLI, config, and error types
Adds Cargo.toml with pinned deps (clap, anyhow, serde, thiserror,
toml), src/{main,lib,cli,config,error}.rs. --version prints
"claude-print <ver> (wrapping claude <ver>)". All lib tests pass.
Builds clean for x86_64-unknown-linux-musl.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:00:19 -04:00