use crate::loader::Loader;
use crate::pricing::{Plan, Provider};
use crate::session::Session;
use crate::util;
use clap::Parser;
use serde::Serialize;
const COMMANDS: &str = "\
Commands:
cctop <agent> [args…] Start claude, codex, opencode or pi on a pty cctop
owns, so the UI can watch it and type into it. Same
as `cctop run <agent>`; the args go to the agent.
cctop attach [pid] Put a running agent on this terminal — no pid lists
them. F12 detaches and leaves it running.
cctop as <acct> <agent> Start an agent under a named account (see
--add-account).
cctop serve Serve the table, and a per-session report, to a
browser. Loopback and read-only by default; `serve
--help` for the flags. Handy on a phone.
cctop wait <session> Block until a session stops working — by id prefix,
tab name or pid. For one agent to wait on another.
cctop doctor Check this installation and say what is wrong with
it: sessions, pricing, hooks, and what `s` reaches.
--host also tests an ssh target.
cctop why [session] Why a row says a session is running, or is not:
every agent process, its session, the rule that
matched it.
cctop optimize What your sessions spent and did not get back.
Reads only.
cctop compare How each model did on the work you actually gave
it — one-shot rate, cost per file, cache hit.
cctop yield Whether what each session spent ended up in a
commit on the default branch. Reads only.
cctop recall <query> What past sessions, from any agent, said about
something — the passages, ranked. Agents get it
through --mcp; --install-mcp sets that up.
cctop burn What your subscription windows were paid for and
did not use.
cctop log Print the event stream CCTOP_LOG writes; -f follows
it.
cctop --trace Time each stage of a run, for a bug report about
slowness.
Each command takes --help for the details.";
#[derive(Parser, Debug)]
#[command(
name = "cctop",
about = "An htop-like monitor for AI coding agent sessions",
// cctop takes no positionals — `run` and `attach` are intercepted in main
// before clap sees them, so clap cannot know they exist and would otherwise
// print a usage line claiming options are all there is.
override_usage = "cctop [OPTIONS]\n \
cctop <agent> [args…]\n \
cctop attach [pid]\n \
cctop as <account> <agent> [args…]\n \
cctop serve [--bind ADDR] [--port PORT]\n \
cctop optimize | compare | yield | burn | log\n \
cctop recall <query> [--read SESSION PASSAGE]\n \
cctop wait <session> [--until …] [--timeout …]\n \
cctop why [session]\n \
cctop doctor",
// The one-line summary is the whole of `-h`; the command list is the part
// that says what cctop is *for*, so both -h and --help carry it — `help`'s
// line count is short enough that it fits underneath.
after_help = COMMANDS,
// --help's own copy of the list: `after_help` is not shown there, and the
// long description's prose names most of the commands without ever saying
// they are commands.
after_long_help = COMMANDS,
long_about = "cctop — an htop-like monitor for AI coding agent sessions\n\n\
Tracks Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Pi, and Windsurf\n\
sessions on your machine, showing real-time cost estimation, token usage, tool\n\
invocations, and OS-level metrics.\n\n\
COST ESTIMATION\n \
Cost figures are estimates based on per-token API pricing from the LiteLLM\n \
database (cached locally for 24 hours). Many subscription plans — such as\n \
Claude Max, Pro, or Team — charge a flat rate or bundle tokens differently,\n \
so reported costs may not reflect your actual bill. Treat the $ column as a\n \
rough indicator of resource consumption, not as an authoritative invoice.\n\n\
LAUNCHING AGENTS\n \
`cctop <command> [args…]`, or `cctop run <command>`, starts the agent on a pty\n \
cctop owns so the UI can type into it with `s`. Everything after the command,\n \
flags included, goes to the agent. The first interactive run aliases the known\n \
agents to this form in your shell startup files; --remove-alias undoes that.\n\n\
ATTACHING\n \
Agents started that way can be watched and driven from anywhere. Press `a` in\n \
the UI, or run `cctop attach [pid]` to put one on the terminal directly —\n \
with no pid it lists what is running. F12 detaches and leaves it running. The\n \
agent is resized to the smallest window watching it, and gets its size back\n \
when that one detaches.\n\n\
RESUMING\n \
`R` in the UI reopens any session in a tab of its own, by running its own\n \
harness's resume command in the directory it was working in. Unlike `a` this\n \
needs nothing of cctop at the time the session ran, so it reaches the sessions\n \
started from anywhere — including ones that ended long ago.\n\n\
IN A BROWSER\n \
`cctop serve` puts the same table on an HTTP port, streaming it over SSE, plus\n \
a page per session: the conversation, what it edited, what it can reach, and\n \
where its money went. From there it can send a prompt to a live session,\n \
resume a dead one, or hand one to a different agent. It listens on 127.0.0.1\n \
with a per-run access token in the URL, which is the whole credential — so\n \
whoever holds the link can drive these agents. `--bind` is what puts it on the\n \
network and `--tunnel` puts it on the internet, both saying so when they do;\n \
`--no-actions` serves the pages without the buttons.\n\n\
SEARCHING\n \
`/` filters on what the table shows plus the full working directory and the\n \
branch; `Tab` in that prompt extends the search into the transcripts, which\n \
reads them off disk and so is opt-in.\n\n\
DIAGNOSING\n \
`cctop doctor` reports where sessions are read from and how many it found,\n \
whether pricing loaded, which agent hooks are installed, and what `s` can\n \
reach. It exits non-zero only for a real fault, so it is usable in a script.\n \
`cctop doctor --host <host>` additionally makes the ssh round trip.\n \
--trace answers the other question, which is why a run is slow. It times each\n \
stage — discovery, transcript parsing, the cache, the pricing fetch — and\n \
writes the totals to a file when cctop exits, for attaching to a bug report.\n \
It carries counts and durations only: no session titles, project paths or\n \
file names, and cctop's own paths are spelled with `~`.\n\n\
EVERY USER\n \
Run as root and cctop reads every user's sessions rather than root's own,\n \
naming whose each row is in the USER column; `/user:<name>` filters to one.\n \
Other homes are only read: cctop writes nothing into them, and refuses to\n \
delete another user's session. CCTOP_ALL_USERS=0 turns that\n \
off, =1 turns it on without root, and CCTOP_HOMES names homes that are\n \
neither in /etc/passwd nor under /home.\n\n\
NOTES\n \
Session data is read from each agent's standard local session store.\n \
UI preferences (active tab, sort order, filters) persist across runs.",
version
)]
pub struct Args {
#[arg(short, long)]
pub list: bool,
#[arg(short, long)]
pub json: bool,
#[arg(short, long, default_value = "retail", value_parser = parse_plan)]
pub plan: Plan,
#[arg(short, long, default_value_t = 2.0, value_parser = parse_delay)]
pub delay: f64,
#[arg(long)]
pub clear_cache: bool,
#[arg(long)]
pub update: bool,
#[arg(long, num_args = 2, value_names = ["PATH", "SHA256"], hide = true)]
pub install_update: Option<Vec<String>>,
#[arg(long)]
pub no_auto_update: bool,
#[arg(long)]
pub install_alias: bool,
#[arg(long)]
pub remove_alias: bool,
#[arg(long, num_args = 0..=1, default_missing_value = "user", value_name = "SCOPE")]
pub install_hooks: Option<String>,
#[arg(long, num_args = 0..=1, default_missing_value = "user", value_name = "SCOPE")]
pub remove_hooks: Option<String>,
#[arg(long)]
pub hooks_status: bool,
#[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "SESSION")]
pub handoff: Option<String>,
#[arg(
long,
num_args = 1..=2,
value_names = ["SESSION", "AGENT"],
default_missing_value = ""
)]
pub convert: Vec<String>,
#[arg(long)]
pub converted: bool,
#[arg(long, requires = "converted")]
pub remove: bool,
#[arg(long)]
pub statusline: bool,
#[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "SESSION")]
pub report: Option<String>,
#[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "SESSION")]
pub chat: Option<String>,
#[arg(long, requires = "chat", value_name = "SEQ")]
pub before: Option<usize>,
#[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "SESSION")]
pub access: Option<String>,
#[arg(long)]
pub fetch_search_model: bool,
#[arg(long, num_args = 0..=1, default_missing_value = "user", value_name = "SCOPE")]
pub install_mcp: Option<String>,
#[arg(long)]
pub mcp: bool,
#[arg(long, num_args = 0..=1, default_missing_value = "default", value_name = "PROFILE")]
pub add_account: Option<String>,
#[arg(long = "host", value_name = "HOST")]
pub hosts: Vec<String>,
#[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "FILE")]
pub trace: Option<String>,
}
#[derive(Parser, Debug)]
#[command(
name = "cctop wait",
about = "Block until a session stops working",
long_about = "Block until a session stops working, then exit.\n\n\
Reads what the dashboard reads: the transcripts, the agents' own hooks \
(heard live while waiting), and what a cctop recorded on the agent's rmux \
session. Useful for one agent to wait on another, or for a script that has \
just typed a prompt at one.",
after_help = "Exit status: 0 when the condition is met, 1 when the session ended \
first without meeting it, 2 when the target names no session (or more than \
one), 124 on timeout."
)]
pub struct WaitArgs {
pub target: String,
#[arg(long, value_enum, default_value = "any-stop")]
pub until: crate::wait::Until,
#[arg(long, default_value = "10m", value_parser = crate::wait::parse_timeout)]
pub timeout: std::time::Duration,
#[arg(short, long)]
pub json: bool,
}
fn parse_plan(s: &str) -> Result<Plan, String> {
Plan::parse(s)
.ok_or_else(|| format!("unsupported plan '{s}'; use one of: included, max, retail"))
}
fn parse_delay(s: &str) -> Result<f64, String> {
let v: f64 = s.parse().map_err(|_| "must be a number".to_string())?;
if !v.is_finite() || !(1.0..(u64::MAX as f64)).contains(&v) {
return Err("must be a finite number from 1 up to the maximum duration (seconds)".into());
}
Ok(v)
}
const W_IDX: usize = 3;
const W_AGE: usize = 5;
const W_TOK: usize = 7;
const W_COST: usize = 9;
const fn fixed_width() -> usize {
(W_IDX + 2) + W_AGE + 2 + W_AGE + 2 + W_TOK + 2 + W_TOK + 2 + W_COST + 2
}
fn flex_widths(width: usize) -> (usize, usize) {
let remaining = width.saturating_sub(fixed_width()).max(16);
let model_w = (remaining / 3).clamp(8, 22);
let label_w = remaining.saturating_sub(model_w + 2).max(8);
(label_w, model_w)
}
fn format_row(index: usize, s: &Session, label: &str, width: usize) -> String {
let now = chrono::Utc::now();
let (label_w, model_w) = flex_widths(width);
let cost = match s.total_cost {
_ if !s.cost_available => "—".into(),
_ if s.cost_is_free => "FREE".into(),
Some(c) => util::compact_usd(c),
None => "incl".into(),
};
format!(
"{index:>W_IDX$}. {:>W_AGE$} {:>W_AGE$} {:>W_TOK$} {:>W_TOK$} {:>W_COST$} {:<label_w$} {}",
util::relative_age(&s.started_at, &now),
util::relative_age(&s.last_active, &now),
util::compact_tokens(s.input_tokens),
util::compact_tokens(s.output_tokens),
cost,
util::truncate(label, label_w),
util::truncate(&s.model, model_w),
)
.trim_end()
.to_string()
}
fn print_group(
name: &str,
sessions: &[&Session],
start_index: usize,
cost_label: &str,
width: usize,
) {
println!("{name}:");
if sessions.is_empty() {
println!(" (none)");
return;
}
let (label_w, _) = flex_widths(width);
println!(
"{:W_IDX$} {:>W_AGE$} {:>W_AGE$} {:>W_TOK$} {:>W_TOK$} {:>W_COST$} {:<label_w$} model",
" ", "start", "last", "in", "out", cost_label, "session"
);
let labels = util::abbreviate_paths(
&sessions
.iter()
.map(|s| s.label_source.clone())
.collect::<Vec<_>>(),
);
for (i, (s, label)) in sessions.iter().zip(labels).enumerate() {
let display = s.title.clone().unwrap_or(label);
let display = match &s.owner {
Some(user) => format!("{user}: {display}"),
None => display,
};
println!("{}", format_row(start_index + i + 1, s, &display, width));
}
}
pub fn run_list(sessions: &[Session], plan: Plan) {
let width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(100)
.max(60);
let cost_label = if plan == Plan::Retail { "est" } else { "cost" };
let mut offset = 0;
for (name, provider) in [
("Codex", Provider::Codex),
("Claude", Provider::Claude),
("Cursor", Provider::Cursor),
("Devin", Provider::Devin),
("OpenCode", Provider::OpenCode),
("Pi", Provider::Pi),
("Gemini", Provider::Gemini),
("Windsurf", Provider::Windsurf),
] {
let group: Vec<&Session> = sessions.iter().filter(|s| s.provider == provider).collect();
if group.is_empty() {
continue;
}
if offset > 0 {
println!();
}
print_group(name, &group, offset, cost_label, width);
offset += group.len();
}
}
#[derive(Serialize)]
pub struct JsonAccount {
#[serde(skip_serializing_if = "Option::is_none")]
email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
organization: Option<String>,
}
#[derive(Serialize)]
pub struct JsonCost {
available: bool,
total: Option<String>,
included: bool,
free: bool,
this_hour: f64,
last_hour: f64,
today: f64,
per_min: f64,
by_day: std::collections::BTreeMap<String, f64>,
by_hour: std::collections::BTreeMap<String, f64>,
#[serde(skip_serializing_if = "Option::is_none")]
breakdown: Option<crate::session::Costs>,
}
const JSON_DAYS: usize = 31;
const JSON_HOURS: usize = 48;
fn trimmed_buckets(
buckets: &std::collections::HashMap<String, std::collections::HashMap<String, f64>>,
keep: usize,
) -> std::collections::BTreeMap<String, f64> {
let mut keys: Vec<&String> = buckets.keys().collect();
keys.sort();
keys.iter()
.rev()
.take(keep)
.map(|k| ((*k).clone(), buckets[*k].values().sum()))
.collect()
}
#[derive(Serialize)]
pub struct JsonTokens {
input: u64,
output: u64,
total: u64,
detail: crate::session::Tokens,
}
#[derive(Serialize)]
pub struct JsonActivity {
tool_count: u64,
#[serde(skip_serializing_if = "Option::is_none")]
tool_errors: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
compactions: Option<u32>,
tools: std::collections::HashMap<String, u64>,
skill_count: u64,
skills: std::collections::HashMap<String, u64>,
web_fetch_count: u64,
web_fetches: Vec<String>,
web_search_count: u64,
web_searches: Vec<String>,
mcp_tool_count: u64,
mcp_tools: Vec<String>,
lines_added: u64,
lines_removed: u64,
}
#[derive(Serialize)]
pub struct JsonSession {
provider: &'static str,
surface: &'static str,
state: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
asking_for: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
asking_question: bool,
session_id: String,
started_at: String,
last_active: String,
project: Option<String>,
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
account: Option<JsonAccount>,
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
harness: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
permission: Option<&'static str>,
models: Vec<String>,
plan: &'static str,
running: bool,
#[serde(skip_serializing_if = "Option::is_none")]
process: Option<JsonProcess>,
cost: JsonCost,
tokens: JsonTokens,
tokens_per_min: f64,
activity: JsonActivity,
#[serde(skip_serializing_if = "Option::is_none")]
rates: Option<crate::session::CodexRates>,
#[serde(skip_serializing_if = "Vec::is_empty")]
subagents: Vec<crate::session::Subagent>,
#[serde(skip_serializing_if = "Option::is_none")]
context: Option<crate::session::ContextUsage>,
#[serde(skip_serializing_if = "Option::is_none")]
conflict: Option<JsonConflict>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Serialize)]
pub struct JsonProcess {
cpu: f32,
memory: u64,
command: String,
pids: usize,
}
#[derive(Serialize)]
pub struct JsonConflict {
level: &'static str,
peers: Vec<String>,
files: Vec<String>,
}
fn find_session<'a>(sessions: &'a [Session], which: &str) -> anyhow::Result<&'a Session> {
let matched: Vec<&Session> = match which.is_empty() {
true => {
sessions
.iter()
.max_by_key(|s| s.last_active.clone())
.into_iter()
.collect()
}
false => sessions
.iter()
.filter(|s| s.session_id.starts_with(which))
.collect(),
};
match matched.as_slice() {
[only] => Ok(*only),
[] if which.is_empty() => anyhow::bail!("no sessions found"),
[] => anyhow::bail!("no session id starts with '{which}'"),
many => anyhow::bail!(
"'{which}' matches {} sessions:\n{}",
many.len(),
many.iter()
.map(|s| format!(" {} ({})", s.session_id, s.provider.as_str()))
.collect::<Vec<_>>()
.join("\n")
),
}
}
pub fn run_handoff(sessions: &[Session], which: &str, loader: &Loader) -> anyhow::Result<()> {
let session = find_session(sessions, which)?;
let data = loader.store().session_data_fresh(session);
let brief = crate::handoff::build(session, Some(&data));
print!("{}", crate::handoff::rendered(&brief));
Ok(())
}
pub fn run_convert(sessions: &[Session], which: &str, agent: &str) -> anyhow::Result<()> {
let session = find_original(sessions, which)?;
if !crate::convert::convertible_session(session) {
anyhow::bail!(
"{} has no transcript on this machine cctop can convert",
session.provider.as_str()
);
}
let target = crate::pricing::Provider::parse(agent)
.ok_or_else(|| anyhow::anyhow!("{agent} is not a harness cctop knows"))?;
if !crate::convert::convertible(session.provider, target) {
anyhow::bail!(
"cctop cannot convert {} sessions into {}",
session.provider.as_str(),
target.as_str()
);
}
let transcript = session
.data_file
.as_deref()
.ok_or_else(|| anyhow::anyhow!("that session has no transcript on this machine"))?;
let home = match target {
crate::pricing::Provider::Claude => crate::config::CLAUDE_CONFIG_DIR.clone(),
crate::pricing::Provider::Codex => crate::config::CODEX_HOME.clone(),
_ => anyhow::bail!("that harness has no store cctop writes into"),
};
let written = crate::convert::convert(session.provider, transcript, target, &home)
.ok_or_else(|| anyhow::anyhow!("the transcript could not be converted"))?;
println!(
"{} {} {} {}",
target.as_str(),
written.session_id,
if written.id_kept { "kept" } else { "renamed" },
written.path.display()
);
Ok(())
}
#[cfg(test)]
mod convert_tests {
use super::*;
use crate::session::Session;
fn session(id: &str, provider: crate::pricing::Provider, converted: bool) -> Session {
let mut s = Session::new(provider, id.into());
s.label_source = "/tmp/proj".into();
s.started_at = "2026-09-28T00:00:00.000Z".into();
s.last_active = "2026-09-28T00:00:00.000Z".into();
if converted {
s.converted_from = Some(crate::convert::Provenance {
harness: "claude".into(),
session_id: id.into(),
session_path: "/tmp/proj/source.jsonl".into(),
converted_at: "2026-09-28T00:00:00.000Z".into(),
});
}
s
}
const ID: &str = "5049dbcd-8ef7-412f-bf57-589e61c41d0e";
#[test]
fn a_prefix_matching_a_session_and_a_copy_of_it_resolves_to_the_session() {
let sessions = vec![
session(ID, crate::pricing::Provider::Claude, false),
session(ID, crate::pricing::Provider::Codex, true),
];
assert_eq!(find_original(&sessions, "5049d").unwrap().session_id, ID);
assert!(
find_original(&sessions, ID)
.unwrap()
.converted_from
.is_none()
);
}
#[test]
fn an_id_that_is_only_a_copy_says_where_the_session_really_is() {
let sessions = vec![session(ID, crate::pricing::Provider::Codex, true)];
let why = find_original(&sessions, ID).unwrap_err().to_string();
assert!(why.contains("copy of a claude session"), "{why}");
assert!(!why.contains("no session id starts with"), "{why}");
}
#[test]
fn an_id_matching_nothing_says_so_plainly() {
let sessions = vec![session(ID, crate::pricing::Provider::Claude, false)];
let why = find_original(&sessions, "zzz").unwrap_err().to_string();
assert_eq!(why, "no session id starts with 'zzz'");
}
#[test]
fn no_argument_takes_the_most_recent_original() {
let mut older = session(ID, crate::pricing::Provider::Claude, false);
older.last_active = "2026-09-28T00:00:00.000Z".into();
let mut newer = session(
"aa4ff133-cde9-470f-aaff-1fd6ac2da49e",
crate::pricing::Provider::Codex,
false,
);
newer.last_active = "2026-09-29T00:00:00.000Z".into();
let want = newer.session_id.clone();
let sessions = vec![older, newer];
assert_eq!(find_original(&sessions, "").unwrap().session_id, want);
}
#[test]
fn only_copies_and_no_argument_says_rather_than_picking_one() {
let sessions = vec![session(ID, crate::pricing::Provider::Codex, true)];
let why = find_original(&sessions, "").unwrap_err().to_string();
assert!(why.contains("copy of a claude session"), "{why}");
}
}
fn find_original<'a>(sessions: &'a [Session], which: &str) -> anyhow::Result<&'a Session> {
if which.is_empty() {
return sessions
.iter()
.filter(|s| s.converted_from.is_none())
.max_by_key(|s| s.last_active.clone())
.ok_or_else(|| no_original(sessions, which));
}
let matched: Vec<&Session> = sessions
.iter()
.filter(|s| s.converted_from.is_none() && s.session_id.starts_with(which))
.collect();
let found = match matched.as_slice() {
[only] => *only,
[] => return Err(no_original(sessions, which)),
many => anyhow::bail!(
"'{which}' matches {} sessions:\n{}",
many.len(),
many.iter()
.map(|s| format!(" {} ({})", s.session_id, s.provider.as_str()))
.collect::<Vec<_>>()
.join("\n")
),
};
Ok(found)
}
fn no_original(sessions: &[Session], which: &str) -> anyhow::Error {
let copy = sessions.iter().find(|s| {
s.converted_from.is_some() && (which.is_empty() || s.session_id.starts_with(which))
});
match copy {
Some(copy) => {
let source = copy.converted_from.as_ref().expect("filtered above");
anyhow::anyhow!(
"{} is a copy of a {} session that is not on this machine — \
convert that one where it lives",
copy.session_id,
source.harness
)
}
None if which.is_empty() => anyhow::anyhow!("no sessions found"),
None => anyhow::anyhow!("no session id starts with '{which}'"),
}
}
pub fn run_converted(sessions: &[Session], remove: bool) -> anyhow::Result<()> {
let converted: Vec<&Session> = sessions
.iter()
.filter(|s| s.converted_from.is_some())
.collect();
if converted.is_empty() {
println!("no converted sessions");
return Ok(());
}
for session in &converted {
let from = session.converted_from.as_ref().expect("filtered above");
let where_ = match session.data_file.as_deref() {
Some(path) => path.display().to_string(),
None => "no transcript on this machine".to_string(),
};
println!(
"{} {} from {} {} ({where_})",
session.provider.as_str(),
session.session_id,
from.harness,
from.session_id,
);
}
if !remove {
return Ok(());
}
for session in &converted {
if session.is_running() {
println!(
"kept {} — a {} is using it",
session.session_id,
session.provider.as_str()
);
continue;
}
let Some(path) = session.data_file.as_deref() else {
continue;
};
match std::fs::remove_file(path) {
Ok(()) => println!("removed {}", path.display()),
Err(e) => println!("could not remove {}: {e}", path.display()),
}
}
Ok(())
}
pub fn run_statusline(sessions: &[Session]) {
println!("{}", statusline(sessions));
}
fn statusline(sessions: &[Session]) -> String {
use crate::session::ActivityState;
let live: Vec<&Session> = sessions.iter().filter(|s| s.is_running()).collect();
if live.is_empty() {
return "no agents running".to_string();
}
let mut parts = Vec::new();
for (state, word) in [
(ActivityState::Working, "working"),
(ActivityState::WaitingForInput, "waiting"),
(ActivityState::Asking, "asking"),
(ActivityState::ApiError, "error"),
] {
let n = live.iter().filter(|s| s.activity_state == state).count();
if n > 0 {
parts.push(format!("{n} {word}"));
}
}
let per_hour = live.iter().map(|s| s.cost_per_min * 60.0).sum::<f64>();
if per_hour >= 0.005 {
parts.push(format!("{}/h", util::adaptive_usd(per_hour)));
}
parts.join(" · ")
}
pub fn run_report(
sessions: &[Session],
which: &str,
plan: Plan,
loader: &Loader,
) -> anyhow::Result<()> {
let session = find_session(sessions, which)?;
let data = loader.store().session_data_fresh(session);
let report = crate::serve::report::build(session, &data, plan);
println!("{}", serde_json::to_string(&report)?);
Ok(())
}
pub fn run_chat(sessions: &[Session], which: &str, before: Option<usize>) -> anyhow::Result<()> {
let session = find_session(sessions, which)?;
let conversation = crate::serve::chat::build(session, before);
println!("{}", serde_json::to_string(&conversation)?);
Ok(())
}
pub fn run_access(sessions: &[Session], which: &str, loader: &Loader) -> anyhow::Result<()> {
let session = find_session(sessions, which)?;
let data = loader.store().session_data_fresh(session);
println!(
"{}",
serde_json::to_string(&crate::access::build(session, Some(&data)))?
);
Ok(())
}
fn json_conflict(sessions: &[Session], c: &crate::collide::Collision) -> JsonConflict {
JsonConflict {
level: match c.level {
crate::collide::Overlap::File => "file",
crate::collide::Overlap::Directory => "directory",
},
peers: c
.peers
.iter()
.filter_map(|key| sessions.iter().find(|s| &s.key() == key))
.map(|s| s.session_id.clone())
.collect(),
files: c.files.clone(),
}
}
pub fn json_sessions(
sessions: &[Session],
plan: Plan,
store: &crate::cache::Store,
) -> Vec<JsonSession> {
let claude_account = crate::quota::claude_account();
let codex_account = crate::quota::codex_account();
let collisions = crate::collide::detect(sessions);
let now = chrono::Utc::now();
sessions
.iter()
.map(|s| {
let data = store.session_data(s);
let m = &data.metrics;
let included = s.cost_available && plan.includes(s.provider);
let account = match s.provider {
Provider::Claude if s.owner.is_none() => claude_account.as_ref(),
Provider::Codex if s.owner.is_none() => codex_account.as_ref(),
Provider::Claude | Provider::Codex => None,
Provider::Cursor
| Provider::Devin
| Provider::Gemini
| Provider::OpenCode
| Provider::Pi
| Provider::Windsurf => None,
}
.map(|a| JsonAccount {
email: a.email.clone(),
organization: a.organization.clone(),
});
JsonSession {
provider: s.provider.as_str(),
state: match s.activity_state {
crate::session::ActivityState::Working => "working",
crate::session::ActivityState::WaitingForInput => "waiting",
crate::session::ActivityState::Asking => "asking",
crate::session::ActivityState::ApiError => "error",
},
asking_for: s.asking_for.clone(),
asking_question: s.asking_question,
surface: match s.surface {
crate::session::Surface::Cli => "cli",
crate::session::Surface::Editor => "editor",
crate::session::Surface::DesktopCode => "desktop-code",
crate::session::Surface::DesktopCowork => "desktop-cowork",
},
session_id: s.session_id.clone(),
started_at: s.started_at.clone(),
last_active: s.last_active.clone(),
project: (!s.label_source.is_empty()).then(|| s.label_source.clone()),
title: s.title.clone(),
user: s.owner.clone(),
profile: s.profile.clone(),
account,
model: (!s.model.is_empty()).then(|| s.model.clone()),
harness: (!s.harness.is_empty()).then(|| s.harness.clone()),
branch: crate::ui::columns::branch_of(s),
permission: s.permission.map(crate::hook::Permission::label),
models: data.models.clone(),
plan: plan.as_str(),
running: s.is_running(),
process: s.process.as_ref().map(|p| JsonProcess {
cpu: p.cpu,
memory: p.memory,
command: p.command.clone(),
pids: p.pids,
}),
cost: JsonCost {
available: s.cost_available,
total: (s.cost_available && !included).then(|| util::money(data.costs.total)),
included,
free: s.cost_is_free,
this_hour: s.cost_clock_hour(&now),
last_hour: s.cost_hour,
today: s.cost_today,
per_min: s.cost_per_min,
by_day: trimmed_buckets(&s.costs_by_day, JSON_DAYS),
by_hour: trimmed_buckets(&s.costs_by_hour, JSON_HOURS),
breakdown: (s.cost_available && !included).then(|| data.costs.clone()),
},
tokens: JsonTokens {
input: s.input_tokens,
output: s.output_tokens,
total: s.input_tokens + s.output_tokens,
detail: data.tokens.clone(),
},
tokens_per_min: s.tokens_per_min,
activity: JsonActivity {
tool_count: m.tool_count,
tool_errors: s.provider.records_tool_outcomes().then_some(m.tool_errors),
compactions: (s.provider == Provider::Claude).then_some(data.compactions),
tools: m.tools.clone(),
skill_count: m.skill_count,
skills: m.skills.clone(),
web_fetch_count: m.web_fetch_count,
web_fetches: m.web_fetches.clone(),
web_search_count: m.web_search_count,
web_searches: m.web_searches.clone(),
mcp_tool_count: m.mcp_tool_count,
mcp_tools: m.mcp_tools.clone(),
lines_added: m.lines_added,
lines_removed: m.lines_removed,
},
rates: data.rates,
subagents: data.subagents.clone(),
context: s.context,
conflict: collisions.get(&s.key()).map(|c| json_conflict(sessions, c)),
error: data.error.clone(),
}
})
.collect()
}
pub fn run_json(sessions: &[Session], plan: Plan, loader: &Loader) -> anyhow::Result<()> {
let out = json_sessions(sessions, plan, loader.store());
println!("{}", serde_json::to_string_pretty(&out)?);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_definition_is_valid() {
Args::command().debug_assert();
WaitArgs::command().debug_assert();
}
#[test]
fn wait_parses_its_target_and_flags() {
use crate::wait::Until;
let parse = |argv: &[&str]| {
WaitArgs::try_parse_from(std::iter::once("cctop wait").chain(argv.iter().copied()))
};
let plain = parse(&["3f2a"]).expect("a bare target");
assert_eq!(plain.target, "3f2a");
assert_eq!(plain.until, Until::AnyStop);
assert_eq!(plain.timeout, std::time::Duration::from_secs(600));
assert!(!plain.json);
let full =
parse(&["reviewer", "--until", "done", "--timeout", "30s", "-j"]).expect("every flag");
assert_eq!(full.until, Until::Done);
assert_eq!(full.timeout, std::time::Duration::from_secs(30));
assert!(full.json);
assert_eq!(
parse(&["1", "--until", "any-stop"]).unwrap().until,
Until::AnyStop
);
assert!(parse(&["1", "--timeout", "0"]).unwrap().timeout.is_zero());
assert!(parse(&[]).is_err(), "no target");
assert!(parse(&["1", "--until", "never"]).is_err());
assert!(parse(&["1", "--timeout", "soon"]).is_err());
assert_eq!(parse(&[]).unwrap_err().exit_code(), 2);
}
#[test]
fn delay_floor_enforced() {
assert!(parse_delay("0.5").is_err());
assert!(parse_delay("abc").is_err());
assert!(parse_delay("NaN").is_err());
assert!(parse_delay("inf").is_err());
assert!(parse_delay("-inf").is_err());
assert!(parse_delay(&f64::MAX.to_string()).is_err());
assert_eq!(parse_delay("2.5").unwrap(), 2.5);
}
#[test]
fn plan_parsing_rejects_unknown() {
assert!(parse_plan("max").is_ok());
assert!(parse_plan("nonsense").is_err());
}
#[test]
fn clear_cache_flag_is_accepted() {
let args = Args::try_parse_from(["cctop", "--clear-cache"]).expect("valid args");
assert!(args.clear_cache);
}
#[test]
fn the_statusline_names_live_states_and_the_burn_rate() {
use crate::session::ActivityState;
let live = |state| {
let mut s = Session::new(crate::pricing::Provider::Claude, "x".into());
s.inferred_running = true;
s.activity_state = state;
s
};
assert_eq!(statusline(&[]), "no agents running");
assert_eq!(
statusline(&[Session::new(crate::pricing::Provider::Claude, "x".into())]),
"no agents running",
"a stale transcript is not an agent at work"
);
let mut working = live(ActivityState::Working);
working.cost_per_min = 0.20; let line = statusline(&[
working,
live(ActivityState::WaitingForInput),
live(ActivityState::WaitingForInput),
live(ActivityState::Asking),
]);
assert!(line.contains("1 working"), "{line}");
assert!(line.contains("2 waiting"), "{line}");
assert!(line.contains("1 asking"), "{line}");
assert!(line.contains("$12"), "{line}");
}
}