Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down
15 changes: 9 additions & 6 deletions src/collector/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
5 changes: 4 additions & 1 deletion src/collector/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
99 changes: 93 additions & 6 deletions src/collector/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,25 @@ pub struct OpenCodeCollector {
sqlite3_available: Option<bool>,
/// Cached DB rows from the last slow-tick query. Reused on fast ticks.
cached_db_sessions: Vec<DbSession>,
/// Whether the "sqlite3 missing" warning has been emitted (once).
#[cfg(target_os = "windows")]
warned_sqlite3_missing: bool,
}

impl OpenCodeCollector {
pub fn new() -> Self {
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,
}
}

Expand All @@ -51,7 +59,24 @@ impl OpenCodeCollector {

fn collect_sessions(&mut self, shared: &super::SharedProcessData) -> Vec<AgentSession> {
// 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![];
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -424,16 +452,75 @@ 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<String> {
std::fs::read_link(format!("/proc/{}/cwd", pid))
.ok()
.map(|p| p.to_string_lossy().into_owned())
}

#[cfg(not(target_os = "linux"))]
#[cfg(target_os = "windows")]
fn get_process_cwd(pid: u32) -> Option<String> {
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<String> {
// -a ANDs the selection terms; without it, lsof ORs `-p <pid>` with
// `-d cwd` and returns cwd entries for unrelated processes too.
Expand Down
99 changes: 90 additions & 9 deletions src/host_info.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<CpuTimes>,
#[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).
Expand All @@ -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<HostMetrics> {
let cpu_pct = self.sample_cpu()?;
let mem_pct = sample_mem()?;
Expand All @@ -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<HostMetrics> {
self.win.sample()
}

#[cfg(not(target_os = "windows"))]
fn sample_cpu(&mut self) -> Option<f64> {
let now = read_cpu_times()?;
let pct = match self.prev {
Expand Down Expand Up @@ -129,19 +144,85 @@ fn sample_load() -> Option<f64> {
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<CpuTimes> {
None
}
#[cfg(not(target_os = "linux"))]
#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))]
fn sample_mem() -> Option<f64> {
None
}
#[cfg(not(target_os = "linux"))]
#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))]
fn sample_load() -> Option<f64> {
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<HostMetrics> {
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 {
Expand Down
Loading