use crate::util::{home_rel, parse_harness, short_id};
use anyhow::Result;
use clap::Subcommand;
use cv_core::ir::truncate;
use cv_core::watch::{Filter, Watcher};
use std::time::Duration;
#[derive(Subcommand)]
pub(crate) enum BoardCmd {
Post {
channel: String,
body: String,
#[arg(long, default_value = "cv")]
from: String,
#[arg(long)]
kind: Option<String>,
#[arg(long = "tag")]
tags: Vec<String>,
#[arg(long = "session-ref")]
session_ref: Option<String>,
},
Read {
channel: String,
#[arg(long)]
since: Option<String>,
#[arg(long, default_value_t = 50)]
limit: usize,
#[arg(long)]
json: bool,
},
Channels,
Watch {
channel: String,
#[arg(long)]
since: Option<String>,
#[arg(long = "match")]
pattern: Option<String>,
#[arg(long, default_value_t = 2.0)]
interval: f64,
},
Request {
channel: String,
body: String,
#[arg(long, default_value = "cv")]
from: String,
},
Reply {
channel: String,
in_reply_to: String,
body: String,
#[arg(long, default_value = "cv")]
from: String,
},
Replies { channel: String, request_id: String },
Claim {
channel: String,
key: String,
#[arg(long, default_value = "cv")]
from: String,
#[arg(long = "ttl-secs", default_value_t = 300)]
ttl_secs: u64,
},
Release {
channel: String,
key: String,
#[arg(long, default_value = "cv")]
from: String,
},
Claims { channel: String },
Who {
channel: String,
#[arg(long = "within-secs", default_value_t = 60)]
within_secs: u64,
},
Ack {
channel: String,
message_id: String,
#[arg(long, default_value = "cv")]
from: String,
},
}
pub(crate) fn cmd_scry(harness: Option<String>, cwd: Option<String>, interval: f64, existing: bool) -> Result<()> {
let filter = Filter {
harness: parse_harness(&harness)?,
cwd_contains: cwd,
};
let mut watcher = Watcher::new(filter, existing);
eprintln!("✦ scrying for agent activity… (Ctrl-C to stop)");
watcher.run(Duration::from_secs_f64(interval.max(0.25)), |ev| {
let r = &ev.reference;
let tag = match ev.kind {
cv_core::watch::EventKind::New => "✷ new",
cv_core::watch::EventKind::Updated => " +",
};
let where_ = r.cwd.as_deref().map(home_rel).unwrap_or_else(|| "?".into());
println!(
"{tag} {:8} {:8} {} ({} msg)",
r.harness.as_str(),
short_id(&r.id),
where_,
ev.new_messages.len()
);
for m in &ev.new_messages {
if let Some(t) = m.text() {
println!(" {} {}", cv_core::render::role_label(m.role), truncate(&t, 100));
}
}
});
}
pub(crate) fn cmd_board(action: BoardCmd) -> Result<()> {
use cv_core::board;
match action {
BoardCmd::Post {
channel,
body,
from,
kind,
tags,
session_ref,
} => {
let m = board::post(&channel, &from, &body, kind.as_deref(), tags, session_ref)?;
println!("✦ posted {} to #{}", short_id(&m.id), channel);
}
BoardCmd::Read {
channel,
since,
limit,
json,
} => {
let msgs = board::read(&channel, since.as_deref(), limit)?;
if json {
println!("{}", serde_json::to_string_pretty(&msgs)?);
} else {
for m in &msgs {
print_board_msg(m);
}
if msgs.is_empty() {
println!("(no messages on #{channel})");
}
}
}
BoardCmd::Channels => {
for c in board::channels()? {
println!("#{c}");
}
}
BoardCmd::Watch {
channel,
since,
pattern,
interval,
} => {
eprintln!("✦ watching #{channel} … (Ctrl-C to stop)");
let mut cursor = since;
loop {
for m in board::read(&channel, cursor.as_deref(), 0)? {
cursor = Some(m.id.clone());
print_board_msg(&m);
if let Some(p) = &pattern {
if m.body.contains(p.as_str()) {
println!("✓ matched {p:?} — done");
return Ok(());
}
}
}
std::thread::sleep(Duration::from_secs_f64(interval.max(0.25)));
}
}
BoardCmd::Request { channel, body, from } => {
let m = board::request(&channel, &from, &body)?;
println!("✦ requested {} on #{}", short_id(&m.id), channel);
println!(" ↳ reply with: cv board reply {channel} {} <body>", m.id);
}
BoardCmd::Reply {
channel,
in_reply_to,
body,
from,
} => {
let m = board::reply(&channel, &from, &in_reply_to, &body)?;
println!(
"✦ replied {} to {} on #{}",
short_id(&m.id),
short_id(&in_reply_to),
channel
);
}
BoardCmd::Replies { channel, request_id } => {
let msgs = board::replies(&channel, &request_id)?;
for m in &msgs {
print_board_msg(m);
}
if msgs.is_empty() {
println!("(no replies to {} on #{channel})", short_id(&request_id));
}
}
BoardCmd::Claim {
channel,
key,
from,
ttl_secs,
} => {
let lease = board::claim(&channel, &from, &key, Duration::from_secs(ttl_secs))?;
match lease {
Some(l) => {
println!(
"GRANTED {key} → {} (expires {})",
l.owner,
l.expires_at.format("%Y-%m-%d %H:%M:%S")
);
}
None => {
let holder = board::active_claims(&channel)?
.into_iter()
.find(|(k, _, _)| *k == key)
.map(|(_, owner, _)| owner)
.unwrap_or_else(|| "someone".into());
println!("CONTENDED {key} is held by {holder}");
std::process::exit(1);
}
}
}
BoardCmd::Release { channel, key, from } => {
let owned = board::active_claims(&channel)?
.into_iter()
.any(|(k, owner, _)| k == key && owner == from);
if !owned {
println!("(you don't hold {key} on #{channel} — nothing to release)");
} else {
match board::claim(&channel, &from, &key, Duration::from_secs(1))? {
Some(lease) => {
board::release(&lease)?;
println!("✦ released {key} on #{channel}");
}
None => {
println!("(claim {key} on #{channel} is held by another agent — nothing to release)");
}
}
}
}
BoardCmd::Claims { channel } => {
let claims = board::active_claims(&channel)?;
for (key, owner, expires) in &claims {
println!("{key:30} {owner:16} expires {}", expires.format("%Y-%m-%d %H:%M:%S"));
}
if claims.is_empty() {
println!("(no active claims on #{channel})");
}
}
BoardCmd::Who { channel, within_secs } => {
board::heartbeat(&channel, "cv")?;
let agents = board::who(&channel, Duration::from_secs(within_secs))?;
for a in &agents {
println!("{a}");
}
if agents.is_empty() {
println!("(nobody seen on #{channel} in the last {within_secs}s)");
}
}
BoardCmd::Ack {
channel,
message_id,
from,
} => {
let m = board::ack(&channel, &from, &message_id)?;
println!("✦ acked {} on #{channel}", short_id(&message_id));
let _ = m;
}
}
Ok(())
}
fn print_board_msg(m: &cv_core::board::BoardMessage) {
println!("{} {} ({}) {}", m.ts.format("%H:%M:%S"), m.from, m.kind, m.body);
}