From 2f5a3f6a600c2bf325e34b70bd5cd8c0752f35a5 Mon Sep 17 00:00:00 2001 From: XKHoshizora Date: Wed, 10 Jun 2026 23:44:01 +0900 Subject: [PATCH 1/5] feat: implement host metrics on Windows via sysinfo HostSampler::sample() previously returned None on every non-Linux platform, so Windows rendered CPU/MEM/LOAD as "n/a". sysinfo is already a Windows-only dependency; hold a sysinfo::System across ticks (CPU usage is a delta between two refreshes, mirroring the Linux prev/delta approach) and report: - cpu_pct: global CPU usage (0.0 on the first tick, like Linux) - mem_pct: used / total memory - load1: 0.0 - Windows has no load average; callers render it as N/A The Linux /proc path and the macOS stub are unchanged; all new code is gated behind #[cfg(target_os = "windows")]. Refs XKHoshizora/abtop#1 (Task 1) Co-Authored-By: Claude Fable 5 --- src/host_info.rs | 99 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 9 deletions(-) diff --git a/src/host_info.rs b/src/host_info.rs index 882ec71..1f44f53 100644 --- a/src/host_info.rs +++ b/src/host_info.rs @@ -1,8 +1,8 @@ //! Lightweight host vitals: CPU%, MEM%, 1-min load average. //! -//! Reads `/proc` directly on Linux. Returns `None` on other platforms (for now); -//! callers should treat absence as "metrics unavailable" and render a graceful -//! fallback. +//! Reads `/proc` directly on Linux and uses `sysinfo` on Windows. Returns +//! `None` on other platforms (for now); callers should treat absence as +//! "metrics unavailable" and render a graceful fallback. use serde::Serialize; @@ -17,12 +17,18 @@ pub struct HostMetrics { } /// Stateful sampler that remembers the previous `/proc/stat` snapshot so it -/// can compute CPU usage as a delta between ticks. +/// can compute CPU usage as a delta between ticks. On Windows it instead +/// holds a `sysinfo::System` across ticks for the same reason: CPU usage is +/// a delta between two refreshes. #[derive(Debug, Default)] pub struct HostSampler { + #[cfg(not(target_os = "windows"))] prev: Option, + #[cfg(target_os = "windows")] + win: windows_impl::WinSampler, } +#[cfg(not(target_os = "windows"))] #[derive(Debug, Clone, Copy)] struct CpuTimes { /// All non-idle jiffies (user + nice + system + irq + softirq + steal). @@ -36,8 +42,9 @@ impl HostSampler { Self::default() } - /// Sample current host metrics. Returns `None` if /proc is unavailable - /// (non-Linux, or first sample where no CPU delta exists yet). + /// Sample current host metrics. Returns `None` if the platform has no + /// metrics source (non-Linux unix, for now). + #[cfg(not(target_os = "windows"))] pub fn sample(&mut self) -> Option { let cpu_pct = self.sample_cpu()?; let mem_pct = sample_mem()?; @@ -49,6 +56,14 @@ impl HostSampler { }) } + /// Windows: CPU/MEM via `sysinfo`. There is no load average on Windows, + /// so `load1` is reported as 0.0 (callers should label it N/A). + #[cfg(target_os = "windows")] + pub fn sample(&mut self) -> Option { + self.win.sample() + } + + #[cfg(not(target_os = "windows"))] fn sample_cpu(&mut self) -> Option { let now = read_cpu_times()?; let pct = match self.prev { @@ -129,19 +144,85 @@ fn sample_load() -> Option { s.split_whitespace().next().and_then(|n| n.parse().ok()) } -#[cfg(not(target_os = "linux"))] +#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] fn read_cpu_times() -> Option { None } -#[cfg(not(target_os = "linux"))] +#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] fn sample_mem() -> Option { None } -#[cfg(not(target_os = "linux"))] +#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] fn sample_load() -> Option { None } +/// Windows host metrics via `sysinfo` (already a Windows-only dependency). +#[cfg(target_os = "windows")] +mod windows_impl { + use super::HostMetrics; + use sysinfo::System; + + /// Holds a `System` across ticks: `sysinfo` computes CPU usage as the + /// delta between two refreshes, so a freshly constructed `System` always + /// reports 0. The collector tick (~2s) is well above + /// `sysinfo::MINIMUM_CPU_UPDATE_INTERVAL`. + pub struct WinSampler { + sys: System, + /// False until the first refresh has happened; the first sample has + /// no CPU delta yet, so report 0.0 (mirrors the Linux first-tick + /// behavior where `prev` is `None`). + primed: bool, + } + + impl Default for WinSampler { + fn default() -> Self { + Self { + sys: System::new(), + primed: false, + } + } + } + + impl std::fmt::Debug for WinSampler { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WinSampler") + .field("primed", &self.primed) + .finish() + } + } + + impl WinSampler { + pub fn sample(&mut self) -> Option { + self.sys.refresh_cpu_usage(); + self.sys.refresh_memory(); + + let cpu_pct = if self.primed { + self.sys.global_cpu_usage() as f64 + } else { + 0.0 + }; + self.primed = true; + + let total = self.sys.total_memory(); + if total == 0 { + return None; + } + let mem_pct = (self.sys.used_memory() as f64 / total as f64) * 100.0; + + // Windows has no load average; sysinfo reports 0.0 there. Callers + // should render load as N/A on Windows. + let load1 = System::load_average().one; + + Some(HostMetrics { + cpu_pct, + mem_pct, + load1, + }) + } + } +} + /// Aggregate per-session metrics into a single agent-wide summary. #[derive(Debug, Clone, Copy, Default, Serialize)] pub struct AgentAggregate { From 9d03556e79c74a082a42b155229bc9194acc7bf0 Mon Sep 17 00:00:00 2001 From: XKHoshizora Date: Wed, 10 Jun 2026 23:44:33 +0900 Subject: [PATCH 2/5] fix: OpenCode session discovery on Windows Three Windows-only root causes made OpenCode sessions invisible: 1. get_process_cwd() fell into the not(linux) branch, which shells out to lsof - a tool that does not exist on Windows - so cwd matching always failed. Add a #[cfg(windows)] impl that reads the cwd via sysinfo (process PEB), refreshing only the queried PID. 2. The OpenCode DB stores `directory` with forward slashes on Windows (e.g. C:/Users/x/proj) while the live process cwd uses backslashes, and NTFS paths are case-insensitive. Compare via a Windows-gated paths_equal() that normalizes separators and case; unix keeps the exact string comparison. 3. collect_sessions() returned an empty vec silently when the sqlite3 CLI is missing - the common case on Windows. When the DB exists but sqlite3 is not on PATH, emit a one-time stderr warning that names the DB path and the winget install command. Also probe %LOCALAPPDATA%/%APPDATA% as DB-path fallbacks (verified: npm-installed OpenCode keeps the XDG-style ~/.local/share layout on Windows, which the existing default already resolves), and derive the project-name fallback through last_path_segment() so backslash paths do not leak a full path as the project name. Unix behavior is byte-for-byte unchanged; everything new is gated behind #[cfg(target_os = "windows")]. Refs XKHoshizora/abtop#1 (Task 2) Co-Authored-By: Claude Fable 5 --- src/collector/opencode.rs | 99 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 6 deletions(-) diff --git a/src/collector/opencode.rs b/src/collector/opencode.rs index 7ef1523..59df773 100644 --- a/src/collector/opencode.rs +++ b/src/collector/opencode.rs @@ -26,6 +26,9 @@ pub struct OpenCodeCollector { sqlite3_available: Option, /// Cached DB rows from the last slow-tick query. Reused on fast ticks. cached_db_sessions: Vec, + /// Whether the "sqlite3 missing" warning has been emitted (once). + #[cfg(target_os = "windows")] + warned_sqlite3_missing: bool, } impl OpenCodeCollector { @@ -33,10 +36,15 @@ impl OpenCodeCollector { let data_dir = std::env::var("XDG_DATA_HOME") .map(PathBuf::from) .unwrap_or_else(|_| dirs::home_dir().unwrap_or_default().join(".local/share")); + let db_path = data_dir.join("opencode").join("opencode.db"); + #[cfg(target_os = "windows")] + let db_path = windows_db_path(db_path); Self { - db_path: data_dir.join("opencode").join("opencode.db"), + db_path, sqlite3_available: None, cached_db_sessions: Vec::new(), + #[cfg(target_os = "windows")] + warned_sqlite3_missing: false, } } @@ -51,7 +59,24 @@ impl OpenCodeCollector { fn collect_sessions(&mut self, shared: &super::SharedProcessData) -> Vec { // Security: skip if db_path is a symlink (fail-closed) - if is_symlink(&self.db_path) || !self.db_path.exists() || !self.check_sqlite3() { + if is_symlink(&self.db_path) || !self.db_path.exists() { + self.cached_db_sessions.clear(); + return vec![]; + } + if !self.check_sqlite3() { + // The DB exists but we can't read it: on Windows sqlite3 is + // usually not preinstalled, so say why sessions are missing + // instead of failing silently. + #[cfg(target_os = "windows")] + if !self.warned_sqlite3_missing { + self.warned_sqlite3_missing = true; + eprintln!( + "abtop: OpenCode database found at {} but the `sqlite3` CLI is not on PATH; \ + OpenCode sessions will not appear. Install it (e.g. `winget install SQLite.SQLite`) \ + and restart abtop.", + self.db_path.display() + ); + } self.cached_db_sessions.clear(); return vec![]; } @@ -115,7 +140,10 @@ impl OpenCodeCollector { let project_name = if !ds.project_name.is_empty() { ds.project_name.clone() } else { - ds.directory.rsplit('/').next().unwrap_or("?").to_string() + // last_path_segment also splits on `\` on Windows. + process::last_path_segment(&ds.directory) + .unwrap_or("?") + .to_string() }; let current_tasks = if matches!(status, SessionStatus::Waiting) { @@ -252,7 +280,7 @@ impl OpenCodeCollector { continue; } if let Some(cwd) = get_process_cwd(pid) { - if cwd == session_dir { + if paths_equal(&cwd, session_dir) { return Some(pid); } } @@ -424,8 +452,49 @@ fn truncate_field(s: &mut String, max_bytes: usize) { } } +/// Compare a process cwd with a DB session directory. +/// On Windows paths are case-insensitive and may mix `/` and `\`, so +/// normalize before comparing; elsewhere keep the exact comparison. +#[cfg(target_os = "windows")] +fn paths_equal(a: &str, b: &str) -> bool { + let norm = |s: &str| { + s.replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase() + }; + norm(a) == norm(b) +} + +#[cfg(not(target_os = "windows"))] +fn paths_equal(a: &str, b: &str) -> bool { + a == b +} + +/// On Windows, OpenCode builds (e.g. installed via npm) have been observed to +/// keep the XDG-style `~/.local/share/opencode` layout, so prefer the same +/// path as unix; fall back to probing `%LOCALAPPDATA%` / `%APPDATA%` in case +/// a build stores the DB there instead. +#[cfg(target_os = "windows")] +fn windows_db_path(default: PathBuf) -> PathBuf { + if default.exists() { + return default; + } + for var in ["LOCALAPPDATA", "APPDATA"] { + if let Ok(base) = std::env::var(var) { + if base.is_empty() { + continue; + } + let candidate = PathBuf::from(base).join("opencode").join("opencode.db"); + if candidate.exists() { + return candidate; + } + } + } + default +} + /// Get the current working directory of a process. -/// Uses /proc on Linux, lsof on macOS/other Unix. +/// Uses /proc on Linux, sysinfo (PEB) on Windows, lsof on macOS/other Unix. #[cfg(target_os = "linux")] fn get_process_cwd(pid: u32) -> Option { std::fs::read_link(format!("/proc/{}/cwd", pid)) @@ -433,7 +502,25 @@ fn get_process_cwd(pid: u32) -> Option { .map(|p| p.to_string_lossy().into_owned()) } -#[cfg(not(target_os = "linux"))] +#[cfg(target_os = "windows")] +fn get_process_cwd(pid: u32) -> Option { + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; + // `lsof` does not exist on Windows; sysinfo reads the cwd from the + // process PEB. Refresh just this one PID — this runs only for the + // handful of opencode PIDs, once per tick. + let mut sys = System::new(); + let pid = Pid::from_u32(pid); + sys.refresh_processes_specifics( + ProcessesToUpdate::Some(&[pid]), + false, + ProcessRefreshKind::new().with_cwd(UpdateKind::Always), + ); + sys.process(pid) + .and_then(|p| p.cwd()) + .map(|p| p.to_string_lossy().into_owned()) +} + +#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] fn get_process_cwd(pid: u32) -> Option { // -a ANDs the selection terms; without it, lsof ORs `-p ` with // `-d cwd` and returns cwd entries for unrelated processes too. From 5b0283ebe155c9495ca2600471baf696fd3bcd81 Mon Sep 17 00:00:00 2001 From: XKHoshizora Date: Wed, 10 Jun 2026 23:45:21 +0900 Subject: [PATCH 3/5] fix: repair 18 test failures on Windows (fixture bugs, not product bugs) - claude.rs write_session_file() interpolated cwd into a JSON literal with format!(); Windows backslash paths produced invalid JSON escape sequences, the session file failed to parse, and 14 tests saw zero sessions. Serialize the fixture with serde_json instead. - codex.rs set_modified() opened the file read-only before calling File::set_modified(); on Windows that fails with PermissionDenied (os error 5) and 4 tests panicked. Open with write access (claude.rs set_mtime() already did this correctly). Test-only changes; behavior on unix is equivalent. All 163 tests now pass on Windows. Refs XKHoshizora/abtop#1 Co-Authored-By: Claude Fable 5 --- src/collector/claude.rs | 15 +++++++++------ src/collector/codex.rs | 5 ++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/collector/claude.rs b/src/collector/claude.rs index 39a2bc5..57e8b84 100644 --- a/src/collector/claude.rs +++ b/src/collector/claude.rs @@ -2038,14 +2038,17 @@ mod tests { } fn write_session_file(path: &Path, pid: u32, session_id: &str, cwd: &Path) { + // Serialize via serde_json so Windows backslash paths are escaped + // correctly instead of producing invalid JSON. std::fs::write( path, - format!( - r#"{{"pid":{},"sessionId":"{}","cwd":"{}","startedAt":1774715116826}}"#, - pid, - session_id, - cwd.display() - ), + serde_json::json!({ + "pid": pid, + "sessionId": session_id, + "cwd": cwd.to_str().unwrap(), + "startedAt": 1774715116826u64, + }) + .to_string(), ) .unwrap(); } diff --git a/src/collector/codex.rs b/src/collector/codex.rs index 3c7b4c0..622fa7c 100644 --- a/src/collector/codex.rs +++ b/src/collector/codex.rs @@ -1477,7 +1477,10 @@ mod tests { } fn set_modified(path: &Path, when: SystemTime) { - File::open(path).unwrap().set_modified(when).unwrap(); + // Open with write access: on Windows, setting timestamps through a + // read-only handle fails with PermissionDenied. + let file = std::fs::OpenOptions::new().write(true).open(path).unwrap(); + file.set_modified(when).unwrap(); } #[cfg(windows)] From 631c52a5e70a501888a76e8cc63de37e2ae15e40 Mon Sep 17 00:00:00 2001 From: XKHoshizora Date: Wed, 10 Jun 2026 23:45:23 +0900 Subject: [PATCH 4/5] docs: document Windows host metrics and the sqlite3 requirement Refs XKHoshizora/abtop#1 Co-Authored-By: Claude Fable 5 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3191e10..7c7929e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ cargo install abtop ### Windows -Native support — no WSL required. Uses `sysinfo` for process info and `netstat -ano` for listening ports. +Native support — no WSL required. Uses `sysinfo` for process info and host CPU/MEM metrics, and `netstat -ano` for listening ports. Windows has no load average, so LOAD is reported as 0. OpenCode session discovery additionally requires the `sqlite3` CLI (`winget install SQLite.SQLite`); without it abtop prints a one-time warning to stderr. ```powershell powershell -c "irm https://github.com/graykode/abtop/releases/latest/download/abtop-installer.ps1 | iex" @@ -83,7 +83,7 @@ tmux new -s work | Subagents | ✅ | ❌ | ❌ | | Memory Status | ✅ | ❌ | ❌ | -OpenCode support reads the local SQLite database at `~/.local/share/opencode/opencode.db` and requires `sqlite3` in `PATH`. +OpenCode support reads the local SQLite database at `~/.local/share/opencode/opencode.db` (also the default location on Windows; `%LOCALAPPDATA%\opencode` and `%APPDATA%\opencode` are probed as fallbacks) and requires `sqlite3` in `PATH` (on Windows: `winget install SQLite.SQLite`). ## Themes From 31b70becefdf248c2081b31f16d91260e465d7c5 Mon Sep 17 00:00:00 2001 From: graykode Date: Mon, 29 Jun 2026 14:18:46 +0900 Subject: [PATCH 5/5] fix: align Windows host metric docs --- src/host_info.rs | 6 +++--- src/snapshot.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/host_info.rs b/src/host_info.rs index 1f44f53..0c10ea9 100644 --- a/src/host_info.rs +++ b/src/host_info.rs @@ -210,9 +210,9 @@ mod windows_impl { } let mem_pct = (self.sys.used_memory() as f64 / total as f64) * 100.0; - // Windows has no load average; sysinfo reports 0.0 there. Callers - // should render load as N/A on Windows. - let load1 = System::load_average().one; + // Windows has no native load average. Keep the wire shape stable + // by reporting 0.0 rather than using sysinfo's approximation. + let load1 = 0.0; Some(HostMetrics { cpu_pct, diff --git a/src/snapshot.rs b/src/snapshot.rs index 3ce950d..d89c257 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -24,8 +24,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub struct Snapshot { /// Unix-epoch milliseconds when this snapshot was built. pub generated_at_ms: u64, - /// Host vitals (CPU / mem / load1). `None` on non-Linux or before the - /// first valid sample. + /// Host vitals (CPU / mem / load1). `None` on unsupported platforms or + /// before the first valid sample. pub host: Option, /// Aggregate metrics across all sessions. pub aggregate: AgentAggregate,