use std::fs;
use std::io::{self, Write};
use std::path::Path;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::thread;
use std::time::Duration;
use chrono::{DateTime, Utc};
use owo_colors::OwoColorize;
use serde_json::{Value, json};
use netsky_core::consts::{AGENT0_NAME, AGENTINFINITY_NAME, TICKER_LOG_PATH, TICKER_SESSION};
use netsky_core::paths::home;
use netsky_sh::tmux;
pub fn run(json: bool, watch: bool, interval_secs: u64) -> netsky_core::Result<()> {
if json && watch {
netsky_core::bail!("--watch does not support --json");
}
if watch {
return run_watch(interval_secs.max(1));
}
let snapshot = gather();
if json {
let envelope = build_envelope(&snapshot);
println!("{}", serde_json::to_string_pretty(&envelope)?);
return Ok(());
}
print!("{}", render_text(&snapshot));
Ok(())
}
struct Snapshot {
version: &'static str,
tmux_sessions: Vec<String>,
last_tick: Option<DateTime<Utc>>,
db_bytes: Option<u64>,
}
fn gather() -> Snapshot {
let mut sessions: Vec<String> = tmux::list_sessions()
.into_iter()
.filter(|s| is_status_session(s))
.collect();
sessions.sort();
let last_tick = fs::metadata(Path::new(TICKER_LOG_PATH))
.and_then(|m| m.modified())
.ok()
.map(DateTime::<Utc>::from);
let db_bytes = fs::metadata(home().join(".netsky/meta.db"))
.ok()
.map(|m| m.len());
Snapshot {
version: env!("CARGO_PKG_VERSION"),
tmux_sessions: sessions,
last_tick,
db_bytes,
}
}
fn build_envelope(s: &Snapshot) -> Value {
let overall = overall_status(s);
let summary = if s.tmux_sessions.is_empty() {
"no netsky tmux sessions".to_string()
} else {
format!(
"netsky {} ({} session(s))",
s.version,
s.tmux_sessions.len()
)
};
json!({
"command": "status",
"status": overall,
"summary": summary,
"generated_at": Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"data": {
"version": s.version,
"tmux_sessions": s.tmux_sessions,
"last_tick_utc": s.last_tick.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()),
"meta_db_bytes": s.db_bytes,
},
})
}
fn render_text(s: &Snapshot) -> String {
format!(
"version: {}\ntmux: {}\nlast tick: {}\nmeta.db: {}\n",
format!("netsky {}", env!("CARGO_PKG_VERSION")).bold(),
render_tmux_sessions(s),
render_last_tick(s),
render_db_size(s)
)
}
fn run_watch(interval_secs: u64) -> netsky_core::Result<()> {
let stop = Arc::new(AtomicBool::new(false));
let stop_flag = Arc::clone(&stop);
ctrlc::set_handler(move || {
stop_flag.store(true, Ordering::SeqCst);
})
.map_err(|e| netsky_core::Error::msg(e.to_string()))?;
let mut stdout = io::stdout().lock();
loop {
let snapshot = gather();
write!(stdout, "\x1b[2J\x1b[H{}", render_text(&snapshot))?;
stdout.flush()?;
if stop.load(Ordering::SeqCst) {
break;
}
for _ in 0..(interval_secs * 10) {
if stop.load(Ordering::SeqCst) {
break;
}
thread::sleep(Duration::from_millis(100));
}
if stop.load(Ordering::SeqCst) {
break;
}
}
writeln!(stdout)?;
stdout.flush()?;
Ok(())
}
fn overall_status(s: &Snapshot) -> &'static str {
if s.tmux_sessions.is_empty() {
"yellow"
} else {
"green"
}
}
fn render_tmux_sessions(s: &Snapshot) -> String {
if s.tmux_sessions.is_empty() {
return "none".yellow().to_string();
}
let total = s.tmux_sessions.len();
let mut shown: Vec<String> = s.tmux_sessions.clone();
if shown.len() > 8 {
let tail = shown.len() - 8;
shown.truncate(8);
shown.push(format!("+{tail} more"));
}
format!("{} active: {}", total.to_string().green(), shown.join(", "))
}
fn is_status_session(name: &str) -> bool {
name == AGENT0_NAME
|| name == AGENTINFINITY_NAME
|| name == TICKER_SESSION
|| (name.starts_with("agent")
&& name.strip_prefix("agent").is_some_and(|suffix| {
!suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit())
}))
}
fn render_last_tick(s: &Snapshot) -> String {
let Some(dt) = s.last_tick else {
return "none".yellow().to_string();
};
let age = (Utc::now() - dt).num_seconds().max(0) as u64;
let pretty = format_age(age);
if age <= 300 {
pretty.green().to_string()
} else if age <= 600 {
format!("{pretty} (stale)").yellow().to_string()
} else {
format!("{pretty} (stalled)").red().to_string()
}
}
fn format_age(secs: u64) -> String {
if secs < 90 {
format!("{secs}s ago")
} else if secs < 5400 {
format!("{}m ago", secs / 60)
} else if secs < 86_400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86_400)
}
}
fn render_db_size(s: &Snapshot) -> String {
match s.db_bytes {
Some(n) => human_bytes(n).green().to_string(),
None => "missing".yellow().to_string(),
}
}
fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = 0usize;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
format!("{value:.1} {}", UNITS[unit])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognises_status_sessions() {
assert!(is_status_session("agent0"));
assert!(is_status_session("agent13"));
assert!(is_status_session("agentinfinity"));
assert!(is_status_session("netsky-ticker"));
assert!(!is_status_session("agentx"));
}
#[test]
fn human_bytes_scales() {
assert_eq!(human_bytes(999), "999.0 B");
assert_eq!(human_bytes(1024), "1.0 KB");
assert_eq!(human_bytes(1024 * 1024), "1.0 MB");
}
#[test]
fn envelope_shape_and_yellow_on_no_sessions() {
let s = Snapshot {
version: "test",
tmux_sessions: vec![],
last_tick: None,
db_bytes: None,
};
let v = build_envelope(&s);
assert_eq!(v["command"], "status");
assert_eq!(v["status"], "yellow");
assert!(v["data"]["tmux_sessions"].as_array().unwrap().is_empty());
assert_eq!(v["data"]["meta_db_bytes"], Value::Null);
}
#[test]
fn envelope_green_on_sessions_and_no_ci() {
let s = Snapshot {
version: "v",
tmux_sessions: vec!["agent0".into()],
last_tick: None,
db_bytes: Some(4096),
};
let v = build_envelope(&s);
assert_eq!(v["status"], "green");
assert_eq!(v["data"]["meta_db_bytes"], 4096);
}
#[test]
fn format_age_buckets_match_human_intuition() {
assert_eq!(format_age(0), "0s ago");
assert_eq!(format_age(45), "45s ago");
assert_eq!(format_age(89), "89s ago");
assert_eq!(format_age(90), "1m ago");
assert_eq!(format_age(3600), "60m ago");
assert_eq!(format_age(5400), "1h ago");
assert_eq!(format_age(86_400), "1d ago");
}
}