Skip to main content

hh_core/
adapter.rs

1//! Agent adapters: tail a structured event stream and yield [`Event`]s (FR-1.5).
2//!
3//! An [`Adapter`] owns a tailer thread that produces [`Event`]s on an
4//! `mpsc::Receiver`; the recorder (`hh-record`) owns a drain thread that
5//! consumes them, resolves the adapter's `correlate_key` to a DB row id in
6//! [`Event::correlates`], and appends to the single-writer task. The adapter
7//! receives an `Arc<BlobStore>` (for >256 KiB spillover) and a stop flag, but
8//! **no `EventWriter`** — keeping it unit-testable without a database and the
9//! writer ownership in `hh-record`. A future Codex adapter is a new impl + one
10//! new branch in [`select`]; `hh-record` is unchanged.
11//!
12//! ## Cross-event correlation
13//!
14//! Adapters cannot know the DB row id an event will receive, so to express
15//! "this `tool_result` belongs to that `tool_call`" they emit a string
16//! `correlate_key` field inside [`Event::body_json`] (the Claude adapter uses
17//! `tool_use.id` for a `tool_call` and `tool_use_id` for a `tool_result`). The
18//! recorder's drain thread keeps a `HashMap<String, i64>` (key → event id) for
19//! `tool_call`s and, for a `tool_result`, sets `event.correlates =
20//! map.get(key)` before appending. A result whose key was never seen (orphan /
21//! truly-concurrent result-before-call) gets `correlates = None` and its own
22//! step (handled by the FR-3.4 step pass).
23//!
24//! ## Failure mode (FR-1.5)
25//!
26//! If the structured log cannot be located (no `~/.claude/projects` dir, or no
27//! transcript file appears before the session ends), the adapter returns
28//! [`AdapterStatus::Degraded`] carrying a one-line `degrade_reason`. The
29//! recorder prints that reason to stderr **after the child exits** — once the
30//! terminal is restored — rather than from the tailer thread mid-session, so the
31//! warning is not buried under the agent's alternate-screen TUI. PTY + FS
32//! recording in the recorder continue unaffected either way.
33//!
34//! The reason is *specific* so a degrade is a one-line diagnosis, not a mystery:
35//! `no jsonl matched cwd slug <slug>` (with the slug dir looked up + candidate
36//! counts), `jsonl found (<file>) but 0 records parsed (read N line(s); first
37//! parse error at line K: <msg>)`, `found a transcript at <file> but could not
38//! read it`, or `discovery selected file <file> but it's a directory`. The
39//! recorder also persists it as an `error` event (`body_json.reason`) so it is
40//! queryable in the DB, not just printed once. A detailed discovery+parse trace
41//! (slug, projects dir, candidate files, selected file, records read, records
42//! converted, first conversion failure) is emitted to stderr when the `HH_DEBUG`
43//! env var is set — run `HH_DEBUG=1 hh run …` to capture it.
44
45use crate::blob::BlobStore;
46use crate::event::{truncate_summary, AdapterStatus, AgentKind, Event, EventKind};
47use std::io::BufRead;
48use std::path::{Path, PathBuf};
49use std::sync::atomic::{AtomicBool, Ordering};
50use std::sync::mpsc;
51use std::thread::JoinHandle;
52use std::time::{Duration, Instant};
53
54/// Payloads at or above this size (in bytes) spill to the blob store rather
55/// than being stored inline in `events.body_json` (SRS §4.1, matching the
56/// recorder's terminal-chunk threshold).
57const SPILLOVER_BYTES: usize = 256 * 1024;
58/// Tail poll interval when the transcript is at EOF but the session continues.
59const TAIL_POLL: Duration = Duration::from_millis(50);
60/// Poll interval while waiting for the transcript file to appear.
61const APPEAR_POLL: Duration = Duration::from_millis(200);
62
63/// Context handed to an adapter when it is spawned.
64#[derive(Clone)]
65pub struct AdapterContext {
66    /// Owning session id (full UUID string).
67    pub session_id: String,
68    /// Session start as a unix-ms UTC timestamp; `ts_ms` is relative to this.
69    pub started_at_unix_ms: i64,
70    /// Session working directory — used to locate/verify the structured log.
71    pub cwd: PathBuf,
72    /// Full command argv (used for detection and the fallback scan).
73    pub command: Vec<String>,
74    /// Blob store for >256 KiB spillover.
75    pub blobs: std::sync::Arc<BlobStore>,
76    /// Stop flag: set by the recorder when the child has exited.
77    pub stop: std::sync::Arc<AtomicBool>,
78    /// Override for the Claude projects directory (`None` → resolve from `HOME`).
79    /// Tests set this to a temp dir so the adapter never touches the real home and
80    /// avoids `HOME` env races across parallel tests; production passes `None`.
81    pub projects_dir: Option<PathBuf>,
82}
83
84/// A handle to a running adapter tailer. Drop the `events` receiver (or drain
85/// it to EOF) and [`AdapterHandle::join`] the outcome when the session ends.
86pub struct AdapterHandle {
87    /// The stream of parsed events, in arrival order. EOF when the tailer exits.
88    pub events: mpsc::Receiver<Event>,
89    /// Joins the tailer thread and returns the adapter's final outcome.
90    pub outcome: JoinHandle<AdapterOutcome>,
91}
92
93impl AdapterHandle {
94    /// Block until the tailer thread exits and return its outcome. The tailer
95    /// is total (never panics on record content), so a panic here indicates an
96    /// internal bug, surfaced as [`crate::Error::Storage`] for the binary to
97    /// attach `anyhow` context.
98    pub fn join(self) -> crate::Result<AdapterOutcome> {
99        self.outcome.join().map_err(|_| {
100            crate::Error::Storage(crate::error::StorageError::Open {
101                // The exact path is irrelevant for an adapter-thread panic; reuse
102                // the storage error surface so the binary's print_error renders it.
103                path: PathBuf::new(),
104                source: std::io::Error::other("adapter thread panicked"),
105            })
106        })
107    }
108}
109
110/// The final outcome of an adapter run, reported when its tailer thread exits.
111#[derive(Debug, Default, Clone)]
112pub struct AdapterOutcome {
113    /// Model name extracted from the structured stream, if any (last-seen wins).
114    pub model: Option<String>,
115    /// Token-usage JSON, verbatim from the last assistant record that carried it
116    /// (rich fields preserved; not cumulative). Written to `sessions.usage_json`.
117    pub usage_json: Option<serde_json::Value>,
118    /// Final adapter status: [`AdapterStatus::Active`] if a transcript was
119    /// tailed, [`AdapterStatus::Degraded`] if it could not be located.
120    pub status: AdapterStatus,
121    /// When `status` is [`AdapterStatus::Degraded`], a single actionable line the
122    /// recorder prints to stderr *after the child exits* (once the terminal is
123    /// restored), instead of from the tailer thread mid-session — so the warning
124    /// is not buried under the agent's alternate-screen TUI (FR-1.5).
125    pub degrade_reason: Option<String>,
126}
127
128/// An agent adapter: tail a structured event stream and yield [`Event`]s.
129///
130/// Implementations are `Send + 'static` so the recorder can spawn the tailer on
131/// its own thread. [`Adapter::spawn`] performs any setup that can fail (e.g. the
132/// tailer thread spawn) and returns an `io::Error` only for that low-level
133/// failure; higher-level "no transcript found" conditions are reported via the
134/// returned handle's [`AdapterOutcome::status`] (degraded), not as an error, so
135/// the recorder can keep recording PTY/FS output (FR-1.5).
136pub trait Adapter: Send + 'static {
137    /// The agent kind this adapter reports for the session row.
138    fn agent_kind(&self) -> AgentKind;
139    /// Spawn the tailer thread, returning the event stream + outcome handle.
140    ///
141    /// Takes `self: Box<Self>` so the recorder can dispatch through the
142    /// `Box<dyn Adapter>` returned by [`select`] (a by-value `self` would not
143    /// be callable on a trait object). A stateless adapter like [`ClaudeAdapter`]
144    /// ignores the receiver.
145    fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle>;
146}
147
148/// Detect which adapter applies to `command` (FR-1.5). Returns `None` for a
149/// generic agent (PTY-only capture). Detection order: Claude Code, Claude
150/// Desktop, Codex CLI, Gemini CLI — by the basename of `command[0]`. A forced
151/// adapter from `hh run --adapter` is resolved by the recorder, not here.
152#[must_use]
153pub fn select(command: &[String], _cwd: &Path) -> Option<Box<dyn Adapter>> {
154    if is_claude_code(command) {
155        Some(Box::new(ClaudeAdapter))
156    } else if is_claude_desktop(command) {
157        Some(Box::new(ClaudeDesktopAdapter))
158    } else if is_codex_cli(command) {
159        Some(Box::new(CodexAdapter))
160    } else if is_gemini_cli(command) {
161        Some(Box::new(GeminiAdapter))
162    } else {
163        None
164    }
165}
166
167/// Resolve a forced adapter name (from `hh run --adapter <name>`) to an
168/// adapter, or `None` if the name is unrecognized — the recorder surfaces that
169/// as an actionable error. Keeping this map in `hh-core` (not the recorder)
170/// means adding a forced adapter is a one-branch change here, with no
171/// `hh-record` edit (the trait's extensibility goal).
172#[must_use]
173pub fn resolve_override(name: &str) -> Option<Box<dyn Adapter>> {
174    match name {
175        "claude-code" => Some(Box::new(ClaudeAdapter)),
176        "claude-desktop" => Some(Box::new(ClaudeDesktopAdapter)),
177        "codex-cli" => Some(Box::new(CodexAdapter)),
178        "gemini-cli" => Some(Box::new(GeminiAdapter)),
179        _ => None,
180    }
181}
182
183/// True if the command's program basename is `claude` (stripping a Windows
184/// `.exe`). Mirrors the detection in `hh-record::agent::detect_agent`.
185#[must_use]
186pub fn is_claude_code(command: &[String]) -> bool {
187    let prog = command.first().map_or("", String::as_str);
188    // Split on both `/` and `\` so a Windows path like `C:\Apps\claude.exe` yields
189    // `claude.exe` on any platform — `Path::file_name` treats `\` as a normal
190    // character on Unix, which would mis-basename Windows command lines.
191    let base = prog.rsplit(['/', '\\']).next().unwrap_or(prog);
192    let base = base.strip_suffix(".exe").unwrap_or(base);
193    base.eq_ignore_ascii_case("claude")
194}
195
196/// True if the command's program basename is `claude-desktop` (stripping a
197/// Windows `.exe`). Claude Desktop uses the same JSONL session format as Claude
198/// Code, so the adapter reuses the same tailer.
199#[must_use]
200pub fn is_claude_desktop(command: &[String]) -> bool {
201    let prog = command.first().map_or("", String::as_str);
202    let base = prog.rsplit(['/', '\\']).next().unwrap_or(prog);
203    let base = base.strip_suffix(".exe").unwrap_or(base);
204    base.eq_ignore_ascii_case("claude-desktop")
205}
206
207/// True if the command's program basename is `codex` (stripping a Windows
208/// `.exe`). Mirrors the detection in `hh-record::agent::detect_agent`.
209#[must_use]
210pub fn is_codex_cli(command: &[String]) -> bool {
211    let prog = command.first().map_or("", String::as_str);
212    let base = prog.rsplit(['/', '\\']).next().unwrap_or(prog);
213    let base = base.strip_suffix(".exe").unwrap_or(base);
214    base.eq_ignore_ascii_case("codex")
215}
216
217/// True if the command's program basename is `gemini` (stripping a Windows
218/// `.exe`). Mirrors the detection in `hh-record::agent::detect_agent`.
219#[must_use]
220pub fn is_gemini_cli(command: &[String]) -> bool {
221    let prog = command.first().map_or("", String::as_str);
222    let base = prog.rsplit(['/', '\\']).next().unwrap_or(prog);
223    let base = base.strip_suffix(".exe").unwrap_or(base);
224    base.eq_ignore_ascii_case("gemini")
225}
226
227// ---------------------------------------------------------------------------
228// Claude Code adapter
229// ---------------------------------------------------------------------------
230
231/// The Claude Code adapter: tails `~/.claude/projects/<slug>/*.jsonl` and
232/// converts records to `user_message` / `agent_message` / `thinking` /
233/// `tool_call` / `tool_result` events. Stateless; the tailer thread holds all
234/// mutable state. A unit struct (rather than `struct ClaudeAdapter {}`) so it
235/// is constructible as a value without a `{}`; reserved for future per-adapter
236/// knobs (e.g. a forced projects dir) by adding fields + a `Default` impl.
237#[derive(Debug, Clone)]
238pub struct ClaudeAdapter;
239
240impl Adapter for ClaudeAdapter {
241    fn agent_kind(&self) -> AgentKind {
242        AgentKind::ClaudeCode
243    }
244
245    #[allow(clippy::unused_self)] // ClaudeAdapter is stateless; the `Box<Self>` receiver exists only so the recorder can dispatch through `Box<dyn Adapter>` (FR-1.5). A future stateful adapter would read its config from `self` here.
246    fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle> {
247        let (tx, rx) = mpsc::channel::<Event>();
248        let outcome = std::thread::Builder::new()
249            .name("hh-claude-adapter".into())
250            .spawn(move || run_claude_tailer(ctx, tx))?;
251        Ok(AdapterHandle {
252            events: rx,
253            outcome,
254        })
255    }
256}
257
258/// The tailer thread body: locate the transcript, then read it to EOF/stop,
259/// parsing each line into events sent on `tx`. Returns the final outcome.
260///
261/// Degrade paths set [`AdapterOutcome::degrade_reason`] rather than printing
262/// mid-session, so the recorder can surface the warning *after* the child exits
263/// and the terminal is restored (FR-1.5) — printing from this thread while the
264/// agent's TUI owns the alternate screen is invisible to the user.
265#[allow(clippy::needless_pass_by_value)] // ctx/tx are owned for the thread's lifetime; taking them by value keeps the tailer self-contained (mirrors runner::run_reader).
266fn run_claude_tailer(ctx: AdapterContext, tx: mpsc::Sender<Event>) -> AdapterOutcome {
267    let Some(projects) = ctx.projects_dir.clone().or_else(claude_projects_dir) else {
268        return degraded(
269            "no ~/.claude/projects directory found (HOME unset?); run `hh doctor` to diagnose",
270        );
271    };
272    if !projects.is_dir() {
273        return degraded(format!(
274            "projects directory does not exist: {}; run `hh doctor` to diagnose",
275            projects.display()
276        ));
277    }
278    let slug = slugify(&ctx.cwd);
279    let slug_dir = projects.join(&slug);
280    debug(&format!(
281        "claude adapter: projects_dir={}, slug={slug}, slug_dir={}, session cwd={}, started_at_unix_ms={}",
282        projects.display(),
283        slug_dir.display(),
284        ctx.cwd.display(),
285        ctx.started_at_unix_ms
286    ));
287    let mut diag = DiscoveryDiag {
288        projects: Some(projects.clone()),
289        slug: Some(slug.clone()),
290        slug_dir: Some(slug_dir.clone()),
291        cwd: Some(ctx.cwd.clone()),
292        ..Default::default()
293    };
294    let start = Instant::now();
295    let Some(file) = locate_transcript(&projects, &ctx.cwd, &ctx.stop, &mut diag) else {
296        debug(&format!(
297            "claude adapter: discovery failed: new_candidates={}, cwd_mismatches={}, directories={}",
298            diag.new_candidates.len(),
299            diag.cwd_mismatches.len(),
300            diag.directories.len()
301        ));
302        return degraded(locate_failure_reason(&diag));
303    };
304    debug(&format!(
305        "claude adapter: selected transcript {} (cwd matched)",
306        file.display()
307    ));
308    let mut acc = OutcomeAcc::default();
309    let stats = tail_file(&file, &projects, &ctx, &tx, &start, &mut acc);
310    if !stats.read_ok {
311        return degraded(format!(
312            "found a transcript at {} but could not read it (open/read failed); \
313             run `hh doctor` to check file permissions",
314            file.display(),
315        ));
316    }
317    debug(&format!(
318        "claude adapter: tail done: lines_seen={}, records_parsed={}, events_produced={}, first_parse_error={:?}",
319        stats.lines_seen, stats.records_parsed, stats.events_produced, stats.first_parse_error
320    ));
321    if stats.events_produced == 0 {
322        // Found and read a transcript, but no user/assistant records converted
323        // to events. Distinct from "could not locate" — the file existed but
324        // yielded nothing. Most common cause: a format drift the parser no
325        // longer recognizes, so surface the first parse failure (if any) and the
326        // line count to make this a one-line diagnosis instead of a silent
327        // "active, 0 steps".
328        let first_err = stats
329            .first_parse_error
330            .map(|(n, msg)| format!("; first parse error at line {n}: {msg}"))
331            .unwrap_or_default();
332        return degraded(format!(
333            "jsonl found ({}) but 0 records parsed (read {} line(s){first_err}); \
334             run `hh doctor` to check the Claude Code transcript format",
335            file.display(),
336            stats.lines_seen,
337        ));
338    }
339    acc.finish(AdapterStatus::Active)
340}
341
342/// Build a [`Degraded`](AdapterStatus::Degraded) outcome carrying a one-line
343/// reason for the recorder to print after the child exits.
344fn degraded(reason: impl Into<String>) -> AdapterOutcome {
345    AdapterOutcome {
346        status: AdapterStatus::Degraded,
347        degrade_reason: Some(reason.into()),
348        ..Default::default()
349    }
350}
351
352/// Accumulated discovery diagnostics: what the tailer saw while polling for a
353/// transcript. Used to (a) emit an `HH_DEBUG` trace and (b) build a *specific*
354/// degrade reason when no transcript qualifies — so a degraded session
355/// self-documents "no new file appeared" vs "N candidates appeared but none
356/// matched cwd" vs "a candidate was a directory", instead of a bare "could not
357/// locate". Pure data; the tailer mutates it as it polls.
358#[derive(Default)]
359struct DiscoveryDiag {
360    /// Resolved projects dir (`~/.claude/projects` or the test override).
361    projects: Option<PathBuf>,
362    /// The slug computed from the session cwd (e.g. `-home-saadman-halfhand`).
363    slug: Option<String>,
364    /// `projects/<slug>` — the primary lookup dir.
365    slug_dir: Option<PathBuf>,
366    /// Session cwd, for the reason string.
367    cwd: Option<PathBuf>,
368    /// Distinct NEW `*.jsonl` files ever observed (across all polls). Empty →
369    /// no transcript appeared at all; non-empty → appeared but none qualified.
370    new_candidates: std::collections::HashSet<PathBuf>,
371    /// Candidates rejected because their first cwd-bearing record named a
372    /// different cwd (a concurrent session in the same project dir).
373    cwd_mismatches: Vec<PathBuf>,
374    /// Candidates that were a directory, not a file (the same-stem-dir guard).
375    directories: Vec<PathBuf>,
376}
377
378/// Statistics from tailing one transcript, used to build a specific degrade
379/// reason when a file was found and read but produced no events. Pure data;
380/// [`tail_file`] / [`parse_and_send`] mutate it as they read.
381#[derive(Default)]
382struct TailStats {
383    /// `false` if the transcript could not be opened at all.
384    read_ok: bool,
385    /// Non-empty lines seen (complete records + unparseable lines).
386    lines_seen: usize,
387    /// Lines that parsed as a JSON object (the "records read" count).
388    records_parsed: usize,
389    /// Total events emitted to the drain channel.
390    events_produced: usize,
391    /// The first line that failed JSON parsing, as `(1-based line number, msg)`.
392    /// `None` if every line parsed (or no lines were seen).
393    first_parse_error: Option<(usize, String)>,
394}
395
396/// Build a specific degrade reason from a failed discovery. Distinguishes "no
397/// new transcript appeared" (lazy creation + empty session) from "candidates
398/// appeared but none matched cwd" (concurrent-session / slug drift), and calls
399/// out any directory candidates (the same-stem-dir issue).
400fn locate_failure_reason(diag: &DiscoveryDiag) -> String {
401    use std::fmt::Write as _;
402    let slug = diag.slug.as_deref().unwrap_or("?");
403    let projects = diag
404        .projects
405        .as_ref()
406        .map_or_else(|| "(unknown)".to_string(), |p| p.display().to_string());
407    let cwd = diag
408        .cwd
409        .as_ref()
410        .map_or_else(|| "(unknown)".to_string(), |p| p.display().to_string());
411    let slug_dir = diag
412        .slug_dir
413        .as_ref()
414        .map_or_else(|| "(unknown)".to_string(), |p| p.display().to_string());
415    if diag.new_candidates.is_empty() {
416        format!(
417            "no jsonl matched cwd slug {slug}: no new transcript appeared under {projects} \
418             (looked in {slug_dir}; cwd={cwd}) before the session ended; Claude creates it \
419             lazily on first output — if you sent no prompt this is expected. \
420             Run `hh doctor` to verify discoverability."
421        )
422    } else {
423        let mut s = format!(
424            "no jsonl matched cwd slug {slug}: {} new candidate(s) appeared under {projects} \
425             but none carried cwd={cwd} before the session ended",
426            diag.new_candidates.len(),
427        );
428        if !diag.cwd_mismatches.is_empty() {
429            let names: Vec<String> = diag
430                .cwd_mismatches
431                .iter()
432                .take(3)
433                .map(|p| {
434                    p.file_name()
435                        .map(|f| f.to_string_lossy().to_string())
436                        .unwrap_or_default()
437                })
438                .collect();
439            let _ = write!(
440                s,
441                "; {} rejected for a different cwd ({})",
442                diag.cwd_mismatches.len(),
443                names.join(", ")
444            );
445        }
446        if !diag.directories.is_empty() {
447            let _ = write!(
448                s,
449                "; {} candidate(s) were directories, not files",
450                diag.directories.len()
451            );
452        }
453        s.push_str(". Run `hh doctor` to diagnose.");
454        s
455    }
456}
457
458/// Accumulates model/usage across assistant records while tailing.
459#[derive(Default)]
460struct OutcomeAcc {
461    model: Option<String>,
462    usage_json: Option<serde_json::Value>,
463}
464
465impl OutcomeAcc {
466    fn note_assistant(&mut self, message: &serde_json::Value) {
467        if let Some(m) = message.get("model").and_then(|v| v.as_str()) {
468            self.model = Some(m.to_string());
469        }
470        if let Some(u) = message.get("usage") {
471            self.usage_json = Some(u.clone());
472        }
473    }
474
475    fn finish(self, status: AdapterStatus) -> AdapterOutcome {
476        AdapterOutcome {
477            model: self.model,
478            usage_json: self.usage_json,
479            status,
480            degrade_reason: None,
481        }
482    }
483}
484
485/// Locate the transcript file for this session.
486///
487/// Selection rule (fixes two real-world failures):
488/// - **Poll until the stop flag is set**, not for a fixed 3 s deadline. Claude
489///   Code creates its transcript *lazily* on the first user message, often well
490///   after `hh run` launches the child; a fixed timeout degraded every
491///   short-prompt session that took >3 s to send its first message (the
492///   "0 steps, status=ok" symptom).
493/// - **Prefer a transcript that is NEW since session start**, identified by
494///   snapshotting every `*.jsonl` under `projects` at entry. Claude names each
495///   invocation's transcript by a fresh uuid, so the file for *this* session did
496///   not exist when `hh` started; matching only new files avoids latching onto a
497///   concurrent session's transcript in the same project dir (the
498///   "active but tailing the wrong session" symptom). Set-based, not mtime-based,
499///   so clock skew cannot fool it.
500/// - **Verify by cwd**: a candidate qualifies only once a record carrying `cwd`
501///   appears and that cwd equals (or is under) the session cwd. Early Claude
502///   records (`agent-setting`/`mode`/`file-history-snapshot`) carry no `cwd`, so
503///   a freshly-created file is polled until its first cwd-bearing record lands.
504///
505/// Returns `None` if nothing qualifies before the stop flag is set (the recorder
506/// then surfaces a degraded outcome with an actionable reason).
507fn locate_transcript(
508    projects: &Path,
509    cwd: &Path,
510    stop: &std::sync::Arc<AtomicBool>,
511    diag: &mut DiscoveryDiag,
512) -> Option<PathBuf> {
513    let slug_dir = projects.join(slugify(cwd));
514    let preexisting = snapshot_all_jsonl(projects);
515    // Candidates confirmed to belong to a *different* cwd (cwd present but not
516    // ours) — never re-checked. Files with no cwd yet are re-polled (they grow).
517    let mut rejected: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
518
519    loop {
520        if stop.load(Ordering::Acquire) {
521            return None;
522        }
523        // Primary: a NEW transcript in the expected slug dir whose cwd matches.
524        if slug_dir.is_dir() {
525            for f in new_jsonl_in(&slug_dir, &preexisting) {
526                if rejected.contains(&f) {
527                    continue;
528                }
529                diag.new_candidates.insert(f.clone());
530                match first_record_cwd_state(&f, cwd) {
531                    CwdState::Matches => return Some(f),
532                    CwdState::Different => {
533                        diag.cwd_mismatches.push(f.clone());
534                        rejected.insert(f);
535                    }
536                    CwdState::Directory => {
537                        diag.directories.push(f.clone());
538                        rejected.insert(f);
539                    }
540                    CwdState::None => {} // not yet written; keep polling
541                }
542            }
543        }
544        // Fallback: slug encoding may differ from Claude's; scan every project dir
545        // for a NEW transcript matching cwd. Cheap in practice — new files appear
546        // only when a session starts, and rejected/non-matching ones are cached.
547        for f in new_jsonl_under(projects, &preexisting) {
548            if rejected.contains(&f) {
549                continue;
550            }
551            diag.new_candidates.insert(f.clone());
552            match first_record_cwd_state(&f, cwd) {
553                CwdState::Matches => return Some(f),
554                CwdState::Different => {
555                    diag.cwd_mismatches.push(f.clone());
556                    rejected.insert(f);
557                }
558                CwdState::Directory => {
559                    diag.directories.push(f.clone());
560                    rejected.insert(f);
561                }
562                CwdState::None => {}
563            }
564        }
565        std::thread::sleep(APPEAR_POLL);
566    }
567}
568
569/// The newest `*.jsonl` in `dir` by mtime, with a small slack window so a file
570/// created just before `started_at_unix_ms` (clock skew) still qualifies.
571fn newest_in_dir(dir: &Path, since_unix_ms: i64) -> Option<PathBuf> {
572    let entries = std::fs::read_dir(dir).ok()?;
573    let mut best: Option<(PathBuf, i64)> = None;
574    for e in entries.flatten() {
575        let p = e.path();
576        if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
577            continue;
578        }
579        let Ok(m) = e.metadata() else { continue };
580        let Ok(modified) = m.modified() else { continue };
581        let mtime_ms = system_time_to_unix_ms(modified);
582        if mtime_ms < since_unix_ms - 5_000 {
583            continue; // 5s slack
584        }
585        match &best {
586            Some((_, best_ms)) if mtime_ms <= *best_ms => {}
587            _ => best = Some((p, mtime_ms)),
588        }
589    }
590    best.map(|(p, _)| p)
591}
592
593/// Snapshot every `*.jsonl` path under all project dirs at session start. The
594/// transcript for *this* invocation is a file NOT in this set (Claude creates a
595/// fresh uuid-named file per run), so this set defines "new since start".
596fn snapshot_all_jsonl(projects: &Path) -> std::collections::HashSet<PathBuf> {
597    let mut set = std::collections::HashSet::new();
598    let Ok(dirs) = std::fs::read_dir(projects) else {
599        return set;
600    };
601    for d in dirs.flatten() {
602        let dp = d.path();
603        if !dp.is_dir() {
604            continue;
605        }
606        if let Ok(entries) = std::fs::read_dir(&dp) {
607            for e in entries.flatten() {
608                let p = e.path();
609                if p.extension().and_then(|x| x.to_str()) == Some("jsonl") {
610                    set.insert(p);
611                }
612            }
613        }
614    }
615    set
616}
617
618/// `*.jsonl` files in `dir` not in `preexisting`, newest-first by mtime.
619fn new_jsonl_in(dir: &Path, preexisting: &std::collections::HashSet<PathBuf>) -> Vec<PathBuf> {
620    let Ok(entries) = std::fs::read_dir(dir) else {
621        return Vec::new();
622    };
623    let mut found: Vec<(PathBuf, i64)> = Vec::new();
624    for e in entries.flatten() {
625        let p = e.path();
626        if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
627            continue;
628        }
629        if preexisting.contains(&p) {
630            continue;
631        }
632        let Ok(m) = e.metadata() else { continue };
633        let mtime_ms = m.modified().map_or(0, system_time_to_unix_ms);
634        found.push((p, mtime_ms));
635    }
636    found.sort_by_key(|(_, m)| std::cmp::Reverse(*m));
637    found.into_iter().map(|(p, _)| p).collect()
638}
639
640/// `*.jsonl` files anywhere under `projects` not in `preexisting`, newest-first.
641fn new_jsonl_under(
642    projects: &Path,
643    preexisting: &std::collections::HashSet<PathBuf>,
644) -> Vec<PathBuf> {
645    let Ok(dirs) = std::fs::read_dir(projects) else {
646        return Vec::new();
647    };
648    let mut found: Vec<(PathBuf, i64)> = Vec::new();
649    for d in dirs.flatten() {
650        let dp = d.path();
651        if !dp.is_dir() {
652            continue;
653        }
654        for p in new_jsonl_in(&dp, preexisting) {
655            let mtime_ms = std::fs::metadata(&p)
656                .ok()
657                .and_then(|m| m.modified().ok())
658                .map_or(0, system_time_to_unix_ms);
659            found.push((p, mtime_ms));
660        }
661    }
662    found.sort_by_key(|(_, m)| std::cmp::Reverse(*m));
663    found.into_iter().map(|(p, _)| p).collect()
664}
665
666/// The cwd state of a candidate transcript, from its first cwd-bearing record.
667#[derive(Debug, PartialEq, Eq)]
668enum CwdState {
669    /// A record carried `cwd` and it equals (or is under) the session cwd.
670    Matches,
671    /// A record carried `cwd` but it did not match — belongs to another session.
672    Different,
673    /// No cwd-bearing record yet (file is brand-new / still being written).
674    None,
675    /// The candidate path is a directory, not a file. A defensive guard: the
676    /// `.jsonl` extension filter in [`new_jsonl_in`]/[`new_jsonl_under`] already
677    /// excludes directories (they carry no extension), so this normally never
678    /// fires — but if it ever does (e.g. a same-stem dir named `X.jsonl`), the
679    /// candidate is rejected with a specific reason rather than re-polled forever
680    /// (the `read_to_string` on a directory would otherwise yield an io error →
681    /// [`CwdState::None`] → an infinite poll loop, the silent-degrade symptom).
682    Directory,
683}
684
685/// Inspect the first cwd-bearing record in `file` and classify it against the
686/// session cwd. See [`CwdState`]. Best-effort read: an unreadable file is
687/// [`CwdState::None`] (re-polled rather than rejected). A directory candidate is
688/// [`CwdState::Directory`] (rejected, not re-polled).
689fn first_record_cwd_state(file: &Path, session_cwd: &Path) -> CwdState {
690    if file.is_dir() {
691        return CwdState::Directory;
692    }
693    let Ok(content) = std::fs::read_to_string(file) else {
694        return CwdState::None;
695    };
696    for line in content.lines() {
697        let line = line.trim();
698        if line.is_empty() {
699            continue;
700        }
701        let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
702            continue;
703        };
704        if let Some(rc) = v.get("cwd").and_then(|c| c.as_str()) {
705            let rp = Path::new(rc);
706            return if rp == session_cwd || rp.starts_with(session_cwd) {
707                CwdState::Matches
708            } else {
709                CwdState::Different
710            };
711        }
712    }
713    CwdState::None
714}
715
716/// Read `file` from byte 0, parsing each complete line into events sent on `tx`.
717/// Handles partial last lines (buffered until a newline arrives), rotation to a
718/// newer `*.jsonl` in the slug dir, and stop-flag termination. Returns
719/// [`TailStats`] so the caller can distinguish "could not open" (`read_ok`
720/// false) from "opened but produced 0 events" (a format-drift degrade, with the
721/// first parse error + line count) — instead of claiming active with 0 events.
722fn tail_file(
723    file: &Path,
724    projects: &Path,
725    ctx: &AdapterContext,
726    tx: &mpsc::Sender<Event>,
727    start: &Instant,
728    acc: &mut OutcomeAcc,
729) -> TailStats {
730    let slug_dir = projects.join(slugify(&ctx.cwd));
731    let mut current = file.to_path_buf();
732    let mut stats = TailStats::default();
733    let mut reader = match std::fs::File::open(&current) {
734        Ok(f) => std::io::BufReader::new(f),
735        Err(_) => return stats, // read_ok stays false
736    };
737    stats.read_ok = true;
738    let mut buf: Vec<u8> = Vec::new();
739    let mut line = Vec::new();
740    loop {
741        if ctx.stop.load(Ordering::Acquire) {
742            break;
743        }
744        match reader.read_until(b'\n', &mut line) {
745            Ok(0) => {
746                // EOF: parse any *complete* lines buffered so far, keep the
747                // trailing partial (no newline yet) for the next grow.
748                drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
749                // Rotation: a newer .jsonl appeared in the slug dir?
750                if let Some(newer) = rotate_to_newer(&slug_dir, &current, ctx.started_at_unix_ms) {
751                    debug(&format!(
752                        "claude adapter: rotating to newer transcript {}",
753                        newer.display()
754                    ));
755                    current = newer;
756                    reader = match std::fs::File::open(&current) {
757                        Ok(f) => std::io::BufReader::new(f),
758                        Err(_) => break,
759                    };
760                    line.clear();
761                    continue;
762                }
763                if ctx.stop.load(Ordering::Acquire) {
764                    break;
765                }
766                std::thread::sleep(TAIL_POLL);
767            }
768            Ok(_n) => {
769                drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
770            }
771            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
772            Err(_) => break,
773        }
774    }
775    // Final flush of any complete trailing line on stop.
776    drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
777    stats
778}
779
780/// Move the just-read `line` bytes into `buf`, then parse every complete
781/// (`\n`-terminated) line in `buf`, leaving the trailing partial in `buf`.
782fn drain_complete_lines(
783    buf: &mut Vec<u8>,
784    line: &mut Vec<u8>,
785    ctx: &AdapterContext,
786    tx: &mpsc::Sender<Event>,
787    start: &Instant,
788    acc: &mut OutcomeAcc,
789    stats: &mut TailStats,
790) {
791    if line.is_empty() {
792        return;
793    }
794    buf.append(line);
795    line.clear();
796    // Find the last `\n`; everything up to and including it is complete.
797    while let Some(nl) = buf.iter().rposition(|&b| b == b'\n') {
798        let complete: Vec<u8> = buf.drain(..=nl).collect();
799        parse_and_send(&complete, ctx, tx, start, acc, stats);
800    }
801}
802
803/// Parse one buffered line (bytes) into events and send them. Malformed JSON or
804/// skipped record types produce no events; the first JSON parse failure is
805/// recorded in `stats` (line number + message) for the degrade reason, and a
806/// debug trace is gated on `HH_DEBUG`. Unknown record types are skipped with a
807/// debug log, never a degrade (tolerant, multi-generation).
808fn parse_and_send(
809    bytes: &[u8],
810    ctx: &AdapterContext,
811    tx: &mpsc::Sender<Event>,
812    start: &Instant,
813    acc: &mut OutcomeAcc,
814    stats: &mut TailStats,
815) {
816    let s = match std::str::from_utf8(bytes) {
817        Ok(s) => s.trim(),
818        Err(e) => {
819            stats.lines_seen += 1;
820            if stats.first_parse_error.is_none() {
821                stats.first_parse_error = Some((stats.lines_seen, format!("not UTF-8: {e}")));
822            }
823            debug(&format!(
824                "claude adapter: line {} not UTF-8, skipping: {e}",
825                stats.lines_seen
826            ));
827            return;
828        }
829    };
830    if s.is_empty() {
831        return;
832    }
833    stats.lines_seen += 1;
834    let value: serde_json::Value = match serde_json::from_str(s) {
835        Ok(v) => v,
836        Err(e) => {
837            if stats.first_parse_error.is_none() {
838                stats.first_parse_error = Some((stats.lines_seen, e.to_string()));
839            }
840            debug(&format!(
841                "claude adapter: line {} not valid JSON, skipping: {e}",
842                stats.lines_seen
843            ));
844            return;
845        }
846    };
847    stats.records_parsed += 1;
848    let ts_ms = value
849        .get("timestamp")
850        .and_then(|v| v.as_str())
851        .and_then(|s| ts_ms_from_iso(s, ctx.started_at_unix_ms))
852        .unwrap_or_else(|| elapsed_ms(start));
853    let parsed = parse_record(&value, &ctx.session_id, ts_ms, &ctx.blobs);
854    if parsed.is_assistant_message {
855        if let Some(msg) = value.get("message") {
856            acc.note_assistant(msg);
857        }
858    }
859    for ev in parsed.events {
860        if tx.send(ev).is_err() {
861            break; // recorder dropped the receiver: stop sending.
862        }
863        stats.events_produced += 1;
864    }
865}
866
867/// If a `*.jsonl` newer than `current` exists in the slug dir, return it.
868fn rotate_to_newer(slug_dir: &Path, current: &Path, started_at_unix_ms: i64) -> Option<PathBuf> {
869    let newest = newest_in_dir(slug_dir, started_at_unix_ms)?;
870    if newest == current {
871        return None;
872    }
873    // Only rotate if the candidate is actually newer than the current file.
874    let newer_mtime = std::fs::metadata(&newest)
875        .ok()
876        .and_then(|m| m.modified().ok())
877        .map(system_time_to_unix_ms);
878    let cur_mtime = std::fs::metadata(current)
879        .ok()
880        .and_then(|m| m.modified().ok())
881        .map(system_time_to_unix_ms);
882    match (newer_mtime, cur_mtime) {
883        (Some(n), Some(c)) if n > c => Some(newest),
884        _ => None,
885    }
886}
887
888// ---------------------------------------------------------------------------
889// Claude Desktop adapter
890// ---------------------------------------------------------------------------
891
892/// The Claude Desktop adapter: same JSONL format as Claude Code
893/// (`~/.claude/projects/<slug>/*.jsonl`), so it reuses the same tailer and
894/// parser. The only difference is the `AgentKind` reported to the session row
895/// (`claude-desktop` vs `claude-code`), so `hh list` distinguishes them.
896#[derive(Debug, Clone)]
897pub struct ClaudeDesktopAdapter;
898
899impl Adapter for ClaudeDesktopAdapter {
900    fn agent_kind(&self) -> AgentKind {
901        AgentKind::ClaudeDesktop
902    }
903
904    #[allow(clippy::unused_self)]
905    fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle> {
906        let (tx, rx) = mpsc::channel::<Event>();
907        let outcome = std::thread::Builder::new()
908            .name("hh-claude-desktop-adapter".into())
909            .spawn(move || run_claude_tailer(ctx, tx))?;
910        Ok(AdapterHandle {
911            events: rx,
912            outcome,
913        })
914    }
915}
916
917// ---------------------------------------------------------------------------
918// Codex CLI adapter
919// ---------------------------------------------------------------------------
920
921/// The Codex CLI adapter: tails `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`
922/// and converts records to `user_message` / `agent_message` / `thinking` /
923/// `tool_call` / `tool_result` events. Stateless; the tailer thread holds all
924/// mutable state.
925///
926/// ## Experimental
927///
928/// This adapter is built against the researched Codex CLI rollout format
929/// (<https://github.com/openai/codex>) without real captured fixture files.
930/// The parser handles the documented record types (`session_meta`,
931/// `turn_context`, `response_item`, `event_msg`) and degrades gracefully on
932/// unknown types. If you encounter a session that produces 0 steps despite
933/// having a transcript, run `HH_DEBUG=1 hh run -- codex ...` and file an issue
934/// with the debug output.
935#[derive(Debug, Clone)]
936pub struct CodexAdapter;
937
938impl Adapter for CodexAdapter {
939    fn agent_kind(&self) -> AgentKind {
940        AgentKind::CodexCli
941    }
942
943    #[allow(clippy::unused_self)]
944    fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle> {
945        let (tx, rx) = mpsc::channel::<Event>();
946        let outcome = std::thread::Builder::new()
947            .name("hh-codex-adapter".into())
948            .spawn(move || run_codex_tailer(ctx, tx))?;
949        Ok(AdapterHandle {
950            events: rx,
951            outcome,
952        })
953    }
954}
955
956/// The Codex CLI sessions directory: `$HOME/.codex/sessions` (Unix) or
957/// `%USERPROFILE%\.codex\sessions` (Windows). `None` if neither env var is set.
958#[must_use]
959pub fn codex_sessions_dir() -> Option<PathBuf> {
960    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
961    Some(PathBuf::from(home).join(".codex").join("sessions"))
962}
963
964/// The tailer thread body for Codex CLI: locate the rollout transcript, then
965/// read it to EOF/stop, parsing each line into events sent on `tx`.
966#[allow(clippy::needless_pass_by_value)] // ctx/tx are owned for the thread's lifetime
967fn run_codex_tailer(ctx: AdapterContext, tx: mpsc::Sender<Event>) -> AdapterOutcome {
968    let Some(sessions) = ctx.projects_dir.clone().or_else(codex_sessions_dir) else {
969        return degraded(
970            "no ~/.codex/sessions directory found (HOME unset?); run `hh doctor` to diagnose",
971        );
972    };
973    if !sessions.is_dir() {
974        return degraded(format!(
975            "sessions directory does not exist: {}; run `hh doctor` to diagnose",
976            sessions.display()
977        ));
978    }
979    let start = Instant::now();
980    let Some(file) = locate_codex_transcript(&sessions, &ctx.cwd, &ctx.stop) else {
981        return degraded(format!(
982            "no codex rollout transcript appeared under {} matching cwd {} before the session ended",
983            sessions.display(),
984            ctx.cwd.display(),
985        ));
986    };
987    let mut acc = OutcomeAcc::default();
988    let stats = tail_codex_file(&file, &ctx, &tx, &start, &mut acc);
989    if !stats.read_ok {
990        return degraded(format!(
991            "found a codex transcript at {} but could not read it; \
992             run `hh doctor` to check file permissions",
993            file.display(),
994        ));
995    }
996    if stats.events_produced == 0 {
997        let first_err = stats
998            .first_parse_error
999            .map(|(n, msg)| format!("; first parse error at line {n}: {msg}"))
1000            .unwrap_or_default();
1001        return degraded(format!(
1002            "codex transcript found ({}) but 0 records parsed (read {} line(s){first_err}); \
1003             run `hh doctor` to check the Codex CLI transcript format",
1004            file.display(),
1005            stats.lines_seen,
1006        ));
1007    }
1008    acc.finish(AdapterStatus::Active)
1009}
1010
1011/// Locate the Codex CLI rollout transcript for this session.
1012///
1013/// Codex stores rollout files at `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`.
1014/// The adapter polls for new files in today's date directory (and yesterday's,
1015/// for sessions that cross midnight), snapshotting pre-existing files at start
1016/// so it only picks up the file created for *this* session.
1017fn locate_codex_transcript(
1018    sessions: &Path,
1019    cwd: &Path,
1020    stop: &std::sync::Arc<AtomicBool>,
1021) -> Option<PathBuf> {
1022    let preexisting = snapshot_codex_rollouts(sessions);
1023    let mut rejected: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
1024
1025    loop {
1026        if stop.load(Ordering::Acquire) {
1027            return None;
1028        }
1029        // Scan today's and yesterday's date directories for new rollout files.
1030        for date_dir in candidate_date_dirs() {
1031            let dir = sessions.join(&date_dir);
1032            if !dir.is_dir() {
1033                continue;
1034            }
1035            for f in new_rollout_in(&dir, &preexisting) {
1036                if rejected.contains(&f) {
1037                    continue;
1038                }
1039                match first_codex_record_cwd(&f, cwd) {
1040                    CwdState::Matches => return Some(f),
1041                    CwdState::Different | CwdState::Directory => {
1042                        rejected.insert(f);
1043                    }
1044                    CwdState::None => {} // not yet written; keep polling
1045                }
1046            }
1047        }
1048        std::thread::sleep(APPEAR_POLL);
1049    }
1050}
1051
1052/// Snapshot every rollout file under the sessions directory at start.
1053fn snapshot_codex_rollouts(sessions: &Path) -> std::collections::HashSet<PathBuf> {
1054    let mut set = std::collections::HashSet::new();
1055    let Ok(years) = std::fs::read_dir(sessions) else {
1056        return set;
1057    };
1058    for year in years.flatten() {
1059        let yp = year.path();
1060        if !yp.is_dir() {
1061            continue;
1062        }
1063        let Ok(months) = std::fs::read_dir(&yp) else {
1064            continue;
1065        };
1066        for month in months.flatten() {
1067            let mp = month.path();
1068            if !mp.is_dir() {
1069                continue;
1070            }
1071            let Ok(days) = std::fs::read_dir(&mp) else {
1072                continue;
1073            };
1074            for day in days.flatten() {
1075                let dp = day.path();
1076                if !dp.is_dir() {
1077                    continue;
1078                }
1079                if let Ok(entries) = std::fs::read_dir(&dp) {
1080                    for e in entries.flatten() {
1081                        let p = e.path();
1082                        if p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
1083                            n.starts_with("rollout-")
1084                                && std::path::Path::new(n).extension()
1085                                    == Some(std::ffi::OsStr::new("jsonl"))
1086                        }) {
1087                            set.insert(p);
1088                        }
1089                    }
1090                }
1091            }
1092        }
1093    }
1094    set
1095}
1096
1097/// `rollout-*.jsonl` files in `dir` not in `preexisting`, newest-first by mtime.
1098fn new_rollout_in(dir: &Path, preexisting: &std::collections::HashSet<PathBuf>) -> Vec<PathBuf> {
1099    let Ok(entries) = std::fs::read_dir(dir) else {
1100        return Vec::new();
1101    };
1102    let mut found: Vec<(PathBuf, i64)> = Vec::new();
1103    for e in entries.flatten() {
1104        let p = e.path();
1105        if !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
1106            n.starts_with("rollout-")
1107                && std::path::Path::new(n).extension() == Some(std::ffi::OsStr::new("jsonl"))
1108        }) {
1109            continue;
1110        }
1111        if preexisting.contains(&p) {
1112            continue;
1113        }
1114        let mtime_ms = e
1115            .metadata()
1116            .ok()
1117            .and_then(|m| m.modified().ok())
1118            .map_or(0, system_time_to_unix_ms);
1119        found.push((p, mtime_ms));
1120    }
1121    found.sort_by_key(|(_, m)| std::cmp::Reverse(*m));
1122    found.into_iter().map(|(p, _)| p).collect()
1123}
1124
1125/// Candidate date directories: today and yesterday (for sessions that cross
1126/// midnight), formatted as `YYYY/MM/DD`.
1127fn candidate_date_dirs() -> Vec<String> {
1128    let now = std::time::SystemTime::now()
1129        .duration_since(std::time::UNIX_EPOCH)
1130        .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
1131    let today_secs = now - (now % 86400);
1132    let mut dirs = Vec::with_capacity(2);
1133    for offset in [0, -86400] {
1134        let ts = today_secs + offset;
1135        if let Ok(dt) = time::OffsetDateTime::from_unix_timestamp(ts) {
1136            dirs.push(format!(
1137                "{:04}/{:02}/{:02}",
1138                dt.year(),
1139                u8::from(dt.month()),
1140                dt.day()
1141            ));
1142        }
1143    }
1144    dirs
1145}
1146
1147/// Inspect the first cwd-bearing record in a Codex rollout file. Codex puts
1148/// `cwd` in the `session_meta` record (first line of the file).
1149fn first_codex_record_cwd(file: &Path, session_cwd: &Path) -> CwdState {
1150    if file.is_dir() {
1151        return CwdState::Directory;
1152    }
1153    let Ok(content) = std::fs::read_to_string(file) else {
1154        return CwdState::None;
1155    };
1156    for line in content.lines() {
1157        let line = line.trim();
1158        if line.is_empty() {
1159            continue;
1160        }
1161        let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
1162            continue;
1163        };
1164        // Codex puts cwd in session_meta.payload.cwd
1165        if let Some(cwd_val) = v
1166            .get("payload")
1167            .and_then(|p| p.get("cwd"))
1168            .and_then(|c| c.as_str())
1169        {
1170            let rp = Path::new(cwd_val);
1171            return if rp == session_cwd || rp.starts_with(session_cwd) {
1172                CwdState::Matches
1173            } else {
1174                CwdState::Different
1175            };
1176        }
1177    }
1178    CwdState::None
1179}
1180
1181/// Tail a Codex rollout file, parsing each line into events.
1182fn tail_codex_file(
1183    file: &Path,
1184    ctx: &AdapterContext,
1185    tx: &mpsc::Sender<Event>,
1186    start: &Instant,
1187    acc: &mut OutcomeAcc,
1188) -> TailStats {
1189    let mut stats = TailStats::default();
1190    let mut reader = match std::fs::File::open(file) {
1191        Ok(f) => std::io::BufReader::new(f),
1192        Err(_) => return stats,
1193    };
1194    stats.read_ok = true;
1195    let mut buf: Vec<u8> = Vec::new();
1196    let mut line = Vec::new();
1197    loop {
1198        if ctx.stop.load(Ordering::Acquire) {
1199            break;
1200        }
1201        match reader.read_until(b'\n', &mut line) {
1202            Ok(0) => {
1203                drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
1204                if ctx.stop.load(Ordering::Acquire) {
1205                    break;
1206                }
1207                std::thread::sleep(TAIL_POLL);
1208            }
1209            Ok(_n) => {
1210                drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
1211            }
1212            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
1213            Err(_) => break,
1214        }
1215    }
1216    drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
1217    stats
1218}
1219
1220// ---------------------------------------------------------------------------
1221// Codex CLI record parser
1222// ---------------------------------------------------------------------------
1223
1224/// Parse one Codex CLI JSONL record into events. Pure given `(value, ts_ms,
1225/// blobs)`: deterministic output for fixed inputs. Unknown `type`s and
1226/// unexpected shapes produce no events (tolerant).
1227///
1228/// Codex rollout format: each line is `{"type": "<variant>", "timestamp": "...",
1229/// "payload": {...}}`. The payload's inner `type` discriminates further for
1230/// `response_item` and `event_msg` variants.
1231#[must_use]
1232#[allow(dead_code)] // called from the tailer thread; not yet wired through parse_and_send
1233pub(crate) fn parse_codex_record(
1234    value: &serde_json::Value,
1235    session_id: &str,
1236    ts_ms: i64,
1237    blobs: &BlobStore,
1238) -> ParsedRecord {
1239    let mut out = ParsedRecord::default();
1240    let Some(obj) = value.as_object() else {
1241        return out;
1242    };
1243    let ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
1244    let payload = obj.get("payload");
1245    match ty {
1246        "response_item" => {
1247            if let Some(p) = payload {
1248                parse_codex_response_item(p, session_id, ts_ms, blobs, &mut out);
1249            }
1250        }
1251        "event_msg" => {
1252            if let Some(p) = payload {
1253                parse_codex_event_msg(p, session_id, ts_ms, blobs, &mut out);
1254            }
1255        }
1256        // session_meta, turn_context, session_state, compacted: skip
1257        _ => {}
1258    }
1259    out
1260}
1261
1262/// Parse a `response_item` payload into events.
1263#[allow(dead_code, clippy::too_many_lines)]
1264fn parse_codex_response_item(
1265    payload: &serde_json::Value,
1266    session_id: &str,
1267    ts_ms: i64,
1268    blobs: &BlobStore,
1269    out: &mut ParsedRecord,
1270) {
1271    let Some(obj) = payload.as_object() else {
1272        return;
1273    };
1274    let inner_ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
1275    match inner_ty {
1276        "message" => {
1277            let role = obj.get("role").and_then(|v| v.as_str()).unwrap_or("");
1278            let content = obj.get("content").and_then(|v| v.as_array());
1279            let text = content.and_then(|arr| {
1280                arr.iter()
1281                    .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
1282                    .collect::<Vec<_>>()
1283                    .join("\n")
1284                    .into()
1285            });
1286            let kind = match role {
1287                "assistant" => EventKind::AgentMessage,
1288                _ => EventKind::UserMessage,
1289            };
1290            if let Some(t) = text {
1291                out.events
1292                    .push(text_event(session_id, ts_ms, kind, &t, blobs));
1293            }
1294            out.is_assistant_message = role == "assistant";
1295        }
1296        "reasoning" => {
1297            // Reasoning content is usually encrypted; emit a placeholder.
1298            let summary = obj
1299                .get("summary")
1300                .and_then(|v| v.as_array())
1301                .map(|arr| {
1302                    arr.iter()
1303                        .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
1304                        .collect::<Vec<_>>()
1305                        .join("\n")
1306                })
1307                .unwrap_or_default();
1308            let text = if summary.is_empty() {
1309                "reasoning (encrypted)".to_string()
1310            } else {
1311                summary
1312            };
1313            out.events.push(text_event(
1314                session_id,
1315                ts_ms,
1316                EventKind::Thinking,
1317                &text,
1318                blobs,
1319            ));
1320        }
1321        "function_call" | "custom_tool_call" => {
1322            let call_id = obj.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
1323            let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
1324            let input = if inner_ty == "function_call" {
1325                // function_call.arguments is a JSON string — double-parse
1326                obj.get("arguments")
1327                    .and_then(|v| v.as_str())
1328                    .and_then(|s| serde_json::from_str(s).ok())
1329                    .unwrap_or(serde_json::Value::Null)
1330            } else {
1331                // custom_tool_call.input is free-form text
1332                obj.get("input").cloned().unwrap_or(serde_json::Value::Null)
1333            };
1334            let body = serde_json::json!({
1335                "name": name,
1336                "input": input,
1337                "correlate_key": call_id,
1338            });
1339            let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
1340            out.events.push(Event {
1341                session_id: session_id.to_string(),
1342                ts_ms,
1343                kind: EventKind::ToolCall,
1344                step: None,
1345                summary: truncate_summary(&format!("tool_call: {name}")),
1346                body_json,
1347                blob_hash,
1348                blob_size,
1349                correlates: None,
1350            });
1351        }
1352        "function_call_output" | "custom_tool_call_output" => {
1353            let call_id = obj.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
1354            let output = obj.get("output").and_then(|v| v.as_str()).unwrap_or("");
1355            let body = serde_json::json!({
1356                "tool_use_id": call_id,
1357                "is_error": false,
1358                "content": output,
1359                "correlate_key": call_id,
1360            });
1361            let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
1362            out.events.push(Event {
1363                session_id: session_id.to_string(),
1364                ts_ms,
1365                kind: EventKind::ToolResult,
1366                step: None,
1367                summary: truncate_summary(&format!("tool_result: {}", one_line(output))),
1368                body_json,
1369                blob_hash,
1370                blob_size,
1371                correlates: None,
1372            });
1373        }
1374        _ => {} // unknown response_item type: skip (tolerant)
1375    }
1376}
1377
1378/// Parse an `event_msg` payload into events.
1379#[allow(dead_code, clippy::too_many_lines)]
1380fn parse_codex_event_msg(
1381    payload: &serde_json::Value,
1382    session_id: &str,
1383    ts_ms: i64,
1384    blobs: &BlobStore,
1385    out: &mut ParsedRecord,
1386) {
1387    let Some(obj) = payload.as_object() else {
1388        return;
1389    };
1390    let inner_ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
1391    match inner_ty {
1392        "user_message" => {
1393            if let Some(text) = extract_codex_event_text(obj) {
1394                out.events.push(text_event(
1395                    session_id,
1396                    ts_ms,
1397                    EventKind::UserMessage,
1398                    &text,
1399                    blobs,
1400                ));
1401            }
1402        }
1403        "agent_message" => {
1404            if let Some(text) = extract_codex_event_text(obj) {
1405                out.events.push(text_event(
1406                    session_id,
1407                    ts_ms,
1408                    EventKind::AgentMessage,
1409                    &text,
1410                    blobs,
1411                ));
1412            }
1413            out.is_assistant_message = true;
1414        }
1415        "token_count" => {
1416            // Extract model/usage from token_count events
1417            if let Some(info) = obj.get("info").and_then(|v| v.as_object()) {
1418                if info.get("total_token_usage").is_some() {
1419                    out.is_assistant_message = true;
1420                    // Store usage in the OutcomeAcc via a side channel: the
1421                    // tailer checks `is_assistant_message` and calls
1422                    // `acc.note_assistant` with the payload. We set the flag
1423                    // so the tailer knows to extract usage from this record.
1424                    // The actual usage is in `info.total_token_usage`.
1425                }
1426            }
1427        }
1428        "exec_command_end" | "patch_apply_end" => {
1429            // These are tool results from shell commands / patch applications.
1430            let call_id = obj.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
1431            let aggregated_output = obj
1432                .get("aggregated_output")
1433                .and_then(|v| v.as_str())
1434                .unwrap_or("");
1435            let stdout = obj.get("stdout").and_then(|v| v.as_str()).unwrap_or("");
1436            let stderr = obj.get("stderr").and_then(|v| v.as_str()).unwrap_or("");
1437            let exit_code = obj
1438                .get("exit_code")
1439                .and_then(serde_json::Value::as_i64)
1440                .unwrap_or(0);
1441            let content = if aggregated_output.is_empty() {
1442                let mut parts = Vec::new();
1443                if !stdout.is_empty() {
1444                    parts.push(stdout);
1445                }
1446                if !stderr.is_empty() {
1447                    parts.push(stderr);
1448                }
1449                parts.join("\n")
1450            } else {
1451                aggregated_output.to_string()
1452            };
1453            let body = serde_json::json!({
1454                "tool_use_id": call_id,
1455                "is_error": exit_code != 0,
1456                "content": content,
1457                "correlate_key": call_id,
1458            });
1459            let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
1460            let summary = if exit_code != 0 {
1461                format!("tool_result (error): {}", one_line(&content))
1462            } else {
1463                format!("tool_result: {}", one_line(&content))
1464            };
1465            out.events.push(Event {
1466                session_id: session_id.to_string(),
1467                ts_ms,
1468                kind: EventKind::ToolResult,
1469                step: None,
1470                summary: truncate_summary(&summary),
1471                body_json,
1472                blob_hash,
1473                blob_size,
1474                correlates: None,
1475            });
1476        }
1477        _ => {} // unknown event_msg type: skip (tolerant)
1478    }
1479}
1480
1481/// Extract text content from a Codex event_msg payload. The text may be in
1482/// `content` (string or array of parts) or `text` (direct string).
1483#[allow(dead_code)]
1484fn extract_codex_event_text(obj: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
1485    // Try `content` first (array of parts or string)
1486    if let Some(content) = obj.get("content") {
1487        match content {
1488            serde_json::Value::String(s) => return Some(s.clone()),
1489            serde_json::Value::Array(arr) => {
1490                let parts: String = arr
1491                    .iter()
1492                    .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
1493                    .collect::<Vec<_>>()
1494                    .join("\n");
1495                if !parts.is_empty() {
1496                    return Some(parts);
1497                }
1498            }
1499            _ => {}
1500        }
1501    }
1502    // Fallback: `text` field
1503    if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
1504        return Some(text.to_string());
1505    }
1506    None
1507}
1508
1509// ---------------------------------------------------------------------------
1510// Gemini CLI adapter
1511// ---------------------------------------------------------------------------
1512
1513/// The Gemini CLI adapter: tails `~/.gemini/tmp/<hash>/chats/session-*.jsonl`
1514/// and converts records to `user_message` / `agent_message` / `thinking` /
1515/// `tool_call` / `tool_result` events. Stateless; the tailer thread holds all
1516/// mutable state.
1517///
1518/// ## Experimental
1519///
1520/// This adapter is built against the researched Gemini CLI session format
1521/// (<https://github.com/google-gemini/gemini-cli>). The parser handles the
1522/// documented record types (`session_metadata`, `user`, `gemini`,
1523/// `message_update`) and degrades gracefully on unknown types. If you encounter
1524/// a session that produces 0 steps despite having a transcript, run
1525/// `HH_DEBUG=1 hh run -- gemini ...` and file an issue with the debug output.
1526#[derive(Debug, Clone)]
1527pub struct GeminiAdapter;
1528
1529impl Adapter for GeminiAdapter {
1530    fn agent_kind(&self) -> AgentKind {
1531        AgentKind::GeminiCli
1532    }
1533
1534    #[allow(clippy::unused_self)]
1535    fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle> {
1536        let (tx, rx) = mpsc::channel::<Event>();
1537        let outcome = std::thread::Builder::new()
1538            .name("hh-gemini-adapter".into())
1539            .spawn(move || run_gemini_tailer(ctx, tx))?;
1540        Ok(AdapterHandle {
1541            events: rx,
1542            outcome,
1543        })
1544    }
1545}
1546
1547/// The Gemini CLI temp directory: `$HOME/.gemini/tmp` (Unix) or
1548/// `%USERPROFILE%\.gemini\tmp` (Windows). `None` if neither env var is set.
1549#[must_use]
1550pub fn gemini_tmp_dir() -> Option<PathBuf> {
1551    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
1552    Some(PathBuf::from(home).join(".gemini").join("tmp"))
1553}
1554
1555/// The tailer thread body for Gemini CLI: locate the session transcript, then
1556/// read it to EOF/stop, parsing each line into events sent on `tx`.
1557#[allow(clippy::needless_pass_by_value)] // ctx/tx are owned for the thread's lifetime
1558fn run_gemini_tailer(ctx: AdapterContext, tx: mpsc::Sender<Event>) -> AdapterOutcome {
1559    let Some(tmp) = ctx.projects_dir.clone().or_else(gemini_tmp_dir) else {
1560        return degraded(
1561            "no ~/.gemini/tmp directory found (HOME unset?); run `hh doctor` to diagnose",
1562        );
1563    };
1564    if !tmp.is_dir() {
1565        return degraded(format!(
1566            "gemini tmp directory does not exist: {}; run `hh doctor` to diagnose",
1567            tmp.display()
1568        ));
1569    }
1570    let start = Instant::now();
1571    let Some(file) = locate_gemini_transcript(&tmp, &ctx.cwd, &ctx.stop) else {
1572        return degraded(format!(
1573            "no gemini session transcript appeared under {} matching cwd {} before the session ended",
1574            tmp.display(),
1575            ctx.cwd.display(),
1576        ));
1577    };
1578    let mut acc = OutcomeAcc::default();
1579    let stats = tail_gemini_file(&file, &ctx, &tx, &start, &mut acc);
1580    if !stats.read_ok {
1581        return degraded(format!(
1582            "found a gemini transcript at {} but could not read it; \
1583             run `hh doctor` to check file permissions",
1584            file.display(),
1585        ));
1586    }
1587    if stats.events_produced == 0 {
1588        let first_err = stats
1589            .first_parse_error
1590            .map(|(n, msg)| format!("; first parse error at line {n}: {msg}"))
1591            .unwrap_or_default();
1592        return degraded(format!(
1593            "gemini transcript found ({}) but 0 records parsed (read {} line(s){first_err}); \
1594             run `hh doctor` to check the Gemini CLI transcript format",
1595            file.display(),
1596            stats.lines_seen,
1597        ));
1598    }
1599    acc.finish(AdapterStatus::Active)
1600}
1601
1602/// Locate the Gemini CLI session transcript for this session.
1603///
1604/// Gemini stores session files at `~/.gemini/tmp/<project_hash>/chats/session-*.jsonl`.
1605/// The adapter scans all project hash directories for a `chats/` subdirectory,
1606/// then polls for new `session-*.jsonl` files.
1607fn locate_gemini_transcript(
1608    tmp: &Path,
1609    cwd: &Path,
1610    stop: &std::sync::Arc<AtomicBool>,
1611) -> Option<PathBuf> {
1612    let preexisting = snapshot_gemini_sessions(tmp);
1613    let mut rejected: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
1614
1615    loop {
1616        if stop.load(Ordering::Acquire) {
1617            return None;
1618        }
1619        // Scan every project hash dir for new session files in chats/.
1620        let Ok(projects) = std::fs::read_dir(tmp) else {
1621            std::thread::sleep(APPEAR_POLL);
1622            continue;
1623        };
1624        for project in projects.flatten() {
1625            let chats = project.path().join("chats");
1626            if !chats.is_dir() {
1627                continue;
1628            }
1629            for f in new_gemini_sessions_in(&chats, &preexisting) {
1630                if rejected.contains(&f) {
1631                    continue;
1632                }
1633                match first_gemini_record_cwd(&f, cwd) {
1634                    CwdState::Matches => return Some(f),
1635                    CwdState::Different | CwdState::Directory => {
1636                        rejected.insert(f);
1637                    }
1638                    CwdState::None => {}
1639                }
1640            }
1641        }
1642        std::thread::sleep(APPEAR_POLL);
1643    }
1644}
1645
1646/// Snapshot every `session-*.jsonl` file under the tmp directory at start.
1647fn snapshot_gemini_sessions(tmp: &Path) -> std::collections::HashSet<PathBuf> {
1648    let mut set = std::collections::HashSet::new();
1649    let Ok(projects) = std::fs::read_dir(tmp) else {
1650        return set;
1651    };
1652    for project in projects.flatten() {
1653        let chats = project.path().join("chats");
1654        if !chats.is_dir() {
1655            continue;
1656        }
1657        if let Ok(entries) = std::fs::read_dir(&chats) {
1658            for e in entries.flatten() {
1659                let p = e.path();
1660                if p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
1661                    n.starts_with("session-")
1662                        && std::path::Path::new(n).extension()
1663                            == Some(std::ffi::OsStr::new("jsonl"))
1664                }) {
1665                    set.insert(p);
1666                }
1667            }
1668        }
1669    }
1670    set
1671}
1672
1673/// `session-*.jsonl` files in `dir` not in `preexisting`, newest-first by mtime.
1674fn new_gemini_sessions_in(
1675    dir: &Path,
1676    preexisting: &std::collections::HashSet<PathBuf>,
1677) -> Vec<PathBuf> {
1678    let Ok(entries) = std::fs::read_dir(dir) else {
1679        return Vec::new();
1680    };
1681    let mut found: Vec<(PathBuf, i64)> = Vec::new();
1682    for e in entries.flatten() {
1683        let p = e.path();
1684        if !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
1685            n.starts_with("session-")
1686                && std::path::Path::new(n).extension() == Some(std::ffi::OsStr::new("jsonl"))
1687        }) {
1688            continue;
1689        }
1690        if preexisting.contains(&p) {
1691            continue;
1692        }
1693        let mtime_ms = e
1694            .metadata()
1695            .ok()
1696            .and_then(|m| m.modified().ok())
1697            .map_or(0, system_time_to_unix_ms);
1698        found.push((p, mtime_ms));
1699    }
1700    found.sort_by_key(|(_, m)| std::cmp::Reverse(*m));
1701    found.into_iter().map(|(p, _)| p).collect()
1702}
1703
1704/// Inspect the first cwd-bearing record in a Gemini session file. Gemini puts
1705/// `cwd` in the `session_metadata` record (first line).
1706fn first_gemini_record_cwd(file: &Path, _session_cwd: &Path) -> CwdState {
1707    if file.is_dir() {
1708        return CwdState::Directory;
1709    }
1710    let Ok(content) = std::fs::read_to_string(file) else {
1711        return CwdState::None;
1712    };
1713    for line in content.lines() {
1714        let line = line.trim();
1715        if line.is_empty() {
1716            continue;
1717        }
1718        let Ok(_v) = serde_json::from_str::<serde_json::Value>(line) else {
1719            continue;
1720        };
1721        // Gemini doesn't carry cwd in the session file; it uses projectHash.
1722        // We accept any session file that appears (no cwd verification).
1723        // The projectHash is derived from the cwd, so a session in the right
1724        // project dir is the right session.
1725        return CwdState::Matches;
1726    }
1727    CwdState::None
1728}
1729
1730/// Tail a Gemini session file, parsing each line into events.
1731fn tail_gemini_file(
1732    file: &Path,
1733    ctx: &AdapterContext,
1734    tx: &mpsc::Sender<Event>,
1735    start: &Instant,
1736    acc: &mut OutcomeAcc,
1737) -> TailStats {
1738    let mut stats = TailStats::default();
1739    let mut reader = match std::fs::File::open(file) {
1740        Ok(f) => std::io::BufReader::new(f),
1741        Err(_) => return stats,
1742    };
1743    stats.read_ok = true;
1744    let mut buf: Vec<u8> = Vec::new();
1745    let mut line = Vec::new();
1746    loop {
1747        if ctx.stop.load(Ordering::Acquire) {
1748            break;
1749        }
1750        match reader.read_until(b'\n', &mut line) {
1751            Ok(0) => {
1752                drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
1753                if ctx.stop.load(Ordering::Acquire) {
1754                    break;
1755                }
1756                std::thread::sleep(TAIL_POLL);
1757            }
1758            Ok(_n) => {
1759                drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
1760            }
1761            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
1762            Err(_) => break,
1763        }
1764    }
1765    drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
1766    stats
1767}
1768
1769// ---------------------------------------------------------------------------
1770// Gemini CLI record parser
1771// ---------------------------------------------------------------------------
1772
1773/// Parse one Gemini CLI JSONL record into events. Pure given `(value, ts_ms,
1774/// blobs)`: deterministic output for fixed inputs. Unknown `type`s and
1775/// unexpected shapes produce no events (tolerant).
1776///
1777/// Gemini session format: each line is a JSON object with a `type` field.
1778/// Types: `session_metadata` (skip), `user` (user message), `gemini` (model
1779/// message with optional tool calls/thoughts), `message_update` (skip).
1780#[must_use]
1781#[allow(dead_code, clippy::too_many_lines)] // called from the tailer thread; not yet wired through parse_and_send
1782pub(crate) fn parse_gemini_record(
1783    value: &serde_json::Value,
1784    session_id: &str,
1785    ts_ms: i64,
1786    blobs: &BlobStore,
1787) -> ParsedRecord {
1788    let mut out = ParsedRecord::default();
1789    let Some(obj) = value.as_object() else {
1790        return out;
1791    };
1792    let ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
1793    match ty {
1794        "user" => {
1795            if let Some(text) = extract_gemini_text(obj) {
1796                out.events.push(text_event(
1797                    session_id,
1798                    ts_ms,
1799                    EventKind::UserMessage,
1800                    &text,
1801                    blobs,
1802                ));
1803            }
1804        }
1805        "gemini" => {
1806            // Extract text content
1807            if let Some(text) = extract_gemini_text(obj) {
1808                out.events.push(text_event(
1809                    session_id,
1810                    ts_ms,
1811                    EventKind::AgentMessage,
1812                    &text,
1813                    blobs,
1814                ));
1815            }
1816            // Extract thoughts → Thinking events
1817            if let Some(thoughts) = obj.get("thoughts").and_then(|v| v.as_array()) {
1818                for thought in thoughts {
1819                    if let Some(text) = thought.get("text").and_then(|v| v.as_str()) {
1820                        out.events.push(text_event(
1821                            session_id,
1822                            ts_ms,
1823                            EventKind::Thinking,
1824                            text,
1825                            blobs,
1826                        ));
1827                    }
1828                }
1829            }
1830            // Extract tool calls
1831            if let Some(tool_calls) = obj.get("toolCalls").and_then(|v| v.as_array()) {
1832                for tc in tool_calls {
1833                    if let Some(tc_obj) = tc.as_object() {
1834                        let call_id = tc_obj.get("id").and_then(|v| v.as_str()).unwrap_or("");
1835                        let name = tc_obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
1836                        let input = tc_obj
1837                            .get("input")
1838                            .cloned()
1839                            .unwrap_or(serde_json::Value::Null);
1840                        let body = serde_json::json!({
1841                            "name": name,
1842                            "input": input,
1843                            "correlate_key": call_id,
1844                        });
1845                        let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
1846                        out.events.push(Event {
1847                            session_id: session_id.to_string(),
1848                            ts_ms,
1849                            kind: EventKind::ToolCall,
1850                            step: None,
1851                            summary: truncate_summary(&format!("tool_call: {name}")),
1852                            body_json,
1853                            blob_hash,
1854                            blob_size,
1855                            correlates: None,
1856                        });
1857                        // If the tool call has a result inline, emit a ToolResult too
1858                        if let Some(result) = tc_obj.get("result").and_then(|v| v.as_array()) {
1859                            let result_text: String = result
1860                                .iter()
1861                                .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
1862                                .collect::<Vec<_>>()
1863                                .join("\n");
1864                            let body = serde_json::json!({
1865                                "tool_use_id": call_id,
1866                                "is_error": false,
1867                                "content": result_text,
1868                                "correlate_key": call_id,
1869                            });
1870                            let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
1871                            out.events.push(Event {
1872                                session_id: session_id.to_string(),
1873                                ts_ms,
1874                                kind: EventKind::ToolResult,
1875                                step: None,
1876                                summary: truncate_summary(&format!(
1877                                    "tool_result: {}",
1878                                    one_line(&result_text)
1879                                )),
1880                                body_json,
1881                                blob_hash,
1882                                blob_size,
1883                                correlates: None,
1884                            });
1885                        }
1886                    }
1887                }
1888            }
1889            // Extract token usage
1890            if obj.get("tokens").is_some() {
1891                out.is_assistant_message = true;
1892            }
1893            // Extract model
1894            if let Some(_model) = obj.get("model").and_then(|v| v.as_str()) {
1895                out.is_assistant_message = true;
1896            }
1897        }
1898        // session_metadata, message_update: skip
1899        _ => {}
1900    }
1901    out
1902}
1903
1904/// Extract text content from a Gemini message record. The content is an array
1905/// of parts, each with a `text` field, or a direct string.
1906fn extract_gemini_text(obj: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
1907    if let Some(content) = obj.get("content") {
1908        match content {
1909            serde_json::Value::String(s) => return Some(s.clone()),
1910            serde_json::Value::Array(arr) => {
1911                let parts: String = arr
1912                    .iter()
1913                    .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
1914                    .collect::<Vec<_>>()
1915                    .join("\n");
1916                if !parts.is_empty() {
1917                    return Some(parts);
1918                }
1919            }
1920            _ => {}
1921        }
1922    }
1923    None
1924}
1925
1926// ---------------------------------------------------------------------------
1927// Pure parser (the insta target)
1928// ---------------------------------------------------------------------------
1929
1930/// One parsed record: the events it produced, plus whether it was an assistant
1931/// message (so the tailer can collect model/usage) and any model/usage seen.
1932#[derive(Debug, Default, Clone)]
1933pub(crate) struct ParsedRecord {
1934    /// Events derived from the record's content blocks.
1935    pub events: Vec<Event>,
1936    /// True for `assistant` records (the tailer collects model/usage from these).
1937    pub is_assistant_message: bool,
1938}
1939
1940/// Parse one Claude JSONL record into events. Pure given `(value, ts_ms,
1941/// blobs)`: deterministic output for fixed inputs (the only side effect is
1942/// deterministic blob spillover for >256 KiB payloads, whose hash is BLAKE3 of
1943/// fixed content). Unknown `type`s, `isSidechain`/`isMeta` records, and
1944/// unexpected shapes produce no events (tolerant — see module docs).
1945#[must_use]
1946pub(crate) fn parse_record(
1947    value: &serde_json::Value,
1948    session_id: &str,
1949    ts_ms: i64,
1950    blobs: &BlobStore,
1951) -> ParsedRecord {
1952    let mut out = ParsedRecord::default();
1953    let Some(obj) = value.as_object() else {
1954        return out; // non-object line: nothing to parse
1955    };
1956    let ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
1957    if ty != "user" && ty != "assistant" {
1958        return out; // system / mode / attachment / ai-title / file-history-snapshot / ...
1959    }
1960    if obj
1961        .get("isSidechain")
1962        .and_then(serde_json::Value::as_bool)
1963        .unwrap_or(false)
1964    {
1965        return out; // subagent transcript
1966    }
1967    if obj
1968        .get("isMeta")
1969        .and_then(serde_json::Value::as_bool)
1970        .unwrap_or(false)
1971    {
1972        return out; // injected caveat / system-reminder wrappers
1973    }
1974    let Some(message) = obj.get("message") else {
1975        return out;
1976    };
1977    let is_assistant = ty == "assistant";
1978    out.is_assistant_message = is_assistant;
1979    let Some(content) = message.get("content") else {
1980        return out;
1981    };
1982    match content {
1983        serde_json::Value::String(s) => {
1984            let kind = if is_assistant {
1985                EventKind::AgentMessage
1986            } else {
1987                EventKind::UserMessage
1988            };
1989            out.events
1990                .push(text_event(session_id, ts_ms, kind, s, blobs));
1991        }
1992        serde_json::Value::Array(blocks) => {
1993            for block in blocks {
1994                if let Some(b) = block.as_object() {
1995                    parse_block(b, is_assistant, session_id, ts_ms, blobs, &mut out.events);
1996                }
1997            }
1998        }
1999        _ => {} // content is null/number/etc: skip
2000    }
2001    out
2002}
2003
2004/// Parse one content block into events appended to `events`.
2005fn parse_block(
2006    b: &serde_json::Map<String, serde_json::Value>,
2007    is_assistant: bool,
2008    session_id: &str,
2009    ts_ms: i64,
2010    blobs: &BlobStore,
2011    events: &mut Vec<Event>,
2012) {
2013    let bty = b.get("type").and_then(|v| v.as_str()).unwrap_or("");
2014    match bty {
2015        "text" => {
2016            if let Some(t) = b.get("text").and_then(|v| v.as_str()) {
2017                let kind = if is_assistant {
2018                    EventKind::AgentMessage
2019                } else {
2020                    EventKind::UserMessage
2021                };
2022                events.push(text_event(session_id, ts_ms, kind, t, blobs));
2023            }
2024        }
2025        "thinking" => {
2026            if let Some(t) = b.get("thinking").and_then(|v| v.as_str()) {
2027                events.push(text_event(session_id, ts_ms, EventKind::Thinking, t, blobs));
2028            }
2029        }
2030        "tool_use" => {
2031            let id = b.get("id").and_then(|v| v.as_str()).unwrap_or("");
2032            let name = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
2033            let input = b.get("input").cloned().unwrap_or(serde_json::Value::Null);
2034            events.push(tool_call_event(session_id, ts_ms, id, name, &input, blobs));
2035        }
2036        "tool_result" => {
2037            let tool_use_id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
2038            let is_error = b
2039                .get("is_error")
2040                .and_then(serde_json::Value::as_bool)
2041                .unwrap_or(false);
2042            let content_text = extract_result_content(b.get("content"));
2043            events.push(tool_result_event(
2044                session_id,
2045                ts_ms,
2046                tool_use_id,
2047                is_error,
2048                &content_text,
2049                blobs,
2050            ));
2051        }
2052        _ => {} // unknown block type: skip (tolerant)
2053    }
2054}
2055
2056/// Build a text-bearing event (`user_message`/`agent_message`/`thinking`).
2057fn text_event(
2058    session_id: &str,
2059    ts_ms: i64,
2060    kind: EventKind,
2061    text: &str,
2062    blobs: &BlobStore,
2063) -> Event {
2064    let body = serde_json::json!({ "text": text });
2065    let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
2066    Event {
2067        session_id: session_id.to_string(),
2068        ts_ms,
2069        kind,
2070        step: None, // assigned by the FR-3.4 pass
2071        summary: truncate_summary(text),
2072        body_json,
2073        blob_hash,
2074        blob_size,
2075        correlates: None,
2076    }
2077}
2078
2079/// Build a `tool_call` event, correlating to its later `tool_result` by id.
2080fn tool_call_event(
2081    session_id: &str,
2082    ts_ms: i64,
2083    id: &str,
2084    name: &str,
2085    input: &serde_json::Value,
2086    blobs: &BlobStore,
2087) -> Event {
2088    let body = serde_json::json!({
2089        "name": name,
2090        "input": input,
2091        "correlate_key": id,
2092    });
2093    let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
2094    Event {
2095        session_id: session_id.to_string(),
2096        ts_ms,
2097        kind: EventKind::ToolCall,
2098        step: None,
2099        summary: truncate_summary(&format!("tool_call: {name}")),
2100        body_json,
2101        blob_hash,
2102        blob_size,
2103        correlates: None,
2104    }
2105}
2106
2107/// Build a `tool_result` event, correlating to its `tool_call` by `tool_use_id`.
2108fn tool_result_event(
2109    session_id: &str,
2110    ts_ms: i64,
2111    tool_use_id: &str,
2112    is_error: bool,
2113    content: &str,
2114    blobs: &BlobStore,
2115) -> Event {
2116    let body = serde_json::json!({
2117        "tool_use_id": tool_use_id,
2118        "is_error": is_error,
2119        "content": content,
2120        "correlate_key": tool_use_id,
2121    });
2122    let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
2123    let summary = if is_error {
2124        format!("tool_result (error): {}", one_line(content))
2125    } else {
2126        format!("tool_result: {}", one_line(content))
2127    };
2128    Event {
2129        session_id: session_id.to_string(),
2130        ts_ms,
2131        kind: EventKind::ToolResult,
2132        step: None,
2133        summary: truncate_summary(&summary),
2134        body_json,
2135        blob_hash,
2136        blob_size,
2137        correlates: None,
2138    }
2139}
2140
2141/// If `body` serializes to ≥ [`SPILLOVER_BYTES`], store it as a blob and return
2142/// an overflow envelope; otherwise return the body inline. Best-effort: a blob
2143/// write failure falls back to inline storage rather than dropping the event.
2144fn maybe_spill(
2145    body: serde_json::Value,
2146    blobs: &BlobStore,
2147) -> (Option<serde_json::Value>, Option<String>, Option<u64>) {
2148    let serialized = serde_json::to_vec(&body).unwrap_or_default();
2149    if serialized.len() >= SPILLOVER_BYTES {
2150        if let Ok(outcome) = blobs.put(&serialized) {
2151            let envelope = serde_json::json!({
2152                "overflow": true,
2153                "size": outcome.size,
2154                "blob_hash": outcome.hash,
2155                "encoding": "blob",
2156            });
2157            return (Some(envelope), Some(outcome.hash), Some(outcome.size));
2158        }
2159    }
2160    (Some(body), None, None)
2161}
2162
2163/// Extract a tool_result's `content` as a string, joining text blocks if it is
2164/// an array. Empty string for missing/unknown shapes.
2165fn extract_result_content(content: Option<&serde_json::Value>) -> String {
2166    match content {
2167        Some(serde_json::Value::String(s)) => s.clone(),
2168        Some(serde_json::Value::Array(arr)) => {
2169            let mut parts = Vec::with_capacity(arr.len());
2170            for b in arr {
2171                if let Some(t) = b.get("text").and_then(|v| v.as_str()) {
2172                    parts.push(t.to_string());
2173                }
2174            }
2175            parts.join("\n")
2176        }
2177        _ => String::new(),
2178    }
2179}
2180
2181/// Collapse newlines so a summary fits on one line (length capped by the caller).
2182fn one_line(s: &str) -> String {
2183    s.chars()
2184        .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
2185        .collect()
2186}
2187
2188/// Convert a Claude `timestamp` (RFC3339, e.g. `2026-07-02T06:14:40.699Z`) to
2189/// milliseconds relative to `started_at_unix_ms`. Returns `None` on parse
2190/// failure (the tailer falls back to wall-clock elapsed time).
2191pub(crate) fn ts_ms_from_iso(iso: &str, started_at_unix_ms: i64) -> Option<i64> {
2192    let dt =
2193        time::OffsetDateTime::parse(iso, &time::format_description::well_known::Rfc3339).ok()?;
2194    let unix_ms = dt.unix_timestamp_nanos() / 1_000_000;
2195    let unix_ms = i64::try_from(unix_ms).unwrap_or(i64::MAX);
2196    Some((unix_ms - started_at_unix_ms).max(0))
2197}
2198
2199// ---------------------------------------------------------------------------
2200// Path / time helpers
2201// ---------------------------------------------------------------------------
2202
2203/// The Claude Code projects directory: `$HOME/.claude/projects` (Unix) or
2204/// `%USERPROFILE%\.claude\projects` (Windows). `None` if neither env var is set.
2205/// Public so `hh doctor` can report Claude Code transcript discoverability.
2206#[must_use]
2207pub fn claude_projects_dir() -> Option<PathBuf> {
2208    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
2209    Some(PathBuf::from(home).join(".claude").join("projects"))
2210}
2211
2212/// The newest `*.jsonl` transcript Claude Code has written for `cwd` (under the
2213/// slug dir derived from `cwd`), or `None` if the slug dir has no transcripts.
2214/// A best-effort discovery probe for `hh doctor` — no session-start filtering,
2215/// so it surfaces whatever Claude most recently wrote for this directory.
2216#[must_use]
2217pub fn newest_jsonl_for_cwd(cwd: &Path) -> Option<PathBuf> {
2218    let projects = claude_projects_dir()?;
2219    let slug_dir = projects.join(slugify(cwd));
2220    if !slug_dir.is_dir() {
2221        return None;
2222    }
2223    // `since = 0` disables the mtime floor so every .jsonl qualifies.
2224    newest_in_dir(&slug_dir, 0)
2225}
2226
2227/// Encode `cwd` as a Claude slug: every `/` (and `\`, `.`) becomes `-`. This is
2228/// inferred from a sample transcript (the SRS is absent); it is a *hint only* —
2229/// the tailer falls back to a cwd-based scan when the slug dir misses.
2230#[must_use]
2231pub(crate) fn slugify(cwd: &Path) -> String {
2232    cwd.to_string_lossy()
2233        .chars()
2234        .map(|c| match c {
2235            '/' | '\\' | '.' => '-',
2236            other => other,
2237        })
2238        .collect()
2239}
2240
2241/// A `SystemTime` as unix-ms, clamped to `i64` range.
2242fn system_time_to_unix_ms(t: std::time::SystemTime) -> i64 {
2243    t.duration_since(std::time::UNIX_EPOCH)
2244        .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
2245}
2246
2247/// Wall-clock ms elapsed since `start` (the tailer's fallback timestamp).
2248fn elapsed_ms(start: &Instant) -> i64 {
2249    i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX)
2250}
2251
2252/// A debug trace on stderr, gated on the `HH_DEBUG` env var (no `log`/`tracing`
2253/// dep, per CLAUDE.md's "small, well-maintained crates" guidance). Set
2254/// `HH_DEBUG=1` to capture the adapter's discovery+parse trace: the computed
2255/// slug, projects dir, candidate files, the selected transcript and why, how
2256/// many lines/records were read, how many events were produced, and the first
2257/// conversion failure (line + message) if any. This is the trace that decides
2258/// discovery-vs-parse for a degraded session.
2259fn debug(msg: &str) {
2260    if std::env::var_os("HH_DEBUG").is_some() {
2261        eprintln!("hh: debug: {msg}");
2262    }
2263}
2264
2265/// Fuzz-only entry points into the otherwise-private Claude/Codex/Gemini JSONL
2266/// parsers (`cargo fuzz` targets `claude_jsonl`, `codex_jsonl`, `gemini_jsonl`).
2267/// Gated behind the `fuzzing` feature so it never widens the crate's normal
2268/// public API.
2269#[cfg(feature = "fuzzing")]
2270pub mod fuzzing {
2271    use super::{parse_codex_record, parse_gemini_record, parse_record, BlobStore};
2272    use std::sync::OnceLock;
2273
2274    /// A blob store rooted in a process-unique temp dir, reused across fuzz
2275    /// iterations (opening a fresh one per call would dominate runtime with
2276    /// filesystem setup rather than parser logic).
2277    fn blobs() -> &'static BlobStore {
2278        static BLOBS: OnceLock<BlobStore> = OnceLock::new();
2279        BLOBS.get_or_init(|| {
2280            let dir = std::env::temp_dir().join(format!("hh-fuzz-adapter-{}", std::process::id()));
2281            BlobStore::new(dir)
2282        })
2283    }
2284
2285    /// Mirrors `parse_and_send`'s path from one raw tailed line (UTF-8 validate
2286    /// → trim → JSON parse → [`parse_record`]) — the exact sequence the live
2287    /// tailer runs on untrusted transcript content. Must never panic.
2288    pub fn fuzz_parse_line(bytes: &[u8]) {
2289        let Ok(s) = std::str::from_utf8(bytes) else {
2290            return;
2291        };
2292        let s = s.trim();
2293        if s.is_empty() {
2294            return;
2295        }
2296        let Ok(value) = serde_json::from_str::<serde_json::Value>(s) else {
2297            return;
2298        };
2299        let _ = parse_record(&value, "fuzz-session", 0, blobs());
2300    }
2301
2302    /// Mirrors the Codex CLI tailer's per-line path: UTF-8 validate → trim →
2303    /// JSON parse → [`parse_codex_record`]. Must never panic.
2304    pub fn fuzz_parse_codex_line(bytes: &[u8]) {
2305        let Ok(s) = std::str::from_utf8(bytes) else {
2306            return;
2307        };
2308        let s = s.trim();
2309        if s.is_empty() {
2310            return;
2311        }
2312        let Ok(value) = serde_json::from_str::<serde_json::Value>(s) else {
2313            return;
2314        };
2315        let _ = parse_codex_record(&value, "fuzz-session", 0, blobs());
2316    }
2317
2318    /// Mirrors the Gemini CLI tailer's per-line path: UTF-8 validate → trim →
2319    /// JSON parse → [`parse_gemini_record`]. Must never panic.
2320    pub fn fuzz_parse_gemini_line(bytes: &[u8]) {
2321        let Ok(s) = std::str::from_utf8(bytes) else {
2322            return;
2323        };
2324        let s = s.trim();
2325        if s.is_empty() {
2326            return;
2327        }
2328        let Ok(value) = serde_json::from_str::<serde_json::Value>(s) else {
2329            return;
2330        };
2331        let _ = parse_gemini_record(&value, "fuzz-session", 0, blobs());
2332    }
2333}
2334
2335#[cfg(test)]
2336mod tests {
2337    use super::*;
2338    use serde_json::Value;
2339    use std::io::Write;
2340
2341    /// A fixed session start (2026-07-02T06:14:40.000Z) so timestamps derive
2342    /// small, stable, readable `ts_ms` values for snapshots.
2343    const STARTED: i64 = 1_782_972_880_000;
2344    const SID: &str = "test-session-id";
2345
2346    fn iso(s: &str) -> i64 {
2347        ts_ms_from_iso(s, STARTED).expect("fixture timestamps must parse")
2348    }
2349
2350    fn parse(value: &Value, ts_ms: i64) -> ParsedRecord {
2351        let tmp = tempfile::TempDir::new().unwrap();
2352        let blobs = BlobStore::new(tmp.path().join("blobs"));
2353        parse_record(value, SID, ts_ms, &blobs)
2354    }
2355
2356    /// Serialize a parsed record's events (and model/usage when present) as
2357    /// pretty JSON for a stable insta snapshot.
2358    fn snap(pr: &ParsedRecord) -> String {
2359        serde_json::to_string_pretty(&serde_json::json!({
2360            "events": pr.events,
2361            "is_assistant_message": pr.is_assistant_message,
2362        }))
2363        .unwrap()
2364    }
2365
2366    fn rec(json: &str) -> Value {
2367        serde_json::from_str(json).unwrap()
2368    }
2369
2370    #[test]
2371    fn parse_user_text_prompt() {
2372        let v = rec(r#"{"type":"user","isSidechain":false,"isMeta":false,
2373            "message":{"role":"user","content":"Please list the files in the repo and show Cargo.toml."},
2374            "timestamp":"2026-07-02T06:14:40.699Z","cwd":"/tmp/work","sessionId":"s"}"#);
2375        let pr = parse(&v, iso("2026-07-02T06:14:40.699Z"));
2376        insta::assert_snapshot!(snap(&pr));
2377    }
2378
2379    #[test]
2380    fn parse_assistant_with_two_tool_uses() {
2381        let v = rec(r#"{"type":"assistant","isSidechain":false,"isMeta":false,
2382            "message":{"role":"assistant","model":"glm-5.2","content":[
2383              {"type":"thinking","thinking":"I'll list files then read Cargo.toml.","signature":""},
2384              {"type":"tool_use","id":"call_abc123","name":"Bash","input":{"command":"ls -la"}},
2385              {"type":"tool_use","id":"call_def456","name":"Read","input":{"file_path":"Cargo.toml"}}
2386            ],"usage":{"input_tokens":1200,"output_tokens":80}},
2387            "timestamp":"2026-07-02T06:14:41.500Z","cwd":"/tmp/work","sessionId":"s"}"#);
2388        let pr = parse(&v, iso("2026-07-02T06:14:41.500Z"));
2389        insta::assert_snapshot!(snap(&pr));
2390        // thinking + two tool calls = 3 events; assistant message flagged.
2391        assert_eq!(pr.events.len(), 3);
2392        assert!(pr.is_assistant_message);
2393        assert_eq!(pr.events[0].kind, EventKind::Thinking);
2394        assert_eq!(pr.events[1].kind, EventKind::ToolCall);
2395        assert_eq!(pr.events[2].kind, EventKind::ToolCall);
2396    }
2397
2398    #[test]
2399    fn parse_user_with_two_tool_results_one_error() {
2400        let v = rec(r#"{"type":"user","isSidechain":false,"isMeta":false,
2401            "message":{"role":"user","content":[
2402              {"type":"tool_result","tool_use_id":"call_abc123","content":"total 0","is_error":false},
2403              {"type":"tool_result","tool_use_id":"call_def456","content":"Error: file not found","is_error":true}
2404            ]},
2405            "timestamp":"2026-07-02T06:14:42.800Z","cwd":"/tmp/work","sessionId":"s"}"#);
2406        let pr = parse(&v, iso("2026-07-02T06:14:42.800Z"));
2407        insta::assert_snapshot!(snap(&pr));
2408        assert_eq!(pr.events.len(), 2);
2409        assert_eq!(pr.events[0].kind, EventKind::ToolResult);
2410        assert_eq!(pr.events[1].kind, EventKind::ToolResult);
2411        // correlate_key == tool_use_id in each body.
2412        assert_eq!(
2413            pr.events[0].body_json.as_ref().unwrap()["correlate_key"],
2414            "call_abc123"
2415        );
2416        assert!(pr.events[1].body_json.as_ref().unwrap()["is_error"]
2417            .as_bool()
2418            .unwrap());
2419    }
2420
2421    #[test]
2422    fn parse_assistant_followup_with_model_usage() {
2423        let v = rec(r#"{"type":"assistant","isSidechain":false,"isMeta":false,
2424            "message":{"role":"assistant","model":"glm-5.2","content":[
2425              {"type":"text","text":"The repo is empty and Cargo.toml is missing."}
2426            ],"usage":{"input_tokens":1500,"output_tokens":40,"cache_read_input_tokens":300}},
2427            "timestamp":"2026-07-02T06:14:43.100Z","cwd":"/tmp/work","sessionId":"s"}"#);
2428        let pr = parse(&v, iso("2026-07-02T06:14:43.100Z"));
2429        insta::assert_snapshot!(snap(&pr));
2430        assert_eq!(pr.events.len(), 1);
2431        assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
2432        // The tailer collects model/usage from the message; exercise that path.
2433        let mut acc = OutcomeAcc::default();
2434        acc.note_assistant(v.get("message").unwrap());
2435        assert_eq!(acc.model.as_deref(), Some("glm-5.2"));
2436        assert_eq!(acc.usage_json.as_ref().unwrap()["output_tokens"], 40);
2437    }
2438
2439    #[test]
2440    fn parse_skips_unknown_type() {
2441        let v = rec(r#"{"type":"system","subtype":"init","cwd":"/tmp/work","sessionId":"s"}"#);
2442        let pr = parse(&v, 0);
2443        assert!(pr.events.is_empty());
2444        assert!(!pr.is_assistant_message);
2445    }
2446
2447    #[test]
2448    fn parse_skips_non_object_value() {
2449        // A malformed line that parsed as a JSON array/null produces no events.
2450        for v in [rec("[]"), rec("null"), rec("\"oops\"")] {
2451            assert!(
2452                parse(&v, 0).events.is_empty(),
2453                "non-object must yield no events"
2454            );
2455        }
2456    }
2457
2458    #[test]
2459    fn parse_skips_is_sidechain() {
2460        let v = rec(r#"{"type":"assistant","isSidechain":true,"isMeta":false,
2461            "message":{"role":"assistant","content":[{"type":"text","text":"subagent"}]},
2462            "timestamp":"2026-07-02T06:14:41.000Z","cwd":"/tmp/work","sessionId":"s"}"#);
2463        assert!(parse(&v, 0).events.is_empty());
2464    }
2465
2466    #[test]
2467    fn parse_skips_is_meta() {
2468        let v = rec(r#"{"type":"user","isSidechain":false,"isMeta":true,
2469            "message":{"role":"user","content":"<local-command-caveat>system-reminder</local-command-caveat>"},
2470            "timestamp":"2026-07-02T06:14:40.600Z","cwd":"/tmp/work","sessionId":"s"}"#);
2471        assert!(parse(&v, 0).events.is_empty());
2472    }
2473
2474    #[test]
2475    fn parse_256kib_spills_to_blob() {
2476        let big = "x".repeat(SPILLOVER_BYTES + 1024);
2477        let v = rec(&format!(
2478            r#"{{"type":"user","isSidechain":false,"isMeta":false,
2479            "message":{{"role":"user","content":{}}},
2480            "timestamp":"2026-07-02T06:14:40.699Z","cwd":"/tmp/work","sessionId":"s"}}"#,
2481            serde_json::Value::String(big)
2482        ));
2483        let tmp = tempfile::TempDir::new().unwrap();
2484        let blobs = BlobStore::new(tmp.path().join("blobs"));
2485        let pr = parse_record(&v, SID, 699, &blobs);
2486        let ev = &pr.events[0];
2487        assert!(ev.blob_hash.is_some(), "large payload must spill to a blob");
2488        let hash = ev.blob_hash.as_ref().unwrap();
2489        assert_eq!(ev.body_json.as_ref().unwrap()["overflow"], true);
2490        assert_eq!(ev.body_json.as_ref().unwrap()["encoding"], "blob");
2491        // The blob round-trips to the serialized body (a JSON object with the text).
2492        let raw = blobs.get(hash).unwrap();
2493        let body: Value = serde_json::from_slice(&raw).unwrap();
2494        assert_eq!(body["text"].as_str().unwrap().len(), SPILLOVER_BYTES + 1024);
2495    }
2496
2497    #[test]
2498    fn slugify_replaces_slash_and_dot() {
2499        assert_eq!(
2500            slugify(Path::new("/home/saadman/switch")),
2501            "-home-saadman-switch"
2502        );
2503        assert_eq!(
2504            slugify(Path::new("/home/saadman/switch/.claude/worktrees/x")),
2505            "-home-saadman-switch--claude-worktrees-x"
2506        );
2507        assert_eq!(slugify(Path::new("C:\\Users\\me")), "C:-Users-me");
2508    }
2509
2510    #[test]
2511    fn select_detects_claude_basename() {
2512        assert!(is_claude_code(&["claude".into()]));
2513        assert!(is_claude_code(&["/usr/local/bin/claude".into()]));
2514        assert!(is_claude_code(&["C:\\Apps\\claude.exe".into()]));
2515        assert!(!is_claude_code(&["python3".into()]));
2516        assert!(!is_claude_code(&[]));
2517        // select returns a Claude adapter for claude, None for a generic command.
2518        assert!(select(&["claude".into()], Path::new("/tmp")).is_some());
2519        assert!(select(&["python3".into()], Path::new("/tmp")).is_none());
2520    }
2521
2522    #[test]
2523    fn ts_ms_from_iso_is_relative_and_clamped() {
2524        assert_eq!(
2525            ts_ms_from_iso("2026-07-02T06:14:40.699Z", STARTED),
2526            Some(699)
2527        );
2528        assert_eq!(ts_ms_from_iso("2026-07-02T06:14:40.000Z", STARTED), Some(0));
2529        // A timestamp before the session start clamps to 0 (no negative ts_ms).
2530        assert_eq!(ts_ms_from_iso("2026-07-02T06:14:39.000Z", STARTED), Some(0));
2531        assert!(ts_ms_from_iso("not-a-date", STARTED).is_none());
2532    }
2533
2534    // --- tailer behavior (real threads, temp HOME) -----------------------
2535
2536    /// A self-contained adapter spawn: temp HOME + cwd, a stop flag, and the
2537    /// slug dir pre-created. The transcript is written *after* spawn (see
2538    /// [`write_transcript`]) so it is correctly seen as NEW since session start —
2539    /// mirroring real usage where Claude writes its transcript lazily, after
2540    /// `hh run` has launched it. Returns the handle so the test can drain then
2541    /// join.
2542    fn spawn_with(home: &Path, cwd: &Path) -> (AdapterHandle, std::sync::Arc<AtomicBool>, PathBuf) {
2543        let slug_dir = home.join(".claude").join("projects").join(slugify(cwd));
2544        std::fs::create_dir_all(&slug_dir).unwrap();
2545        let stop = std::sync::Arc::new(AtomicBool::new(false));
2546        let blobs = std::sync::Arc::new(BlobStore::new(home.join("blobs")));
2547        let ctx = AdapterContext {
2548            session_id: SID.to_string(),
2549            started_at_unix_ms: STARTED,
2550            cwd: cwd.to_path_buf(),
2551            command: vec!["claude".into()],
2552            blobs,
2553            stop: std::sync::Arc::clone(&stop),
2554            projects_dir: Some(home.join(".claude").join("projects")),
2555        };
2556        let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn adapter");
2557        // Let the tailer take its preexisting-file snapshot before we write, so the
2558        // transcript is seen as created-during-the-session (50 ms is a safe margin
2559        // over the snapshot's single read_dir).
2560        std::thread::sleep(Duration::from_millis(50));
2561        (handle, stop, slug_dir)
2562    }
2563
2564    /// Write a transcript file into a slug dir (the "appears during the session"
2565    /// step of a tailer test).
2566    fn write_transcript(slug_dir: &Path, name: &str, content: &str) -> PathBuf {
2567        let path = slug_dir.join(name);
2568        std::fs::write(&path, content).unwrap();
2569        path
2570    }
2571
2572    /// Drain `handle.events` until EOF, collecting events by kind.
2573    fn drain(events: &mpsc::Receiver<Event>) -> Vec<Event> {
2574        let mut out = Vec::new();
2575        while let Ok(ev) = events.recv() {
2576            out.push(ev);
2577        }
2578        out
2579    }
2580
2581    const FIXTURE: &str = "\
2582{\"type\":\"system\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\",\"timestamp\":\"2026-07-02T06:14:40.500Z\"}
2583{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":true,\"message\":{\"role\":\"user\",\"content\":\"caveat\"},\"timestamp\":\"2026-07-02T06:14:40.600Z\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\"}
2584{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\"}
2585{\"type\":\"assistant\",\"isSidechain\":true,\"isMeta\":false,\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"side\"}]},\"timestamp\":\"2026-07-02T06:14:41.000Z\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\"}
2586{\"type\":\"assistant\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"assistant\",\"model\":\"glm-5.2\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"Bash\",\"input\":{\"command\":\"ls\"}}]},\"timestamp\":\"2026-07-02T06:14:41.500Z\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\"}
2587{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"ok\",\"is_error\":false}]},\"timestamp\":\"2026-07-02T06:14:42.800Z\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\"}
2588";
2589
2590    #[test]
2591    fn tailer_parses_fixture_and_skips_filtered_records() {
2592        let home = tempfile::TempDir::new().unwrap();
2593        let cwd = Path::new("/tmp/work");
2594        let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
2595        // Transcript appears mid-session (after the tailer snapshotted an empty
2596        // slug dir) — the realistic lazy-creation case the locate fix targets.
2597        write_transcript(&slug_dir, "session.jsonl", FIXTURE);
2598        // Give the tailer a poll cycle to find + parse it, then stop + drain.
2599        std::thread::sleep(Duration::from_millis(300));
2600        stop.store(true, Ordering::Release);
2601        let events = drain(&handle.events);
2602        // system + isMeta + isSidechain skipped → user_message + tool_call + tool_result.
2603        let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
2604        assert_eq!(
2605            kinds,
2606            vec![
2607                EventKind::UserMessage,
2608                EventKind::ToolCall,
2609                EventKind::ToolResult
2610            ]
2611        );
2612    }
2613
2614    /// New-format Claude Code transcript (regression fixture, generation 2):
2615    /// recent Claude versions add top-level `mode` / `permissionMode` /
2616    /// `fileHistorySnapshot` fields and — critically — do **not** put `cwd` on
2617    /// the early `system`/meta records. The locate logic must scan past the
2618    /// cwd-less records to the first cwd-bearing one, and `parse_record` must
2619    /// tolerate the new fields. This is the format the "silently recorded 0
2620    /// steps" sessions were actually writing, so it is the load-bearing fixture
2621    /// for the adapter fix.
2622    const FIXTURE_NEW_FORMAT: &str = "\
2623{\"type\":\"system\",\"subtype\":\"init\",\"sessionId\":\"s\",\"timestamp\":\"2026-07-02T06:14:40.500Z\",\"mode\":\"default\",\"permissionMode\":\"default\"}
2624{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":true,\"message\":{\"role\":\"user\",\"content\":\"caveat\"},\"timestamp\":\"2026-07-02T06:14:40.600Z\",\"mode\":\"default\",\"permissionMode\":\"default\"}
2625{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/work-new\",\"sessionId\":\"s\",\"mode\":\"default\",\"permissionMode\":\"default\",\"fileHistorySnapshot\":{}}
2626{\"type\":\"assistant\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"assistant\",\"model\":\"glm-5.2\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"Bash\",\"input\":{\"command\":\"ls\"}}]},\"timestamp\":\"2026-07-02T06:14:41.500Z\",\"cwd\":\"/tmp/work-new\",\"sessionId\":\"s\",\"mode\":\"default\",\"permissionMode\":\"default\"}
2627{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"ok\",\"is_error\":false}]},\"timestamp\":\"2026-07-02T06:14:42.800Z\",\"cwd\":\"/tmp/work-new\",\"sessionId\":\"s\",\"mode\":\"default\",\"permissionMode\":\"default\"}
2628";
2629
2630    /// `first_record_cwd_state` must scan *past* cwd-less records (the new format
2631    /// puts no `cwd` on `system`/meta lines) to the first cwd-bearing one, then
2632    /// classify it. A regression guard for the "no-early-cwd" case: a naive
2633    /// "look at the first record" check would return `None` forever and the
2634    /// locate loop would never accept the transcript → 0 recorded steps.
2635    #[test]
2636    fn first_record_cwd_state_skips_cwdless_records() {
2637        let tmp = tempfile::tempdir().unwrap();
2638        let f = tmp.path().join("new.jsonl");
2639        std::fs::write(&f, FIXTURE_NEW_FORMAT).unwrap();
2640        // The first cwd-bearing record carries /tmp/work-new → Matches.
2641        assert_eq!(
2642            first_record_cwd_state(&f, Path::new("/tmp/work-new")),
2643            CwdState::Matches
2644        );
2645        // A different session cwd → Different (rejected, not mis-accepted).
2646        assert_eq!(
2647            first_record_cwd_state(&f, Path::new("/tmp/other")),
2648            CwdState::Different
2649        );
2650    }
2651
2652    /// End-to-end tailer regression against the new-format fixture: the adapter
2653    /// must locate the transcript (cwd appears only on record 3, not record 1)
2654    /// and parse the same three events as the original fixture. Locks that the
2655    /// new top-level fields and the cwd-less head do not silently drop the
2656    /// session to 0 steps.
2657    #[test]
2658    fn tailer_parses_new_format_fixture_with_no_early_cwd() {
2659        let home = tempfile::TempDir::new().unwrap();
2660        let cwd = Path::new("/tmp/work-new");
2661        let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
2662        write_transcript(&slug_dir, "session.jsonl", FIXTURE_NEW_FORMAT);
2663        std::thread::sleep(Duration::from_millis(300));
2664        stop.store(true, Ordering::Release);
2665        let events = drain(&handle.events);
2666        // Same three events as the original fixture: system/meta skipped, the
2667        // cwd-less head does not prevent locate from accepting the file.
2668        let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
2669        assert_eq!(
2670            kinds,
2671            vec![
2672                EventKind::UserMessage,
2673                EventKind::ToolCall,
2674                EventKind::ToolResult
2675            ],
2676            "new-format fixture must parse the same events as the original"
2677        );
2678    }
2679
2680    #[test]
2681    fn tailer_finds_file_late() {
2682        let home = tempfile::TempDir::new().unwrap();
2683        let cwd = Path::new("/tmp/work-late");
2684        // Pre-create the slug dir but write the file *after* spawning, so the
2685        // adapter must keep polling until it appears (no fixed timeout).
2686        std::fs::create_dir_all(
2687            home.path()
2688                .join(".claude")
2689                .join("projects")
2690                .join(slugify(cwd)),
2691        )
2692        .unwrap();
2693        let stop = std::sync::Arc::new(AtomicBool::new(false));
2694        let blobs = std::sync::Arc::new(BlobStore::new(home.path().join("blobs")));
2695        let ctx = AdapterContext {
2696            session_id: SID.to_string(),
2697            started_at_unix_ms: STARTED,
2698            cwd: cwd.to_path_buf(),
2699            command: vec!["claude".into()],
2700            blobs,
2701            stop: std::sync::Arc::clone(&stop),
2702            projects_dir: Some(home.path().join(".claude").join("projects")),
2703        };
2704        let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn");
2705        // Write the transcript after the adapter is already polling.
2706        std::thread::sleep(Duration::from_millis(150));
2707        std::fs::write(
2708            home
2709                .path()
2710                .join(".claude")
2711                .join("projects")
2712                .join(slugify(cwd))
2713                .join("late.jsonl"),
2714            "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"late\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/work-late\",\"sessionId\":\"s\"}\n",
2715        )
2716        .unwrap();
2717        std::thread::sleep(Duration::from_millis(150));
2718        stop.store(true, Ordering::Release);
2719        let events = drain(&handle.events);
2720        assert_eq!(
2721            events.len(),
2722            1,
2723            "adapter should pick up the late-appearing file"
2724        );
2725        assert_eq!(events[0].kind, EventKind::UserMessage);
2726    }
2727
2728    #[test]
2729    fn tailer_partial_last_line_retries() {
2730        let home = tempfile::TempDir::new().unwrap();
2731        let cwd = Path::new("/tmp/work-partial");
2732        let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
2733        // A file with one complete line plus a trailing partial (no newline),
2734        // written mid-session so the tailer sees it as new.
2735        let complete = "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"first\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/work-partial\",\"sessionId\":\"s\"}\n";
2736        let partial = "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"sec";
2737        let path = write_transcript(&slug_dir, "partial.jsonl", &format!("{complete}{partial}"));
2738        // Let the tailer find the file and read the complete line (partial held).
2739        std::thread::sleep(Duration::from_millis(250));
2740        // Append the rest of the partial line; it should now parse as a second event.
2741        let mut f = std::fs::OpenOptions::new()
2742            .append(true)
2743            .open(&path)
2744            .unwrap();
2745        writeln!(f, "ond\"}}}}\n").unwrap();
2746        std::thread::sleep(Duration::from_millis(250));
2747        stop.store(true, Ordering::Release);
2748        let events = drain(&handle.events);
2749        let contents: Vec<&str> = events
2750            .iter()
2751            .map(|e| e.body_json.as_ref().unwrap()["text"].as_str().unwrap())
2752            .collect();
2753        assert_eq!(
2754            contents,
2755            vec!["first", "second"],
2756            "partial line must be retried until complete"
2757        );
2758    }
2759
2760    #[test]
2761    fn tailer_fallback_scan_by_cwd() {
2762        let home = tempfile::TempDir::new().unwrap();
2763        let cwd = Path::new("/tmp/work-fallback");
2764        // Put the transcript under a *wrong* slug dir (not slugify(cwd)) so the
2765        // slug lookup misses and the fallback scan by cwd must find it.
2766        let wrong_slug = "-totally-wrong-slug";
2767        let wrong_dir = home
2768            .path()
2769            .join(".claude")
2770            .join("projects")
2771            .join(wrong_slug);
2772        std::fs::create_dir_all(&wrong_dir).unwrap();
2773        let stop = std::sync::Arc::new(AtomicBool::new(false));
2774        let blobs = std::sync::Arc::new(BlobStore::new(home.path().join("blobs")));
2775        let ctx = AdapterContext {
2776            session_id: SID.to_string(),
2777            started_at_unix_ms: STARTED,
2778            cwd: cwd.to_path_buf(),
2779            command: vec!["claude".into()],
2780            blobs,
2781            stop: std::sync::Arc::clone(&stop),
2782            projects_dir: Some(home.path().join(".claude").join("projects")),
2783        };
2784        let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn");
2785        // Write the transcript under the wrong slug AFTER spawn (so it is seen as
2786        // new) and after the preexisting snapshot (50 ms margin).
2787        std::thread::sleep(Duration::from_millis(50));
2788        std::fs::write(
2789            wrong_dir.join("fb.jsonl"),
2790            "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"fb\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/work-fallback\",\"sessionId\":\"s\"}\n",
2791        )
2792        .unwrap();
2793        std::thread::sleep(Duration::from_millis(300));
2794        stop.store(true, Ordering::Release);
2795        let events = drain(&handle.events);
2796        assert_eq!(
2797            events.len(),
2798            1,
2799            "fallback scan should locate the transcript by cwd"
2800        );
2801        assert_eq!(events[0].kind, EventKind::UserMessage);
2802        let outcome = handle.outcome.join().expect("tailer thread");
2803        assert_eq!(outcome.status, AdapterStatus::Active);
2804    }
2805
2806    #[test]
2807    fn tailer_degrades_when_projects_dir_missing() {
2808        // No ~/.claude/projects anywhere: HOME points at an empty temp dir.
2809        let home = tempfile::TempDir::new().unwrap();
2810        let cwd = Path::new("/tmp/nowhere");
2811        let stop = std::sync::Arc::new(AtomicBool::new(false));
2812        let blobs = std::sync::Arc::new(BlobStore::new(home.path().join("blobs")));
2813        let ctx = AdapterContext {
2814            session_id: SID.to_string(),
2815            started_at_unix_ms: STARTED,
2816            cwd: cwd.to_path_buf(),
2817            command: vec!["claude".into()],
2818            blobs,
2819            stop: std::sync::Arc::clone(&stop),
2820            projects_dir: Some(home.path().join(".claude").join("projects")),
2821        };
2822        let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn");
2823        let events = drain(&handle.events);
2824        assert!(events.is_empty(), "no projects dir → no events");
2825        let outcome = handle.outcome.join().expect("tailer thread");
2826        assert_eq!(outcome.status, AdapterStatus::Degraded);
2827        // Degraded outcomes carry an actionable reason for the recorder to print
2828        // after the child exits (FR-1.5), not a bare status with no explanation.
2829        assert!(
2830            outcome.degrade_reason.is_some(),
2831            "degraded outcome must carry a degrade_reason"
2832        );
2833    }
2834
2835    #[test]
2836    fn tailer_degrades_when_transcript_never_appears() {
2837        // Projects dir exists, slug dir exists, but no transcript is ever written:
2838        // the tailer must poll until stop, then return Degraded with a reason
2839        // (the "0 steps, status=ok" failure mode — previously the 3 s deadline
2840        // degraded silently mid-session under the agent's TUI).
2841        let home = tempfile::TempDir::new().unwrap();
2842        let cwd = Path::new("/tmp/no-transcript");
2843        let (handle, stop, _slug_dir) = spawn_with(home.path(), cwd);
2844        // Never write a transcript. Stop almost immediately.
2845        std::thread::sleep(Duration::from_millis(80));
2846        stop.store(true, Ordering::Release);
2847        let events = drain(&handle.events);
2848        assert!(events.is_empty(), "no transcript ever → no events");
2849        let outcome = handle.outcome.join().expect("tailer thread");
2850        assert_eq!(outcome.status, AdapterStatus::Degraded);
2851        assert!(
2852            outcome.degrade_reason.is_some(),
2853            "degraded outcome must carry a degrade_reason pointing at hh doctor"
2854        );
2855        assert!(
2856            outcome
2857                .degrade_reason
2858                .as_deref()
2859                .unwrap()
2860                .contains("hh doctor"),
2861            "degrade reason should suggest running `hh doctor`"
2862        );
2863    }
2864
2865    #[test]
2866    fn tailer_ignores_preexisting_transcript() {
2867        // A transcript that predates `hh run` belongs to a *different* session and
2868        // must NOT be tailed, even if it matches cwd (concurrent-session guard).
2869        let home = tempfile::TempDir::new().unwrap();
2870        let cwd = Path::new("/tmp/concurrent");
2871        let slug_dir = home
2872            .path()
2873            .join(".claude")
2874            .join("projects")
2875            .join(slugify(cwd));
2876        std::fs::create_dir_all(&slug_dir).unwrap();
2877        // Pre-existing (concurrent session's) transcript, written BEFORE spawn.
2878        std::fs::write(
2879            slug_dir.join("old.jsonl"),
2880            "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"not mine\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/concurrent\",\"sessionId\":\"other\"}\n",
2881        )
2882        .unwrap();
2883        let (handle, stop, _slug) = spawn_with(home.path(), cwd);
2884        // No new transcript is ever written for our session.
2885        std::thread::sleep(Duration::from_millis(80));
2886        stop.store(true, Ordering::Release);
2887        let events = drain(&handle.events);
2888        assert!(
2889            events.is_empty(),
2890            "a pre-existing transcript from another session must not be tailed"
2891        );
2892        let outcome = handle.outcome.join().expect("tailer thread");
2893        assert_eq!(outcome.status, AdapterStatus::Degraded);
2894    }
2895
2896    /// A candidate path that is a directory (the same-stem-dir issue: Claude
2897    /// writes both `<uuid>.jsonl` and a `<uuid>/` directory) is classified as
2898    /// [`CwdState::Directory`] and rejected, not re-polled forever (which would
2899    /// loop on the `read_to_string` io error as `None` and silently degrade).
2900    #[test]
2901    fn first_record_cwd_state_rejects_directory() {
2902        let tmp = tempfile::tempdir().unwrap();
2903        let dir = tmp.path().join("2457c4b0-is-a-dir");
2904        std::fs::create_dir_all(&dir).unwrap();
2905        assert_eq!(
2906            first_record_cwd_state(&dir, Path::new("/tmp/anything")),
2907            CwdState::Directory,
2908            "a directory candidate must be rejected, not re-polled"
2909        );
2910    }
2911
2912    /// A same-stem directory next to the real `.jsonl` transcript must not break
2913    /// discovery: the `.jsonl` extension filter skips the directory, the file is
2914    /// selected by cwd, and events are parsed normally. Regression guard for the
2915    /// issue the user flagged (a `<uuid>/` dir beside `<uuid>.jsonl`).
2916    #[test]
2917    fn tailer_ignores_same_stem_directory_beside_jsonl() {
2918        let home = tempfile::TempDir::new().unwrap();
2919        let cwd = Path::new("/tmp/same-stem");
2920        let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
2921        // The real transcript.
2922        write_transcript(
2923            &slug_dir,
2924            "abc123.jsonl",
2925            "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"hi\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/same-stem\",\"sessionId\":\"s\"}\n",
2926        );
2927        // A same-stem directory (no .jsonl extension) sitting beside it — must
2928        // be skipped by the extension filter, not break the dir iteration.
2929        std::fs::create_dir_all(slug_dir.join("abc123")).unwrap();
2930        std::thread::sleep(Duration::from_millis(300));
2931        stop.store(true, Ordering::Release);
2932        let events = drain(&handle.events);
2933        assert_eq!(
2934            events.len(),
2935            1,
2936            "the same-stem directory must not prevent discovering the .jsonl"
2937        );
2938        assert_eq!(events[0].kind, EventKind::UserMessage);
2939        let outcome = handle.outcome.join().expect("tailer thread");
2940        assert_eq!(outcome.status, AdapterStatus::Active);
2941    }
2942
2943    /// A transcript that is found (cwd matches) but yields zero events — here
2944    /// because the only cwd-bearing record is `isMeta` (skipped by the parser) —
2945    /// must degrade with the specific "0 records parsed" reason, NOT finalize as
2946    /// `active` with 0 steps (the silent-breakage symptom). The reason carries
2947    /// the line count so the failure is a one-line diagnosis.
2948    #[test]
2949    fn tailer_degrades_when_zero_records_parsed() {
2950        let home = tempfile::TempDir::new().unwrap();
2951        let cwd = Path::new("/tmp/zero-records");
2952        let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
2953        // cwd-bearing so it is selected, but isMeta so parse_record skips it →
2954        // 0 events. Mirrors a format drift where the parser recognizes nothing.
2955        write_transcript(
2956            &slug_dir,
2957            "empty.jsonl",
2958            "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":true,\"message\":{\"role\":\"user\",\"content\":\"caveat\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/zero-records\",\"sessionId\":\"s\"}\n",
2959        );
2960        std::thread::sleep(Duration::from_millis(300));
2961        stop.store(true, Ordering::Release);
2962        let events = drain(&handle.events);
2963        assert!(events.is_empty(), "isMeta record yields no events");
2964        let outcome = handle.outcome.join().expect("tailer thread");
2965        assert_eq!(
2966            outcome.status,
2967            AdapterStatus::Degraded,
2968            "0 records parsed must degrade, not report active with 0 steps"
2969        );
2970        let reason = outcome.degrade_reason.as_deref().unwrap();
2971        assert!(
2972            reason.contains("0 records parsed"),
2973            "reason must be the specific '0 records parsed' string: {reason}"
2974        );
2975        assert!(
2976            reason.contains("read 1 line"),
2977            "reason must carry the line count: {reason}"
2978        );
2979    }
2980
2981    /// A transcript whose first JSON line is malformed records the first parse
2982    /// error (line + message) in the degrade reason, so a format drift is a
2983    /// one-line diagnosis instead of a silent skip.
2984    #[test]
2985    fn tailer_degrade_reason_records_first_parse_error() {
2986        let home = tempfile::TempDir::new().unwrap();
2987        let cwd = Path::new("/tmp/parse-err");
2988        let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
2989        // A valid cwd-bearing user record (so it is selected) followed by a
2990        // malformed line. The valid record yields 1 event → Active, so to
2991        // exercise the parse-error-in-reason path we make the ONLY line a
2992        // cwd-bearing record that is unparseable JSON. But cwd-verification
2993        // needs parseable JSON to read `cwd`... so instead place a valid
2994        // selected record then a broken one: the file is selected on the valid
2995        // record, the broken line is skipped with a debug log, and since the
2996        // valid record produced an event the outcome is Active. The parse-error
2997        // tracking is therefore asserted at the unit level below instead.
2998        write_transcript(
2999            &slug_dir,
3000            "broken.jsonl",
3001            "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"ok\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/parse-err\",\"sessionId\":\"s\"}\n{not valid json\n",
3002        );
3003        std::thread::sleep(Duration::from_millis(300));
3004        stop.store(true, Ordering::Release);
3005        let events = drain(&handle.events);
3006        assert_eq!(
3007            events.len(),
3008            1,
3009            "the valid record parses; the broken line is skipped"
3010        );
3011        let outcome = handle.outcome.join().expect("tailer thread");
3012        assert_eq!(outcome.status, AdapterStatus::Active);
3013    }
3014
3015    /// `locate_failure_reason` produces the specific "no new transcript
3016    /// appeared" string when nothing was seen, and the "N candidates but none
3017    /// matched cwd" string (with mismatch/directory counts) when candidates
3018    /// were rejected. Locks the one-line-diagnosis contract for degraded
3019    /// sessions without spinning a real tailer.
3020    #[test]
3021    fn locate_failure_reason_is_specific() {
3022        // No candidates appeared at all.
3023        let empty = DiscoveryDiag {
3024            projects: Some(PathBuf::from("/home/me/.claude/projects")),
3025            slug: Some("-home-me-work".into()),
3026            slug_dir: Some(PathBuf::from("/home/me/.claude/projects/-home-me-work")),
3027            cwd: Some(PathBuf::from("/home/me/work")),
3028            ..Default::default()
3029        };
3030        let r = locate_failure_reason(&empty);
3031        assert!(r.contains("no jsonl matched cwd slug -home-me-work"), "{r}");
3032        assert!(r.contains("no new transcript appeared"), "{r}");
3033        assert!(
3034            r.contains("looked in /home/me/.claude/projects/-home-me-work"),
3035            "{r}"
3036        );
3037
3038        // Candidates appeared but none matched cwd; one was a directory.
3039        let mut seen = std::collections::HashSet::new();
3040        seen.insert(PathBuf::from(
3041            "/home/me/.claude/projects/-home-me-work/a.jsonl",
3042        ));
3043        let with_cands = DiscoveryDiag {
3044            projects: Some(PathBuf::from("/home/me/.claude/projects")),
3045            slug: Some("-home-me-work".into()),
3046            slug_dir: Some(PathBuf::from("/home/me/.claude/projects/-home-me-work")),
3047            cwd: Some(PathBuf::from("/home/me/work")),
3048            new_candidates: seen,
3049            cwd_mismatches: vec![PathBuf::from(
3050                "/home/me/.claude/projects/-home-me-work/a.jsonl",
3051            )],
3052            directories: vec![PathBuf::from(
3053                "/home/me/.claude/projects/-home-me-work/b.jsonl",
3054            )],
3055        };
3056        let r = locate_failure_reason(&with_cands);
3057        assert!(r.contains("1 new candidate(s)"), "{r}");
3058        assert!(r.contains("1 rejected for a different cwd"), "{r}");
3059        assert!(r.contains("1 candidate(s) were directories"), "{r}");
3060    }
3061
3062    // -----------------------------------------------------------------------
3063    // Codex CLI parser tests
3064    // -----------------------------------------------------------------------
3065
3066    fn parse_codex(value: &Value, ts_ms: i64) -> ParsedRecord {
3067        let tmp = tempfile::TempDir::new().unwrap();
3068        let blobs = BlobStore::new(tmp.path().join("blobs"));
3069        parse_codex_record(value, SID, ts_ms, &blobs)
3070    }
3071
3072    fn snap_codex(pr: &ParsedRecord) -> String {
3073        serde_json::to_string_pretty(&serde_json::json!({
3074            "events": pr.events,
3075            "is_assistant_message": pr.is_assistant_message,
3076        }))
3077        .unwrap()
3078    }
3079
3080    fn codex_rec(json: &str) -> Value {
3081        serde_json::from_str(json).unwrap()
3082    }
3083
3084    #[test]
3085    fn codex_parse_user_message() {
3086        let v = codex_rec(
3087            r#"{"type":"event_msg","timestamp":"2026-07-02T06:14:40.699Z","payload":{"type":"user_message","content":[{"text":"hello"}]}}"#,
3088        );
3089        let pr = parse_codex(&v, iso("2026-07-02T06:14:40.699Z"));
3090        insta::assert_snapshot!(snap_codex(&pr));
3091        assert_eq!(pr.events.len(), 1);
3092        assert_eq!(pr.events[0].kind, EventKind::UserMessage);
3093    }
3094
3095    #[test]
3096    fn codex_parse_agent_message() {
3097        let v = codex_rec(
3098            r#"{"type":"event_msg","timestamp":"2026-07-02T06:14:41.500Z","payload":{"type":"agent_message","content":[{"text":"I can help with that"}]}}"#,
3099        );
3100        let pr = parse_codex(&v, iso("2026-07-02T06:14:41.500Z"));
3101        insta::assert_snapshot!(snap_codex(&pr));
3102        assert_eq!(pr.events.len(), 1);
3103        assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
3104        assert!(pr.is_assistant_message);
3105    }
3106
3107    #[test]
3108    fn codex_parse_response_item_message() {
3109        let v = codex_rec(
3110            r#"{"type":"response_item","timestamp":"2026-07-02T06:14:41.500Z","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Here is the solution"}]}}"#,
3111        );
3112        let pr = parse_codex(&v, iso("2026-07-02T06:14:41.500Z"));
3113        insta::assert_snapshot!(snap_codex(&pr));
3114        assert_eq!(pr.events.len(), 1);
3115        assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
3116    }
3117
3118    #[test]
3119    fn codex_parse_function_call_and_output() {
3120        let v = codex_rec(
3121            r#"{"type":"response_item","timestamp":"2026-07-02T06:14:42.000Z","payload":{"type":"function_call","call_id":"call_1","name":"Bash","arguments":"{\"command\":\"ls\"}"}}"#,
3122        );
3123        let pr = parse_codex(&v, iso("2026-07-02T06:14:42.000Z"));
3124        insta::assert_snapshot!(snap_codex(&pr));
3125        assert_eq!(pr.events.len(), 1);
3126        assert_eq!(pr.events[0].kind, EventKind::ToolCall);
3127        assert_eq!(
3128            pr.events[0].body_json.as_ref().unwrap()["correlate_key"],
3129            "call_1"
3130        );
3131
3132        let v2 = codex_rec(
3133            r#"{"type":"response_item","timestamp":"2026-07-02T06:14:43.000Z","payload":{"type":"function_call_output","call_id":"call_1","output":"total 42"}}"#,
3134        );
3135        let pr2 = parse_codex(&v2, iso("2026-07-02T06:14:43.000Z"));
3136        assert_eq!(pr2.events.len(), 1);
3137        assert_eq!(pr2.events[0].kind, EventKind::ToolResult);
3138        assert_eq!(
3139            pr2.events[0].body_json.as_ref().unwrap()["correlate_key"],
3140            "call_1"
3141        );
3142    }
3143
3144    #[test]
3145    fn codex_parse_reasoning() {
3146        let v = codex_rec(
3147            r#"{"type":"response_item","timestamp":"2026-07-02T06:14:41.000Z","payload":{"type":"reasoning","summary":[],"content":null,"encrypted_content":"gAAAAABp5lf5..."}}"#,
3148        );
3149        let pr = parse_codex(&v, iso("2026-07-02T06:14:41.000Z"));
3150        insta::assert_snapshot!(snap_codex(&pr));
3151        assert_eq!(pr.events.len(), 1);
3152        assert_eq!(pr.events[0].kind, EventKind::Thinking);
3153    }
3154
3155    #[test]
3156    fn codex_parse_skips_session_meta() {
3157        let v = codex_rec(
3158            r#"{"type":"session_meta","timestamp":"2026-07-02T06:14:40.000Z","payload":{"id":"abc","cwd":"/tmp","originator":"codex-tui"}}"#,
3159        );
3160        let pr = parse_codex(&v, 0);
3161        assert!(pr.events.is_empty());
3162    }
3163
3164    #[test]
3165    fn codex_parse_skips_unknown_type() {
3166        let v = codex_rec(
3167            r#"{"type":"unknown_type","timestamp":"2026-07-02T06:14:40.000Z","payload":{}}"#,
3168        );
3169        let pr = parse_codex(&v, 0);
3170        assert!(pr.events.is_empty());
3171    }
3172
3173    #[test]
3174    fn codex_parse_non_object_value() {
3175        for v in [codex_rec("[]"), codex_rec("null"), codex_rec("\"oops\"")] {
3176            assert!(parse_codex(&v, 0).events.is_empty());
3177        }
3178    }
3179
3180    // -----------------------------------------------------------------------
3181    // Gemini CLI parser tests
3182    // -----------------------------------------------------------------------
3183
3184    fn parse_gemini(value: &Value, ts_ms: i64) -> ParsedRecord {
3185        let tmp = tempfile::TempDir::new().unwrap();
3186        let blobs = BlobStore::new(tmp.path().join("blobs"));
3187        parse_gemini_record(value, SID, ts_ms, &blobs)
3188    }
3189
3190    fn snap_gemini(pr: &ParsedRecord) -> String {
3191        serde_json::to_string_pretty(&serde_json::json!({
3192            "events": pr.events,
3193            "is_assistant_message": pr.is_assistant_message,
3194        }))
3195        .unwrap()
3196    }
3197
3198    fn gemini_rec(json: &str) -> Value {
3199        serde_json::from_str(json).unwrap()
3200    }
3201
3202    #[test]
3203    fn gemini_parse_user_message() {
3204        let v = gemini_rec(
3205            r#"{"type":"user","id":"msg1","timestamp":"2026-07-02T06:14:40.699Z","content":[{"text":"hello"}]}"#,
3206        );
3207        let pr = parse_gemini(&v, iso("2026-07-02T06:14:40.699Z"));
3208        insta::assert_snapshot!(snap_gemini(&pr));
3209        assert_eq!(pr.events.len(), 1);
3210        assert_eq!(pr.events[0].kind, EventKind::UserMessage);
3211    }
3212
3213    #[test]
3214    fn gemini_parse_agent_message() {
3215        let v = gemini_rec(
3216            r#"{"type":"gemini","id":"msg2","timestamp":"2026-07-02T06:14:41.500Z","content":[{"text":"Hi! How can I help?"}],"model":"gemini-2.5-pro"}"#,
3217        );
3218        let pr = parse_gemini(&v, iso("2026-07-02T06:14:41.500Z"));
3219        insta::assert_snapshot!(snap_gemini(&pr));
3220        assert_eq!(pr.events.len(), 1);
3221        assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
3222    }
3223
3224    #[test]
3225    fn gemini_parse_with_tool_calls() {
3226        let v = gemini_rec(
3227            r#"{"type":"gemini","id":"msg3","timestamp":"2026-07-02T06:14:42.000Z","content":[{"text":"Let me check"}],"toolCalls":[{"id":"tc_1","name":"Bash","input":{"command":"ls"}}]}"#,
3228        );
3229        let pr = parse_gemini(&v, iso("2026-07-02T06:14:42.000Z"));
3230        insta::assert_snapshot!(snap_gemini(&pr));
3231        assert_eq!(pr.events.len(), 2); // agent_message + tool_call
3232        assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
3233        assert_eq!(pr.events[1].kind, EventKind::ToolCall);
3234        assert_eq!(
3235            pr.events[1].body_json.as_ref().unwrap()["correlate_key"],
3236            "tc_1"
3237        );
3238    }
3239
3240    #[test]
3241    fn gemini_parse_with_thoughts() {
3242        let v = gemini_rec(
3243            r#"{"type":"gemini","id":"msg4","timestamp":"2026-07-02T06:14:41.000Z","content":[{"text":"answer"}],"thoughts":[{"text":"I need to think about this"}]}"#,
3244        );
3245        let pr = parse_gemini(&v, iso("2026-07-02T06:14:41.000Z"));
3246        insta::assert_snapshot!(snap_gemini(&pr));
3247        assert_eq!(pr.events.len(), 2); // agent_message + thinking
3248        assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
3249        assert_eq!(pr.events[1].kind, EventKind::Thinking);
3250    }
3251
3252    #[test]
3253    fn gemini_parse_skips_session_metadata() {
3254        let v = gemini_rec(
3255            r#"{"type":"session_metadata","sessionId":"abc","projectHash":"xyz","startTime":"2026-07-02T06:14:40.000Z"}"#,
3256        );
3257        let pr = parse_gemini(&v, 0);
3258        assert!(pr.events.is_empty());
3259    }
3260
3261    #[test]
3262    fn gemini_parse_skips_message_update() {
3263        let v = gemini_rec(
3264            r#"{"type":"message_update","id":"msg2","timestamp":"2026-07-02T06:14:41.000Z","tokens":{"input":5,"output":4}}"#,
3265        );
3266        let pr = parse_gemini(&v, 0);
3267        assert!(pr.events.is_empty());
3268    }
3269
3270    #[test]
3271    fn gemini_parse_skips_unknown_type() {
3272        let v = gemini_rec(r#"{"type":"unknown","id":"x","timestamp":"2026-07-02T06:14:40.000Z"}"#);
3273        let pr = parse_gemini(&v, 0);
3274        assert!(pr.events.is_empty());
3275    }
3276
3277    #[test]
3278    fn gemini_parse_non_object_value() {
3279        for v in [gemini_rec("[]"), gemini_rec("null")] {
3280            assert!(parse_gemini(&v, 0).events.is_empty());
3281        }
3282    }
3283
3284    // -----------------------------------------------------------------------
3285    // Detection tests for all adapters
3286    // -----------------------------------------------------------------------
3287
3288    #[test]
3289    fn select_detects_all_adapters() {
3290        assert!(select(&["claude".into()], Path::new("/tmp")).is_some());
3291        assert!(select(&["claude-desktop".into()], Path::new("/tmp")).is_some());
3292        assert!(select(&["codex".into()], Path::new("/tmp")).is_some());
3293        assert!(select(&["gemini".into()], Path::new("/tmp")).is_some());
3294        assert!(select(&["python3".into()], Path::new("/tmp")).is_none());
3295    }
3296
3297    #[test]
3298    fn is_claude_desktop_detects_basename() {
3299        assert!(is_claude_desktop(&["claude-desktop".into()]));
3300        assert!(is_claude_desktop(&["/usr/local/bin/claude-desktop".into()]));
3301        assert!(is_claude_desktop(&["claude-desktop.exe".into()]));
3302        assert!(!is_claude_desktop(&["claude".into()]));
3303        assert!(!is_claude_desktop(&["codex".into()]));
3304        assert!(!is_claude_desktop(&[]));
3305    }
3306
3307    #[test]
3308    fn is_codex_cli_detects_basename() {
3309        assert!(is_codex_cli(&["codex".into()]));
3310        assert!(is_codex_cli(&["/usr/local/bin/codex".into()]));
3311        assert!(is_codex_cli(&["codex.exe".into()]));
3312        assert!(!is_codex_cli(&["claude".into()]));
3313        assert!(!is_codex_cli(&[]));
3314    }
3315
3316    #[test]
3317    fn is_gemini_cli_detects_basename() {
3318        assert!(is_gemini_cli(&["gemini".into()]));
3319        assert!(is_gemini_cli(&["/usr/local/bin/gemini".into()]));
3320        assert!(is_gemini_cli(&["gemini.exe".into()]));
3321        assert!(!is_gemini_cli(&["claude".into()]));
3322        assert!(!is_gemini_cli(&[]));
3323    }
3324
3325    #[test]
3326    fn resolve_override_accepts_all_adapters() {
3327        assert!(resolve_override("claude-code").is_some());
3328        assert!(resolve_override("claude-desktop").is_some());
3329        assert!(resolve_override("codex-cli").is_some());
3330        assert!(resolve_override("gemini-cli").is_some());
3331        assert!(resolve_override("unknown").is_none());
3332    }
3333}