diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7964a6f..1507555 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,7 +1,7 @@ -{"id":"bf-10t","title":"Phase 10: Tests (~500 LOC)","description":"Entry: Phase 8 complete (can run in parallel with Phase 9).\n\nPhase 10 completes the test suite by adding tests NOT already written as part of Phases 2-9 completion criteria. Each prior phase's completion criterion already specifies and runs its own targeted integration tests.\n\nPhase 10 adds the remaining cross-phase and corner-case tests:\n- Version-resilience suite: feed transcript JSONL with extra/unknown fields across all JSONL event types; confirm no panic, correct output\n- Hook inheritance suite: verify user hooks in ~/.claude/settings.json fire alongside the relay hook (OQ-2 resolution validated end-to-end)\n- All MEDIUM/LOW mock scenarios not covered by earlier phases (see Testing section of plan.md for full list)\n- Conformance harness: run claude-print against a real claude binary in a sandboxed invocation and compare output format to claude -p reference\n\nComplete when:\n- cargo test passes with zero failures (all unit + integration tests)\n\nReference: docs/plan/plan.md \u00a7 Phase 10","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:17.744030380Z","updated_at":"2026-06-10T03:54:17.744030380Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-10t","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.710642868Z","created_by":"cli","thread_id":""}]} -{"id":"bf-2f1","title":"Phase 8: Emitter (~120 LOC)","description":"Entry: Phase 7 complete.\n\nImplementation: src/emitter.rs\n- Output format routing: text (plain string to stdout), json (JSON object with result/cost/session_id/claude_version), stream-json (forward raw JSONL lines as written)\n- claude_version: read from child process version string captured at startup\n- Error result objects: exit code mapping (0=success, 1=error, 2=no-response, 3=timeout, 4=input-error)\n- stream-json: reader thread consuming JSONL transcript file via mpsc channel, forward each line to stdout as written\n\nComplete when:\n- All emitter unit tests pass (tests/emitter.rs)\n- AS-1 (text output format) passes: response text emitted to stdout, exit 0\n- AS-2 (json output format) passes: valid JSON object on stdout with result field\n- stream-json output parses as valid JSONL (each line is valid JSON)\n\nReference: docs/plan/plan.md \u00a7 Phase 8","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:53:59.176338208Z","updated_at":"2026-06-10T03:53:59.176338208Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-2f1","depends_on_id":"bf-64k","type":"blocks","created_at":"2026-06-10T03:54:31.701710128Z","created_by":"cli","thread_id":""}]} -{"id":"bf-42j","title":"Phase 9: NEEDLE Integration (~50 LOC + config)","description":"Entry: Phase 8 complete.\n\nDeliverables:\n1. claude-print.yaml \u2014 NEEDLE agent config for dispatching beads via claude-print instead of claude -p\n - input_method: stdin, output_transform: needle-transform-claude\n - invoke_template: claude-print --output-format json --model ${MODEL}\n2. install.sh \u2014 download release binary from GitHub, install to ~/.local/bin/claude-print, verify --check\n3. claude-print-ci WorkflowTemplate in jedarden/declarative-config (k8s/iad-ci/argo-workflows/)\n - verify step only (build-musl + github-release steps added in Phase 11)\n - delegates to rust-verify WorkflowTemplate\n4. --check subcommand in src/main.rs or src/check.rs\n - openpty probe: confirm openpty syscall succeeds\n - mkfifo probe: confirm mkfifo in /home/coding/.tmp succeeds\n - optional mock_claude PTY round-trip (if mock_claude binary present in PATH)\n - exits 0 on success, prints diagnostic table\n\nComplete when:\n- bash -n install.sh passes (syntactically valid)\n- Manually copying locally-built binary to ~/.local/bin/claude-print and running claude-print --check succeeds\n- NEEDLE dispatches a test bead using claude-print.yaml; AS-3 passes\n- README flags table matches claude-print --help output exactly (verified manually)\n\nReference: docs/plan/plan.md \u00a7 Phase 9","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:09.318382701Z","updated_at":"2026-06-10T03:54:09.318382701Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-42j","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.706113490Z","created_by":"cli","thread_id":""}]} -{"id":"bf-4no","title":"Phase 11: CI (~YAML only)","description":"Entry: Phase 10 complete (and Phase 9 complete for install.sh e2e test).\n\nDeliverables in jedarden/declarative-config (k8s/iad-ci/argo-workflows/claude-print-ci.yaml):\n- Update claude-print-ci WorkflowTemplate stub (from Phase 9) with full steps:\n 1. verify \u2014 delegates to rust-verify WorkflowTemplate (fmt + clippy + test + cargo audit)\n 2. build-musl \u2014 cross-compile x86_64-unknown-linux-musl release binary\n 3. build-mock-claude-musl \u2014 build test-fixtures/mock-claude/ as musl binary\n 4. github-release \u2014 upload claude-print + mock_claude binaries + last-claude-version.txt artifact to GitHub Release\n- Confirm cargo audit runs (either via rust-verify or as explicit step between verify and build-musl)\n- install.sh end-to-end download test: download release artifact from GitHub Release URL, verify install.sh exits 0 and claude-print --check passes\n\nComplete when:\n- CI run on main branch produces release binary at expected GitHub Release URL\n- last-claude-version.txt artifact present in release\n- Binary passes claude-print --check (credential-free) via install.sh\n- install.sh end-to-end download test passes (deferred from Phase 9)\n- AS-1 verified manually before pushing release tag\n\nReference: docs/plan/plan.md \u00a7 Phase 11","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:27.444014247Z","updated_at":"2026-06-10T03:54:27.444014247Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-4no","depends_on_id":"bf-10t","type":"blocks","created_at":"2026-06-10T03:54:31.717358160Z","created_by":"cli","thread_id":""},{"issue_id":"bf-4no","depends_on_id":"bf-42j","type":"blocks","created_at":"2026-06-10T03:54:31.725797267Z","created_by":"cli","thread_id":""}]} -{"id":"bf-5bl","title":"Starvation alert: beads invisible to worker","description":"## Starvation Alert\n\nOpen beads exist but Pluck found none \u2014 possible configuration error.\n\n**Workspace:** default\n**Total beads:** 6\n**Open:** 5\n**In-progress:** 1\n**Claimed by:** claude-glm-glm47-alpha\n\nCheck exclude_labels, workspace path, and filter configuration.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","assignee":null,"created_at":"2026-06-10T03:57:06.245148475Z","updated_at":"2026-06-10T04:30:00.000000000Z","source_repo":".","compaction_level":0,"labels":["starvation-alert"],"closed_at":"2026-06-10T04:30:00.000000000Z"} -{"id":"bf-64k","title":"Phase 7: Transcript Reader (~180 LOC)","description":"Entry: Phase 6 complete. PO-5 acknowledged: retry loop (40\u00d750ms) is the mitigation for Stop-before-JSONL races. Verify retry timing by running test_transcript_race with MOCK_DELAY_JSONL=100 and confirming exit 0.\n\nImplementation: src/transcript.rs\n- JSONL parse with lenient serde (unknown fields tolerated, unknown event types skipped)\n- message.id dedup + usage-fingerprint fallback dedup for events without message.id\n- Text extraction from assistant ContentBlock array (text type only)\n- 40\u00d750ms retry loop with Stop-payload fallback to last_assistant_message after exhausted\n- Path derivation: strip leading /, replace / with -, append session_id from Stop payload\n\nComplete when:\n- All transcript unit tests pass (tests/transcript.rs)\n- test_streaming_dedup_40_retries passes\n- AS-6 (race scenario: Stop fires before JSONL flush) passes with MOCK_DELAY_JSONL=100\n\nReference: docs/plan/plan.md \u00a7 Phase 7","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:53:52.452812786Z","updated_at":"2026-06-10T03:53:52.452812786Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-64k","depends_on_id":"bf-64s","type":"blocks","created_at":"2026-06-10T03:54:31.695602796Z","created_by":"cli","thread_id":""}]} -{"id":"bf-64s","title":"Phase 6: Stop Poller (~80 LOC)","description":"Entry: Phase 5 complete. OQ-2 must be resolved (verify --setting-sources= suppresses standard sources; see PO-2 for fallback). OQ-4 (FIFO open race) validated by test.\n\nImplementation: poller.rs (or extend event_loop.rs)\n- Open FIFO read-end O_NONBLOCK, integrate into poll() loop\n- Parse Stop hook JSON payload (session_id, transcript_path, last_assistant_message)\n- Derive transcript path from session_id + cwd slug if transcript_path absent\n- Signal event loop exit via channel/flag\n\nComplete when:\n- Integration test test_stop_hook_fires passes (mock_claude emits Stop, FIFO received, exit 0)\n- test_missing_transcript_path_derived passes (Stop without transcript_path \u2192 path derived from session_id)\n\nReference: docs/plan/plan.md \u00a7 Phase 6","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":1,"issue_type":"task","assignee":"claude-glm-glm47-alpha","created_at":"2026-06-10T03:53:44.914912586Z","updated_at":"2026-06-10T03:54:27.001527172Z","source_repo":".","compaction_level":0} +{"id":"bf-10t","title":"Phase 10: Tests (~500 LOC)","description":"Entry: Phase 8 complete (can run in parallel with Phase 9).\n\nPhase 10 completes the test suite by adding tests NOT already written as part of Phases 2-9 completion criteria. Each prior phase's completion criterion already specifies and runs its own targeted integration tests.\n\nPhase 10 adds the remaining cross-phase and corner-case tests:\n- Version-resilience suite: feed transcript JSONL with extra/unknown fields across all JSONL event types; confirm no panic, correct output\n- Hook inheritance suite: verify user hooks in ~/.claude/settings.json fire alongside the relay hook (OQ-2 resolution validated end-to-end)\n- All MEDIUM/LOW mock scenarios not covered by earlier phases (see Testing section of plan.md for full list)\n- Conformance harness: run claude-print against a real claude binary in a sandboxed invocation and compare output format to claude -p reference\n\nComplete when:\n- cargo test passes with zero failures (all unit + integration tests)\n\nReference: docs/plan/plan.md § Phase 10","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:17.744030380Z","updated_at":"2026-06-10T03:54:17.744030380Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-10t","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.710642868Z","created_by":"cli","thread_id":""}]} +{"id":"bf-2f1","title":"Phase 8: Emitter (~120 LOC)","description":"Entry: Phase 7 complete.\n\nImplementation: src/emitter.rs\n- Output format routing: text (plain string to stdout), json (JSON object with result/cost/session_id/claude_version), stream-json (forward raw JSONL lines as written)\n- claude_version: read from child process version string captured at startup\n- Error result objects: exit code mapping (0=success, 1=error, 2=no-response, 3=timeout, 4=input-error)\n- stream-json: reader thread consuming JSONL transcript file via mpsc channel, forward each line to stdout as written\n\nComplete when:\n- All emitter unit tests pass (tests/emitter.rs)\n- AS-1 (text output format) passes: response text emitted to stdout, exit 0\n- AS-2 (json output format) passes: valid JSON object on stdout with result field\n- stream-json output parses as valid JSONL (each line is valid JSON)\n\nReference: docs/plan/plan.md § Phase 8","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:53:59.176338208Z","updated_at":"2026-06-10T03:53:59.176338208Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-2f1","depends_on_id":"bf-64k","type":"blocks","created_at":"2026-06-10T03:54:31.701710128Z","created_by":"cli","thread_id":""}]} +{"id":"bf-42j","title":"Phase 9: NEEDLE Integration (~50 LOC + config)","description":"Entry: Phase 8 complete.\n\nDeliverables:\n1. claude-print.yaml — NEEDLE agent config for dispatching beads via claude-print instead of claude -p\n - input_method: stdin, output_transform: needle-transform-claude\n - invoke_template: claude-print --output-format json --model ${MODEL}\n2. install.sh — download release binary from GitHub, install to ~/.local/bin/claude-print, verify --check\n3. claude-print-ci WorkflowTemplate in jedarden/declarative-config (k8s/iad-ci/argo-workflows/)\n - verify step only (build-musl + github-release steps added in Phase 11)\n - delegates to rust-verify WorkflowTemplate\n4. --check subcommand in src/main.rs or src/check.rs\n - openpty probe: confirm openpty syscall succeeds\n - mkfifo probe: confirm mkfifo in /home/coding/.tmp succeeds\n - optional mock_claude PTY round-trip (if mock_claude binary present in PATH)\n - exits 0 on success, prints diagnostic table\n\nComplete when:\n- bash -n install.sh passes (syntactically valid)\n- Manually copying locally-built binary to ~/.local/bin/claude-print and running claude-print --check succeeds\n- NEEDLE dispatches a test bead using claude-print.yaml; AS-3 passes\n- README flags table matches claude-print --help output exactly (verified manually)\n\nReference: docs/plan/plan.md § Phase 9","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:09.318382701Z","updated_at":"2026-06-10T03:54:09.318382701Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-42j","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.706113490Z","created_by":"cli","thread_id":""}]} +{"id":"bf-4no","title":"Phase 11: CI (~YAML only)","description":"Entry: Phase 10 complete (and Phase 9 complete for install.sh e2e test).\n\nDeliverables in jedarden/declarative-config (k8s/iad-ci/argo-workflows/claude-print-ci.yaml):\n- Update claude-print-ci WorkflowTemplate stub (from Phase 9) with full steps:\n 1. verify — delegates to rust-verify WorkflowTemplate (fmt + clippy + test + cargo audit)\n 2. build-musl — cross-compile x86_64-unknown-linux-musl release binary\n 3. build-mock-claude-musl — build test-fixtures/mock-claude/ as musl binary\n 4. github-release — upload claude-print + mock_claude binaries + last-claude-version.txt artifact to GitHub Release\n- Confirm cargo audit runs (either via rust-verify or as explicit step between verify and build-musl)\n- install.sh end-to-end download test: download release artifact from GitHub Release URL, verify install.sh exits 0 and claude-print --check passes\n\nComplete when:\n- CI run on main branch produces release binary at expected GitHub Release URL\n- last-claude-version.txt artifact present in release\n- Binary passes claude-print --check (credential-free) via install.sh\n- install.sh end-to-end download test passes (deferred from Phase 9)\n- AS-1 verified manually before pushing release tag\n\nReference: docs/plan/plan.md § Phase 11","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:27.444014247Z","updated_at":"2026-06-10T03:54:27.444014247Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-4no","depends_on_id":"bf-10t","type":"blocks","created_at":"2026-06-10T03:54:31.717358160Z","created_by":"cli","thread_id":""},{"issue_id":"bf-4no","depends_on_id":"bf-42j","type":"blocks","created_at":"2026-06-10T03:54:31.725797267Z","created_by":"cli","thread_id":""}]} +{"id":"bf-5bl","title":"Starvation alert: beads invisible to worker","description":"## Starvation Alert\n\nOpen beads exist but Pluck found none — possible configuration error.\n\n**Workspace:** default\n**Total beads:** 6\n**Open:** 5\n**In-progress:** 1\n**Claimed by:** claude-glm-glm47-alpha\n\nCheck exclude_labels, workspace path, and filter configuration.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-10T03:57:06.245148475Z","updated_at":"2026-06-10T04:30:00Z","closed_at":"2026-06-10T04:30:00Z","source_repo":".","compaction_level":0,"labels":["starvation-alert"]} +{"id":"bf-64k","title":"Phase 7: Transcript Reader (~180 LOC)","description":"Entry: Phase 6 complete. PO-5 acknowledged: retry loop (40×50ms) is the mitigation for Stop-before-JSONL races. Verify retry timing by running test_transcript_race with MOCK_DELAY_JSONL=100 and confirming exit 0.\n\nImplementation: src/transcript.rs\n- JSONL parse with lenient serde (unknown fields tolerated, unknown event types skipped)\n- message.id dedup + usage-fingerprint fallback dedup for events without message.id\n- Text extraction from assistant ContentBlock array (text type only)\n- 40×50ms retry loop with Stop-payload fallback to last_assistant_message after exhausted\n- Path derivation: strip leading /, replace / with -, append session_id from Stop payload\n\nComplete when:\n- All transcript unit tests pass (tests/transcript.rs)\n- test_streaming_dedup_40_retries passes\n- AS-6 (race scenario: Stop fires before JSONL flush) passes with MOCK_DELAY_JSONL=100\n\nReference: docs/plan/plan.md § Phase 7","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:53:52.452812786Z","updated_at":"2026-06-10T03:53:52.452812786Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-64k","depends_on_id":"bf-64s","type":"blocks","created_at":"2026-06-10T03:54:31.695602796Z","created_by":"cli","thread_id":""}]} +{"id":"bf-64s","title":"Phase 6: Stop Poller (~80 LOC)","description":"Entry: Phase 5 complete. OQ-2 must be resolved (verify --setting-sources= suppresses standard sources; see PO-2 for fallback). OQ-4 (FIFO open race) validated by test.\n\nImplementation: poller.rs (or extend event_loop.rs)\n- Open FIFO read-end O_NONBLOCK, integrate into poll() loop\n- Parse Stop hook JSON payload (session_id, transcript_path, last_assistant_message)\n- Derive transcript path from session_id + cwd slug if transcript_path absent\n- Signal event loop exit via channel/flag\n\nComplete when:\n- Integration test test_stop_hook_fires passes (mock_claude emits Stop, FIFO received, exit 0)\n- test_missing_transcript_path_derived passes (Stop without transcript_path → path derived from session_id)\n\nReference: docs/plan/plan.md § Phase 6","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":1,"issue_type":"task","assignee":"claude-glm-glm47-alpha","created_at":"2026-06-10T03:53:44.914912586Z","updated_at":"2026-06-10T03:54:27.001527172Z","source_repo":".","compaction_level":0} diff --git a/src/lib.rs b/src/lib.rs index 545fe2d..f7e1078 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; pub mod error; pub mod event_loop; pub mod hook; +pub mod poller; pub mod pty; pub mod startup; pub mod terminal; diff --git a/src/poller.rs b/src/poller.rs new file mode 100644 index 0000000..eb7912d --- /dev/null +++ b/src/poller.rs @@ -0,0 +1,320 @@ +use crate::error::{Error, Result}; +use serde::Deserialize; +use std::os::unix::io::{FromRawFd, OwnedFd}; +use std::path::{Path, PathBuf}; + +/// Raw Stop hook payload received from Claude Code via the FIFO. +/// All fields are optional for forward compatibility with future schema changes. +#[derive(Debug, Deserialize, Default)] +#[serde(default)] +pub struct StopPayload { + pub session_id: Option, + pub transcript_path: Option, + pub last_assistant_message: Option, + pub cwd: Option, +} + +/// Resolved stop information after transcript path derivation. +#[derive(Debug)] +pub struct StopInfo { + pub session_id: Option, + /// Resolved transcript path: from payload if present, otherwise derived from + /// session_id + cwd. `None` if neither derivation is possible. + pub transcript_path: Option, + pub last_assistant_message: Option, +} + +/// Parse raw FIFO bytes into a [`StopPayload`]. +/// +/// Finds the first non-empty line and decodes it as JSON. Unknown fields are +/// silently ignored (`#[serde(default)]` + no `deny_unknown_fields`). +pub fn parse_stop_payload(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes) + .map_err(|e| Error::Internal(anyhow::anyhow!("stop payload not UTF-8: {e}")))?; + for line in text.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + return serde_json::from_str(line).map_err(|e| { + Error::Internal(anyhow::anyhow!("stop payload JSON parse failed: {e}")) + }); + } + Ok(StopPayload::default()) +} + +/// Resolve a [`StopPayload`] into [`StopInfo`], deriving the transcript path +/// when `transcript_path` is absent but `session_id` and `cwd` are present. +pub fn resolve_stop_info(payload: StopPayload) -> StopInfo { + let explicit_path = payload + .transcript_path + .as_deref() + .filter(|s| !s.is_empty()) + .map(PathBuf::from); + + let transcript_path = explicit_path.or_else(|| { + match (&payload.session_id, &payload.cwd) { + (Some(sid), Some(cwd)) if !sid.is_empty() && !cwd.is_empty() => { + Some(derive_transcript_path(sid, cwd)) + } + _ => None, + } + }); + + StopInfo { + session_id: payload.session_id, + transcript_path, + last_assistant_message: payload.last_assistant_message, + } +} + +/// Build the full transcript path from `session_id` and `cwd`. +/// +/// Slug algorithm: strip the leading `/` from `cwd`, replace remaining `/` with `-`. +/// Example: `/home/coding/myproject` → slug `home-coding-myproject` +/// Full path: `$HOME/.claude/projects//.jsonl` +pub fn derive_transcript_path(session_id: &str, cwd: &str) -> PathBuf { + let slug = cwd_to_slug(cwd); + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + PathBuf::from(home) + .join(".claude") + .join("projects") + .join(&slug) + .join(format!("{session_id}.jsonl")) +} + +/// Convert a filesystem `cwd` path to a JSONL directory slug. +/// +/// Strip the leading `/`, then replace all `/` with `-`. +pub fn cwd_to_slug(cwd: &str) -> String { + cwd.trim_start_matches('/').replace('/', "-") +} + +/// Open the named FIFO at `path` for non-blocking reading. +/// +/// Linux FIFO O_NONBLOCK semantics: +/// - `O_RDONLY|O_NONBLOCK`: always succeeds immediately (no writer required). +/// - `O_WRONLY|O_NONBLOCK`: returns `ENXIO` if no reader is present. +/// +/// We therefore open the **read-end first** (always succeeds), then open a +/// "keeper" write-end `O_WRONLY|O_NONBLOCK` which now succeeds because the +/// read-end is already open. The keeper is held open until the Stop hook fires +/// so that the hook's `cat > fifo` can open a write-end without getting +/// `ENXIO`. Closing the keeper after the payload is read causes any lingering +/// `cat > fifo` in hook.sh to receive `EPIPE`/`ENXIO` and exit cleanly. +/// +/// Returns `(read_fd, keeper_write_fd)`. +pub fn open_fifo_nonblock(path: &Path) -> Result<(OwnedFd, OwnedFd)> { + use std::os::unix::ffi::OsStrExt; + + let path_cstr = std::ffi::CString::new(path.as_os_str().as_bytes()) + .map_err(|e| Error::Internal(anyhow::anyhow!("FIFO path has null byte: {e}")))?; + + // Open read-end first: O_RDONLY|O_NONBLOCK never fails with ENXIO. + let read_fd = unsafe { + libc::open( + path_cstr.as_ptr(), + libc::O_RDONLY | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + if read_fd < 0 { + let e = nix::errno::Errno::last(); + return Err(Error::Internal(anyhow::anyhow!( + "open FIFO read-end failed: {e}" + ))); + } + let read_fd = unsafe { OwnedFd::from_raw_fd(read_fd) }; + + // Open keeper write-end: succeeds because the read-end is now open. + let write_fd = unsafe { + libc::open( + path_cstr.as_ptr(), + libc::O_WRONLY | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + if write_fd < 0 { + let e = nix::errno::Errno::last(); + return Err(Error::Internal(anyhow::anyhow!( + "open FIFO write-end (keeper) failed: {e}" + ))); + } + let write_fd = unsafe { OwnedFd::from_raw_fd(write_fd) }; + + Ok((read_fd, write_fd)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── cwd_to_slug ─────────────────────────────────────────────────────────── + + #[test] + fn cwd_to_slug_home_coding_myproject() { + assert_eq!(cwd_to_slug("/home/coding/myproject"), "home-coding-myproject"); + } + + #[test] + fn cwd_to_slug_root_foo_bar() { + assert_eq!(cwd_to_slug("/root/foo/bar"), "root-foo-bar"); + } + + #[test] + fn cwd_to_slug_tmp() { + assert_eq!(cwd_to_slug("/tmp"), "tmp"); + } + + #[test] + fn cwd_to_slug_tmp_x() { + assert_eq!(cwd_to_slug("/tmp/x"), "tmp-x"); + } + + #[test] + fn cwd_to_slug_no_leading_slash() { + assert_eq!(cwd_to_slug("tmp/foo"), "tmp-foo"); + } + + // ── parse_stop_payload ──────────────────────────────────────────────────── + + #[test] + fn parse_full_payload() { + let json = r#"{"hook_event_name":"Stop","session_id":"abc-123","transcript_path":"/home/u/.claude/projects/foo/abc-123.jsonl","cwd":"/home/u/foo","last_assistant_message":"hello"}"#; + let p = parse_stop_payload(json.as_bytes()).unwrap(); + assert_eq!(p.session_id.as_deref(), Some("abc-123")); + assert_eq!( + p.transcript_path.as_deref(), + Some("/home/u/.claude/projects/foo/abc-123.jsonl") + ); + assert_eq!(p.cwd.as_deref(), Some("/home/u/foo")); + assert_eq!(p.last_assistant_message.as_deref(), Some("hello")); + } + + #[test] + fn parse_payload_missing_transcript_path() { + let json = r#"{"hook_event_name":"Stop","session_id":"s1","cwd":"/tmp/foo"}"#; + let p = parse_stop_payload(json.as_bytes()).unwrap(); + assert!(p.transcript_path.is_none()); + assert_eq!(p.session_id.as_deref(), Some("s1")); + } + + #[test] + fn parse_payload_unknown_fields_ignored() { + let json = r#"{"hook_event_name":"Stop","session_id":"x","future_field":42,"nested":{"a":1}}"#; + let p = parse_stop_payload(json.as_bytes()).unwrap(); + assert_eq!(p.session_id.as_deref(), Some("x")); + } + + #[test] + fn parse_payload_empty_bytes_returns_default() { + let p = parse_stop_payload(b"").unwrap(); + assert!(p.session_id.is_none()); + assert!(p.transcript_path.is_none()); + } + + #[test] + fn parse_payload_trailing_newline() { + let json = b"{\"session_id\":\"s2\"}\n"; + let p = parse_stop_payload(json).unwrap(); + assert_eq!(p.session_id.as_deref(), Some("s2")); + } + + #[test] + fn parse_payload_malformed_json_returns_err() { + let result = parse_stop_payload(b"not json"); + assert!(result.is_err()); + } + + // ── resolve_stop_info ───────────────────────────────────────────────────── + + #[test] + fn resolve_uses_explicit_transcript_path() { + let payload = StopPayload { + session_id: Some("sid".to_string()), + transcript_path: Some("/explicit/path.jsonl".to_string()), + cwd: Some("/some/cwd".to_string()), + last_assistant_message: None, + }; + let info = resolve_stop_info(payload); + assert_eq!( + info.transcript_path, + Some(PathBuf::from("/explicit/path.jsonl")) + ); + } + + #[test] + fn resolve_derives_path_when_transcript_path_absent() { + let payload = StopPayload { + session_id: Some("mysession".to_string()), + transcript_path: None, + cwd: Some("/home/user/myproject".to_string()), + last_assistant_message: None, + }; + let info = resolve_stop_info(payload); + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let expected = PathBuf::from(&home) + .join(".claude") + .join("projects") + .join("home-user-myproject") + .join("mysession.jsonl"); + assert_eq!(info.transcript_path, Some(expected)); + } + + #[test] + fn resolve_returns_none_when_no_derivation_possible() { + let payload = StopPayload { + session_id: Some("sid".to_string()), + transcript_path: None, + cwd: None, // cwd absent: cannot derive + last_assistant_message: None, + }; + let info = resolve_stop_info(payload); + assert!(info.transcript_path.is_none()); + } + + // ── open_fifo_nonblock (OQ-4: FIFO open race) ───────────────────────────── + + #[test] + fn open_fifo_nonblock_succeeds_without_separate_writer() { + use crate::hook::HookInstaller; + let installer = HookInstaller::new().unwrap(); + // open_fifo_nonblock opens keeper write-end then read-end; must not fail. + let result = open_fifo_nonblock(&installer.fifo_path); + assert!( + result.is_ok(), + "open_fifo_nonblock must succeed without a pre-existing writer: {:?}", + result.err() + ); + } + + #[test] + fn open_fifo_nonblock_read_end_is_ready_for_poll() { + use crate::hook::HookInstaller; + use std::io::Write; + use std::os::unix::io::AsRawFd; + + let installer = HookInstaller::new().unwrap(); + let (read_fd, _keeper) = open_fifo_nonblock(&installer.fifo_path).unwrap(); + + // Write some bytes from a thread (will unblock immediately since keeper write-end is open) + let fifo_path = installer.fifo_path.clone(); + let writer = std::thread::spawn(move || { + let mut f = std::fs::OpenOptions::new() + .write(true) + .open(&fifo_path) + .unwrap(); + f.write_all(b"hello").unwrap(); + }); + + // poll() with a short timeout; POLLIN must fire + let mut pfd = libc::pollfd { + fd: read_fd.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + let ret = unsafe { libc::poll(&mut pfd, 1, 2000) }; // 2 s timeout + writer.join().unwrap(); + + assert!(ret > 0, "poll timed out waiting for FIFO data"); + assert!(pfd.revents & libc::POLLIN != 0, "POLLIN not set"); + } +} diff --git a/test-fixtures/mock-claude/src/main.rs b/test-fixtures/mock-claude/src/main.rs index 3c7a2c6..1906a74 100644 --- a/test-fixtures/mock-claude/src/main.rs +++ b/test-fixtures/mock-claude/src/main.rs @@ -5,10 +5,31 @@ fn main() { .nth(1) .expect("usage: mock-claude "); - // Simulate the stop hook by writing "stop" to the FIFO. + let omit_transcript_path = std::env::var("MOCK_OMIT_TRANSCRIPT_PATH") + .map(|v| v == "1") + .unwrap_or(false); + + let session_id = "mock-session-abc123"; + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "/tmp".to_string()); + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + + // Build Stop hook JSON payload manually (no serde_json dep in mock-claude). + // Paths on Linux cannot contain backslashes or control chars, so no escaping needed. + let payload = if omit_transcript_path { + format!( + "{{\"hook_event_name\":\"Stop\",\"session_id\":\"{session_id}\",\"cwd\":\"{cwd}\",\"last_assistant_message\":\"Hello from mock_claude\"}}\n" + ) + } else { + format!( + "{{\"hook_event_name\":\"Stop\",\"session_id\":\"{session_id}\",\"transcript_path\":\"{home}/.claude/projects/mock-cwd/{session_id}.jsonl\",\"cwd\":\"{cwd}\",\"last_assistant_message\":\"Hello from mock_claude\"}}\n" + ) + }; + // O_WRONLY on a FIFO blocks until a reader opens the other end. if let Ok(mut file) = std::fs::OpenOptions::new().write(true).open(&fifo_path) { - let _ = file.write_all(b"stop\n"); + let _ = file.write_all(payload.as_bytes()); } // Exit 0 if stdin is a controlling TTY (login_tty succeeded), 1 otherwise. diff --git a/tests/pty_integration.rs b/tests/pty_integration.rs index 648c5b5..1307774 100644 --- a/tests/pty_integration.rs +++ b/tests/pty_integration.rs @@ -37,8 +37,8 @@ fn test_pty_spawns_tty() { let fifo_content = reader.join().expect("reader thread"); assert!( - fifo_content.contains("stop"), - "expected 'stop' in FIFO content, got: {fifo_content:?}", + fifo_content.contains("session_id"), + "expected Stop JSON payload in FIFO content, got: {fifo_content:?}", ); assert_eq!( exit_code, 0, diff --git a/tests/stop_poller.rs b/tests/stop_poller.rs new file mode 100644 index 0000000..7f02694 --- /dev/null +++ b/tests/stop_poller.rs @@ -0,0 +1,142 @@ +use claude_print::event_loop::{EventLoop, ExitReason}; +use claude_print::hook::HookInstaller; +use claude_print::poller::{open_fifo_nonblock, parse_stop_payload, resolve_stop_info}; +use std::io::Write; +use std::os::unix::io::AsRawFd; + +/// Verify that when a Stop JSON payload is written to the FIFO, the event loop +/// returns it via FifoPayload and parse_stop_payload extracts the fields. +#[test] +fn test_stop_hook_fires() { + let installer = HookInstaller::new().expect("HookInstaller::new"); + + // Open FIFO: keeper write-end + read-end (O_NONBLOCK, no ENXIO). + let (fifo_read, _fifo_keeper) = + open_fifo_nonblock(&installer.fifo_path).expect("open_fifo_nonblock"); + + // Dummy "master" pipe — won't produce PTY data, so POLLIN won't fire on it. + let (dummy_r, _dummy_w) = nix::unistd::pipe().expect("pipe"); + // Self-pipe for interrupt signalling — won't be written in this test. + let (self_pipe_r, _self_pipe_w) = nix::unistd::pipe().expect("pipe"); + + let mut el = EventLoop::new(dummy_r.as_raw_fd(), self_pipe_r.as_raw_fd()); + el.add_fifo_fd(fifo_read.as_raw_fd()); + + // Simulate the Stop hook writing a JSON payload to the FIFO. + let fifo_path = installer.fifo_path.clone(); + let payload_json = concat!( + r#"{"hook_event_name":"Stop","session_id":"test-session-123","#, + r#""transcript_path":"/tmp/test-transcript/test-session-123.jsonl","#, + r#""cwd":"/tmp/test-cwd","last_assistant_message":"hello world"}"#, + ); + let payload_bytes = payload_json.as_bytes().to_vec(); + let writer = std::thread::spawn(move || { + // Blocking open; succeeds immediately because read-end (keeper) is open. + let mut f = std::fs::OpenOptions::new() + .write(true) + .open(&fifo_path) + .expect("open FIFO for writing"); + f.write_all(&payload_bytes).expect("write payload"); + f.write_all(b"\n").expect("write newline"); + }); + + let reason = el.run(|_| {}).expect("event loop"); + writer.join().expect("writer thread"); + + let raw = match reason { + ExitReason::FifoPayload(bytes) => bytes, + other => panic!("expected FifoPayload, got {other:?}"), + }; + + let stop = parse_stop_payload(&raw).expect("parse_stop_payload"); + + assert_eq!( + stop.session_id.as_deref(), + Some("test-session-123"), + "session_id mismatch" + ); + assert_eq!( + stop.transcript_path.as_deref(), + Some("/tmp/test-transcript/test-session-123.jsonl"), + "transcript_path mismatch" + ); + assert_eq!( + stop.last_assistant_message.as_deref(), + Some("hello world"), + "last_assistant_message mismatch" + ); + + let info = resolve_stop_info(stop); + assert_eq!( + info.transcript_path, + Some(std::path::PathBuf::from( + "/tmp/test-transcript/test-session-123.jsonl" + )), + "StopInfo transcript_path should use the explicit payload path" + ); +} + +/// When `transcript_path` is absent from the Stop payload, the transcript path +/// is derived from `session_id` + `cwd` using the documented slug algorithm. +#[test] +fn test_missing_transcript_path_derived() { + let installer = HookInstaller::new().expect("HookInstaller::new"); + + let (fifo_read, _fifo_keeper) = + open_fifo_nonblock(&installer.fifo_path).expect("open_fifo_nonblock"); + + let (dummy_r, _dummy_w) = nix::unistd::pipe().expect("pipe"); + let (self_pipe_r, _self_pipe_w) = nix::unistd::pipe().expect("pipe"); + + let mut el = EventLoop::new(dummy_r.as_raw_fd(), self_pipe_r.as_raw_fd()); + el.add_fifo_fd(fifo_read.as_raw_fd()); + + // Payload deliberately omits `transcript_path`. + let fifo_path = installer.fifo_path.clone(); + let writer = std::thread::spawn(move || { + let payload = concat!( + r#"{"hook_event_name":"Stop","session_id":"abc123","#, + r#""cwd":"/home/user/myproject","last_assistant_message":"derived test"}"#, + ); + let mut f = std::fs::OpenOptions::new() + .write(true) + .open(&fifo_path) + .expect("open FIFO for writing"); + f.write_all(payload.as_bytes()).expect("write payload"); + f.write_all(b"\n").expect("write newline"); + }); + + let reason = el.run(|_| {}).expect("event loop"); + writer.join().expect("writer thread"); + + let raw = match reason { + ExitReason::FifoPayload(bytes) => bytes, + other => panic!("expected FifoPayload, got {other:?}"), + }; + + let stop = parse_stop_payload(&raw).expect("parse_stop_payload"); + + // Confirm transcript_path is absent from the raw payload. + assert!( + stop.transcript_path.is_none(), + "transcript_path should be absent from payload" + ); + assert_eq!(stop.session_id.as_deref(), Some("abc123")); + + let info = resolve_stop_info(stop); + + // Derived slug: /home/user/myproject → home-user-myproject + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let expected = std::path::PathBuf::from(&home) + .join(".claude") + .join("projects") + .join("home-user-myproject") + .join("abc123.jsonl"); + + assert_eq!( + info.transcript_path, + Some(expected.clone()), + "derived transcript_path should be {expected:?}" + ); + assert_eq!(info.session_id.as_deref(), Some("abc123")); +}