use agent_top_core::Harness;
use agent_top_core::harness::{self, SessionSummary, SpanRetention};
use agent_top_core::model::{SpanKind, ToolSpan};
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum Format {
Chrome,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Source {
pub path: PathBuf,
pub harness: Harness,
}
pub fn resolve(what: &str) -> Result<Source> {
let as_path = Path::new(what);
if as_path.is_file() {
let harness = harness::detect(as_path)
.with_context(|| format!("{what}: not a transcript agent-top knows how to read (Claude Code or Codex JSONL)"))?;
return Ok(Source { path: as_path.to_path_buf(), harness });
}
if what.is_empty() {
bail!("a session id or transcript path is required");
}
let candidates = candidates(what, &harness::claude::recent_transcripts(UNIX_EPOCH), &harness::codex::recent_rollouts(UNIX_EPOCH));
match candidates.len() {
1 => Ok(candidates.into_iter().next().unwrap()),
0 => bail!("no session id starts with {what:?}, and it is not a file"),
_ => {
let mut msg = format!("{what:?} matches {} sessions; give more of the id:", candidates.len());
for c in &candidates {
msg.push_str(&format!("\n {:<7} {}", c.harness.label(), c.path.display()));
}
bail!(msg)
}
}
}
fn candidates(prefix: &str, claude: &[PathBuf], codex: &[PathBuf]) -> Vec<Source> {
let mut out = Vec::new();
for p in claude {
if stem(p).starts_with(prefix) {
out.push(Source { path: p.clone(), harness: Harness::Claude });
}
}
for p in codex {
if codex_id(&stem(p)).starts_with(prefix) {
out.push(Source { path: p.clone(), harness: Harness::Codex });
}
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
fn stem(p: &Path) -> String {
p.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()
}
fn codex_id(stem: &str) -> &str {
const TS_LEN: usize = "2026-05-14T21-37-50-".len();
stem.strip_prefix("rollout-").and_then(|s| s.get(TS_LEN..)).unwrap_or(stem)
}
pub fn read(src: &Source) -> Result<SessionSummary> {
let mut tracker = harness::open_transcript(&src.path, src.harness, SpanRetention::All);
tracker.refresh_all().with_context(|| format!("reading {}", src.path.display()))?;
Ok(tracker.summary().clone())
}
pub fn render(src: &Source, summary: &SessionSummary, format: Format) -> Value {
match format {
Format::Chrome => chrome(src, summary),
}
}
fn chrome(src: &Source, s: &SessionSummary) -> Value {
let pid = pid_for(s.session_id.as_deref().unwrap_or(&stem(&src.path)));
let label = match &s.cwd {
Some(cwd) => format!("{} {}", src.harness.label(), cwd.file_name().map(|n| n.to_string_lossy()).unwrap_or_default()),
None => src.harness.label().to_string(),
};
let mut events = vec![meta("process_name", pid, 0, &label)];
let mut used: Vec<u64> = s.spans.iter().map(tid_for).collect();
used.sort_unstable();
used.dedup();
for tid in used {
events.push(meta("thread_name", pid, tid, track_name(tid)));
}
events.extend(s.spans.iter().map(|sp| span_event(sp, pid)));
let open = s.spans.iter().filter(|sp| sp.is_open()).count();
let count = |k: SpanKind| s.spans.iter().filter(|sp| sp.kind == k).count();
json!({
"traceEvents": events,
"displayTimeUnit": "ms",
"otherData": {
"generator": "agent-top",
"harness": src.harness.label(),
"harness_version": s.harness_version,
"session_id": s.session_id,
"model": s.model,
"cwd": s.cwd.as_ref().map(|p| p.to_string_lossy().into_owned()),
"transcript": src.path.to_string_lossy(),
"tool_calls": s.tool_calls,
"web_searches": s.web_searches,
"spans": s.spans.len(),
"tool_spans": count(SpanKind::Tool),
"inference_spans": count(SpanKind::Inference),
"turn_spans": count(SpanKind::Turn),
"open_spans": open,
},
})
}
fn tid_for(sp: &ToolSpan) -> u64 {
let kind = match sp.kind {
SpanKind::Turn => 1,
SpanKind::Tool => 2,
SpanKind::Inference => 3,
};
if sp.sidechain { kind + 3 } else { kind }
}
fn track_name(tid: u64) -> &'static str {
match tid {
1 => "turns",
2 => "tools",
3 => "model",
4 => "subagent turns",
5 => "subagent tools",
_ => "subagent model",
}
}
fn meta(name: &str, pid: u64, tid: u64, value: &str) -> Value {
json!({"name": name, "ph": "M", "pid": pid, "tid": tid, "args": {"name": value}})
}
fn span_event(sp: &ToolSpan, pid: u64) -> Value {
let tid = tid_for(sp);
let cat = sp.kind.label();
let args = json!({"call_id": sp.id, "error": sp.error, "sidechain": sp.sidechain});
match sp.duration_ms {
Some(ms) => json!({
"name": sp.name, "cat": cat, "ph": "X",
"ts": micros(sp.started_at), "dur": ms * 1000,
"pid": pid, "tid": tid, "args": args,
}),
None => json!({
"name": sp.name, "cat": cat, "ph": "B",
"ts": micros(sp.started_at),
"pid": pid, "tid": tid, "args": args,
}),
}
}
fn micros(t: SystemTime) -> u64 {
t.duration_since(UNIX_EPOCH).map(|d| d.as_micros() as u64).unwrap_or(0)
}
fn pid_for(session_id: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in session_id.bytes() {
h ^= u64::from(b);
h = h.wrapping_mul(0x0100_0000_01b3);
}
((h ^ (h >> 32)) & 0x7fff_ffff).max(1)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn pids_are_deterministic_positive_and_distinct() {
assert_eq!(pid_for("abc"), pid_for("abc"));
assert_ne!(pid_for("abc"), pid_for("abd"));
assert!(pid_for("") >= 1);
assert!(pid_for("00000000-1111-2222-3333-444444444444") <= 0x7fff_ffff);
}
#[test]
fn matches_ids_by_prefix_in_both_layouts() {
let claude = vec![PathBuf::from("/c/p/00000000-1111-2222-3333-444444444444.jsonl"), PathBuf::from("/c/p/agent-0000aaaa.jsonl")];
let codex = vec![
PathBuf::from("/x/2026/05/14/rollout-2026-05-14T21-37-50-01000000-0000-7000-0000-000000000000.jsonl"),
PathBuf::from("/x/2026/05/15/rollout-2026-05-15T09-00-00-0f000000-0000-7000-0000-000000000000.jsonl"),
];
let one = candidates("0100", &claude, &codex);
assert_eq!(one.len(), 1);
assert_eq!(one[0].harness, Harness::Codex);
let one = candidates("00000000-1111", &claude, &codex);
assert_eq!(one.len(), 1);
assert_eq!(one[0].harness, Harness::Claude);
assert_eq!(candidates("0", &claude, &codex).len(), 3);
assert!(candidates("2026-05", &claude, &codex).is_empty());
assert!(candidates("zzz", &claude, &codex).is_empty());
}
#[test]
fn open_spans_become_begin_events_on_their_own_track() {
let at = UNIX_EPOCH + Duration::from_millis(1_700_000_000_123);
let span = |id: &str, name: &str, dur: Option<u64>, sidechain: bool, error: bool, kind: SpanKind| ToolSpan {
id: id.into(),
name: name.into(),
started_at: at,
duration_ms: dur,
sidechain,
error,
kind,
};
let closed = span("a", "Bash", Some(2_500), false, true, SpanKind::Tool);
let open = span("b", "Grep", None, true, false, SpanKind::Tool);
let thinking = span("inference:1", "inference", Some(900), false, false, SpanKind::Inference);
let turn = span("turn:1", "turn", None, true, false, SpanKind::Turn);
let x = span_event(&closed, 7);
assert_eq!(x["ph"], "X");
assert_eq!(x["ts"], 1_700_000_000_123_000u64);
assert_eq!(x["dur"], 2_500_000u64);
assert_eq!(x["tid"], 2);
assert_eq!(x["cat"], "tool");
assert_eq!(x["args"]["error"], true);
let b = span_event(&open, 7);
assert_eq!(b["ph"], "B");
assert!(b.get("dur").is_none());
assert_eq!(b["tid"], 5);
assert_eq!(span_event(&thinking, 7)["tid"], 3);
assert_eq!(span_event(&thinking, 7)["cat"], "inference");
assert_eq!(span_event(&turn, 7)["tid"], 4);
assert_eq!(track_name(4), "subagent turns");
}
}