use crate::git;
use std::io::{Read, Write};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
pub struct Answer {
pub text: String,
pub reviewer: String,
pub session: String,
pub stop_reason: String,
}
pub enum Session {
Fresh(String),
Resume(String),
}
impl Session {
pub fn opened() -> Session {
Session::Fresh(assigned())
}
pub fn resumed(id: &str) -> Session {
Session::Resume(id.to_string())
}
pub fn id(&self) -> &str {
let (Session::Fresh(id) | Session::Resume(id)) = self;
id
}
}
#[derive(Clone, Copy)]
pub enum Role {
Review,
JudgeIntent,
}
pub struct Agent;
impl Agent {
pub fn named(name: &str) -> Result<Agent, String> {
if name.trim() != "claude" {
return Err(format!(
"unknown agent '{}': this build knows claude.\n git config --global agent-verdict.runner claude",
name.trim()
));
}
Ok(Agent)
}
pub fn run(
&self,
role: Role,
system: &str,
prompt: &str,
session: &Session,
model: Option<&str>,
ceiling: Duration,
) -> Result<Answer, String> {
claude(role, system, prompt, session, model, ceiling)
}
}
fn system_file(text: &str) -> Result<std::path::PathBuf, String> {
let path = git::git_path("AGENT_VERDICT_SYSTEM")?;
std::fs::write(&path, text).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
Ok(path)
}
fn claude_model(role: Role, asked: Option<&str>) -> Option<&str> {
match role {
Role::Review => asked,
Role::JudgeIntent => Some("haiku"),
}
}
const JUDGE_CEILING: Duration = Duration::from_secs(5 * 60);
fn claude_ceiling(role: Role, asked: Duration) -> Duration {
match role {
Role::Review => asked,
Role::JudgeIntent => JUDGE_CEILING,
}
}
fn assigned() -> String {
let mut bytes = [0u8; 16];
let taken =
std::fs::File::open("/dev/urandom").and_then(|mut urandom| urandom.read_exact(&mut bytes));
match taken {
Ok(()) => {}
Err(_) => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
bytes[..8].copy_from_slice(&(now as u64).to_le_bytes());
bytes[8..12].copy_from_slice(&std::process::id().to_le_bytes());
}
}
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
format!(
"{}-{}-{}-{}-{}",
&hex[..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..]
)
}
fn claude(
role: Role,
system: &str,
prompt: &str,
session: &Session,
model: Option<&str>,
ceiling: Duration,
) -> Result<Answer, String> {
let file = system_file(system)?;
let mut command = Command::new("claude");
command.args(["-p", "--output-format", "json"]);
command.arg("--append-system-prompt-file").arg(&file);
match session {
Session::Fresh(id) => command.args(["--session-id", id]),
Session::Resume(id) => command.args(["--resume", id]),
};
if let Some(model) = claude_model(role, model) {
command.args(["--model", model]);
}
let told = |detail: String| with_transcript(&detail, session.id());
let narrate = matches!(role, Role::Review);
let said = piped(command, prompt, claude_ceiling(role, ceiling), narrate).map_err(told)?;
let _ = std::fs::remove_file(&file);
read_claude(&said).map_err(told)
}
struct Said {
out: String,
err: String,
}
const POLL: Duration = Duration::from_millis(200);
const HEARTBEAT: Duration = Duration::from_secs(60);
fn heartbeat(ceiling: Duration) -> Duration {
HEARTBEAT.min(ceiling / 4)
}
const KEPT: usize = 2000;
fn clipped(text: &str) -> String {
let text = text.trim();
match text.char_indices().nth(KEPT) {
Some((cut, _)) => format!("{}…", &text[..cut]),
None => text.to_string(),
}
}
type Seen = std::sync::Arc<std::sync::Mutex<Vec<u8>>>;
fn drain(pipe: Option<impl Read + Send + 'static>) -> (std::thread::JoinHandle<()>, Seen) {
let seen: Seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let filling = std::sync::Arc::clone(&seen);
let reader = std::thread::spawn(move || {
let Some(mut pipe) = pipe else { return };
let mut chunk = [0u8; 8192];
while let Ok(n) = pipe.read(&mut chunk) {
if n == 0 {
return;
}
hold(&filling).extend_from_slice(&chunk[..n]);
}
});
(reader, seen)
}
fn hold(seen: &Seen) -> std::sync::MutexGuard<'_, Vec<u8>> {
seen.lock().unwrap_or_else(|held| held.into_inner())
}
const SETTLING: Duration = Duration::from_secs(5);
fn settling(readers: &[std::thread::JoinHandle<()>]) {
let until = Instant::now() + SETTLING;
while Instant::now() < until && readers.iter().any(|r| !r.is_finished()) {
std::thread::sleep(POLL);
}
}
fn text_of(seen: &Seen) -> String {
String::from_utf8_lossy(&hold(seen)).into_owned()
}
fn wait_by(
child: &mut Child,
ceiling: Duration,
narrate: bool,
) -> Result<std::process::ExitStatus, String> {
let started = Instant::now();
let mut said_at = Duration::ZERO;
loop {
match child.try_wait() {
Ok(Some(status)) => return Ok(status),
Ok(None) => {}
Err(e) => return Err(format!("the reviewer did not finish: {e}")),
}
if narrate && started.elapsed() >= said_at + heartbeat(ceiling) {
said_at = started.elapsed();
crate::report::still_reviewing(said_at.as_secs(), ceiling.as_secs());
}
if started.elapsed() >= ceiling {
let _ = child.kill();
let _ = child.wait();
return Err(format!(
"the reviewer ran {}s without answering and was killed at the {}s ceiling.\nRaise it with --timeout <minutes> if a review here is genuinely this long; otherwise this is an agent that has stopped rather than one that is thinking.",
started.elapsed().as_secs(),
ceiling.as_secs()
));
}
std::thread::sleep(POLL);
}
}
fn piped(
mut command: Command,
prompt: &str,
ceiling: Duration,
narrate: bool,
) -> Result<Said, String> {
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("cannot run the reviewer: {e}"))?;
let mut stdin = child.stdin.take().ok_or("the reviewer took no stdin")?;
let text = prompt.to_string();
let writer = std::thread::spawn(move || stdin.write_all(text.as_bytes()));
let (reading_out, out) = drain(child.stdout.take());
let (reading_err, err) = drain(child.stderr.take());
let status = match wait_by(&mut child, ceiling, narrate) {
Ok(status) => status,
Err(timeout) => return Err(with_noise(&timeout, &text_of(&err))),
};
settling(&[reading_out, reading_err]);
let said = Said {
out: text_of(&out),
err: text_of(&err),
};
match writer.join() {
Err(_) => return Err("the prompt was never written to the reviewer".to_string()),
Ok(Err(e)) if e.kind() != std::io::ErrorKind::BrokenPipe => {
return Err(format!("cannot brief the reviewer: {e}"));
}
Ok(_) => {}
}
if !status.success() {
let noise = clipped(&said.err);
if noise.is_empty() {
return Err(format!("the reviewer exited {status}"));
}
return Err(noise);
}
Ok(said)
}
fn slug(dir: &std::path::Path) -> String {
dir.to_string_lossy()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect()
}
pub fn transcript_path(session: &str) -> Option<std::path::PathBuf> {
let home = std::env::var("HOME").ok()?;
Some(
std::path::Path::new(&home)
.join(".claude")
.join("projects")
.join(slug(&std::env::current_dir().ok()?))
.join(format!("{session}.jsonl")),
)
}
pub fn transcript(session: &str) -> Option<std::path::PathBuf> {
let home = std::env::var("HOME").ok()?;
let projects = std::path::Path::new(&home).join(".claude").join("projects");
let named = format!("{session}.jsonl");
let derived = transcript_path(session)?;
if derived.is_file() {
return Some(derived);
}
std::fs::read_dir(&projects)
.ok()?
.flatten()
.map(|project| project.path().join(&named))
.find(|path| path.is_file())
}
pub fn last_wrote(session: &str) -> Option<u64> {
let written = transcript(session)?.metadata().ok()?.modified().ok()?;
Some(written.elapsed().ok()?.as_secs())
}
fn with_transcript(detail: &str, session: &str) -> String {
match transcript(session) {
Some(path) => format!(
"{detail}\n\nwhat the reviewer actually did is in its transcript:\n {}",
path.display()
),
None => detail.to_string(),
}
}
fn with_noise(detail: &str, err: &str) -> String {
let noise = clipped(err);
if noise.is_empty() {
return detail.to_string();
}
format!("{detail}\n\nthe reviewer also said:\n{noise}")
}
fn read_claude(said: &Said) -> Result<Answer, String> {
let out = &said.out;
let json: serde_json::Value = serde_json::from_str(out).map_err(|e| {
with_noise(
&format!("the reviewer's answer is not JSON: {e}"),
&said.err,
)
})?;
if json["is_error"].as_bool().unwrap_or(false) {
let reported = json["result"].as_str().unwrap_or("no reason given");
return Err(with_noise(
&format!("the reviewer reported an error: {reported}"),
&said.err,
));
}
let text = json["result"]
.as_str()
.ok_or_else(|| with_noise("the reviewer's answer carries no result", &said.err))?;
let session = json["session_id"]
.as_str()
.ok_or_else(|| with_noise("the reviewer's answer carries no session_id", &said.err))?;
let reviewer = json["modelUsage"]
.as_object()
.and_then(|used| used.keys().next().cloned())
.unwrap_or_else(|| "claude".to_string());
Ok(Answer {
text: text.to_string(),
reviewer,
session: session.to_string(),
stop_reason: json["stop_reason"].as_str().unwrap_or_default().to_string(),
})
}
#[cfg(test)]
mod tests {
use super::{assigned, slug};
#[test]
fn a_directory_is_keyed_with_every_other_character_as_a_hyphen() {
assert_eq!(
slug(std::path::Path::new("/home/me/src/my_test.dir v2")),
"-home-me-src-my-test-dir-v2"
);
assert_eq!(
slug(std::path::Path::new("/home/me/.claude")),
"-home-me--claude"
);
}
#[test]
fn an_assigned_session_is_a_version_four_uuid_and_is_not_reused() {
let id = assigned();
let fields: Vec<&str> = id.split('-').collect();
assert_eq!(fields.len(), 5, "{id}");
assert_eq!(
fields.iter().map(|f| f.len()).collect::<Vec<_>>(),
vec![8, 4, 4, 4, 12],
"{id}"
);
assert!(
id.chars().all(|c| c.is_ascii_hexdigit() || c == '-'),
"{id}"
);
assert!(fields[2].starts_with('4'), "{id} is not version 4");
assert!(matches!(&fields[3][..1], "8" | "9" | "a" | "b"), "{id}");
assert_ne!(id, assigned());
}
}