use crate::blob::BlobStore;
use crate::event::{truncate_summary, AdapterStatus, AgentKind, Event, EventKind};
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
const SPILLOVER_BYTES: usize = 256 * 1024;
const TAIL_POLL: Duration = Duration::from_millis(50);
const APPEAR_POLL: Duration = Duration::from_millis(200);
#[derive(Clone)]
pub struct AdapterContext {
pub session_id: String,
pub started_at_unix_ms: i64,
pub cwd: PathBuf,
pub command: Vec<String>,
pub blobs: std::sync::Arc<BlobStore>,
pub stop: std::sync::Arc<AtomicBool>,
pub projects_dir: Option<PathBuf>,
}
pub struct AdapterHandle {
pub events: mpsc::Receiver<Event>,
pub outcome: JoinHandle<AdapterOutcome>,
}
impl AdapterHandle {
pub fn join(self) -> crate::Result<AdapterOutcome> {
self.outcome.join().map_err(|_| {
crate::Error::Storage(crate::error::StorageError::Open {
path: PathBuf::new(),
source: std::io::Error::other("adapter thread panicked"),
})
})
}
}
#[derive(Debug, Default, Clone)]
pub struct AdapterOutcome {
pub model: Option<String>,
pub usage_json: Option<serde_json::Value>,
pub status: AdapterStatus,
pub degrade_reason: Option<String>,
}
pub trait Adapter: Send + 'static {
fn agent_kind(&self) -> AgentKind;
fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle>;
}
#[must_use]
pub fn select(command: &[String], _cwd: &Path) -> Option<Box<dyn Adapter>> {
if is_claude_code(command) {
Some(Box::new(ClaudeAdapter))
} else if is_claude_desktop(command) {
Some(Box::new(ClaudeDesktopAdapter))
} else if is_codex_cli(command) {
Some(Box::new(CodexAdapter))
} else if is_gemini_cli(command) {
Some(Box::new(GeminiAdapter))
} else {
None
}
}
#[must_use]
pub fn resolve_override(name: &str) -> Option<Box<dyn Adapter>> {
match name {
"claude-code" => Some(Box::new(ClaudeAdapter)),
"claude-desktop" => Some(Box::new(ClaudeDesktopAdapter)),
"codex-cli" => Some(Box::new(CodexAdapter)),
"gemini-cli" => Some(Box::new(GeminiAdapter)),
_ => None,
}
}
#[must_use]
pub fn is_claude_code(command: &[String]) -> bool {
let prog = command.first().map_or("", String::as_str);
let base = prog.rsplit(['/', '\\']).next().unwrap_or(prog);
let base = base.strip_suffix(".exe").unwrap_or(base);
base.eq_ignore_ascii_case("claude")
}
#[must_use]
pub fn is_claude_desktop(command: &[String]) -> bool {
let prog = command.first().map_or("", String::as_str);
let base = prog.rsplit(['/', '\\']).next().unwrap_or(prog);
let base = base.strip_suffix(".exe").unwrap_or(base);
base.eq_ignore_ascii_case("claude-desktop")
}
#[must_use]
pub fn is_codex_cli(command: &[String]) -> bool {
let prog = command.first().map_or("", String::as_str);
let base = prog.rsplit(['/', '\\']).next().unwrap_or(prog);
let base = base.strip_suffix(".exe").unwrap_or(base);
base.eq_ignore_ascii_case("codex")
}
#[must_use]
pub fn is_gemini_cli(command: &[String]) -> bool {
let prog = command.first().map_or("", String::as_str);
let base = prog.rsplit(['/', '\\']).next().unwrap_or(prog);
let base = base.strip_suffix(".exe").unwrap_or(base);
base.eq_ignore_ascii_case("gemini")
}
#[derive(Debug, Clone)]
pub struct ClaudeAdapter;
impl Adapter for ClaudeAdapter {
fn agent_kind(&self) -> AgentKind {
AgentKind::ClaudeCode
}
#[allow(clippy::unused_self)] fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle> {
let (tx, rx) = mpsc::channel::<Event>();
let outcome = std::thread::Builder::new()
.name("hh-claude-adapter".into())
.spawn(move || run_claude_tailer(ctx, tx))?;
Ok(AdapterHandle {
events: rx,
outcome,
})
}
}
#[allow(clippy::needless_pass_by_value)] fn run_claude_tailer(ctx: AdapterContext, tx: mpsc::Sender<Event>) -> AdapterOutcome {
let Some(projects) = ctx.projects_dir.clone().or_else(claude_projects_dir) else {
return degraded(
"no ~/.claude/projects directory found (HOME unset?); run `hh doctor` to diagnose",
);
};
if !projects.is_dir() {
return degraded(format!(
"projects directory does not exist: {}; run `hh doctor` to diagnose",
projects.display()
));
}
let slug = slugify(&ctx.cwd);
let slug_dir = projects.join(&slug);
debug(&format!(
"claude adapter: projects_dir={}, slug={slug}, slug_dir={}, session cwd={}, started_at_unix_ms={}",
projects.display(),
slug_dir.display(),
ctx.cwd.display(),
ctx.started_at_unix_ms
));
let mut diag = DiscoveryDiag {
projects: Some(projects.clone()),
slug: Some(slug.clone()),
slug_dir: Some(slug_dir.clone()),
cwd: Some(ctx.cwd.clone()),
..Default::default()
};
let start = Instant::now();
let Some(file) = locate_transcript(&projects, &ctx.cwd, &ctx.stop, &mut diag) else {
debug(&format!(
"claude adapter: discovery failed: new_candidates={}, cwd_mismatches={}, directories={}",
diag.new_candidates.len(),
diag.cwd_mismatches.len(),
diag.directories.len()
));
return degraded(locate_failure_reason(&diag));
};
debug(&format!(
"claude adapter: selected transcript {} (cwd matched)",
file.display()
));
let mut acc = OutcomeAcc::default();
let stats = tail_file(&file, &projects, &ctx, &tx, &start, &mut acc);
if !stats.read_ok {
return degraded(format!(
"found a transcript at {} but could not read it (open/read failed); \
run `hh doctor` to check file permissions",
file.display(),
));
}
debug(&format!(
"claude adapter: tail done: lines_seen={}, records_parsed={}, events_produced={}, first_parse_error={:?}",
stats.lines_seen, stats.records_parsed, stats.events_produced, stats.first_parse_error
));
if stats.events_produced == 0 {
let first_err = stats
.first_parse_error
.map(|(n, msg)| format!("; first parse error at line {n}: {msg}"))
.unwrap_or_default();
return degraded(format!(
"jsonl found ({}) but 0 records parsed (read {} line(s){first_err}); \
run `hh doctor` to check the Claude Code transcript format",
file.display(),
stats.lines_seen,
));
}
acc.finish(AdapterStatus::Active)
}
fn degraded(reason: impl Into<String>) -> AdapterOutcome {
AdapterOutcome {
status: AdapterStatus::Degraded,
degrade_reason: Some(reason.into()),
..Default::default()
}
}
#[derive(Default)]
struct DiscoveryDiag {
projects: Option<PathBuf>,
slug: Option<String>,
slug_dir: Option<PathBuf>,
cwd: Option<PathBuf>,
new_candidates: std::collections::HashSet<PathBuf>,
cwd_mismatches: Vec<PathBuf>,
directories: Vec<PathBuf>,
}
#[derive(Default)]
struct TailStats {
read_ok: bool,
lines_seen: usize,
records_parsed: usize,
events_produced: usize,
first_parse_error: Option<(usize, String)>,
}
fn locate_failure_reason(diag: &DiscoveryDiag) -> String {
use std::fmt::Write as _;
let slug = diag.slug.as_deref().unwrap_or("?");
let projects = diag
.projects
.as_ref()
.map_or_else(|| "(unknown)".to_string(), |p| p.display().to_string());
let cwd = diag
.cwd
.as_ref()
.map_or_else(|| "(unknown)".to_string(), |p| p.display().to_string());
let slug_dir = diag
.slug_dir
.as_ref()
.map_or_else(|| "(unknown)".to_string(), |p| p.display().to_string());
if diag.new_candidates.is_empty() {
format!(
"no jsonl matched cwd slug {slug}: no new transcript appeared under {projects} \
(looked in {slug_dir}; cwd={cwd}) before the session ended; Claude creates it \
lazily on first output — if you sent no prompt this is expected. \
Run `hh doctor` to verify discoverability."
)
} else {
let mut s = format!(
"no jsonl matched cwd slug {slug}: {} new candidate(s) appeared under {projects} \
but none carried cwd={cwd} before the session ended",
diag.new_candidates.len(),
);
if !diag.cwd_mismatches.is_empty() {
let names: Vec<String> = diag
.cwd_mismatches
.iter()
.take(3)
.map(|p| {
p.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default()
})
.collect();
let _ = write!(
s,
"; {} rejected for a different cwd ({})",
diag.cwd_mismatches.len(),
names.join(", ")
);
}
if !diag.directories.is_empty() {
let _ = write!(
s,
"; {} candidate(s) were directories, not files",
diag.directories.len()
);
}
s.push_str(". Run `hh doctor` to diagnose.");
s
}
}
#[derive(Default)]
struct OutcomeAcc {
model: Option<String>,
usage_json: Option<serde_json::Value>,
}
impl OutcomeAcc {
fn note_assistant(&mut self, message: &serde_json::Value) {
if let Some(m) = message.get("model").and_then(|v| v.as_str()) {
self.model = Some(m.to_string());
}
if let Some(u) = message.get("usage") {
self.usage_json = Some(u.clone());
}
}
fn finish(self, status: AdapterStatus) -> AdapterOutcome {
AdapterOutcome {
model: self.model,
usage_json: self.usage_json,
status,
degrade_reason: None,
}
}
}
fn locate_transcript(
projects: &Path,
cwd: &Path,
stop: &std::sync::Arc<AtomicBool>,
diag: &mut DiscoveryDiag,
) -> Option<PathBuf> {
let slug_dir = projects.join(slugify(cwd));
let preexisting = snapshot_all_jsonl(projects);
let mut rejected: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
loop {
if stop.load(Ordering::Acquire) {
return None;
}
if slug_dir.is_dir() {
for f in new_jsonl_in(&slug_dir, &preexisting) {
if rejected.contains(&f) {
continue;
}
diag.new_candidates.insert(f.clone());
match first_record_cwd_state(&f, cwd) {
CwdState::Matches => return Some(f),
CwdState::Different => {
diag.cwd_mismatches.push(f.clone());
rejected.insert(f);
}
CwdState::Directory => {
diag.directories.push(f.clone());
rejected.insert(f);
}
CwdState::None => {} }
}
}
for f in new_jsonl_under(projects, &preexisting) {
if rejected.contains(&f) {
continue;
}
diag.new_candidates.insert(f.clone());
match first_record_cwd_state(&f, cwd) {
CwdState::Matches => return Some(f),
CwdState::Different => {
diag.cwd_mismatches.push(f.clone());
rejected.insert(f);
}
CwdState::Directory => {
diag.directories.push(f.clone());
rejected.insert(f);
}
CwdState::None => {}
}
}
std::thread::sleep(APPEAR_POLL);
}
}
fn newest_in_dir(dir: &Path, since_unix_ms: i64) -> Option<PathBuf> {
let entries = std::fs::read_dir(dir).ok()?;
let mut best: Option<(PathBuf, i64)> = None;
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
continue;
}
let Ok(m) = e.metadata() else { continue };
let Ok(modified) = m.modified() else { continue };
let mtime_ms = system_time_to_unix_ms(modified);
if mtime_ms < since_unix_ms - 5_000 {
continue; }
match &best {
Some((_, best_ms)) if mtime_ms <= *best_ms => {}
_ => best = Some((p, mtime_ms)),
}
}
best.map(|(p, _)| p)
}
fn snapshot_all_jsonl(projects: &Path) -> std::collections::HashSet<PathBuf> {
let mut set = std::collections::HashSet::new();
let Ok(dirs) = std::fs::read_dir(projects) else {
return set;
};
for d in dirs.flatten() {
let dp = d.path();
if !dp.is_dir() {
continue;
}
if let Ok(entries) = std::fs::read_dir(&dp) {
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) == Some("jsonl") {
set.insert(p);
}
}
}
}
set
}
fn new_jsonl_in(dir: &Path, preexisting: &std::collections::HashSet<PathBuf>) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut found: Vec<(PathBuf, i64)> = Vec::new();
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
continue;
}
if preexisting.contains(&p) {
continue;
}
let Ok(m) = e.metadata() else { continue };
let mtime_ms = m.modified().map_or(0, system_time_to_unix_ms);
found.push((p, mtime_ms));
}
found.sort_by_key(|(_, m)| std::cmp::Reverse(*m));
found.into_iter().map(|(p, _)| p).collect()
}
fn new_jsonl_under(
projects: &Path,
preexisting: &std::collections::HashSet<PathBuf>,
) -> Vec<PathBuf> {
let Ok(dirs) = std::fs::read_dir(projects) else {
return Vec::new();
};
let mut found: Vec<(PathBuf, i64)> = Vec::new();
for d in dirs.flatten() {
let dp = d.path();
if !dp.is_dir() {
continue;
}
for p in new_jsonl_in(&dp, preexisting) {
let mtime_ms = std::fs::metadata(&p)
.ok()
.and_then(|m| m.modified().ok())
.map_or(0, system_time_to_unix_ms);
found.push((p, mtime_ms));
}
}
found.sort_by_key(|(_, m)| std::cmp::Reverse(*m));
found.into_iter().map(|(p, _)| p).collect()
}
#[derive(Debug, PartialEq, Eq)]
enum CwdState {
Matches,
Different,
None,
Directory,
}
fn first_record_cwd_state(file: &Path, session_cwd: &Path) -> CwdState {
if file.is_dir() {
return CwdState::Directory;
}
let Ok(content) = std::fs::read_to_string(file) else {
return CwdState::None;
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if let Some(rc) = v.get("cwd").and_then(|c| c.as_str()) {
let rp = Path::new(rc);
return if rp == session_cwd || rp.starts_with(session_cwd) {
CwdState::Matches
} else {
CwdState::Different
};
}
}
CwdState::None
}
fn tail_file(
file: &Path,
projects: &Path,
ctx: &AdapterContext,
tx: &mpsc::Sender<Event>,
start: &Instant,
acc: &mut OutcomeAcc,
) -> TailStats {
let slug_dir = projects.join(slugify(&ctx.cwd));
let mut current = file.to_path_buf();
let mut stats = TailStats::default();
let mut reader = match std::fs::File::open(¤t) {
Ok(f) => std::io::BufReader::new(f),
Err(_) => return stats, };
stats.read_ok = true;
let mut buf: Vec<u8> = Vec::new();
let mut line = Vec::new();
loop {
if ctx.stop.load(Ordering::Acquire) {
break;
}
match reader.read_until(b'\n', &mut line) {
Ok(0) => {
drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
if let Some(newer) = rotate_to_newer(&slug_dir, ¤t, ctx.started_at_unix_ms) {
debug(&format!(
"claude adapter: rotating to newer transcript {}",
newer.display()
));
current = newer;
reader = match std::fs::File::open(¤t) {
Ok(f) => std::io::BufReader::new(f),
Err(_) => break,
};
line.clear();
continue;
}
if ctx.stop.load(Ordering::Acquire) {
break;
}
std::thread::sleep(TAIL_POLL);
}
Ok(_n) => {
drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
stats
}
fn drain_complete_lines(
buf: &mut Vec<u8>,
line: &mut Vec<u8>,
ctx: &AdapterContext,
tx: &mpsc::Sender<Event>,
start: &Instant,
acc: &mut OutcomeAcc,
stats: &mut TailStats,
) {
if line.is_empty() {
return;
}
buf.append(line);
line.clear();
while let Some(nl) = buf.iter().rposition(|&b| b == b'\n') {
let complete: Vec<u8> = buf.drain(..=nl).collect();
parse_and_send(&complete, ctx, tx, start, acc, stats);
}
}
fn parse_and_send(
bytes: &[u8],
ctx: &AdapterContext,
tx: &mpsc::Sender<Event>,
start: &Instant,
acc: &mut OutcomeAcc,
stats: &mut TailStats,
) {
let s = match std::str::from_utf8(bytes) {
Ok(s) => s.trim(),
Err(e) => {
stats.lines_seen += 1;
if stats.first_parse_error.is_none() {
stats.first_parse_error = Some((stats.lines_seen, format!("not UTF-8: {e}")));
}
debug(&format!(
"claude adapter: line {} not UTF-8, skipping: {e}",
stats.lines_seen
));
return;
}
};
if s.is_empty() {
return;
}
stats.lines_seen += 1;
let value: serde_json::Value = match serde_json::from_str(s) {
Ok(v) => v,
Err(e) => {
if stats.first_parse_error.is_none() {
stats.first_parse_error = Some((stats.lines_seen, e.to_string()));
}
debug(&format!(
"claude adapter: line {} not valid JSON, skipping: {e}",
stats.lines_seen
));
return;
}
};
stats.records_parsed += 1;
let ts_ms = value
.get("timestamp")
.and_then(|v| v.as_str())
.and_then(|s| ts_ms_from_iso(s, ctx.started_at_unix_ms))
.unwrap_or_else(|| elapsed_ms(start));
let parsed = parse_record(&value, &ctx.session_id, ts_ms, &ctx.blobs);
if parsed.is_assistant_message {
if let Some(msg) = value.get("message") {
acc.note_assistant(msg);
}
}
for ev in parsed.events {
if tx.send(ev).is_err() {
break; }
stats.events_produced += 1;
}
}
fn rotate_to_newer(slug_dir: &Path, current: &Path, started_at_unix_ms: i64) -> Option<PathBuf> {
let newest = newest_in_dir(slug_dir, started_at_unix_ms)?;
if newest == current {
return None;
}
let newer_mtime = std::fs::metadata(&newest)
.ok()
.and_then(|m| m.modified().ok())
.map(system_time_to_unix_ms);
let cur_mtime = std::fs::metadata(current)
.ok()
.and_then(|m| m.modified().ok())
.map(system_time_to_unix_ms);
match (newer_mtime, cur_mtime) {
(Some(n), Some(c)) if n > c => Some(newest),
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct ClaudeDesktopAdapter;
impl Adapter for ClaudeDesktopAdapter {
fn agent_kind(&self) -> AgentKind {
AgentKind::ClaudeDesktop
}
#[allow(clippy::unused_self)]
fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle> {
let (tx, rx) = mpsc::channel::<Event>();
let outcome = std::thread::Builder::new()
.name("hh-claude-desktop-adapter".into())
.spawn(move || run_claude_tailer(ctx, tx))?;
Ok(AdapterHandle {
events: rx,
outcome,
})
}
}
#[derive(Debug, Clone)]
pub struct CodexAdapter;
impl Adapter for CodexAdapter {
fn agent_kind(&self) -> AgentKind {
AgentKind::CodexCli
}
#[allow(clippy::unused_self)]
fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle> {
let (tx, rx) = mpsc::channel::<Event>();
let outcome = std::thread::Builder::new()
.name("hh-codex-adapter".into())
.spawn(move || run_codex_tailer(ctx, tx))?;
Ok(AdapterHandle {
events: rx,
outcome,
})
}
}
#[must_use]
pub fn codex_sessions_dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".codex").join("sessions"))
}
#[allow(clippy::needless_pass_by_value)] fn run_codex_tailer(ctx: AdapterContext, tx: mpsc::Sender<Event>) -> AdapterOutcome {
let Some(sessions) = ctx.projects_dir.clone().or_else(codex_sessions_dir) else {
return degraded(
"no ~/.codex/sessions directory found (HOME unset?); run `hh doctor` to diagnose",
);
};
if !sessions.is_dir() {
return degraded(format!(
"sessions directory does not exist: {}; run `hh doctor` to diagnose",
sessions.display()
));
}
let start = Instant::now();
let Some(file) = locate_codex_transcript(&sessions, &ctx.cwd, &ctx.stop) else {
return degraded(format!(
"no codex rollout transcript appeared under {} matching cwd {} before the session ended",
sessions.display(),
ctx.cwd.display(),
));
};
let mut acc = OutcomeAcc::default();
let stats = tail_codex_file(&file, &ctx, &tx, &start, &mut acc);
if !stats.read_ok {
return degraded(format!(
"found a codex transcript at {} but could not read it; \
run `hh doctor` to check file permissions",
file.display(),
));
}
if stats.events_produced == 0 {
let first_err = stats
.first_parse_error
.map(|(n, msg)| format!("; first parse error at line {n}: {msg}"))
.unwrap_or_default();
return degraded(format!(
"codex transcript found ({}) but 0 records parsed (read {} line(s){first_err}); \
run `hh doctor` to check the Codex CLI transcript format",
file.display(),
stats.lines_seen,
));
}
acc.finish(AdapterStatus::Active)
}
fn locate_codex_transcript(
sessions: &Path,
cwd: &Path,
stop: &std::sync::Arc<AtomicBool>,
) -> Option<PathBuf> {
let preexisting = snapshot_codex_rollouts(sessions);
let mut rejected: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
loop {
if stop.load(Ordering::Acquire) {
return None;
}
for date_dir in candidate_date_dirs() {
let dir = sessions.join(&date_dir);
if !dir.is_dir() {
continue;
}
for f in new_rollout_in(&dir, &preexisting) {
if rejected.contains(&f) {
continue;
}
match first_codex_record_cwd(&f, cwd) {
CwdState::Matches => return Some(f),
CwdState::Different | CwdState::Directory => {
rejected.insert(f);
}
CwdState::None => {} }
}
}
std::thread::sleep(APPEAR_POLL);
}
}
fn snapshot_codex_rollouts(sessions: &Path) -> std::collections::HashSet<PathBuf> {
let mut set = std::collections::HashSet::new();
let Ok(years) = std::fs::read_dir(sessions) else {
return set;
};
for year in years.flatten() {
let yp = year.path();
if !yp.is_dir() {
continue;
}
let Ok(months) = std::fs::read_dir(&yp) else {
continue;
};
for month in months.flatten() {
let mp = month.path();
if !mp.is_dir() {
continue;
}
let Ok(days) = std::fs::read_dir(&mp) else {
continue;
};
for day in days.flatten() {
let dp = day.path();
if !dp.is_dir() {
continue;
}
if let Ok(entries) = std::fs::read_dir(&dp) {
for e in entries.flatten() {
let p = e.path();
if p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
n.starts_with("rollout-")
&& std::path::Path::new(n).extension()
== Some(std::ffi::OsStr::new("jsonl"))
}) {
set.insert(p);
}
}
}
}
}
}
set
}
fn new_rollout_in(dir: &Path, preexisting: &std::collections::HashSet<PathBuf>) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut found: Vec<(PathBuf, i64)> = Vec::new();
for e in entries.flatten() {
let p = e.path();
if !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
n.starts_with("rollout-")
&& std::path::Path::new(n).extension() == Some(std::ffi::OsStr::new("jsonl"))
}) {
continue;
}
if preexisting.contains(&p) {
continue;
}
let mtime_ms = e
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.map_or(0, system_time_to_unix_ms);
found.push((p, mtime_ms));
}
found.sort_by_key(|(_, m)| std::cmp::Reverse(*m));
found.into_iter().map(|(p, _)| p).collect()
}
fn candidate_date_dirs() -> Vec<String> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
let today_secs = now - (now % 86400);
let mut dirs = Vec::with_capacity(2);
for offset in [0, -86400] {
let ts = today_secs + offset;
if let Ok(dt) = time::OffsetDateTime::from_unix_timestamp(ts) {
dirs.push(format!(
"{:04}/{:02}/{:02}",
dt.year(),
u8::from(dt.month()),
dt.day()
));
}
}
dirs
}
fn first_codex_record_cwd(file: &Path, session_cwd: &Path) -> CwdState {
if file.is_dir() {
return CwdState::Directory;
}
let Ok(content) = std::fs::read_to_string(file) else {
return CwdState::None;
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if let Some(cwd_val) = v
.get("payload")
.and_then(|p| p.get("cwd"))
.and_then(|c| c.as_str())
{
let rp = Path::new(cwd_val);
return if rp == session_cwd || rp.starts_with(session_cwd) {
CwdState::Matches
} else {
CwdState::Different
};
}
}
CwdState::None
}
fn tail_codex_file(
file: &Path,
ctx: &AdapterContext,
tx: &mpsc::Sender<Event>,
start: &Instant,
acc: &mut OutcomeAcc,
) -> TailStats {
let mut stats = TailStats::default();
let mut reader = match std::fs::File::open(file) {
Ok(f) => std::io::BufReader::new(f),
Err(_) => return stats,
};
stats.read_ok = true;
let mut buf: Vec<u8> = Vec::new();
let mut line = Vec::new();
loop {
if ctx.stop.load(Ordering::Acquire) {
break;
}
match reader.read_until(b'\n', &mut line) {
Ok(0) => {
drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
if ctx.stop.load(Ordering::Acquire) {
break;
}
std::thread::sleep(TAIL_POLL);
}
Ok(_n) => {
drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
stats
}
#[must_use]
#[allow(dead_code)] pub(crate) fn parse_codex_record(
value: &serde_json::Value,
session_id: &str,
ts_ms: i64,
blobs: &BlobStore,
) -> ParsedRecord {
let mut out = ParsedRecord::default();
let Some(obj) = value.as_object() else {
return out;
};
let ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
let payload = obj.get("payload");
match ty {
"response_item" => {
if let Some(p) = payload {
parse_codex_response_item(p, session_id, ts_ms, blobs, &mut out);
}
}
"event_msg" => {
if let Some(p) = payload {
parse_codex_event_msg(p, session_id, ts_ms, blobs, &mut out);
}
}
_ => {}
}
out
}
#[allow(dead_code, clippy::too_many_lines)]
fn parse_codex_response_item(
payload: &serde_json::Value,
session_id: &str,
ts_ms: i64,
blobs: &BlobStore,
out: &mut ParsedRecord,
) {
let Some(obj) = payload.as_object() else {
return;
};
let inner_ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
match inner_ty {
"message" => {
let role = obj.get("role").and_then(|v| v.as_str()).unwrap_or("");
let content = obj.get("content").and_then(|v| v.as_array());
let text = content.and_then(|arr| {
arr.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("\n")
.into()
});
let kind = match role {
"assistant" => EventKind::AgentMessage,
_ => EventKind::UserMessage,
};
if let Some(t) = text {
out.events
.push(text_event(session_id, ts_ms, kind, &t, blobs));
}
out.is_assistant_message = role == "assistant";
}
"reasoning" => {
let summary = obj
.get("summary")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
let text = if summary.is_empty() {
"reasoning (encrypted)".to_string()
} else {
summary
};
out.events.push(text_event(
session_id,
ts_ms,
EventKind::Thinking,
&text,
blobs,
));
}
"function_call" | "custom_tool_call" => {
let call_id = obj.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
let input = if inner_ty == "function_call" {
obj.get("arguments")
.and_then(|v| v.as_str())
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(serde_json::Value::Null)
} else {
obj.get("input").cloned().unwrap_or(serde_json::Value::Null)
};
let body = serde_json::json!({
"name": name,
"input": input,
"correlate_key": call_id,
});
let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
out.events.push(Event {
session_id: session_id.to_string(),
ts_ms,
kind: EventKind::ToolCall,
step: None,
summary: truncate_summary(&format!("tool_call: {name}")),
body_json,
blob_hash,
blob_size,
correlates: None,
});
}
"function_call_output" | "custom_tool_call_output" => {
let call_id = obj.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
let output = obj.get("output").and_then(|v| v.as_str()).unwrap_or("");
let body = serde_json::json!({
"tool_use_id": call_id,
"is_error": false,
"content": output,
"correlate_key": call_id,
});
let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
out.events.push(Event {
session_id: session_id.to_string(),
ts_ms,
kind: EventKind::ToolResult,
step: None,
summary: truncate_summary(&format!("tool_result: {}", one_line(output))),
body_json,
blob_hash,
blob_size,
correlates: None,
});
}
_ => {} }
}
#[allow(dead_code, clippy::too_many_lines)]
fn parse_codex_event_msg(
payload: &serde_json::Value,
session_id: &str,
ts_ms: i64,
blobs: &BlobStore,
out: &mut ParsedRecord,
) {
let Some(obj) = payload.as_object() else {
return;
};
let inner_ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
match inner_ty {
"user_message" => {
if let Some(text) = extract_codex_event_text(obj) {
out.events.push(text_event(
session_id,
ts_ms,
EventKind::UserMessage,
&text,
blobs,
));
}
}
"agent_message" => {
if let Some(text) = extract_codex_event_text(obj) {
out.events.push(text_event(
session_id,
ts_ms,
EventKind::AgentMessage,
&text,
blobs,
));
}
out.is_assistant_message = true;
}
"token_count" => {
if let Some(info) = obj.get("info").and_then(|v| v.as_object()) {
if info.get("total_token_usage").is_some() {
out.is_assistant_message = true;
}
}
}
"exec_command_end" | "patch_apply_end" => {
let call_id = obj.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
let aggregated_output = obj
.get("aggregated_output")
.and_then(|v| v.as_str())
.unwrap_or("");
let stdout = obj.get("stdout").and_then(|v| v.as_str()).unwrap_or("");
let stderr = obj.get("stderr").and_then(|v| v.as_str()).unwrap_or("");
let exit_code = obj
.get("exit_code")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let content = if aggregated_output.is_empty() {
let mut parts = Vec::new();
if !stdout.is_empty() {
parts.push(stdout);
}
if !stderr.is_empty() {
parts.push(stderr);
}
parts.join("\n")
} else {
aggregated_output.to_string()
};
let body = serde_json::json!({
"tool_use_id": call_id,
"is_error": exit_code != 0,
"content": content,
"correlate_key": call_id,
});
let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
let summary = if exit_code != 0 {
format!("tool_result (error): {}", one_line(&content))
} else {
format!("tool_result: {}", one_line(&content))
};
out.events.push(Event {
session_id: session_id.to_string(),
ts_ms,
kind: EventKind::ToolResult,
step: None,
summary: truncate_summary(&summary),
body_json,
blob_hash,
blob_size,
correlates: None,
});
}
_ => {} }
}
#[allow(dead_code)]
fn extract_codex_event_text(obj: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
if let Some(content) = obj.get("content") {
match content {
serde_json::Value::String(s) => return Some(s.clone()),
serde_json::Value::Array(arr) => {
let parts: String = arr
.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("\n");
if !parts.is_empty() {
return Some(parts);
}
}
_ => {}
}
}
if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
return Some(text.to_string());
}
None
}
#[derive(Debug, Clone)]
pub struct GeminiAdapter;
impl Adapter for GeminiAdapter {
fn agent_kind(&self) -> AgentKind {
AgentKind::GeminiCli
}
#[allow(clippy::unused_self)]
fn spawn(self: Box<Self>, ctx: AdapterContext) -> std::io::Result<AdapterHandle> {
let (tx, rx) = mpsc::channel::<Event>();
let outcome = std::thread::Builder::new()
.name("hh-gemini-adapter".into())
.spawn(move || run_gemini_tailer(ctx, tx))?;
Ok(AdapterHandle {
events: rx,
outcome,
})
}
}
#[must_use]
pub fn gemini_tmp_dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".gemini").join("tmp"))
}
#[allow(clippy::needless_pass_by_value)] fn run_gemini_tailer(ctx: AdapterContext, tx: mpsc::Sender<Event>) -> AdapterOutcome {
let Some(tmp) = ctx.projects_dir.clone().or_else(gemini_tmp_dir) else {
return degraded(
"no ~/.gemini/tmp directory found (HOME unset?); run `hh doctor` to diagnose",
);
};
if !tmp.is_dir() {
return degraded(format!(
"gemini tmp directory does not exist: {}; run `hh doctor` to diagnose",
tmp.display()
));
}
let start = Instant::now();
let Some(file) = locate_gemini_transcript(&tmp, &ctx.cwd, &ctx.stop) else {
return degraded(format!(
"no gemini session transcript appeared under {} matching cwd {} before the session ended",
tmp.display(),
ctx.cwd.display(),
));
};
let mut acc = OutcomeAcc::default();
let stats = tail_gemini_file(&file, &ctx, &tx, &start, &mut acc);
if !stats.read_ok {
return degraded(format!(
"found a gemini transcript at {} but could not read it; \
run `hh doctor` to check file permissions",
file.display(),
));
}
if stats.events_produced == 0 {
let first_err = stats
.first_parse_error
.map(|(n, msg)| format!("; first parse error at line {n}: {msg}"))
.unwrap_or_default();
return degraded(format!(
"gemini transcript found ({}) but 0 records parsed (read {} line(s){first_err}); \
run `hh doctor` to check the Gemini CLI transcript format",
file.display(),
stats.lines_seen,
));
}
acc.finish(AdapterStatus::Active)
}
fn locate_gemini_transcript(
tmp: &Path,
cwd: &Path,
stop: &std::sync::Arc<AtomicBool>,
) -> Option<PathBuf> {
let preexisting = snapshot_gemini_sessions(tmp);
let mut rejected: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
loop {
if stop.load(Ordering::Acquire) {
return None;
}
let Ok(projects) = std::fs::read_dir(tmp) else {
std::thread::sleep(APPEAR_POLL);
continue;
};
for project in projects.flatten() {
let chats = project.path().join("chats");
if !chats.is_dir() {
continue;
}
for f in new_gemini_sessions_in(&chats, &preexisting) {
if rejected.contains(&f) {
continue;
}
match first_gemini_record_cwd(&f, cwd) {
CwdState::Matches => return Some(f),
CwdState::Different | CwdState::Directory => {
rejected.insert(f);
}
CwdState::None => {}
}
}
}
std::thread::sleep(APPEAR_POLL);
}
}
fn snapshot_gemini_sessions(tmp: &Path) -> std::collections::HashSet<PathBuf> {
let mut set = std::collections::HashSet::new();
let Ok(projects) = std::fs::read_dir(tmp) else {
return set;
};
for project in projects.flatten() {
let chats = project.path().join("chats");
if !chats.is_dir() {
continue;
}
if let Ok(entries) = std::fs::read_dir(&chats) {
for e in entries.flatten() {
let p = e.path();
if p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
n.starts_with("session-")
&& std::path::Path::new(n).extension()
== Some(std::ffi::OsStr::new("jsonl"))
}) {
set.insert(p);
}
}
}
}
set
}
fn new_gemini_sessions_in(
dir: &Path,
preexisting: &std::collections::HashSet<PathBuf>,
) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut found: Vec<(PathBuf, i64)> = Vec::new();
for e in entries.flatten() {
let p = e.path();
if !p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
n.starts_with("session-")
&& std::path::Path::new(n).extension() == Some(std::ffi::OsStr::new("jsonl"))
}) {
continue;
}
if preexisting.contains(&p) {
continue;
}
let mtime_ms = e
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.map_or(0, system_time_to_unix_ms);
found.push((p, mtime_ms));
}
found.sort_by_key(|(_, m)| std::cmp::Reverse(*m));
found.into_iter().map(|(p, _)| p).collect()
}
fn first_gemini_record_cwd(file: &Path, _session_cwd: &Path) -> CwdState {
if file.is_dir() {
return CwdState::Directory;
}
let Ok(content) = std::fs::read_to_string(file) else {
return CwdState::None;
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(_v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
return CwdState::Matches;
}
CwdState::None
}
fn tail_gemini_file(
file: &Path,
ctx: &AdapterContext,
tx: &mpsc::Sender<Event>,
start: &Instant,
acc: &mut OutcomeAcc,
) -> TailStats {
let mut stats = TailStats::default();
let mut reader = match std::fs::File::open(file) {
Ok(f) => std::io::BufReader::new(f),
Err(_) => return stats,
};
stats.read_ok = true;
let mut buf: Vec<u8> = Vec::new();
let mut line = Vec::new();
loop {
if ctx.stop.load(Ordering::Acquire) {
break;
}
match reader.read_until(b'\n', &mut line) {
Ok(0) => {
drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
if ctx.stop.load(Ordering::Acquire) {
break;
}
std::thread::sleep(TAIL_POLL);
}
Ok(_n) => {
drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
drain_complete_lines(&mut buf, &mut line, ctx, tx, start, acc, &mut stats);
stats
}
#[must_use]
#[allow(dead_code, clippy::too_many_lines)] pub(crate) fn parse_gemini_record(
value: &serde_json::Value,
session_id: &str,
ts_ms: i64,
blobs: &BlobStore,
) -> ParsedRecord {
let mut out = ParsedRecord::default();
let Some(obj) = value.as_object() else {
return out;
};
let ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
match ty {
"user" => {
if let Some(text) = extract_gemini_text(obj) {
out.events.push(text_event(
session_id,
ts_ms,
EventKind::UserMessage,
&text,
blobs,
));
}
}
"gemini" => {
if let Some(text) = extract_gemini_text(obj) {
out.events.push(text_event(
session_id,
ts_ms,
EventKind::AgentMessage,
&text,
blobs,
));
}
if let Some(thoughts) = obj.get("thoughts").and_then(|v| v.as_array()) {
for thought in thoughts {
if let Some(text) = thought.get("text").and_then(|v| v.as_str()) {
out.events.push(text_event(
session_id,
ts_ms,
EventKind::Thinking,
text,
blobs,
));
}
}
}
if let Some(tool_calls) = obj.get("toolCalls").and_then(|v| v.as_array()) {
for tc in tool_calls {
if let Some(tc_obj) = tc.as_object() {
let call_id = tc_obj.get("id").and_then(|v| v.as_str()).unwrap_or("");
let name = tc_obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
let input = tc_obj
.get("input")
.cloned()
.unwrap_or(serde_json::Value::Null);
let body = serde_json::json!({
"name": name,
"input": input,
"correlate_key": call_id,
});
let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
out.events.push(Event {
session_id: session_id.to_string(),
ts_ms,
kind: EventKind::ToolCall,
step: None,
summary: truncate_summary(&format!("tool_call: {name}")),
body_json,
blob_hash,
blob_size,
correlates: None,
});
if let Some(result) = tc_obj.get("result").and_then(|v| v.as_array()) {
let result_text: String = result
.iter()
.filter_map(|p| p.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("\n");
let body = serde_json::json!({
"tool_use_id": call_id,
"is_error": false,
"content": result_text,
"correlate_key": call_id,
});
let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
out.events.push(Event {
session_id: session_id.to_string(),
ts_ms,
kind: EventKind::ToolResult,
step: None,
summary: truncate_summary(&format!(
"tool_result: {}",
one_line(&result_text)
)),
body_json,
blob_hash,
blob_size,
correlates: None,
});
}
}
}
}
if obj.get("tokens").is_some() {
out.is_assistant_message = true;
}
if let Some(_model) = obj.get("model").and_then(|v| v.as_str()) {
out.is_assistant_message = true;
}
}
_ => {}
}
out
}
fn extract_gemini_text(obj: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
if let Some(content) = obj.get("content") {
match content {
serde_json::Value::String(s) => return Some(s.clone()),
serde_json::Value::Array(arr) => {
let parts: String = arr
.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("\n");
if !parts.is_empty() {
return Some(parts);
}
}
_ => {}
}
}
None
}
#[derive(Debug, Default, Clone)]
pub(crate) struct ParsedRecord {
pub events: Vec<Event>,
pub is_assistant_message: bool,
}
#[must_use]
pub(crate) fn parse_record(
value: &serde_json::Value,
session_id: &str,
ts_ms: i64,
blobs: &BlobStore,
) -> ParsedRecord {
let mut out = ParsedRecord::default();
let Some(obj) = value.as_object() else {
return out; };
let ty = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
if ty != "user" && ty != "assistant" {
return out; }
if obj
.get("isSidechain")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
return out; }
if obj
.get("isMeta")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
return out; }
let Some(message) = obj.get("message") else {
return out;
};
let is_assistant = ty == "assistant";
out.is_assistant_message = is_assistant;
let Some(content) = message.get("content") else {
return out;
};
match content {
serde_json::Value::String(s) => {
let kind = if is_assistant {
EventKind::AgentMessage
} else {
EventKind::UserMessage
};
out.events
.push(text_event(session_id, ts_ms, kind, s, blobs));
}
serde_json::Value::Array(blocks) => {
for block in blocks {
if let Some(b) = block.as_object() {
parse_block(b, is_assistant, session_id, ts_ms, blobs, &mut out.events);
}
}
}
_ => {} }
out
}
fn parse_block(
b: &serde_json::Map<String, serde_json::Value>,
is_assistant: bool,
session_id: &str,
ts_ms: i64,
blobs: &BlobStore,
events: &mut Vec<Event>,
) {
let bty = b.get("type").and_then(|v| v.as_str()).unwrap_or("");
match bty {
"text" => {
if let Some(t) = b.get("text").and_then(|v| v.as_str()) {
let kind = if is_assistant {
EventKind::AgentMessage
} else {
EventKind::UserMessage
};
events.push(text_event(session_id, ts_ms, kind, t, blobs));
}
}
"thinking" => {
if let Some(t) = b.get("thinking").and_then(|v| v.as_str()) {
events.push(text_event(session_id, ts_ms, EventKind::Thinking, t, blobs));
}
}
"tool_use" => {
let id = b.get("id").and_then(|v| v.as_str()).unwrap_or("");
let name = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
let input = b.get("input").cloned().unwrap_or(serde_json::Value::Null);
events.push(tool_call_event(session_id, ts_ms, id, name, &input, blobs));
}
"tool_result" => {
let tool_use_id = b.get("tool_use_id").and_then(|v| v.as_str()).unwrap_or("");
let is_error = b
.get("is_error")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let content_text = extract_result_content(b.get("content"));
events.push(tool_result_event(
session_id,
ts_ms,
tool_use_id,
is_error,
&content_text,
blobs,
));
}
_ => {} }
}
fn text_event(
session_id: &str,
ts_ms: i64,
kind: EventKind,
text: &str,
blobs: &BlobStore,
) -> Event {
let body = serde_json::json!({ "text": text });
let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
Event {
session_id: session_id.to_string(),
ts_ms,
kind,
step: None, summary: truncate_summary(text),
body_json,
blob_hash,
blob_size,
correlates: None,
}
}
fn tool_call_event(
session_id: &str,
ts_ms: i64,
id: &str,
name: &str,
input: &serde_json::Value,
blobs: &BlobStore,
) -> Event {
let body = serde_json::json!({
"name": name,
"input": input,
"correlate_key": id,
});
let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
Event {
session_id: session_id.to_string(),
ts_ms,
kind: EventKind::ToolCall,
step: None,
summary: truncate_summary(&format!("tool_call: {name}")),
body_json,
blob_hash,
blob_size,
correlates: None,
}
}
fn tool_result_event(
session_id: &str,
ts_ms: i64,
tool_use_id: &str,
is_error: bool,
content: &str,
blobs: &BlobStore,
) -> Event {
let body = serde_json::json!({
"tool_use_id": tool_use_id,
"is_error": is_error,
"content": content,
"correlate_key": tool_use_id,
});
let (body_json, blob_hash, blob_size) = maybe_spill(body, blobs);
let summary = if is_error {
format!("tool_result (error): {}", one_line(content))
} else {
format!("tool_result: {}", one_line(content))
};
Event {
session_id: session_id.to_string(),
ts_ms,
kind: EventKind::ToolResult,
step: None,
summary: truncate_summary(&summary),
body_json,
blob_hash,
blob_size,
correlates: None,
}
}
fn maybe_spill(
body: serde_json::Value,
blobs: &BlobStore,
) -> (Option<serde_json::Value>, Option<String>, Option<u64>) {
let serialized = serde_json::to_vec(&body).unwrap_or_default();
if serialized.len() >= SPILLOVER_BYTES {
if let Ok(outcome) = blobs.put(&serialized) {
let envelope = serde_json::json!({
"overflow": true,
"size": outcome.size,
"blob_hash": outcome.hash,
"encoding": "blob",
});
return (Some(envelope), Some(outcome.hash), Some(outcome.size));
}
}
(Some(body), None, None)
}
fn extract_result_content(content: Option<&serde_json::Value>) -> String {
match content {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(arr)) => {
let mut parts = Vec::with_capacity(arr.len());
for b in arr {
if let Some(t) = b.get("text").and_then(|v| v.as_str()) {
parts.push(t.to_string());
}
}
parts.join("\n")
}
_ => String::new(),
}
}
fn one_line(s: &str) -> String {
s.chars()
.map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
.collect()
}
pub(crate) fn ts_ms_from_iso(iso: &str, started_at_unix_ms: i64) -> Option<i64> {
let dt =
time::OffsetDateTime::parse(iso, &time::format_description::well_known::Rfc3339).ok()?;
let unix_ms = dt.unix_timestamp_nanos() / 1_000_000;
let unix_ms = i64::try_from(unix_ms).unwrap_or(i64::MAX);
Some((unix_ms - started_at_unix_ms).max(0))
}
#[must_use]
pub fn claude_projects_dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".claude").join("projects"))
}
#[must_use]
pub fn newest_jsonl_for_cwd(cwd: &Path) -> Option<PathBuf> {
let projects = claude_projects_dir()?;
let slug_dir = projects.join(slugify(cwd));
if !slug_dir.is_dir() {
return None;
}
newest_in_dir(&slug_dir, 0)
}
#[must_use]
pub(crate) fn slugify(cwd: &Path) -> String {
cwd.to_string_lossy()
.chars()
.map(|c| match c {
'/' | '\\' | '.' => '-',
other => other,
})
.collect()
}
fn system_time_to_unix_ms(t: std::time::SystemTime) -> i64 {
t.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
}
fn elapsed_ms(start: &Instant) -> i64 {
i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX)
}
fn debug(msg: &str) {
if std::env::var_os("HH_DEBUG").is_some() {
eprintln!("hh: debug: {msg}");
}
}
#[cfg(feature = "fuzzing")]
pub mod fuzzing {
use super::{parse_codex_record, parse_gemini_record, parse_record, BlobStore};
use std::sync::OnceLock;
fn blobs() -> &'static BlobStore {
static BLOBS: OnceLock<BlobStore> = OnceLock::new();
BLOBS.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("hh-fuzz-adapter-{}", std::process::id()));
BlobStore::new(dir)
})
}
pub fn fuzz_parse_line(bytes: &[u8]) {
let Ok(s) = std::str::from_utf8(bytes) else {
return;
};
let s = s.trim();
if s.is_empty() {
return;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(s) else {
return;
};
let _ = parse_record(&value, "fuzz-session", 0, blobs());
}
pub fn fuzz_parse_codex_line(bytes: &[u8]) {
let Ok(s) = std::str::from_utf8(bytes) else {
return;
};
let s = s.trim();
if s.is_empty() {
return;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(s) else {
return;
};
let _ = parse_codex_record(&value, "fuzz-session", 0, blobs());
}
pub fn fuzz_parse_gemini_line(bytes: &[u8]) {
let Ok(s) = std::str::from_utf8(bytes) else {
return;
};
let s = s.trim();
if s.is_empty() {
return;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(s) else {
return;
};
let _ = parse_gemini_record(&value, "fuzz-session", 0, blobs());
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
use std::io::Write;
const STARTED: i64 = 1_782_972_880_000;
const SID: &str = "test-session-id";
fn iso(s: &str) -> i64 {
ts_ms_from_iso(s, STARTED).expect("fixture timestamps must parse")
}
fn parse(value: &Value, ts_ms: i64) -> ParsedRecord {
let tmp = tempfile::TempDir::new().unwrap();
let blobs = BlobStore::new(tmp.path().join("blobs"));
parse_record(value, SID, ts_ms, &blobs)
}
fn snap(pr: &ParsedRecord) -> String {
serde_json::to_string_pretty(&serde_json::json!({
"events": pr.events,
"is_assistant_message": pr.is_assistant_message,
}))
.unwrap()
}
fn rec(json: &str) -> Value {
serde_json::from_str(json).unwrap()
}
#[test]
fn parse_user_text_prompt() {
let v = rec(r#"{"type":"user","isSidechain":false,"isMeta":false,
"message":{"role":"user","content":"Please list the files in the repo and show Cargo.toml."},
"timestamp":"2026-07-02T06:14:40.699Z","cwd":"/tmp/work","sessionId":"s"}"#);
let pr = parse(&v, iso("2026-07-02T06:14:40.699Z"));
insta::assert_snapshot!(snap(&pr));
}
#[test]
fn parse_assistant_with_two_tool_uses() {
let v = rec(r#"{"type":"assistant","isSidechain":false,"isMeta":false,
"message":{"role":"assistant","model":"glm-5.2","content":[
{"type":"thinking","thinking":"I'll list files then read Cargo.toml.","signature":""},
{"type":"tool_use","id":"call_abc123","name":"Bash","input":{"command":"ls -la"}},
{"type":"tool_use","id":"call_def456","name":"Read","input":{"file_path":"Cargo.toml"}}
],"usage":{"input_tokens":1200,"output_tokens":80}},
"timestamp":"2026-07-02T06:14:41.500Z","cwd":"/tmp/work","sessionId":"s"}"#);
let pr = parse(&v, iso("2026-07-02T06:14:41.500Z"));
insta::assert_snapshot!(snap(&pr));
assert_eq!(pr.events.len(), 3);
assert!(pr.is_assistant_message);
assert_eq!(pr.events[0].kind, EventKind::Thinking);
assert_eq!(pr.events[1].kind, EventKind::ToolCall);
assert_eq!(pr.events[2].kind, EventKind::ToolCall);
}
#[test]
fn parse_user_with_two_tool_results_one_error() {
let v = rec(r#"{"type":"user","isSidechain":false,"isMeta":false,
"message":{"role":"user","content":[
{"type":"tool_result","tool_use_id":"call_abc123","content":"total 0","is_error":false},
{"type":"tool_result","tool_use_id":"call_def456","content":"Error: file not found","is_error":true}
]},
"timestamp":"2026-07-02T06:14:42.800Z","cwd":"/tmp/work","sessionId":"s"}"#);
let pr = parse(&v, iso("2026-07-02T06:14:42.800Z"));
insta::assert_snapshot!(snap(&pr));
assert_eq!(pr.events.len(), 2);
assert_eq!(pr.events[0].kind, EventKind::ToolResult);
assert_eq!(pr.events[1].kind, EventKind::ToolResult);
assert_eq!(
pr.events[0].body_json.as_ref().unwrap()["correlate_key"],
"call_abc123"
);
assert!(pr.events[1].body_json.as_ref().unwrap()["is_error"]
.as_bool()
.unwrap());
}
#[test]
fn parse_assistant_followup_with_model_usage() {
let v = rec(r#"{"type":"assistant","isSidechain":false,"isMeta":false,
"message":{"role":"assistant","model":"glm-5.2","content":[
{"type":"text","text":"The repo is empty and Cargo.toml is missing."}
],"usage":{"input_tokens":1500,"output_tokens":40,"cache_read_input_tokens":300}},
"timestamp":"2026-07-02T06:14:43.100Z","cwd":"/tmp/work","sessionId":"s"}"#);
let pr = parse(&v, iso("2026-07-02T06:14:43.100Z"));
insta::assert_snapshot!(snap(&pr));
assert_eq!(pr.events.len(), 1);
assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
let mut acc = OutcomeAcc::default();
acc.note_assistant(v.get("message").unwrap());
assert_eq!(acc.model.as_deref(), Some("glm-5.2"));
assert_eq!(acc.usage_json.as_ref().unwrap()["output_tokens"], 40);
}
#[test]
fn parse_skips_unknown_type() {
let v = rec(r#"{"type":"system","subtype":"init","cwd":"/tmp/work","sessionId":"s"}"#);
let pr = parse(&v, 0);
assert!(pr.events.is_empty());
assert!(!pr.is_assistant_message);
}
#[test]
fn parse_skips_non_object_value() {
for v in [rec("[]"), rec("null"), rec("\"oops\"")] {
assert!(
parse(&v, 0).events.is_empty(),
"non-object must yield no events"
);
}
}
#[test]
fn parse_skips_is_sidechain() {
let v = rec(r#"{"type":"assistant","isSidechain":true,"isMeta":false,
"message":{"role":"assistant","content":[{"type":"text","text":"subagent"}]},
"timestamp":"2026-07-02T06:14:41.000Z","cwd":"/tmp/work","sessionId":"s"}"#);
assert!(parse(&v, 0).events.is_empty());
}
#[test]
fn parse_skips_is_meta() {
let v = rec(r#"{"type":"user","isSidechain":false,"isMeta":true,
"message":{"role":"user","content":"<local-command-caveat>system-reminder</local-command-caveat>"},
"timestamp":"2026-07-02T06:14:40.600Z","cwd":"/tmp/work","sessionId":"s"}"#);
assert!(parse(&v, 0).events.is_empty());
}
#[test]
fn parse_256kib_spills_to_blob() {
let big = "x".repeat(SPILLOVER_BYTES + 1024);
let v = rec(&format!(
r#"{{"type":"user","isSidechain":false,"isMeta":false,
"message":{{"role":"user","content":{}}},
"timestamp":"2026-07-02T06:14:40.699Z","cwd":"/tmp/work","sessionId":"s"}}"#,
serde_json::Value::String(big)
));
let tmp = tempfile::TempDir::new().unwrap();
let blobs = BlobStore::new(tmp.path().join("blobs"));
let pr = parse_record(&v, SID, 699, &blobs);
let ev = &pr.events[0];
assert!(ev.blob_hash.is_some(), "large payload must spill to a blob");
let hash = ev.blob_hash.as_ref().unwrap();
assert_eq!(ev.body_json.as_ref().unwrap()["overflow"], true);
assert_eq!(ev.body_json.as_ref().unwrap()["encoding"], "blob");
let raw = blobs.get(hash).unwrap();
let body: Value = serde_json::from_slice(&raw).unwrap();
assert_eq!(body["text"].as_str().unwrap().len(), SPILLOVER_BYTES + 1024);
}
#[test]
fn slugify_replaces_slash_and_dot() {
assert_eq!(
slugify(Path::new("/home/saadman/switch")),
"-home-saadman-switch"
);
assert_eq!(
slugify(Path::new("/home/saadman/switch/.claude/worktrees/x")),
"-home-saadman-switch--claude-worktrees-x"
);
assert_eq!(slugify(Path::new("C:\\Users\\me")), "C:-Users-me");
}
#[test]
fn select_detects_claude_basename() {
assert!(is_claude_code(&["claude".into()]));
assert!(is_claude_code(&["/usr/local/bin/claude".into()]));
assert!(is_claude_code(&["C:\\Apps\\claude.exe".into()]));
assert!(!is_claude_code(&["python3".into()]));
assert!(!is_claude_code(&[]));
assert!(select(&["claude".into()], Path::new("/tmp")).is_some());
assert!(select(&["python3".into()], Path::new("/tmp")).is_none());
}
#[test]
fn ts_ms_from_iso_is_relative_and_clamped() {
assert_eq!(
ts_ms_from_iso("2026-07-02T06:14:40.699Z", STARTED),
Some(699)
);
assert_eq!(ts_ms_from_iso("2026-07-02T06:14:40.000Z", STARTED), Some(0));
assert_eq!(ts_ms_from_iso("2026-07-02T06:14:39.000Z", STARTED), Some(0));
assert!(ts_ms_from_iso("not-a-date", STARTED).is_none());
}
fn spawn_with(home: &Path, cwd: &Path) -> (AdapterHandle, std::sync::Arc<AtomicBool>, PathBuf) {
let slug_dir = home.join(".claude").join("projects").join(slugify(cwd));
std::fs::create_dir_all(&slug_dir).unwrap();
let stop = std::sync::Arc::new(AtomicBool::new(false));
let blobs = std::sync::Arc::new(BlobStore::new(home.join("blobs")));
let ctx = AdapterContext {
session_id: SID.to_string(),
started_at_unix_ms: STARTED,
cwd: cwd.to_path_buf(),
command: vec!["claude".into()],
blobs,
stop: std::sync::Arc::clone(&stop),
projects_dir: Some(home.join(".claude").join("projects")),
};
let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn adapter");
std::thread::sleep(Duration::from_millis(50));
(handle, stop, slug_dir)
}
fn write_transcript(slug_dir: &Path, name: &str, content: &str) -> PathBuf {
let path = slug_dir.join(name);
std::fs::write(&path, content).unwrap();
path
}
fn drain(events: &mpsc::Receiver<Event>) -> Vec<Event> {
let mut out = Vec::new();
while let Ok(ev) = events.recv() {
out.push(ev);
}
out
}
const FIXTURE: &str = "\
{\"type\":\"system\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\",\"timestamp\":\"2026-07-02T06:14:40.500Z\"}
{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":true,\"message\":{\"role\":\"user\",\"content\":\"caveat\"},\"timestamp\":\"2026-07-02T06:14:40.600Z\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\"}
{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"hello\"},\"timestamp\":\"2026-07-02T06:14:40.699Z\",\"cwd\":\"/tmp/work\",\"sessionId\":\"s\"}
{\"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\"}
{\"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\"}
{\"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\"}
";
#[test]
fn tailer_parses_fixture_and_skips_filtered_records() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/work");
let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
write_transcript(&slug_dir, "session.jsonl", FIXTURE);
std::thread::sleep(Duration::from_millis(300));
stop.store(true, Ordering::Release);
let events = drain(&handle.events);
let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
assert_eq!(
kinds,
vec![
EventKind::UserMessage,
EventKind::ToolCall,
EventKind::ToolResult
]
);
}
const FIXTURE_NEW_FORMAT: &str = "\
{\"type\":\"system\",\"subtype\":\"init\",\"sessionId\":\"s\",\"timestamp\":\"2026-07-02T06:14:40.500Z\",\"mode\":\"default\",\"permissionMode\":\"default\"}
{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":true,\"message\":{\"role\":\"user\",\"content\":\"caveat\"},\"timestamp\":\"2026-07-02T06:14:40.600Z\",\"mode\":\"default\",\"permissionMode\":\"default\"}
{\"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\":{}}
{\"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\"}
{\"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\"}
";
#[test]
fn first_record_cwd_state_skips_cwdless_records() {
let tmp = tempfile::tempdir().unwrap();
let f = tmp.path().join("new.jsonl");
std::fs::write(&f, FIXTURE_NEW_FORMAT).unwrap();
assert_eq!(
first_record_cwd_state(&f, Path::new("/tmp/work-new")),
CwdState::Matches
);
assert_eq!(
first_record_cwd_state(&f, Path::new("/tmp/other")),
CwdState::Different
);
}
#[test]
fn tailer_parses_new_format_fixture_with_no_early_cwd() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/work-new");
let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
write_transcript(&slug_dir, "session.jsonl", FIXTURE_NEW_FORMAT);
std::thread::sleep(Duration::from_millis(300));
stop.store(true, Ordering::Release);
let events = drain(&handle.events);
let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
assert_eq!(
kinds,
vec![
EventKind::UserMessage,
EventKind::ToolCall,
EventKind::ToolResult
],
"new-format fixture must parse the same events as the original"
);
}
#[test]
fn tailer_finds_file_late() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/work-late");
std::fs::create_dir_all(
home.path()
.join(".claude")
.join("projects")
.join(slugify(cwd)),
)
.unwrap();
let stop = std::sync::Arc::new(AtomicBool::new(false));
let blobs = std::sync::Arc::new(BlobStore::new(home.path().join("blobs")));
let ctx = AdapterContext {
session_id: SID.to_string(),
started_at_unix_ms: STARTED,
cwd: cwd.to_path_buf(),
command: vec!["claude".into()],
blobs,
stop: std::sync::Arc::clone(&stop),
projects_dir: Some(home.path().join(".claude").join("projects")),
};
let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn");
std::thread::sleep(Duration::from_millis(150));
std::fs::write(
home
.path()
.join(".claude")
.join("projects")
.join(slugify(cwd))
.join("late.jsonl"),
"{\"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",
)
.unwrap();
std::thread::sleep(Duration::from_millis(150));
stop.store(true, Ordering::Release);
let events = drain(&handle.events);
assert_eq!(
events.len(),
1,
"adapter should pick up the late-appearing file"
);
assert_eq!(events[0].kind, EventKind::UserMessage);
}
#[test]
fn tailer_partial_last_line_retries() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/work-partial");
let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
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";
let partial = "{\"type\":\"user\",\"isSidechain\":false,\"isMeta\":false,\"message\":{\"role\":\"user\",\"content\":\"sec";
let path = write_transcript(&slug_dir, "partial.jsonl", &format!("{complete}{partial}"));
std::thread::sleep(Duration::from_millis(250));
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
writeln!(f, "ond\"}}}}\n").unwrap();
std::thread::sleep(Duration::from_millis(250));
stop.store(true, Ordering::Release);
let events = drain(&handle.events);
let contents: Vec<&str> = events
.iter()
.map(|e| e.body_json.as_ref().unwrap()["text"].as_str().unwrap())
.collect();
assert_eq!(
contents,
vec!["first", "second"],
"partial line must be retried until complete"
);
}
#[test]
fn tailer_fallback_scan_by_cwd() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/work-fallback");
let wrong_slug = "-totally-wrong-slug";
let wrong_dir = home
.path()
.join(".claude")
.join("projects")
.join(wrong_slug);
std::fs::create_dir_all(&wrong_dir).unwrap();
let stop = std::sync::Arc::new(AtomicBool::new(false));
let blobs = std::sync::Arc::new(BlobStore::new(home.path().join("blobs")));
let ctx = AdapterContext {
session_id: SID.to_string(),
started_at_unix_ms: STARTED,
cwd: cwd.to_path_buf(),
command: vec!["claude".into()],
blobs,
stop: std::sync::Arc::clone(&stop),
projects_dir: Some(home.path().join(".claude").join("projects")),
};
let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn");
std::thread::sleep(Duration::from_millis(50));
std::fs::write(
wrong_dir.join("fb.jsonl"),
"{\"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",
)
.unwrap();
std::thread::sleep(Duration::from_millis(300));
stop.store(true, Ordering::Release);
let events = drain(&handle.events);
assert_eq!(
events.len(),
1,
"fallback scan should locate the transcript by cwd"
);
assert_eq!(events[0].kind, EventKind::UserMessage);
let outcome = handle.outcome.join().expect("tailer thread");
assert_eq!(outcome.status, AdapterStatus::Active);
}
#[test]
fn tailer_degrades_when_projects_dir_missing() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/nowhere");
let stop = std::sync::Arc::new(AtomicBool::new(false));
let blobs = std::sync::Arc::new(BlobStore::new(home.path().join("blobs")));
let ctx = AdapterContext {
session_id: SID.to_string(),
started_at_unix_ms: STARTED,
cwd: cwd.to_path_buf(),
command: vec!["claude".into()],
blobs,
stop: std::sync::Arc::clone(&stop),
projects_dir: Some(home.path().join(".claude").join("projects")),
};
let handle = Box::new(ClaudeAdapter).spawn(ctx).expect("spawn");
let events = drain(&handle.events);
assert!(events.is_empty(), "no projects dir → no events");
let outcome = handle.outcome.join().expect("tailer thread");
assert_eq!(outcome.status, AdapterStatus::Degraded);
assert!(
outcome.degrade_reason.is_some(),
"degraded outcome must carry a degrade_reason"
);
}
#[test]
fn tailer_degrades_when_transcript_never_appears() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/no-transcript");
let (handle, stop, _slug_dir) = spawn_with(home.path(), cwd);
std::thread::sleep(Duration::from_millis(80));
stop.store(true, Ordering::Release);
let events = drain(&handle.events);
assert!(events.is_empty(), "no transcript ever → no events");
let outcome = handle.outcome.join().expect("tailer thread");
assert_eq!(outcome.status, AdapterStatus::Degraded);
assert!(
outcome.degrade_reason.is_some(),
"degraded outcome must carry a degrade_reason pointing at hh doctor"
);
assert!(
outcome
.degrade_reason
.as_deref()
.unwrap()
.contains("hh doctor"),
"degrade reason should suggest running `hh doctor`"
);
}
#[test]
fn tailer_ignores_preexisting_transcript() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/concurrent");
let slug_dir = home
.path()
.join(".claude")
.join("projects")
.join(slugify(cwd));
std::fs::create_dir_all(&slug_dir).unwrap();
std::fs::write(
slug_dir.join("old.jsonl"),
"{\"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",
)
.unwrap();
let (handle, stop, _slug) = spawn_with(home.path(), cwd);
std::thread::sleep(Duration::from_millis(80));
stop.store(true, Ordering::Release);
let events = drain(&handle.events);
assert!(
events.is_empty(),
"a pre-existing transcript from another session must not be tailed"
);
let outcome = handle.outcome.join().expect("tailer thread");
assert_eq!(outcome.status, AdapterStatus::Degraded);
}
#[test]
fn first_record_cwd_state_rejects_directory() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("2457c4b0-is-a-dir");
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(
first_record_cwd_state(&dir, Path::new("/tmp/anything")),
CwdState::Directory,
"a directory candidate must be rejected, not re-polled"
);
}
#[test]
fn tailer_ignores_same_stem_directory_beside_jsonl() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/same-stem");
let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
write_transcript(
&slug_dir,
"abc123.jsonl",
"{\"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",
);
std::fs::create_dir_all(slug_dir.join("abc123")).unwrap();
std::thread::sleep(Duration::from_millis(300));
stop.store(true, Ordering::Release);
let events = drain(&handle.events);
assert_eq!(
events.len(),
1,
"the same-stem directory must not prevent discovering the .jsonl"
);
assert_eq!(events[0].kind, EventKind::UserMessage);
let outcome = handle.outcome.join().expect("tailer thread");
assert_eq!(outcome.status, AdapterStatus::Active);
}
#[test]
fn tailer_degrades_when_zero_records_parsed() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/zero-records");
let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
write_transcript(
&slug_dir,
"empty.jsonl",
"{\"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",
);
std::thread::sleep(Duration::from_millis(300));
stop.store(true, Ordering::Release);
let events = drain(&handle.events);
assert!(events.is_empty(), "isMeta record yields no events");
let outcome = handle.outcome.join().expect("tailer thread");
assert_eq!(
outcome.status,
AdapterStatus::Degraded,
"0 records parsed must degrade, not report active with 0 steps"
);
let reason = outcome.degrade_reason.as_deref().unwrap();
assert!(
reason.contains("0 records parsed"),
"reason must be the specific '0 records parsed' string: {reason}"
);
assert!(
reason.contains("read 1 line"),
"reason must carry the line count: {reason}"
);
}
#[test]
fn tailer_degrade_reason_records_first_parse_error() {
let home = tempfile::TempDir::new().unwrap();
let cwd = Path::new("/tmp/parse-err");
let (handle, stop, slug_dir) = spawn_with(home.path(), cwd);
write_transcript(
&slug_dir,
"broken.jsonl",
"{\"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",
);
std::thread::sleep(Duration::from_millis(300));
stop.store(true, Ordering::Release);
let events = drain(&handle.events);
assert_eq!(
events.len(),
1,
"the valid record parses; the broken line is skipped"
);
let outcome = handle.outcome.join().expect("tailer thread");
assert_eq!(outcome.status, AdapterStatus::Active);
}
#[test]
fn locate_failure_reason_is_specific() {
let empty = DiscoveryDiag {
projects: Some(PathBuf::from("/home/me/.claude/projects")),
slug: Some("-home-me-work".into()),
slug_dir: Some(PathBuf::from("/home/me/.claude/projects/-home-me-work")),
cwd: Some(PathBuf::from("/home/me/work")),
..Default::default()
};
let r = locate_failure_reason(&empty);
assert!(r.contains("no jsonl matched cwd slug -home-me-work"), "{r}");
assert!(r.contains("no new transcript appeared"), "{r}");
assert!(
r.contains("looked in /home/me/.claude/projects/-home-me-work"),
"{r}"
);
let mut seen = std::collections::HashSet::new();
seen.insert(PathBuf::from(
"/home/me/.claude/projects/-home-me-work/a.jsonl",
));
let with_cands = DiscoveryDiag {
projects: Some(PathBuf::from("/home/me/.claude/projects")),
slug: Some("-home-me-work".into()),
slug_dir: Some(PathBuf::from("/home/me/.claude/projects/-home-me-work")),
cwd: Some(PathBuf::from("/home/me/work")),
new_candidates: seen,
cwd_mismatches: vec![PathBuf::from(
"/home/me/.claude/projects/-home-me-work/a.jsonl",
)],
directories: vec![PathBuf::from(
"/home/me/.claude/projects/-home-me-work/b.jsonl",
)],
};
let r = locate_failure_reason(&with_cands);
assert!(r.contains("1 new candidate(s)"), "{r}");
assert!(r.contains("1 rejected for a different cwd"), "{r}");
assert!(r.contains("1 candidate(s) were directories"), "{r}");
}
fn parse_codex(value: &Value, ts_ms: i64) -> ParsedRecord {
let tmp = tempfile::TempDir::new().unwrap();
let blobs = BlobStore::new(tmp.path().join("blobs"));
parse_codex_record(value, SID, ts_ms, &blobs)
}
fn snap_codex(pr: &ParsedRecord) -> String {
serde_json::to_string_pretty(&serde_json::json!({
"events": pr.events,
"is_assistant_message": pr.is_assistant_message,
}))
.unwrap()
}
fn codex_rec(json: &str) -> Value {
serde_json::from_str(json).unwrap()
}
#[test]
fn codex_parse_user_message() {
let v = codex_rec(
r#"{"type":"event_msg","timestamp":"2026-07-02T06:14:40.699Z","payload":{"type":"user_message","content":[{"text":"hello"}]}}"#,
);
let pr = parse_codex(&v, iso("2026-07-02T06:14:40.699Z"));
insta::assert_snapshot!(snap_codex(&pr));
assert_eq!(pr.events.len(), 1);
assert_eq!(pr.events[0].kind, EventKind::UserMessage);
}
#[test]
fn codex_parse_agent_message() {
let v = codex_rec(
r#"{"type":"event_msg","timestamp":"2026-07-02T06:14:41.500Z","payload":{"type":"agent_message","content":[{"text":"I can help with that"}]}}"#,
);
let pr = parse_codex(&v, iso("2026-07-02T06:14:41.500Z"));
insta::assert_snapshot!(snap_codex(&pr));
assert_eq!(pr.events.len(), 1);
assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
assert!(pr.is_assistant_message);
}
#[test]
fn codex_parse_response_item_message() {
let v = codex_rec(
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"}]}}"#,
);
let pr = parse_codex(&v, iso("2026-07-02T06:14:41.500Z"));
insta::assert_snapshot!(snap_codex(&pr));
assert_eq!(pr.events.len(), 1);
assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
}
#[test]
fn codex_parse_function_call_and_output() {
let v = codex_rec(
r#"{"type":"response_item","timestamp":"2026-07-02T06:14:42.000Z","payload":{"type":"function_call","call_id":"call_1","name":"Bash","arguments":"{\"command\":\"ls\"}"}}"#,
);
let pr = parse_codex(&v, iso("2026-07-02T06:14:42.000Z"));
insta::assert_snapshot!(snap_codex(&pr));
assert_eq!(pr.events.len(), 1);
assert_eq!(pr.events[0].kind, EventKind::ToolCall);
assert_eq!(
pr.events[0].body_json.as_ref().unwrap()["correlate_key"],
"call_1"
);
let v2 = codex_rec(
r#"{"type":"response_item","timestamp":"2026-07-02T06:14:43.000Z","payload":{"type":"function_call_output","call_id":"call_1","output":"total 42"}}"#,
);
let pr2 = parse_codex(&v2, iso("2026-07-02T06:14:43.000Z"));
assert_eq!(pr2.events.len(), 1);
assert_eq!(pr2.events[0].kind, EventKind::ToolResult);
assert_eq!(
pr2.events[0].body_json.as_ref().unwrap()["correlate_key"],
"call_1"
);
}
#[test]
fn codex_parse_reasoning() {
let v = codex_rec(
r#"{"type":"response_item","timestamp":"2026-07-02T06:14:41.000Z","payload":{"type":"reasoning","summary":[],"content":null,"encrypted_content":"gAAAAABp5lf5..."}}"#,
);
let pr = parse_codex(&v, iso("2026-07-02T06:14:41.000Z"));
insta::assert_snapshot!(snap_codex(&pr));
assert_eq!(pr.events.len(), 1);
assert_eq!(pr.events[0].kind, EventKind::Thinking);
}
#[test]
fn codex_parse_skips_session_meta() {
let v = codex_rec(
r#"{"type":"session_meta","timestamp":"2026-07-02T06:14:40.000Z","payload":{"id":"abc","cwd":"/tmp","originator":"codex-tui"}}"#,
);
let pr = parse_codex(&v, 0);
assert!(pr.events.is_empty());
}
#[test]
fn codex_parse_skips_unknown_type() {
let v = codex_rec(
r#"{"type":"unknown_type","timestamp":"2026-07-02T06:14:40.000Z","payload":{}}"#,
);
let pr = parse_codex(&v, 0);
assert!(pr.events.is_empty());
}
#[test]
fn codex_parse_non_object_value() {
for v in [codex_rec("[]"), codex_rec("null"), codex_rec("\"oops\"")] {
assert!(parse_codex(&v, 0).events.is_empty());
}
}
fn parse_gemini(value: &Value, ts_ms: i64) -> ParsedRecord {
let tmp = tempfile::TempDir::new().unwrap();
let blobs = BlobStore::new(tmp.path().join("blobs"));
parse_gemini_record(value, SID, ts_ms, &blobs)
}
fn snap_gemini(pr: &ParsedRecord) -> String {
serde_json::to_string_pretty(&serde_json::json!({
"events": pr.events,
"is_assistant_message": pr.is_assistant_message,
}))
.unwrap()
}
fn gemini_rec(json: &str) -> Value {
serde_json::from_str(json).unwrap()
}
#[test]
fn gemini_parse_user_message() {
let v = gemini_rec(
r#"{"type":"user","id":"msg1","timestamp":"2026-07-02T06:14:40.699Z","content":[{"text":"hello"}]}"#,
);
let pr = parse_gemini(&v, iso("2026-07-02T06:14:40.699Z"));
insta::assert_snapshot!(snap_gemini(&pr));
assert_eq!(pr.events.len(), 1);
assert_eq!(pr.events[0].kind, EventKind::UserMessage);
}
#[test]
fn gemini_parse_agent_message() {
let v = gemini_rec(
r#"{"type":"gemini","id":"msg2","timestamp":"2026-07-02T06:14:41.500Z","content":[{"text":"Hi! How can I help?"}],"model":"gemini-2.5-pro"}"#,
);
let pr = parse_gemini(&v, iso("2026-07-02T06:14:41.500Z"));
insta::assert_snapshot!(snap_gemini(&pr));
assert_eq!(pr.events.len(), 1);
assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
}
#[test]
fn gemini_parse_with_tool_calls() {
let v = gemini_rec(
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"}}]}"#,
);
let pr = parse_gemini(&v, iso("2026-07-02T06:14:42.000Z"));
insta::assert_snapshot!(snap_gemini(&pr));
assert_eq!(pr.events.len(), 2); assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
assert_eq!(pr.events[1].kind, EventKind::ToolCall);
assert_eq!(
pr.events[1].body_json.as_ref().unwrap()["correlate_key"],
"tc_1"
);
}
#[test]
fn gemini_parse_with_thoughts() {
let v = gemini_rec(
r#"{"type":"gemini","id":"msg4","timestamp":"2026-07-02T06:14:41.000Z","content":[{"text":"answer"}],"thoughts":[{"text":"I need to think about this"}]}"#,
);
let pr = parse_gemini(&v, iso("2026-07-02T06:14:41.000Z"));
insta::assert_snapshot!(snap_gemini(&pr));
assert_eq!(pr.events.len(), 2); assert_eq!(pr.events[0].kind, EventKind::AgentMessage);
assert_eq!(pr.events[1].kind, EventKind::Thinking);
}
#[test]
fn gemini_parse_skips_session_metadata() {
let v = gemini_rec(
r#"{"type":"session_metadata","sessionId":"abc","projectHash":"xyz","startTime":"2026-07-02T06:14:40.000Z"}"#,
);
let pr = parse_gemini(&v, 0);
assert!(pr.events.is_empty());
}
#[test]
fn gemini_parse_skips_message_update() {
let v = gemini_rec(
r#"{"type":"message_update","id":"msg2","timestamp":"2026-07-02T06:14:41.000Z","tokens":{"input":5,"output":4}}"#,
);
let pr = parse_gemini(&v, 0);
assert!(pr.events.is_empty());
}
#[test]
fn gemini_parse_skips_unknown_type() {
let v = gemini_rec(r#"{"type":"unknown","id":"x","timestamp":"2026-07-02T06:14:40.000Z"}"#);
let pr = parse_gemini(&v, 0);
assert!(pr.events.is_empty());
}
#[test]
fn gemini_parse_non_object_value() {
for v in [gemini_rec("[]"), gemini_rec("null")] {
assert!(parse_gemini(&v, 0).events.is_empty());
}
}
#[test]
fn select_detects_all_adapters() {
assert!(select(&["claude".into()], Path::new("/tmp")).is_some());
assert!(select(&["claude-desktop".into()], Path::new("/tmp")).is_some());
assert!(select(&["codex".into()], Path::new("/tmp")).is_some());
assert!(select(&["gemini".into()], Path::new("/tmp")).is_some());
assert!(select(&["python3".into()], Path::new("/tmp")).is_none());
}
#[test]
fn is_claude_desktop_detects_basename() {
assert!(is_claude_desktop(&["claude-desktop".into()]));
assert!(is_claude_desktop(&["/usr/local/bin/claude-desktop".into()]));
assert!(is_claude_desktop(&["claude-desktop.exe".into()]));
assert!(!is_claude_desktop(&["claude".into()]));
assert!(!is_claude_desktop(&["codex".into()]));
assert!(!is_claude_desktop(&[]));
}
#[test]
fn is_codex_cli_detects_basename() {
assert!(is_codex_cli(&["codex".into()]));
assert!(is_codex_cli(&["/usr/local/bin/codex".into()]));
assert!(is_codex_cli(&["codex.exe".into()]));
assert!(!is_codex_cli(&["claude".into()]));
assert!(!is_codex_cli(&[]));
}
#[test]
fn is_gemini_cli_detects_basename() {
assert!(is_gemini_cli(&["gemini".into()]));
assert!(is_gemini_cli(&["/usr/local/bin/gemini".into()]));
assert!(is_gemini_cli(&["gemini.exe".into()]));
assert!(!is_gemini_cli(&["claude".into()]));
assert!(!is_gemini_cli(&[]));
}
#[test]
fn resolve_override_accepts_all_adapters() {
assert!(resolve_override("claude-code").is_some());
assert!(resolve_override("claude-desktop").is_some());
assert!(resolve_override("codex-cli").is_some());
assert!(resolve_override("gemini-cli").is_some());
assert!(resolve_override("unknown").is_none());
}
}