use cctop_core::hook::Signal;
use cctop_core::loader::Loader;
use cctop_core::pricing::Plan;
use cctop_core::session::{ActivityState, Session};
use std::time::{Duration, Instant};
const REFRESH_EVERY: Duration = Duration::from_secs(1);
const RMUX_EVERY: Duration = Duration::from_secs(2);
const TICK: Duration = Duration::from_millis(200);
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum Until {
Idle,
Waiting,
Done,
AnyStop,
}
impl Until {
pub fn label(self) -> &'static str {
match self {
Until::Idle => "idle",
Until::Waiting => "waiting",
Until::Done => "done",
Until::AnyStop => "any-stop",
}
}
pub fn parse(word: &str) -> Option<Until> {
[Until::Idle, Until::Waiting, Until::Done, Until::AnyStop]
.into_iter()
.find(|until| until.label() == word)
}
pub fn met(self, now: Now, worked: bool) -> Option<bool> {
match (self, now) {
(_, Now::Working) => None,
(Until::AnyStop, _) => Some(true),
(Until::Done, Now::Ended) => Some(true),
(Until::Done, _) => worked.then_some(true),
(Until::Idle, Now::Idle) | (Until::Waiting, Now::Asking) => Some(true),
(Until::Idle | Until::Waiting, Now::Ended) => Some(false),
(Until::Idle | Until::Waiting, _) => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Now {
Working,
Idle,
Asking,
Error,
Ended,
}
impl Now {
pub fn label(self) -> &'static str {
match self {
Now::Working => "working",
Now::Idle => "idle",
Now::Asking => "waiting",
Now::Error => "error",
Now::Ended => "ended",
}
}
fn of(session: &Session, heard: Option<Signal>) -> Now {
if !session.is_running() {
return Now::Ended;
}
match heard {
Some(Signal::Ended) => Now::Ended,
Some(Signal::NeedsInput) => Now::Asking,
Some(Signal::Idle) => Now::Idle,
Some(signal) if signal.is_working() => Now::Working,
_ => match session.activity_state {
ActivityState::Working => Now::Working,
ActivityState::WaitingForInput => Now::Idle,
ActivityState::Asking => Now::Asking,
ActivityState::ApiError => Now::Error,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Unresolved {
Unknown,
Ambiguous(Vec<String>),
}
pub fn resolve(
sessions: &[Session],
tabs: &[cctop_core::rmux::Running],
target: &str,
) -> Result<usize, Unresolved> {
let target = target.trim();
if target.is_empty() {
return Err(Unresolved::Unknown);
}
let by_pid = |pid: u32| {
sessions
.iter()
.position(|s| s.root_pid() == Some(pid))
.or_else(|| {
sessions.iter().position(|s| {
s.process
.as_ref()
.is_some_and(|p| p.process_list.iter().any(|e| e.pid == pid && !e.ghost))
})
})
};
if let Ok(pid) = target.parse::<u32>()
&& let Some(found) = by_pid(pid)
{
return Ok(found);
}
let lower = target.to_lowercase();
if let Some(found) = tabs
.iter()
.filter(|tab| {
tab.name == target
|| tab
.label
.as_deref()
.is_some_and(|label| label.to_lowercase() == lower)
})
.find_map(|tab| tab.pid.and_then(by_pid))
{
return Ok(found);
}
let matching: Vec<usize> = sessions
.iter()
.enumerate()
.filter(|(_, s)| s.session_id.starts_with(target))
.map(|(i, _)| i)
.collect();
match matching.as_slice() {
[] => Err(Unresolved::Unknown),
[one] => Ok(*one),
several => {
let live: Vec<usize> = several
.iter()
.copied()
.filter(|&i| sessions[i].is_running())
.collect();
match live.as_slice() {
[one] => Ok(*one),
_ => Err(Unresolved::Ambiguous(
several
.iter()
.map(|&i| sessions[i].session_id.clone())
.collect(),
)),
}
}
}
}
#[derive(Debug, Clone)]
pub struct Outcome {
pub session_id: String,
pub provider: &'static str,
pub pid: Option<u32>,
pub label: String,
pub state: Now,
pub until: Until,
pub met: Option<bool>,
pub waited: Duration,
}
impl Outcome {
pub fn exit_code(&self) -> i32 {
match self.met {
Some(true) => 0,
Some(false) => 1,
None => 124,
}
}
pub fn json(&self) -> serde_json::Value {
serde_json::json!({
"session": self.session_id,
"provider": self.provider,
"pid": self.pid,
"label": self.label,
"state": self.state.label(),
"until": self.until.label(),
"met": self.met == Some(true),
"timed_out": self.met.is_none(),
"waited_secs": (self.waited.as_secs_f64() * 10.0).round() / 10.0,
})
}
pub fn sentence(&self) -> String {
let secs = self.waited.as_secs();
let who = format!("{} ({})", short(&self.session_id), self.label);
match self.met {
Some(true) => format!("{who} is {} after {secs}s", self.state.label()),
Some(false) => format!(
"{who} ended after {secs}s without becoming {}",
self.until.label()
),
None => format!(
"{who} is still {} after {secs}s; gave up waiting for {}",
self.state.label(),
self.until.label()
),
}
}
}
fn short(id: &str) -> &str {
id.get(..8).unwrap_or(id)
}
#[derive(Debug, Clone, Copy)]
struct Heard {
signal: Signal,
at: u64,
provisional: bool,
}
impl Heard {
fn believed(&self, now: u64) -> Option<Signal> {
let age = Duration::from_secs(now.saturating_sub(self.at));
let settled = !self.provisional || age >= cctop_core::hook::PERMISSION_GRACE;
(settled && self.signal.is_current_after(age)).then_some(self.signal)
}
}
fn hear(slot: &mut Option<Heard>, heard: Heard) {
if slot.is_none_or(|old| heard.at >= old.at) {
*slot = Some(heard);
}
}
pub fn wait(
target: &str,
until: Until,
timeout: Option<Duration>,
listen: bool,
) -> Result<Outcome, Unresolved> {
let started = Instant::now();
let wall_start = chrono::Utc::now();
let listener = listen.then(cctop_core::hook::Listener::start).flatten();
let mut loader = Loader::new();
loader.set_hook_claims(cctop_core::hook::load_claims());
let plan = Plan::Retail;
let mut sessions = loader.load(plan);
let mut tabs = cctop_core::rmux::running();
let at = resolve(&sessions, &tabs, target)?;
let key = sessions[at].key();
let id = sessions[at].session_id.clone();
let mut pid = sessions[at].root_pid();
let mut heard: Option<Heard> = None;
let mut worked = false;
let mut refreshed = Instant::now();
let mut reread_rmux = Instant::now();
loop {
let now_secs = cctop_core::rmux::now_secs();
let current = sessions
.iter()
.find(|s| s.key() == key)
.or_else(|| pid.and_then(|p| sessions.iter().find(|s| s.root_pid() == Some(p))));
if let Some(recorded) = pid
.and_then(|p| tabs.iter().find(|tab| tab.pid == Some(p)))
.and_then(|tab| tab.state)
{
hear(
&mut heard,
Heard {
signal: recorded.signal,
at: recorded.at,
provisional: false,
},
);
}
let state = match current {
Some(session) => Now::of(session, heard.and_then(|h| h.believed(now_secs))),
None => Now::Ended,
};
worked |= state == Now::Working
|| current
.and_then(|s| cctop_core::util::parse_ts(&s.last_active))
.is_some_and(|last| last > wall_start);
let met = until.met(state, worked);
let timed_out = timeout.is_some_and(|limit| started.elapsed() >= limit);
if met.is_some() || timed_out {
let fallback = || sessions.iter().find(|s| s.session_id == id);
let session = current.or_else(fallback);
return Ok(Outcome {
session_id: session.map_or(id.clone(), |s| s.session_id.clone()),
provider: session.map_or("", |s| s.provider.as_str()),
pid,
label: session.map_or_else(String::new, |s| s.display_label().to_string()),
state,
until,
met,
waited: started.elapsed(),
});
}
std::thread::sleep(TICK);
if let Some(listener) = &listener {
for event in listener.drain() {
let ours = event.session_id == id
|| current.is_some_and(|s| s.launched_as() == event.session_id)
|| pid.is_some_and(|p| event.pids.contains(&p));
if ours {
hear(
&mut heard,
Heard {
signal: event.reported.signal,
at: cctop_core::rmux::now_secs(),
provisional: event.reported.provisional,
},
);
}
}
}
if refreshed.elapsed() >= REFRESH_EVERY {
loader.refresh_live(plan, &mut sessions);
refreshed = Instant::now();
if pid.is_none() {
pid = sessions
.iter()
.find(|s| s.key() == key)
.and_then(Session::root_pid);
}
}
if reread_rmux.elapsed() >= RMUX_EVERY && !tabs.is_empty() {
tabs = cctop_core::rmux::running();
reread_rmux = Instant::now();
}
}
}
pub fn parse_timeout(text: &str) -> Result<Duration, String> {
let text = text.trim();
let (number, unit) = match text.find(|c: char| !c.is_ascii_digit() && c != '.') {
Some(at) => text.split_at(at),
None => (text, "s"),
};
let scale = match unit {
"s" => 1.0,
"m" => 60.0,
"h" => 3600.0,
_ => {
return Err(format!(
"'{text}': use a number of seconds, or 30s, 10m, 2h"
));
}
};
let value: f64 = number
.parse()
.map_err(|_| format!("'{text}': use a number of seconds, or 30s, 10m, 2h"))?;
let secs = value * scale;
if !secs.is_finite() || secs < 0.0 || secs > u32::MAX as f64 {
return Err(format!("'{text}' is not a timeout this can wait"));
}
Ok(Duration::from_secs_f64(secs))
}
pub fn run(args: &crate::cli::WaitArgs) -> i32 {
let timeout = (!args.timeout.is_zero()).then_some(args.timeout);
match wait(&args.target, args.until, timeout, true) {
Ok(outcome) => {
match args.json {
true => println!("{}", outcome.json()),
false => println!("{}", outcome.sentence()),
}
outcome.exit_code()
}
Err(why) => {
let text = match &why {
Unresolved::Unknown => format!(
"No session, tab or pid here is '{}'. `cctop -l` lists them.",
args.target
),
Unresolved::Ambiguous(ids) => format!(
"'{}' could be any of {} sessions: {}",
args.target,
ids.len(),
ids.iter()
.map(|id| short(id))
.collect::<Vec<_>>()
.join(", ")
),
};
match args.json {
true => println!(
"{}",
serde_json::json!({ "target": args.target, "error": text })
),
false => eprintln!("{text}"),
}
2
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use cctop_core::pricing::Provider;
fn live(id: &str, pid: u32, state: ActivityState) -> Session {
let mut s = Session::new(Provider::Claude, id.into());
s.process = Some(cctop_core::proc::ProcInfo {
process_list: vec![
cctop_core::proc::ProcEntry {
pid,
is_root: true,
ghost: false,
cpu: 0.0,
memory: 0,
args: String::new(),
},
cctop_core::proc::ProcEntry {
pid: pid + 1,
is_root: false,
ghost: false,
cpu: 0.0,
memory: 0,
args: String::new(),
},
],
..Default::default()
});
s.activity_state = state;
s
}
fn dead(id: &str) -> Session {
Session::new(Provider::Claude, id.into())
}
fn tab(label: &str, pid: u32) -> cctop_core::rmux::Running {
cctop_core::rmux::Running {
name: format!("cctop-{label}"),
pid: Some(pid),
cwd: None,
attached: false,
activity: None,
label: Some(label.to_string()),
profile: None,
order: None,
state: None,
color: None,
tab: None,
pane: None,
axis: None,
window: None,
}
}
#[test]
fn a_target_is_a_pid_a_tab_or_an_id_prefix() {
let sessions = vec![
live("3f2a-live", 500, ActivityState::Working),
dead("3f2a-old"),
dead("9c1d-only"),
live("12345-digits", 700, ActivityState::Working),
];
let tabs = vec![tab("Reviewer", 500)];
assert_eq!(resolve(&sessions, &tabs, "500"), Ok(0), "the agent's pid");
assert_eq!(resolve(&sessions, &tabs, "501"), Ok(0), "a child's pid");
assert_eq!(resolve(&sessions, &tabs, "reviewer"), Ok(0), "a tab name");
assert_eq!(resolve(&sessions, &tabs, "cctop-Reviewer"), Ok(0));
assert_eq!(resolve(&sessions, &tabs, "9c1d"), Ok(2), "an id prefix");
assert_eq!(resolve(&sessions, &tabs, "3f2a"), Ok(0));
assert_eq!(resolve(&sessions, &tabs, "1234"), Ok(3));
}
#[test]
fn a_target_naming_nothing_or_too_much_says_which() {
let sessions = vec![dead("aa-1"), dead("aa-2")];
assert_eq!(resolve(&sessions, &[], "zz"), Err(Unresolved::Unknown));
assert_eq!(resolve(&sessions, &[], " "), Err(Unresolved::Unknown));
assert_eq!(
resolve(&sessions, &[], "aa"),
Err(Unresolved::Ambiguous(vec!["aa-1".into(), "aa-2".into()]))
);
assert_eq!(
resolve(&sessions, &[tab("shell", 99)], "shell"),
Err(Unresolved::Unknown)
);
}
#[test]
fn each_condition_is_met_by_the_states_it_names() {
use Now::*;
use Until::*;
for until in [Until::Idle, Waiting, Done, AnyStop] {
assert_eq!(until.met(Working, true), None, "{until:?} met mid-turn");
}
assert_eq!(AnyStop.met(Now::Idle, false), Some(true));
assert_eq!(AnyStop.met(Error, false), Some(true));
assert_eq!(AnyStop.met(Ended, false), Some(true));
assert_eq!(Until::Idle.met(Now::Idle, false), Some(true));
assert_eq!(
Until::Idle.met(Asking, true),
None,
"a question may still end idle"
);
assert_eq!(Until::Idle.met(Ended, true), Some(false));
assert_eq!(Waiting.met(Asking, false), Some(true));
assert_eq!(Waiting.met(Now::Idle, true), None);
assert_eq!(Waiting.met(Ended, false), Some(false));
assert_eq!(Done.met(Now::Idle, false), None, "an old stop counted");
assert_eq!(Done.met(Now::Idle, true), Some(true));
assert_eq!(Done.met(Asking, true), Some(true));
assert_eq!(Done.met(Ended, false), Some(true));
}
#[test]
fn the_state_is_the_hooks_over_the_transcript() {
let working = live("a", 1, ActivityState::Working);
assert_eq!(Now::of(&working, None), Now::Working);
assert_eq!(Now::of(&working, Some(Signal::Idle)), Now::Idle);
assert_eq!(Now::of(&working, Some(Signal::NeedsInput)), Now::Asking);
let idle = live("a", 1, ActivityState::WaitingForInput);
assert_eq!(Now::of(&idle, Some(Signal::Busy)), Now::Working);
assert_eq!(Now::of(&idle, Some(Signal::Ended)), Now::Ended);
assert_eq!(Now::of(&dead("a"), Some(Signal::Busy)), Now::Ended);
}
#[test]
fn a_report_is_believed_on_the_dashboards_terms() {
let asked = Heard {
signal: Signal::NeedsInput,
at: 1000,
provisional: true,
};
assert_eq!(asked.believed(1001), None);
assert_eq!(asked.believed(1010), Some(Signal::NeedsInput));
let busy = Heard {
signal: Signal::Busy,
at: 1000,
provisional: false,
};
assert_eq!(busy.believed(1001), Some(Signal::Busy));
assert_eq!(busy.believed(1000 + 3600), None);
let mut slot = None;
hear(&mut slot, busy);
hear(&mut slot, Heard { at: 900, ..asked });
assert_eq!(
slot.map(|h| h.signal),
Some(Signal::Busy),
"an older report won"
);
}
#[test]
fn a_timeout_takes_a_unit() {
assert_eq!(parse_timeout("90"), Ok(Duration::from_secs(90)));
assert_eq!(parse_timeout("30s"), Ok(Duration::from_secs(30)));
assert_eq!(parse_timeout("10m"), Ok(Duration::from_secs(600)));
assert_eq!(parse_timeout("2h"), Ok(Duration::from_secs(7200)));
assert_eq!(parse_timeout("1.5m"), Ok(Duration::from_secs(90)));
assert_eq!(parse_timeout("0"), Ok(Duration::ZERO), "0 is no limit");
assert!(parse_timeout("10d").is_err());
assert!(parse_timeout("soon").is_err());
assert!(parse_timeout("").is_err());
}
#[test]
fn the_exit_code_says_how_it_ended() {
let outcome = |met| Outcome {
session_id: "abcdef123456".into(),
provider: "claude",
pid: Some(1),
label: "proj".into(),
state: Now::Idle,
until: Until::AnyStop,
met,
waited: Duration::from_secs(3),
};
assert_eq!(outcome(Some(true)).exit_code(), 0);
assert_eq!(outcome(Some(false)).exit_code(), 1);
assert_eq!(outcome(None).exit_code(), 124);
let json = outcome(Some(true)).json();
assert_eq!(json["state"], "idle");
assert_eq!(json["met"], true);
assert_eq!(json["timed_out"], false);
assert!(outcome(None).sentence().contains("gave up"));
}
}