use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use netsky_core::config::Config;
use netsky_core::consts::{
AGENT0_NAME, AGENTINFINITY_NAME, CLAUDE, DISK_MIN_MB_DEFAULT, ENV_DISK_MIN_MB,
ENV_TICKER_INTERVAL, LAUNCHD_LABEL, NETSKY_BIN, TICKER_INTERVAL_DEFAULT_S, TICKER_LOG_PATH,
TICKER_SESSION, TMUX_BIN,
};
use netsky_core::paths::{
agent0_hang_marker, agent0_inbox_dir, agentinfinity_ready_marker, agentinit_escalation_marker,
agentinit_failures_file, handoff_archive_dir, is_netsky_source_tree, resolve_netsky_dir,
ticker_missing_count_file,
};
use netsky_sh::{tmux, which};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Verdict {
Green,
Yellow,
Red,
}
struct Doc {
brief: bool,
quiet: bool,
red: u32,
yellow: u32,
}
pub fn run(brief: bool, quiet: bool) -> netsky_core::Result<()> {
let mut d = Doc {
brief,
quiet,
red: 0,
yellow: 0,
};
d.section("tmux");
for sess in [AGENT0_NAME, AGENTINFINITY_NAME] {
if tmux::session_is_alive(sess) {
d.check(sess, Verdict::Green, "session alive");
} else if tmux::has_session(sess) {
d.check(sess, Verdict::Red, "session pane exited");
} else {
d.check(sess, Verdict::Red, "session MISSING");
}
}
check_ticker(&mut d);
d.section("channels");
check_inbox(&mut d);
d.section("markers");
check_markers(&mut d);
d.section("watchdog");
check_watchdog(&mut d);
d.section("disk");
check_disk(&mut d);
d.section("archives");
check_archives(&mut d);
d.section("binaries");
for bin in [CLAUDE, NETSKY_BIN, TMUX_BIN] {
match which(bin) {
Some(p) => d.check(bin, Verdict::Green, &p.display().to_string()),
None => d.check(
bin,
Verdict::Red,
"not on PATH — see ONBOARDING.md or run /onboard",
),
}
}
d.section("config");
check_config(&mut d);
d.section("hooks");
check_hooks(&mut d);
d.section("launchctl");
check_launchctl(&mut d);
let red = d.red;
let yellow = d.yellow;
d.summary(red, yellow);
if red > 0 {
netsky_core::bail!("doctor: red")
} else {
Ok(())
}
}
fn check_inbox(d: &mut Doc) {
let inbox = agent0_inbox_dir();
match fs::read_dir(&inbox) {
Ok(entries) => {
let n = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("json"))
.count();
let (v, det) = match n {
0 => (Verdict::Green, "empty (drained)".to_string()),
1..=4 => (Verdict::Yellow, format!("{n} envelope(s) pending")),
_ => (
Verdict::Red,
format!("{n} envelopes backing up — MCP poll stalled?"),
),
};
d.check("agent0 inbox", v, &det);
}
Err(_) => d.check(
"agent0 inbox",
Verdict::Red,
&format!("directory missing: {}", inbox.display()),
),
}
}
fn check_markers(d: &mut Doc) {
let ready = agentinfinity_ready_marker();
if ready.exists() {
d.check("agentinfinity-ready", Verdict::Green, "present");
} else {
d.check(
"agentinfinity-ready",
Verdict::Red,
"marker missing; agentinit phase-2 never completed",
);
}
let esc = agentinit_escalation_marker();
if esc.exists() {
d.check(
"agentinit-escalation",
Verdict::Red,
&format!("ESCALATION FIRED — see {}", esc.display()),
);
} else {
d.check(
"agentinit-escalation",
Verdict::Green,
"absent (no escalation)",
);
}
let hang = agent0_hang_marker();
if hang.exists() {
d.check(
"agent0-hang-suspected",
Verdict::Red,
&format!("HANG SUSPECTED — see {}", hang.display()),
);
} else {
d.check(
"agent0-hang-suspected",
Verdict::Green,
"absent (pane is progressing)",
);
}
let fails = agentinit_failures_file();
match fs::read_to_string(&fails) {
Ok(s) => {
let n = s.lines().filter(|l| !l.trim().is_empty()).count();
let (v, det) = match n {
0 => (Verdict::Green, "empty".to_string()),
1..=2 => (Verdict::Yellow, format!("{n} recent failure(s) in window")),
_ => (
Verdict::Red,
format!("{n} failures in window (at/over threshold)"),
),
};
d.check("agentinit-failures", v, &det);
}
Err(_) => d.check("agentinit-failures", Verdict::Green, "no failures recorded"),
}
}
fn check_watchdog(d: &mut Doc) {
let log = Path::new(TICKER_LOG_PATH);
if !log.exists() {
d.check(
"watchdog.out.log",
Verdict::Red,
&format!("log not present at {}", log.display()),
);
return;
}
let age = age_seconds(log).unwrap_or(u64::MAX);
let (v, det) = match age {
a if a <= 300 => (Verdict::Green, format!("{a}s ago")),
a if a <= 600 => (Verdict::Yellow, format!("{a}s ago (>5min)")),
a => (
Verdict::Red,
format!("{a}s ago (>10min — watchdog stalled?)"),
),
};
d.check("watchdog.out.log mtime", v, &det);
if let Some(last) = last_line(log) {
let truncated: String = last.chars().take(70).collect();
d.check("watchdog last entry", Verdict::Green, &truncated);
}
let interval = std::env::var(ENV_TICKER_INTERVAL)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(TICKER_INTERVAL_DEFAULT_S);
let expected = 600u64.saturating_div(interval).max(1);
let half = expected / 2;
if let Ok(content) = fs::read_to_string(log) {
let cutoff_s = unix_now().saturating_sub(600);
let count = content
.lines()
.filter(|l| tick_line_after(l, cutoff_s))
.count() as u64;
let detail = format!("{count} tick(s) in last 10min (expected ~{expected})");
let v = if count >= half {
Verdict::Green
} else if count > 0 {
Verdict::Yellow
} else {
Verdict::Red
};
d.check("watchdog tick-rate", v, &detail);
}
}
fn check_ticker(d: &mut Doc) {
if tmux::has_session(TICKER_SESSION) {
d.check(TICKER_SESSION, Verdict::Green, "session alive");
return;
}
let missing = fs::read_to_string(ticker_missing_count_file())
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0);
let detail = match missing {
0 => "session missing; watchdog will self-heal on the next miss".to_string(),
1 => "missing for 1 tick; watchdog will respawn on the second miss".to_string(),
n => format!("missing for {n} ticks; watchdog self-heal pending"),
};
d.check(TICKER_SESSION, Verdict::Yellow, &detail);
}
fn check_disk(d: &mut Doc) {
let min_mb = std::env::var(ENV_DISK_MIN_MB)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DISK_MIN_MB_DEFAULT);
let avail_mb = match Command::new("df").args(["-Pk", "/tmp"]).output() {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.nth(1)
.and_then(|line| line.split_whitespace().nth(3))
.and_then(|s| s.parse::<u64>().ok())
.map(|kb| kb / 1024),
_ => None,
};
match avail_mb {
Some(mb) if mb >= min_mb * 2 => d.check(
"/tmp free",
Verdict::Green,
&format!("{mb}MB (threshold {min_mb}MB)"),
),
Some(mb) if mb >= min_mb => d.check(
"/tmp free",
Verdict::Yellow,
&format!("{mb}MB (close to {min_mb}MB threshold)"),
),
Some(mb) => d.check(
"/tmp free",
Verdict::Red,
&format!("{mb}MB < {min_mb}MB threshold"),
),
None => d.check("/tmp free", Verdict::Yellow, "df parse failed"),
}
}
fn check_archives(d: &mut Doc) {
let archive = handoff_archive_dir();
if archive.exists() {
if fs::metadata(&archive).is_ok_and(|m| !m.permissions().readonly()) {
let n = fs::read_dir(&archive)
.map(|es| {
es.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("json"))
.count()
})
.unwrap_or(0);
d.check(
"handoff archive",
Verdict::Green,
&format!("{n} envelope(s); writable"),
);
} else {
d.check("handoff archive", Verdict::Red, "exists but not writable");
}
} else if let Some(parent) = archive.parent()
&& fs::metadata(parent).is_ok_and(|m| !m.permissions().readonly())
{
d.check(
"handoff archive",
Verdict::Green,
"not yet created (parent writable; created on next restart)",
);
} else {
d.check("handoff archive", Verdict::Red, "parent dir not writable");
}
}
fn check_config(d: &mut Doc) {
let dir = resolve_netsky_dir();
if is_netsky_source_tree(&dir) {
d.check("netsky dir", Verdict::Green, &dir.display().to_string());
} else {
d.check(
"netsky dir",
Verdict::Green,
&format!(
"binary mode at {} (prompts, addenda, and notes live under ~/.netsky)",
dir.display()
),
);
}
let toml_path = netsky_core::config::netsky_toml_path();
match Config::load_from(&toml_path) {
Ok(None) => d.check(
"netsky.toml",
Verdict::Green,
&format!("absent at {} (env + defaults active)", toml_path.display()),
),
Ok(Some(cfg)) => {
let owner = cfg
.owner
.as_ref()
.and_then(|o| o.name.as_deref())
.unwrap_or("(no [owner].name)");
let machine = cfg
.netsky
.as_ref()
.and_then(|n| n.machine_id.as_deref())
.unwrap_or("(no [netsky].machine_id)");
let addendum = cfg
.addendum
.as_ref()
.and_then(|a| a.agent0.as_deref())
.unwrap_or("(no [addendum].agent0; defaults to 0.md)");
d.check(
"netsky.toml",
Verdict::Green,
&format!(
"loaded from {} (owner={owner}, machine={machine}, addendum.agent0={addendum})",
toml_path.display()
),
);
}
Err(e) => d.check(
"netsky.toml",
Verdict::Red,
&format!("parse failed at {}: {e}", toml_path.display()),
),
}
}
fn check_hooks(d: &mut Doc) {
let root = match Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
{
Ok(o) if o.status.success() => {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
if s.is_empty() {
return;
}
Path::new(&s).to_path_buf()
}
_ => return,
};
let canonical = root.join(".githooks/pre-push");
if !canonical.exists() {
return;
}
let hook = root.join(".git/hooks/pre-push");
match crate::cmd::hooks::classify(&hook) {
crate::cmd::hooks::HookState::CanonicalLink => {
if !canonical.exists() {
d.check(
"pre-push hook",
Verdict::Yellow,
"symlink ok but canonical missing",
);
} else if !fs::metadata(&canonical)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
{
d.check(
"pre-push hook",
Verdict::Yellow,
"symlink ok but canonical not executable",
);
} else {
d.check("pre-push hook", Verdict::Green, "canonical symlink");
}
}
crate::cmd::hooks::HookState::Absent => {
d.check(
"pre-push hook",
Verdict::Red,
"absent — run `netsky hooks install`",
);
}
crate::cmd::hooks::HookState::Drift => {
d.check(
"pre-push hook",
Verdict::Red,
"drifted — run `netsky hooks install --force`",
);
}
}
if std::env::var("SKIP_PREPUSH_CHECK").is_ok_and(|v| v == "1") {
d.check(
"SKIP_PREPUSH_CHECK",
Verdict::Yellow,
"set in env; hook will be bypassed",
);
}
}
fn check_launchctl(d: &mut Doc) {
if which("launchctl").is_none() {
return;
}
let target = format!("gui/{}/{LAUNCHD_LABEL}", unsafe { getuid() });
let loaded = Command::new("launchctl")
.args(["print", &target])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if loaded {
d.check(LAUNCHD_LABEL, Verdict::Green, "plist loaded");
} else {
d.check(
LAUNCHD_LABEL,
Verdict::Yellow,
"plist not loaded (tmux-ticker is primary; this is fallback)",
);
}
}
impl Doc {
fn section(&self, label: &str) {
if !self.brief && !self.quiet {
println!();
println!("{label}");
}
}
fn check(&mut self, name: &str, v: Verdict, detail: &str) {
match v {
Verdict::Green => {}
Verdict::Yellow => self.yellow += 1,
Verdict::Red => self.red += 1,
}
if self.brief || self.quiet {
return;
}
let badge = match v {
Verdict::Green => "ok",
Verdict::Yellow => "warn",
Verdict::Red => "FAIL",
};
println!(" [{badge:>4}] {name:<34} {detail}");
}
fn summary(&self, red: u32, yellow: u32) {
if self.quiet {
return;
}
if !self.brief {
println!();
print!("summary: ");
}
if red > 0 {
println!("RED — {red} fail, {yellow} warn");
} else if yellow > 0 {
println!("YELLOW — {yellow} warn");
} else {
println!("GREEN — all checks passed");
}
}
}
fn age_seconds(path: &Path) -> Option<u64> {
let mtime = fs::metadata(path).ok()?.modified().ok()?;
SystemTime::now()
.duration_since(mtime)
.ok()
.map(|d| d.as_secs())
}
fn last_line(path: &Path) -> Option<String> {
fs::read_to_string(path)
.ok()
.and_then(|s| s.lines().last().map(|s| s.to_string()))
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn tick_line_after(line: &str, cutoff_s: u64) -> bool {
let Some(rest) = line.strip_prefix("[watchdog-tick ") else {
return false;
};
let Some(end) = rest.find(']') else {
return false;
};
let ts = &rest[..end];
let cutoff_iso = chrono::DateTime::<chrono::Utc>::from_timestamp(cutoff_s as i64, 0)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string());
match cutoff_iso {
Some(c) => ts >= c.as_str(),
None => false,
}
}
#[allow(non_snake_case)]
unsafe fn getuid() -> u32 {
unsafe extern "C" {
fn getuid() -> u32;
}
unsafe { getuid() }
}