diff --git a/AGENTS.md b/AGENTS.md index d1a254c8..aad63e48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,8 @@ src/ │ ├── codex.rs # Codex CLI: session discovery via ps+lsof, JSONL parsing │ ├── opencode.rs # OpenCode: session discovery via ps + SQLite DB parsing │ ├── process.rs # Child process tree (ps) + open ports (lsof) + git stats -│ └── rate_limit.rs # Rate limit file reading (~/.claude/abtop-rate-limits.json) +│ ├── rate_limit.rs # Rate limit file reading (~/.claude/abtop-rate-limits.json) +│ └── remote.rs # Remote/SSH: polls `abtop --json` on [[remote_hosts]] entries └── model/ ├── mod.rs # Re-exports └── session.rs # AgentSession, SessionStatus, RateLimitInfo, @@ -348,9 +349,12 @@ cargo clippy # Lint - Gemini/Cursor support - Cost estimation -- Remote/SSH monitoring - Notifications/alerts +Remote/SSH monitoring (`[[remote_hosts]]` in config.toml) was a v0.1 +non-goal but has since shipped — see `src/collector/remote.rs` and +`docs/design/remote-ssh-monitoring.md`. + ## Terminal Jump (`Enter`) `Enter` focuses the terminal running the selected session's agent process. diff --git a/README.md b/README.md index 68116b7d..715abe0c 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ OpenCode support reads the local SQLite database at `~/.local/share/opencode/ope ## Themes -12 built-in themes, including 4 colorblind-friendly options (`high-contrast`, `protanopia`, `deuteranopia`, `tritanopia`). Press `t` to cycle at runtime, or launch with `--theme `. Your choice is saved to `~/.config/abtop/config.toml`. +12 built-in themes, including 4 colorblind-friendly options (`high-contrast`, `protanopia`, `deuteranopia`, `tritanopia`). Press `t` to cycle at runtime, or launch with `--theme `. Your choice is saved to the config file (see [Configuration](#configuration) for its path). | btop (default) | dracula | catppuccin | |:-:|:-:|:-:| @@ -120,7 +120,17 @@ Light themes (`light` — Solarized cream, `white` — GitHub-style pure white) ## Configuration -`~/.config/abtop/config.toml` supports: +The config file lives wherever your platform keeps per-user config +(resolved by the [`dirs`](https://docs.rs/dirs) crate) — notably **not** +`~/.config` on macOS, unlike most CLI tools: + +| Platform | Path | +| --- | --- | +| Linux | `~/.config/abtop/config.toml` | +| macOS | `~/Library/Application Support/abtop/config.toml` | +| Windows | `%APPDATA%\abtop\config.toml` | + +It supports: ```toml theme = "btop" @@ -133,6 +143,16 @@ hidden_agents = ["codex"] claude_config_dirs = ["~/.claude-personal", "~/.claude-work-team"] # UI language. Omit or leave empty to auto-detect from LANG. language = "zh" + +# Monitor agent sessions on another machine over SSH. Requires `abtop` +# installed on the remote host and passwordless (key/agent) SSH access — +# abtop never prompts for a password. Repeat this block per host. +[[remote_hosts]] +name = "devbox" # shown as the "[devbox]" row prefix +ssh_target = "devbox.internal" # anything `ssh` accepts: host, user@host, or an ~/.ssh/config alias +ssh_opts = ["-p", "2222"] # optional extra ssh args +poll_interval_secs = 10 # default 10 +allow_remote_kill = false # reserved for a future release; kill/jump always no-op on remote sessions today ``` ### Supported Languages diff --git a/docs/design/remote-ssh-monitoring.md b/docs/design/remote-ssh-monitoring.md new file mode 100644 index 00000000..9b95473a --- /dev/null +++ b/docs/design/remote-ssh-monitoring.md @@ -0,0 +1,232 @@ +# Design: Remote/SSH session monitoring + +Status: implemented on `feat/remote-ssh-monitoring` (all four phases below) +and verified against a live remote host — a real Claude Code session on a +Rocky Linux box, reached over SSH, showed up correctly (including its real +git branch/added/modified, confirming the Phase 2 collection-time guard +works) once the `--json`/`--once` one-shot wait fix below was added. + +## Summary + +Add optional SSH-based monitoring of `abtop`-supported agent sessions (Claude +Code, Codex CLI, OpenCode) running on remote hosts, e.g. a dev box reached via +SSH. Today this is out of scope: the whole collection pipeline (`ps`/`lsof`/ +filesystem reads) is local-only by design. + +## Proposed approach + +Rather than abstracting every `ps`/`lsof`/filesystem call in +`collector/process.rs`, `collector/claude.rs`, `collector/codex.rs`, +`collector/opencode.rs` behind a remote-capable backend trait (a large, risky +refactor, and wasteful on the wire — it would mean shipping raw multi-MB +JSONL transcripts over SSH just to re-parse them locally), reuse the fact +that `abtop` already has a complete, correct local collection pipeline via +its `--once` snapshot mode: + +> Remote monitoring = run `abtop --once --json` *on the remote host* over +> SSH, parse its output, tag it with a hostname, and merge it into the local +> session list. + +### New pieces + +**1. `abtop --once --json` output contract** +- New flag alongside existing `--once`. Emits a slim, versioned DTO (not the + raw `AgentSession`, to decouple wire format from internal refactors): + ```rust + struct RemoteSnapshot { + schema_version: u32, + sessions: Vec, + orphan_ports: Vec, + rate_limits: Vec, + } + ``` +- Reuses the existing `--once` redaction path (tool_use inputs already + reduced to "tool name + file path", per the Privacy section of AGENTS.md) + — no new redaction logic needed; secrets never hit the wire. + +**2. `src/collector/remote.rs` — `RemoteCollector: AgentCollector`** +- Config-driven host list in `~/.config/abtop/config.toml`: + ```toml + [[remote_hosts]] + name = "devbox" + ssh_target = "devbox.internal" # or user@host + ssh_opts = ["-p", "2222"] + poll_interval_secs = 10 + allow_remote_kill = false # see risks below + ``` +- Must not block the 2s tick loop: SSH round trips (auth handshake + remote + `ps`/`lsof`/JSONL scan) can take hundreds of ms to seconds. Each host polls + on its own background thread (same idiom already used for `claude --print` + summary generation: background process + timeout, capped concurrency), + writing into a shared cache that the main tick reads without blocking. +- SSH multiplexing is effectively required, not optional: + `-o ControlMaster=auto -o ControlPersist=60s -o ControlPath=...` so + repeated polls reuse one authenticated connection instead of a full + handshake every poll. + +**3. Model change**: add `host: Option` to `AgentSession` (`None` = +local; existing fixtures/tests unaffected). UI prefixes remote rows, e.g. +`[devbox] CC 7336 ...`. + +**4. Identity fix**: PIDs are only unique per host. Anywhere sessions are +currently keyed/selected/killed by `pid` alone (selection state, kill +action, orphan-port tracking) needs to become keyed by `(host, pid)` — this +is the one change that touches existing code paths rather than being purely +additive. + +### Failure handling (mirrors existing staleness/heuristic conventions in AGENTS.md) +- Unreachable host / auth failure → mark `Unreachable`, backoff, don't retry + faster than `poll_interval_secs`; show stale cached rows grayed out + ("stale, Ns ago") rather than blanking them immediately. +- Remote `abtop` missing or schema mismatch → `schema_version` checked + before parse; clear error message instead of a serde panic. +- Clock skew across hosts → compute "last seen" from local receipt time, not + the remote's absolute timestamps. + +### Deliberately out of scope for v1 +- Kill (`x`/`X`) on remote sessions/ports: default-disabled + (`allow_remote_kill = false`) — SIGKILL over SSH on a box you don't + locally control is a materially different risk than the existing local + safety-checked kill. +- tmux jump (`Enter`) for remote sessions: would need + `ssh -t host tmux select-pane`; changes the meaning of "jump," left for a + follow-up. +- Streaming/push: stick to the existing poll-tier model, just add a slower + "remote" tier (~10-30s) given network cost. + +## Trade-offs to flag explicitly + +This is a genuine scope change from today's design, which is intentionally +local-only, network-free, and auth-free ("No network, no auth" — Privacy / +Data Sources sections of AGENTS.md). This proposal introduces SSH as a real +dependency: connectivity, key/agent auth, and requiring `abtop` installed on +the remote host. Worth deciding deliberately rather than as a side effect of +an unrelated change. + +## Benefit vs. cost + +Nearly all new code is additive (one flag, one collector module, one config +block, one model field); the existing 3990-line `claude.rs` and friends stay +untouched. + +## Corrections against the current codebase (2026-09) + +The sketch above predates a look at the actual code. Two assumptions don't +hold and one is broader than necessary: + +1. **`--once`/`--json` already exist**, as two separate flags — `--json` + alone already prints the full `Snapshot`/`SessionView` (`src/snapshot.rs`), + richer than the slim `RemoteSnapshot` sketched above (it includes chat + tails, tool calls, subagents). There is no `schema_version` field today. + So "piece 1" isn't new; it's (a) add `schema_version: u32` to `Snapshot`, + and (b) decide whether `RemoteCollector` consumes the existing rich + `Snapshot` as-is, or a new slimmer DTO behind its own flag so chat/tool + tails aren't shipped over SSH by default. +2. **Config parsing (`src/config.rs`) is a hand-rolled line-by-line + `key = value` parser**, not a TOML crate — no support for + `[[remote_hosts]]` array-of-tables today. Adding it means either + extending the hand-rolled parser for this one nested shape, or pulling in + a real `toml`/`toml_edit` dependency. This repo has never needed a real + TOML dependency before; worth deciding deliberately. +3. **The "keyed by (host, pid)" identity fix is narrower than it sounds.** + Pid-keyed caches in `collector/mod.rs` (`cached_ports`, + `tracked_port_children`, `cached_port_pids`) are local-scan-only and never + touched by remote data — remote ports/orphans arrive pre-computed from the + remote `abtop --once`, so those maps stay pid-only. The part that actually + needs `(host, pid)` awareness is narrower and sharper: `kill_selected`, + `kill_orphan_ports`, and `jump_to_selected` in `app.rs` (lines ~710/758/801) + shell out `ps -p ` / `kill -9 ` against whatever pid sits in + `AgentSession.pid`, with **no check on origin today**. Once remote sessions + with foreign pids sit in `self.sessions`, `allow_remote_kill = false` in + config isn't sufficient on its own — those three call sites must explicitly + bail when `session.host.is_some()`, or a remote pid that happens to collide + with a real local killable-agent pid gets SIGKILLed locally. Treat this as + a correctness/safety must-fix, not a nice-to-have. + +## Implementation phasing + +- **Phase 0 — wire contract. Done.** Added `schema_version: u32` to + `Snapshot` and `host: Option` to `AgentSession`/`SessionView` + (`None` = local), plus `AgentSession::is_local()`. +- **Phase 1 — config. Done.** Extended the hand-rolled parser (not `toml`) + for `[[remote_hosts]]` — see `RemoteHostConfig` in `src/config.rs`. + `remote_hosts` is read-only from the app's perspective, so the + comment-preserving writer never needs to reproduce it. +- **Phase 2 — `RemoteCollector`. Done.** `src/collector/remote.rs`, one + background thread per host, cached last-good sessions + reachability, + registered in `MultiCollector::with_hidden_and_claude_config_dirs` gated on + `remote_hosts` being non-empty. A few decisions landed differently than + this sketch originally proposed: + - **No new CLI flag.** `--json` (added independently of this design, + before Phase 0) already emits a fast, redacted, versioned snapshot with + no up-to-30s summary wait — reused as-is over SSH rather than inventing + `--once --json`. + - **A dedicated, narrower `Deserialize` DTO** (`RemoteSnapshotDto` / + `RemoteSessionDto` in `remote.rs`) parses the same JSON `--json` emits, + rather than adding `Deserialize` to the internal `Snapshot`/`SessionView` + types. Every field is `#[serde(default)]`, so an older/newer remote + `abtop` degrades field-by-field; `schema_version` is still checked + up front for a clear reject message. A few `AgentSession` fields aren't + on the wire at all (`context_history`, `mem_file_count`/`mem_line_count`, + `pending_since_ms`/`thinking_since_ms`, `file_accesses`) and are simply + defaulted empty/zero for remote sessions — reduced fidelity, not a bug. + - **The remote's precomputed `summary` string is reused as `initial_prompt`** + so `App::session_summary` shows it directly, and + `drain_and_retry_summaries`/`has_retryable_summaries` now gate on + `host.is_none()` — otherwise every remote session would get a second, + local `claude --print` summarization over the same text. + - **Two host guards landed in `MultiCollector::collect` itself, not + deferred to Phase 3**: local git-stats recomputation and orphan-port + tracking both now skip `host.is_some()` sessions. Without the first, + `git -C ` would run against a local path that doesn't exist + (or worse, one that does); without the second, a remote child's PID + could coincidentally collide with a live local PID and feed + `kill_orphan_ports`. Fixing both at collection time also means + `OrphanPort` never needs a `host` field, and `kill_orphan_ports` never + needs its own guard. +- **Phase 3 — remaining safety guards + UI. Done.** + - `kill_selected` and `jump_to_session` (`App`) now bail with a status + message naming the host when `session.host.is_some()`, rather than + acting on a PID that's only meaningful on another machine + (`kill_orphan_ports` needed no guard — see the Phase 2 note above). + - `MultiCollector` holds its `RemoteCollector` in its own field (not + type-erased into the generic `collectors: Vec>`) + specifically so `remote_host_statuses()` can be queried directly for the + UI, without downcasting a trait object. + - Sessions panel (`ui/sessions.rs`): the project column shows `[host] + project` for a remote session (`project_display_name`); a session whose + host is currently unreachable gets the same dimming as a Done session, + and its task line is replaced with `stale, Ns ago` / `unreachable` + (`remote_stale_task_text`) instead of showing last-known-good task text + as if it were live. Note: the project column is only 8-14 chars + depending on terminal width, so on a narrow terminal `[host]` can crowd + out most of the actual project name — accepted as the same kind of + graceful truncation the rest of this panel already does, not a new + failure mode. + +## Verified against a live host + +Tested against a real remote box (Rocky Linux, a live Claude Code session) +over passwordless SSH: installed a user-level Rust toolchain there, built +this branch from source, ran `abtop --json` non-interactively exactly as +`RemoteCollector` does, then confirmed the session merged into a local +`abtop --json` run tagged `"host": "trockysvr"` with its real git branch, +added/modified counts, tokens, and model. This exercise found two real bugs: + +- **`--json`/`--once` never showed remote sessions on a fresh process.** + Both do exactly one tick and exit immediately, but a `RemoteCollector` + poll runs on a background thread and is only picked up on the *next* + `collect()` call — so a single-shot invocation always raced past the + first SSH round trip before it could land. Fixed with + `wait_for_remote_hosts` in `lib.rs`: re-ticks every 200ms (up to 10s, a + no-op when no hosts are configured) until every configured host has + reported at least once, success or failure, mirroring the existing + summary-wait loop `--once` already had for a different subsystem. +- **The config file isn't at `~/.config/abtop/config.toml` on macOS** — + `dirs::config_dir()` resolves to `~/Library/Application Support/abtop/` + there (`~/.config` is a Linux-only XDG convention this crate doesn't + special-case). Pre-existing README inaccuracy, not something this + feature introduced, but this is exactly the kind of mistake a + `[[remote_hosts]]` block silently no-ops on (the file at the wrong path + is just never read, no error) — README now documents the actual path per + platform. diff --git a/src/app.rs b/src/app.rs index e47408b2..047036ac 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use crate::collector::{read_rate_limits, McpServer, MultiCollector}; +use crate::collector::{read_rate_limits, McpServer, MultiCollector, RemoteHostStatus}; use crate::host_info::{AgentAggregate, HostMetrics, HostSampler}; use crate::model::{AgentSession, OrphanPort, RateLimitInfo, SessionStatus}; use crate::theme::Theme; @@ -158,7 +158,7 @@ impl App { hidden_agents: &[String], panels: crate::config::PanelVisibility, ) -> Self { - Self::new_with_config_and_claude_dirs(theme, hidden_agents, panels, &[]) + Self::new_with_config_and_claude_dirs(theme, hidden_agents, panels, &[], &[]) } pub fn new_with_config_and_claude_dirs( @@ -166,11 +166,15 @@ impl App { hidden_agents: &[String], panels: crate::config::PanelVisibility, claude_config_dirs: &[PathBuf], + remote_hosts: &[crate::config::RemoteHostConfig], ) -> Self { let (tx, rx) = mpsc::channel(); let summaries = load_summary_cache(); - let mut collector = - MultiCollector::with_hidden_and_claude_config_dirs(hidden_agents, claude_config_dirs); + let mut collector = MultiCollector::with_hidden_and_claude_config_dirs( + hidden_agents, + claude_config_dirs, + remote_hosts, + ); collector.set_mcp_suppress(true); Self { sessions: Vec::new(), @@ -579,7 +583,14 @@ impl App { .get(&s.session_id) .copied() .unwrap_or(0); - let has_input = !s.initial_prompt.is_empty() || !s.first_assistant_text.is_empty(); + // Remote sessions (`host.is_some()`) reuse the remote's own + // already-computed summary as `initial_prompt` (see + // `RemoteSessionDto::into_agent_session`) purely so + // `session_summary` picks it up below — it must never be fed + // back into a *local* `claude --print` call, or every remote + // session would get summarized twice. + let has_input = s.host.is_none() + && (!s.initial_prompt.is_empty() || !s.first_assistant_text.is_empty()); if has_input && !self.summaries.contains_key(&s.session_id) && !self.pending_summaries.contains(&s.session_id) @@ -611,7 +622,8 @@ impl App { /// True if any session still qualifies for a summary retry. pub fn has_retryable_summaries(&self) -> bool { self.sessions.iter().any(|s| { - (!s.initial_prompt.is_empty() || !s.first_assistant_text.is_empty()) + s.host.is_none() // see the matching guard in drain_and_retry_summaries + && (!s.initial_prompt.is_empty() || !s.first_assistant_text.is_empty()) && !self.summaries.contains_key(&s.session_id) && !self.pending_summaries.contains(&s.session_id) && self @@ -715,6 +727,19 @@ impl App { if matches!(session.status, SessionStatus::Done | SessionStatus::Unknown) { return; } + // PIDs are only unique per host: `session.pid` for a remote session + // means nothing against this machine's `ps`/`kill`, and could even + // collide with an unrelated live local process. Remote kill isn't + // implemented (see docs/design/remote-ssh-monitoring.md) — bail + // with a clear message rather than silently doing nothing, or + // worse, acting on the wrong local PID. + if let Some(host) = &session.host { + self.set_status(format!( + "kill isn't supported for remote sessions ({})", + host + )); + return; + } // Check if we have a pending confirmation for this exact session if let Some((idx, ts)) = self.kill_confirm.take() { @@ -790,6 +815,13 @@ impl App { self.should_quit = true; } + /// Status of every configured `[[remote_hosts]]` entry, in config order. + /// Empty when none are configured. For the sessions panel's `[host]` + /// row prefix and stale-while-unreachable treatment. + pub fn remote_host_statuses(&self) -> Vec { + self.collector.remote_host_statuses() + } + /// Jump to the terminal running the selected session's agent process. /// Delegates to the terminal-jumper registry (cmux / tmux / iTerm2); /// see [`crate::jump`]. No-op when nothing is selected or no backend @@ -798,7 +830,18 @@ impl App { if self.sessions.is_empty() { return JumpOutcome::NoOp; } - let target_pid = self.sessions[self.selected].pid; + let selected = &self.sessions[self.selected]; + // Same reasoning as the remote guard in `kill_selected`: `pid` is + // only meaningful on the host that collected it, and terminal jump + // for remote sessions isn't implemented (see + // docs/design/remote-ssh-monitoring.md). + if let Some(host) = &selected.host { + return JumpOutcome::Failed(format!( + "jump isn't supported for remote sessions ({})", + host + )); + } + let target_pid = selected.pid; crate::jump::run_jump(target_pid) } @@ -1054,6 +1097,7 @@ mod tests { config_root: String::new(), git_added: 0, git_modified: 0, + host: None, } } @@ -1114,4 +1158,56 @@ mod tests { "/Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled" )); } + + fn remote_session(host: &str) -> AgentSession { + let mut s = waiting_session("claude"); + s.host = Some(host.to_string()); + s + } + + #[test] + fn kill_selected_refuses_remote_sessions() { + // PIDs are only unique per host; kill must never fire against + // `session.pid` for a session tagged with a remote host. + let mut app = App::new_with_config( + Theme::default(), + &[], + crate::config::PanelVisibility::default(), + ); + app.sessions = vec![remote_session("devbox")]; + app.selected = 0; + + app.kill_selected(); + + assert!( + app.kill_confirm.is_none(), + "must never arm a kill confirmation for a remote session" + ); + let msg = app + .status_msg + .as_ref() + .map(|(m, _)| m.as_str()) + .unwrap_or(""); + assert!(msg.contains("remote"), "status should explain why: {msg}"); + assert!(msg.contains("devbox"), "status should name the host: {msg}"); + } + + #[test] + fn jump_to_session_refuses_remote_sessions() { + let mut app = App::new_with_config( + Theme::default(), + &[], + crate::config::PanelVisibility::default(), + ); + app.sessions = vec![remote_session("devbox")]; + app.selected = 0; + + match app.jump_to_session() { + JumpOutcome::Failed(msg) => { + assert!(msg.contains("remote"), "status should explain why: {msg}"); + assert!(msg.contains("devbox"), "status should name the host: {msg}"); + } + other => panic!("expected Failed for a remote session, got {other:?}"), + } + } } diff --git a/src/collector/claude.rs b/src/collector/claude.rs index c4d84745..3674ab43 100644 --- a/src/collector/claude.rs +++ b/src/collector/claude.rs @@ -712,6 +712,7 @@ impl ClaudeCollector { thinking_since_ms: cached.last_user_ts_ms, file_accesses, config_root: super::abbrev_path(&config.base_dir()), + host: None, }) } diff --git a/src/collector/codex.rs b/src/collector/codex.rs index 68d9cbe0..066a120c 100644 --- a/src/collector/codex.rs +++ b/src/collector/codex.rs @@ -673,6 +673,7 @@ impl CodexCollector { .parent() .unwrap_or(std::path::Path::new(".")), ), + host: None, }, rate_limit, )) diff --git a/src/collector/mod.rs b/src/collector/mod.rs index 930caf1a..f019fdf7 100644 --- a/src/collector/mod.rs +++ b/src/collector/mod.rs @@ -4,12 +4,14 @@ pub mod mcp; pub mod opencode; pub mod process; pub mod rate_limit; +pub mod remote; pub use claude::ClaudeCollector; pub use codex::CodexCollector; pub use mcp::McpServer; pub use opencode::OpenCodeCollector; pub use rate_limit::read_rate_limits; +pub use remote::{RemoteCollector, RemoteHostStatus}; /// Abbreviate a filesystem path by replacing the home directory prefix with `~`. pub(crate) fn abbrev_path(path: &std::path::Path) -> String { @@ -285,6 +287,10 @@ impl Drop for DesktopRolloutScanner { /// Aggregates sessions from multiple collectors (Claude, Codex, etc.) pub struct MultiCollector { collectors: Vec>, + /// Kept out of `collectors` (rather than boxed as `dyn AgentCollector` + /// alongside the rest) so the UI can query per-host reachability + /// (`remote_host_statuses`) without downcasting a trait object. + remote: Option, codex_enabled: bool, tick_count: u32, cached_ports: HashMap>, @@ -315,12 +321,13 @@ impl MultiCollector { /// `agent_cli` name (e.g. `"claude"`, `"codex"`). #[cfg(test)] pub fn with_hidden(hidden: &[String]) -> Self { - Self::with_hidden_and_claude_config_dirs(hidden, &[]) + Self::with_hidden_and_claude_config_dirs(hidden, &[], &[]) } pub fn with_hidden_and_claude_config_dirs( hidden: &[String], claude_config_dirs: &[PathBuf], + remote_hosts: &[crate::config::RemoteHostConfig], ) -> Self { let is_hidden = |name: &str| hidden.iter().any(|h| h.eq_ignore_ascii_case(name)); let mut collectors: Vec> = Vec::new(); @@ -335,9 +342,15 @@ impl MultiCollector { if !is_hidden("opencode") { collectors.push(Box::new(OpenCodeCollector::new())); } + let remote = if remote_hosts.is_empty() { + None + } else { + Some(RemoteCollector::new(remote_hosts.to_vec())) + }; let codex_enabled = !is_hidden("codex"); Self { collectors, + remote, codex_enabled, tick_count: SLOW_POLL_INTERVAL, // trigger on first tick cached_ports: HashMap::new(), @@ -355,6 +368,16 @@ impl MultiCollector { self.mcp_suppress = on; } + /// Status of every configured `[[remote_hosts]]` entry, in config order. + /// Empty when none are configured. For the UI's `[host]` row prefix and + /// stale-while-unreachable treatment. + pub fn remote_host_statuses(&self) -> Vec { + self.remote + .as_ref() + .map(RemoteCollector::host_statuses) + .unwrap_or_default() + } + /// Collect rate limit info from all registered collectors. pub fn agent_rate_limits(&self) -> Vec { self.collectors @@ -415,11 +438,23 @@ impl MultiCollector { for collector in &mut self.collectors { all.extend(collector.collect(&shared)); } + if let Some(remote) = &mut self.remote { + all.extend(remote.collect(&shared)); + } - // Git stats: refresh only on slow tick + // Git stats: refresh only on slow tick. `s.cwd` is a path on + // whichever machine collected `s` — for a remote session (`host` is + // `Some`) that's a path on the *remote* host, so `git -C ` + // here would run against a local path that doesn't exist (or, worse, + // happens to exist and belongs to something unrelated). Remote + // sessions already carry git stats computed on their own host by + // the remote `abtop --json`; leave those alone. if slow_tick { self.cached_git.clear(); for s in &mut all { + if s.host.is_some() { + continue; + } let stats = process::collect_git_stats(&s.cwd); self.cached_git.insert(s.cwd.clone(), stats); s.git_added = stats.0; @@ -427,6 +462,9 @@ impl MultiCollector { } } else { for s in &mut all { + if s.host.is_some() { + continue; + } if let Some(&(added, modified)) = self.cached_git.get(&s.cwd) { s.git_added = added; s.git_modified = modified; @@ -445,9 +483,19 @@ impl MultiCollector { all.sort_by_key(|s| std::cmp::Reverse(s.started_at)); // --- Orphan port detection --- - // 1. Update tracked port children from live sessions + // 1. Update tracked port children from live sessions. Remote + // sessions are excluded: PIDs are only unique per host, and + // `shared.ports`/`shared.process_info` below are local-only + // `ps`/`lsof` scans, so a remote child's PID checked against them + // is at best meaningless and at worst a coincidental collision with + // an unrelated local process — which `kill_orphan_ports` could then + // act on. Remote ports are shown informationally on the session + // detail view instead; they never enter local orphan tracking. let mut live_child_pids = std::collections::HashSet::new(); for s in &all { + if s.host.is_some() { + continue; + } if !matches!(s.status, SessionStatus::Done) { for child in &s.children { live_child_pids.insert(child.pid); diff --git a/src/collector/opencode.rs b/src/collector/opencode.rs index 7acb9114..9818c39a 100644 --- a/src/collector/opencode.rs +++ b/src/collector/opencode.rs @@ -234,6 +234,7 @@ impl OpenCodeCollector { config_root: super::abbrev_path( self.db_path.parent().unwrap_or(std::path::Path::new(".")), ), + host: None, }); } diff --git a/src/collector/remote.rs b/src/collector/remote.rs new file mode 100644 index 00000000..ad252e99 --- /dev/null +++ b/src/collector/remote.rs @@ -0,0 +1,650 @@ +//! Remote/SSH session monitoring: `abtop --json` on another host over SSH, +//! merged into the local session list. See +//! `docs/design/remote-ssh-monitoring.md` for the full design. +//! +//! Reuses the existing `--json` snapshot (`crate::snapshot::Snapshot`) as the +//! wire format rather than inventing a new flag: it's already redacted the +//! same way `--once` is, and already versioned via `schema_version` +//! (`crate::snapshot::SCHEMA_VERSION`). [`RemoteSnapshotDto`] below is a +//! separate, intentionally narrower `Deserialize` view of that same JSON — +//! it only picks out the fields needed to reconstruct an [`AgentSession`], +//! and every field is defaulted (`#[serde(default)]`) so an older/newer +//! remote `abtop` degrades gracefully instead of failing to parse. +//! +//! One background thread per configured host polls on its own schedule +//! (`poll_interval_secs`) and never blocks the 2s tick loop: [`collect`] +//! only ever reads the last-known-good cache, mirroring the +//! `DesktopRolloutScanner` idiom used for the Codex desktop-app rollout +//! scan elsewhere in this module. +//! +//! [`collect`]: RemoteCollector::collect + +use super::{AgentCollector, SharedProcessData}; +use crate::config::RemoteHostConfig; +use crate::model::{ + AgentSession, ChatMessage, ChatRole, ChildProcess, LaunchSurface, SessionStatus, SubAgent, + ToolCall, +}; +use serde::Deserialize; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// `ssh` gets this long (connect + remote `abtop --json` + transfer) before +/// we give up and kill it. Generous because a first connection (no +/// multiplexed master yet) does a full handshake. +const SSH_TIMEOUT: Duration = Duration::from_secs(20); +/// `ssh -o ConnectTimeout`: fail fast on an unreachable host rather than +/// waiting out the full `SSH_TIMEOUT` on a TCP connect that will never land. +const SSH_CONNECT_TIMEOUT_SECS: u64 = 5; + +/// One host's poll state. Config plus the last-known-good result and enough +/// bookkeeping to run one fetch at a time on its own schedule. +struct RemoteHostState { + cfg: RemoteHostConfig, + /// Last-known-good sessions, shown (stale) even while unreachable — + /// per the design doc, blanking a host on a transient failure is worse + /// than a grayed-out "stale, Ns ago" row. Empty until the first success. + cached_sessions: Vec, + reachable: bool, + last_error: Option, + last_success_at: Option, + last_attempt_at: Option, + in_flight: bool, + /// PID of the currently-running `ssh` child, if any (0 = none). Lets + /// `Drop` reap a still-running poll instead of leaking it on exit, + /// mirroring `DesktopRolloutScanner::child_pid`. + child_pid: Arc, + tx: Sender, + rx: Receiver, +} + +struct RemoteFetchResult { + outcome: Result, String>, +} + +/// Point-in-time status for one configured host, for the UI (host-prefix +/// row + "stale, Ns ago" treatment — see Phase 3 in the design doc). +#[derive(Debug, Clone)] +pub struct RemoteHostStatus { + pub name: String, + pub reachable: bool, + pub last_error: Option, + pub seconds_since_success: Option, +} + +/// Polls `abtop --json` on each configured `[[remote_hosts]]` entry over +/// SSH and merges the sessions it reports, tagged with `host`. +pub struct RemoteCollector { + hosts: Vec, + /// Directory backing SSH `ControlPath` sockets so repeated polls to the + /// same host reuse one authenticated connection. `None` when the + /// directory couldn't be created (e.g. no cache dir on this platform); + /// polls still work, just without connection reuse. + control_dir: Option, +} + +impl RemoteCollector { + pub fn new(hosts: Vec) -> Self { + let control_dir = dirs::cache_dir() + .map(|d| d.join("abtop").join("ssh")) + .filter(|d| std::fs::create_dir_all(d).is_ok()); + Self { + hosts: hosts.into_iter().map(RemoteHostState::new).collect(), + control_dir, + } + } + + /// Status of every configured host, in config order. For the UI. + pub fn host_statuses(&self) -> Vec { + self.hosts.iter().map(RemoteHostState::status).collect() + } +} + +impl AgentCollector for RemoteCollector { + fn collect(&mut self, _shared: &SharedProcessData) -> Vec { + let mut all = Vec::new(); + for host in &mut self.hosts { + host.poll_completed(); + if host.should_start() { + host.start(self.control_dir.as_deref()); + } + all.extend(host.cached_sessions.iter().cloned()); + } + all + } +} + +impl RemoteHostState { + fn new(cfg: RemoteHostConfig) -> Self { + let (tx, rx) = mpsc::channel(); + Self { + cfg, + cached_sessions: Vec::new(), + reachable: false, + last_error: None, + last_success_at: None, + last_attempt_at: None, + in_flight: false, + child_pid: Arc::new(AtomicU32::new(0)), + tx, + rx, + } + } + + fn status(&self) -> RemoteHostStatus { + RemoteHostStatus { + name: self.cfg.name.clone(), + reachable: self.reachable, + last_error: self.last_error.clone(), + seconds_since_success: self.last_success_at.map(|t| t.elapsed().as_secs()), + } + } + + fn poll_completed(&mut self) { + while let Ok(result) = self.rx.try_recv() { + self.in_flight = false; + match result.outcome { + Ok(sessions) => { + self.reachable = true; + self.last_error = None; + self.last_success_at = Some(Instant::now()); + self.cached_sessions = sessions; + } + Err(err) => { + self.reachable = false; + self.last_error = Some(err); + // Keep serving the last-known-good sessions (stale) — + // see docs/design/remote-ssh-monitoring.md failure handling. + } + } + } + } + + /// Never faster than `poll_interval_secs`, and never a second poll while + /// one is already in flight — a wedged SSH round trip must not pile up + /// threads on a host that's just slow to answer. + fn should_start(&self) -> bool { + if self.in_flight { + return false; + } + let interval = Duration::from_secs(self.cfg.poll_interval_secs.max(1)); + self.last_attempt_at + .is_none_or(|started| started.elapsed() >= interval) + } + + fn start(&mut self, control_dir: Option<&std::path::Path>) { + self.in_flight = true; + self.last_attempt_at = Some(Instant::now()); + let cfg = self.cfg.clone(); + let control_dir = control_dir.map(|p| p.to_path_buf()); + let tx = self.tx.clone(); + let child_pid = self.child_pid.clone(); + std::thread::spawn(move || { + let outcome = fetch_remote_sessions(&cfg, control_dir.as_deref(), child_pid); + let _ = tx.send(RemoteFetchResult { outcome }); + }); + } +} + +impl Drop for RemoteHostState { + fn drop(&mut self) { + // Best-effort: reap a still-running `ssh` poll on exit rather than + // leaking it. Matches the `kill -9` used by `App::kill_selected` + // elsewhere in this codebase — no Windows-specific path, same as + // there. + let pid = self.child_pid.swap(0, Ordering::SeqCst); + if pid != 0 { + let _ = Command::new("kill").args(["-9", &pid.to_string()]).status(); + } + } +} + +/// Run `ssh abtop --json`, parse the result, and map it to +/// `AgentSession`s tagged with this host's name. Blocking — call from a +/// background thread only. +fn fetch_remote_sessions( + cfg: &RemoteHostConfig, + control_dir: Option<&std::path::Path>, + child_pid_slot: Arc, +) -> Result, String> { + let stdout_file = + tempfile::NamedTempFile::new().map_err(|e| format!("failed to create temp file: {}", e))?; + let stderr_file = + tempfile::NamedTempFile::new().map_err(|e| format!("failed to create temp file: {}", e))?; + let stdout_for_child = stdout_file + .reopen() + .map_err(|e| format!("failed to reopen temp file: {}", e))?; + let stderr_for_child = stderr_file + .reopen() + .map_err(|e| format!("failed to reopen temp file: {}", e))?; + + let mut command = Command::new("ssh"); + command + .arg("-o") + .arg("BatchMode=yes") // never block on an interactive password prompt + .arg("-o") + .arg(format!("ConnectTimeout={}", SSH_CONNECT_TIMEOUT_SECS)); + // ControlMaster is a Unix-domain-socket feature; skip it on Windows + // rather than risk an unsupported -o option failing the whole command. + #[cfg(not(target_os = "windows"))] + if let Some(dir) = control_dir { + command + .arg("-o") + .arg("ControlMaster=auto") + .arg("-o") + .arg("ControlPersist=60s") + .arg("-o") + .arg(format!("ControlPath={}/%C", dir.display())); + } + command.args(&cfg.ssh_opts); + command.arg(&cfg.ssh_target); + command.args(["abtop", "--json"]); + command + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout_for_child)) + .stderr(Stdio::from(stderr_for_child)); + + let mut child = command + .spawn() + .map_err(|e| format!("failed to spawn ssh: {}", e))?; + child_pid_slot.store(child.id(), Ordering::SeqCst); + + let started = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Ok(status), + Ok(None) if started.elapsed() >= SSH_TIMEOUT => { + let _ = Command::new("kill") + .args(["-9", &child.id().to_string()]) + .status(); + let _ = child.wait(); + break Err(format!("ssh timed out after {:?}", SSH_TIMEOUT)); + } + Ok(None) => std::thread::sleep(Duration::from_millis(100)), + Err(e) => break Err(format!("failed to wait on ssh: {}", e)), + } + }; + child_pid_slot.store(0, Ordering::SeqCst); + let status = status?; + + if !status.success() { + let stderr = std::fs::read_to_string(stderr_file.path()).unwrap_or_default(); + let stderr = stderr.trim(); + return Err(if stderr.is_empty() { + format!("ssh exited with {}", status) + } else { + format!("ssh exited with {}: {}", status, stderr) + }); + } + + let stdout = std::fs::read_to_string(stdout_file.path()) + .map_err(|e| format!("failed to read ssh output: {}", e))?; + parse_remote_snapshot(&stdout, &cfg.name) +} + +/// Parse `abtop --json` output into `AgentSession`s tagged with `host`. +/// Split out from [`fetch_remote_sessions`] so it can be unit tested without +/// spawning `ssh`. +fn parse_remote_snapshot(json: &str, host: &str) -> Result, String> { + let dto: RemoteSnapshotDto = + serde_json::from_str(json).map_err(|e| format!("failed to parse remote JSON: {}", e))?; + if dto.schema_version != crate::snapshot::SCHEMA_VERSION { + return Err(format!( + "remote abtop schema_version {} != {} (local); upgrade abtop on '{}'", + dto.schema_version, + crate::snapshot::SCHEMA_VERSION, + host + )); + } + Ok(dto + .sessions + .into_iter() + .map(|s| s.into_agent_session(host)) + .collect()) +} + +/// Map a JSON `agent_cli` string to the matching interned `&'static str` +/// `AgentSession::agent_cli` requires. Falls back to `"unknown"` for a +/// value this build doesn't recognize (e.g. a newer remote abtop added an +/// agent type) rather than mislabeling it as one of the known three. +fn intern_agent_cli(s: &str) -> &'static str { + match s { + "claude" => "claude", + "codex" => "codex", + "opencode" => "opencode", + _ => "unknown", + } +} + +/// Narrow, defensively-parsed mirror of `crate::snapshot::Snapshot`: only +/// the fields needed to rebuild an `AgentSession`. `#[serde(default)]` +/// (container-level, backed by `#[derive(Default)]`) means a missing or +/// renamed field degrades to its default instead of failing the whole +/// parse — the schema_version check above is what actually gates +/// compatibility. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +struct RemoteSnapshotDto { + schema_version: u32, + sessions: Vec, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +struct RemoteSessionDto { + agent_cli: String, + launch_surface: LaunchSurface, + pid: u32, + session_id: String, + project_name: String, + cwd: String, + config_root: String, + status: SessionStatus, + model: String, + effort: String, + version: String, + context_percent: f64, + context_window: u64, + input_tokens: u64, + output_tokens: u64, + cache_read_tokens: u64, + cache_create_tokens: u64, + turn_count: u32, + mem_mb: u64, + git_branch: String, + git_added: u32, + git_modified: u32, + started_at_ms: u64, + summary: String, + children: Vec, + compaction_count: u32, + token_history: Vec, + subagents: Vec, + tool_calls: Vec, + chat_messages: Vec, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +struct RemoteSubAgentDto { + name: String, + status: String, + tokens: u64, +} + +impl From for SubAgent { + fn from(d: RemoteSubAgentDto) -> Self { + SubAgent { + name: d.name, + status: d.status, + tokens: d.tokens, + } + } +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +struct RemoteToolCallDto { + name: String, + arg: String, + duration_ms: u64, +} + +impl From for ToolCall { + fn from(d: RemoteToolCallDto) -> Self { + ToolCall { + name: d.name, + arg: d.arg, + duration_ms: d.duration_ms, + } + } +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(default)] +struct RemoteChatMsgDto { + role: String, + text: String, +} + +impl From for ChatMessage { + fn from(d: RemoteChatMsgDto) -> Self { + ChatMessage { + role: if d.role == "assistant" { + ChatRole::Assistant + } else { + ChatRole::User + }, + text: d.text, + } + } +} + +impl RemoteSessionDto { + fn into_agent_session(self, host: &str) -> AgentSession { + AgentSession { + agent_cli: intern_agent_cli(&self.agent_cli), + launch_surface: self.launch_surface, + pid: self.pid, + session_id: self.session_id, + cwd: self.cwd, + project_name: self.project_name, + started_at: self.started_at_ms, + status: self.status, + model: self.model, + effort: self.effort, + context_percent: self.context_percent, + total_input_tokens: self.input_tokens, + total_output_tokens: self.output_tokens, + total_cache_read: self.cache_read_tokens, + total_cache_create: self.cache_create_tokens, + turn_count: self.turn_count, + current_tasks: Vec::new(), + mem_mb: self.mem_mb, + version: self.version, + git_branch: self.git_branch, + git_added: self.git_added, + git_modified: self.git_modified, + token_history: self.token_history, + // Per-turn context history isn't on the wire (SessionView omits + // it); the context-evolution sparkline just has nothing to show + // for remote sessions rather than a misleading local guess. + context_history: Vec::new(), + compaction_count: self.compaction_count, + context_window: self.context_window, + subagents: self.subagents.into_iter().map(Into::into).collect(), + // Memory file/line counts read `~/.claude/.../memory/` locally; + // no remote equivalent is on the wire. + mem_file_count: 0, + mem_line_count: 0, + children: self.children, + // The remote already computed a final display summary (cached + // LLM title, or its own sanitized-prompt fallback); reuse it + // as `initial_prompt` so `App::session_summary` picks it up + // directly. `App::drain_and_retry_summaries` must skip + // `host.is_some()` sessions so this never triggers a *second*, + // local `claude --print` call over the same text. + initial_prompt: self.summary, + first_assistant_text: String::new(), + chat_messages: self.chat_messages.into_iter().map(Into::into).collect(), + tool_calls: self.tool_calls.into_iter().map(Into::into).collect(), + pending_since_ms: 0, + thinking_since_ms: 0, + file_accesses: Vec::new(), + config_root: self.config_root, + host: Some(host.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_json(schema_version: u32) -> String { + format!( + r#"{{ + "schema_version": {schema_version}, + "generated_at_ms": 0, + "host": null, + "aggregate": {{}}, + "token_rate": 0.0, + "interval_ms": 2000, + "sessions": [{{ + "host": null, + "agent_cli": "claude", + "launch_surface": "Ide", + "pid": 4242, + "session_id": "abc-123", + "project_name": "abtop", + "cwd": "/home/dev/abtop", + "config_root": "~/.claude", + "status": "Executing", + "model": "claude-opus-4-6", + "effort": "", + "version": "2.1.86", + "context_percent": 42.5, + "context_window": 200000, + "total_tokens": 1000, + "input_tokens": 400, + "output_tokens": 200, + "cache_read_tokens": 300, + "cache_create_tokens": 100, + "turn_count": 7, + "mem_mb": 128, + "git_branch": "main", + "git_added": 2, + "git_modified": 3, + "started_at_ms": 1000, + "elapsed_secs": 500, + "summary": "Fixing the payment webhook", + "current_task": "Edit src/pay.rs", + "children": [{{"pid": 99, "command": "node", "mem_kb": 1024, "port": 3000}}], + "compaction_count": 0, + "token_history": [10, 20, 30], + "subagents": [{{"name": "explore", "status": "done", "tokens": 500}}], + "tool_calls": [{{"name": "Edit", "arg": "src/pay.rs", "duration_ms": 120}}], + "chat_messages": [{{"role": "assistant", "text": "done"}}] + }}], + "rate_limits": [], + "orphan_ports": [], + "mcp_servers": [] + }}"# + ) + } + + #[test] + fn parses_a_well_formed_snapshot_and_tags_host() { + let sessions = + parse_remote_snapshot(&sample_json(crate::snapshot::SCHEMA_VERSION), "devbox") + .expect("valid snapshot parses"); + assert_eq!(sessions.len(), 1); + let s = &sessions[0]; + assert_eq!(s.host.as_deref(), Some("devbox")); + assert_eq!(s.agent_cli, "claude"); + assert_eq!(s.launch_surface, LaunchSurface::Ide); + assert_eq!(s.pid, 4242); + assert_eq!(s.session_id, "abc-123"); + assert_eq!(s.model, "claude-opus-4-6"); + assert_eq!(s.status, SessionStatus::Executing); + assert_eq!(s.total_input_tokens, 400); + assert_eq!(s.total_cache_read, 300); + assert_eq!(s.children.len(), 1); + assert_eq!(s.children[0].port, Some(3000)); + assert_eq!(s.subagents.len(), 1); + assert_eq!(s.tool_calls.len(), 1); + assert_eq!(s.chat_messages.len(), 1); + assert_eq!(s.chat_messages[0].role, ChatRole::Assistant); + // The remote's precomputed summary becomes the display title source, + // never raw transcript text. + assert_eq!(s.initial_prompt, "Fixing the payment webhook"); + assert!(s.first_assistant_text.is_empty()); + } + + #[test] + fn rejects_a_schema_version_mismatch_with_a_clear_message() { + let err = + parse_remote_snapshot(&sample_json(crate::snapshot::SCHEMA_VERSION + 1), "devbox") + .unwrap_err(); + assert!(err.contains("schema_version")); + assert!(err.contains("devbox")); + } + + #[test] + fn rejects_non_json_with_an_error_not_a_panic() { + let err = parse_remote_snapshot("not json at all", "devbox").unwrap_err(); + assert!(err.contains("failed to parse")); + } + + #[test] + fn missing_optional_fields_default_instead_of_failing() { + // A minimal, schema_version-only payload (e.g. an older remote + // abtop that predates most SessionView fields) still parses. + let json = format!( + r#"{{"schema_version": {}, "sessions": []}}"#, + crate::snapshot::SCHEMA_VERSION + ); + let sessions = parse_remote_snapshot(&json, "devbox").expect("degrades, doesn't fail"); + assert!(sessions.is_empty()); + } + + #[test] + fn session_missing_launch_surface_defaults_to_cli() { + // An older remote abtop built before launch_surface existed (e.g. + // one built from this branch alone, without the launch-surface + // feature merged in) shouldn't fail to parse — every other field + // in a minimal session object degrades the same way. + let json = format!( + r#"{{"schema_version": {}, "sessions": [{{"agent_cli": "claude", "pid": 1, "session_id": "s"}}]}}"#, + crate::snapshot::SCHEMA_VERSION + ); + let sessions = parse_remote_snapshot(&json, "devbox").expect("degrades, doesn't fail"); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].launch_surface, LaunchSurface::Cli); + } + + #[test] + fn unknown_agent_cli_falls_back_instead_of_mislabeling() { + assert_eq!(intern_agent_cli("gemini"), "unknown"); + assert_eq!(intern_agent_cli("claude"), "claude"); + } + + #[test] + fn host_statuses_reports_unreachable_before_first_poll() { + let collector = RemoteCollector::new(vec![RemoteHostConfig { + name: "devbox".into(), + ssh_target: "devbox.internal".into(), + ssh_opts: Vec::new(), + poll_interval_secs: 10, + allow_remote_kill: false, + }]); + let statuses = collector.host_statuses(); + assert_eq!(statuses.len(), 1); + assert_eq!(statuses[0].name, "devbox"); + assert!(!statuses[0].reachable); + assert!(statuses[0].seconds_since_success.is_none()); + } + + #[test] + fn should_start_respects_poll_interval_and_in_flight_guard() { + let mut host = RemoteHostState::new(RemoteHostConfig { + name: "devbox".into(), + ssh_target: "devbox.internal".into(), + ssh_opts: Vec::new(), + poll_interval_secs: 10, + allow_remote_kill: false, + }); + assert!(host.should_start(), "never polled yet"); + + host.in_flight = true; + assert!(!host.should_start(), "a poll is already running"); + + host.in_flight = false; + host.last_attempt_at = Some(Instant::now()); + assert!(!host.should_start(), "polled moments ago"); + } +} diff --git a/src/config.rs b/src/config.rs index 4cd5532f..c282ca6c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,6 +25,40 @@ impl Default for PanelVisibility { } } +/// One `[[remote_hosts]]` block: an SSH-reachable host to poll for remote +/// session monitoring (see docs/design/remote-ssh-monitoring.md). Read-only +/// config — nothing in the app writes this back, so `write_with_updates`'s +/// comment/unknown-key preservation never needs to reproduce it. +#[derive(Clone, Debug, PartialEq)] +pub struct RemoteHostConfig { + /// Display name, e.g. "devbox". Used as the `host` tag on remote + /// sessions and must be non-empty for the entry to be kept. + pub name: String, + /// `ssh` destination, e.g. "devbox.internal" or "user@host". Must be + /// non-empty for the entry to be kept. + pub ssh_target: String, + /// Extra arguments passed to `ssh` verbatim, e.g. `["-p", "2222"]`. + pub ssh_opts: Vec, + /// How often to poll this host, in seconds. + pub poll_interval_secs: u64, + /// Whether `x`/`X` kill actions may target this host's sessions/ports. + /// Defaults to `false`: SIGKILL over SSH on a box you don't locally + /// control is a materially different risk than local kill. + pub allow_remote_kill: bool, +} + +impl Default for RemoteHostConfig { + fn default() -> Self { + Self { + name: String::new(), + ssh_target: String::new(), + ssh_opts: Vec::new(), + poll_interval_secs: 10, + allow_remote_kill: false, + } + } +} + pub struct AppConfig { pub theme: String, /// Agent CLI names to exclude from the TUI (e.g. ["codex"] to hide Codex). @@ -37,6 +71,9 @@ pub struct AppConfig { /// UI language override. Empty string means auto-detect from `LANG`. /// Recognized values: "en", "zh" (anything starting with "zh" maps to Simplified Chinese). pub language: String, + /// SSH-reachable hosts to poll for remote session monitoring, from + /// `[[remote_hosts]]` blocks. Empty unless configured. + pub remote_hosts: Vec, } impl Default for AppConfig { @@ -47,6 +84,7 @@ impl Default for AppConfig { claude_config_dirs: Vec::new(), panels: PanelVisibility::default(), language: String::new(), + remote_hosts: Vec::new(), } } } @@ -71,20 +109,48 @@ pub fn load_config() -> AppConfig { fn parse_config_body(content: &str) -> AppConfig { let mut config = AppConfig::default(); + // `true` while inside a `[[remote_hosts]]` block, i.e. until the next + // table header (of any kind) or EOF — same scoping TOML itself uses for + // array-of-tables. There's exactly one supported table shape today, so + // this is a bool rather than a general section stack. + let mut in_remote_host = false; for line in content.lines() { let line = line.trim(); if line.starts_with('#') || line.is_empty() { continue; } + // Strip inline comments up front so table headers (no `=`) get the + // same `# comment` handling as key/value lines below. + let line = match line.find('#') { + Some(pos) => line[..pos].trim(), + None => line, + }; + if line.is_empty() { + continue; + } + if let Some(inner) = line.strip_prefix("[[").and_then(|s| s.strip_suffix("]]")) { + in_remote_host = inner.trim() == "remote_hosts"; + if in_remote_host { + config.remote_hosts.push(RemoteHostConfig::default()); + } + continue; + } + if line.starts_with('[') && line.ends_with(']') { + // Any other (currently unsupported) table header ends the + // remote_hosts block rather than letting its keys leak in. + in_remote_host = false; + continue; + } if let Some((key, val)) = line.split_once('=') { let key = key.trim(); - // Strip quotes (double or single) and inline comments let val = val.trim(); - let val = if let Some(comment_pos) = val.find('#') { - val[..comment_pos].trim() - } else { - val - }; + if in_remote_host { + // `push` above guarantees a current entry whenever this flag is set. + if let Some(host) = config.remote_hosts.last_mut() { + apply_remote_host_key(host, key, val); + } + continue; + } if key == "hidden_agents" { config.hidden_agents = parse_string_array(val); continue; @@ -108,9 +174,39 @@ fn parse_config_body(content: &str) -> AppConfig { } } } + // Drop entries missing a name or ssh_target — nothing downstream can + // address a host without both, and config loading must stay infallible + // rather than surface a parse error for a malformed block. + config + .remote_hosts + .retain(|h| !h.name.is_empty() && !h.ssh_target.is_empty()); config } +/// Apply one `key = value` line from inside a `[[remote_hosts]]` block. +/// Unknown keys are ignored (defensive parsing, matches the top-level +/// parser's behavior for forward/backward config compatibility). +fn apply_remote_host_key(host: &mut RemoteHostConfig, key: &str, val: &str) { + if key == "ssh_opts" { + host.ssh_opts = parse_string_array(val); + return; + } + let quoted = val.trim_matches('"').trim_matches('\''); + match key { + "name" => host.name = quoted.to_string(), + "ssh_target" => host.ssh_target = quoted.to_string(), + "poll_interval_secs" => { + if let Ok(secs) = val.trim().parse::() { + host.poll_interval_secs = secs; + } + } + "allow_remote_kill" => { + host.allow_remote_kill = parse_bool(val).unwrap_or(false); + } + _ => {} + } +} + fn parse_bool(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().as_str() { "true" => Some(true), @@ -219,6 +315,122 @@ fn rewrite_kv_lines(content: &str, updates: &[(&str, String)]) -> String { mod tests { use super::*; + #[test] + fn parse_config_body_reads_a_full_remote_host_block() { + let cfg = parse_config_body( + r#" +[[remote_hosts]] +name = "devbox" +ssh_target = "devbox.internal" +ssh_opts = ["-p", "2222"] +poll_interval_secs = 30 +allow_remote_kill = true +"#, + ); + assert_eq!( + cfg.remote_hosts, + vec![RemoteHostConfig { + name: "devbox".into(), + ssh_target: "devbox.internal".into(), + ssh_opts: vec!["-p".into(), "2222".into()], + poll_interval_secs: 30, + allow_remote_kill: true, + }] + ); + } + + #[test] + fn parse_config_body_applies_remote_host_defaults_for_omitted_keys() { + // poll_interval_secs and allow_remote_kill are safety/perf defaults + // (10s poll, kill disabled) when a block doesn't set them. + let cfg = parse_config_body( + r#" +[[remote_hosts]] +name = "devbox" +ssh_target = "devbox.internal" +"#, + ); + let host = &cfg.remote_hosts[0]; + assert_eq!(host.poll_interval_secs, 10); + assert!(!host.allow_remote_kill); + assert!(host.ssh_opts.is_empty()); + } + + #[test] + fn parse_config_body_reads_multiple_remote_host_blocks() { + let cfg = parse_config_body( + r#" +[[remote_hosts]] +name = "devbox" +ssh_target = "devbox.internal" + +[[remote_hosts]] +name = "gpu-box" +ssh_target = "gpu.internal" +"#, + ); + assert_eq!(cfg.remote_hosts.len(), 2); + assert_eq!(cfg.remote_hosts[0].name, "devbox"); + assert_eq!(cfg.remote_hosts[1].name, "gpu-box"); + } + + #[test] + fn parse_config_body_drops_remote_host_missing_required_fields() { + // A block missing `name` or `ssh_target` can't be addressed by + // anything downstream, so it's dropped rather than kept half-formed. + let cfg = parse_config_body( + r#" +[[remote_hosts]] +ssh_target = "devbox.internal" + +[[remote_hosts]] +name = "no-target" +"#, + ); + assert!(cfg.remote_hosts.is_empty()); + } + + #[test] + fn parse_config_body_ignores_inline_comment_on_table_header() { + let cfg = parse_config_body( + r#" +[[remote_hosts]] # my dev box +name = "devbox" +ssh_target = "devbox.internal" +"#, + ); + assert_eq!(cfg.remote_hosts.len(), 1); + } + + #[test] + fn parse_config_body_keeps_top_level_keys_working_alongside_remote_hosts() { + let cfg = parse_config_body( + r#" +theme = "dracula" +[[remote_hosts]] +name = "devbox" +ssh_target = "devbox.internal" +"#, + ); + assert_eq!(cfg.theme, "dracula"); + assert_eq!(cfg.remote_hosts.len(), 1); + } + + #[test] + fn rewrite_kv_lines_leaves_a_remote_hosts_block_untouched() { + // remote_hosts is read-only from the app's perspective (only ever + // hand-edited); a save_* call for an unrelated key must not mangle it. + let before = "theme = \"btop\"\n[[remote_hosts]]\nname = \"devbox\"\nssh_target = \"devbox.internal\"\n"; + let after = rewrite_kv_lines(before, &theme_update("nord")); + assert!(after.contains("[[remote_hosts]]")); + assert!(after.contains("name = \"devbox\"")); + assert!(after.contains("ssh_target = \"devbox.internal\"")); + assert!(after.contains("theme = \"nord\"")); + // And the rewritten body still parses back to the same host. + let cfg = parse_config_body(&after); + assert_eq!(cfg.remote_hosts[0].name, "devbox"); + } + #[test] fn parse_string_array_basic() { assert_eq!(parse_string_array(r#"["codex"]"#), vec!["codex"]); diff --git a/src/demo.rs b/src/demo.rs index b8d32716..92ddff99 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -200,6 +200,7 @@ pub fn populate_demo(app: &mut App) { pending_since_ms: now - 6_000, // 6s ago => bar animates thinking_since_ms: 0, config_root: "~/.claude".into(), + host: None, file_accesses: vec![ FileAccess { path: "src/checkout/payment.rs".into(), @@ -309,6 +310,7 @@ pub fn populate_demo(app: &mut App) { pending_since_ms: 0, thinking_since_ms: 0, config_root: "~/.claude-work".into(), + host: None, file_accesses: vec![], }, AgentSession { @@ -414,6 +416,7 @@ pub fn populate_demo(app: &mut App) { // session's 2.8s max tool duration before it caps at 100%. thinking_since_ms: now - 1_000, config_root: "~/.claude".into(), + host: None, file_accesses: vec![], }, AgentSession { @@ -469,6 +472,7 @@ pub fn populate_demo(app: &mut App) { pending_since_ms: 0, thinking_since_ms: 0, config_root: "~/.codex".into(), + host: None, file_accesses: vec![], }, AgentSession { @@ -512,6 +516,7 @@ pub fn populate_demo(app: &mut App) { pending_since_ms: 0, thinking_since_ms: 0, config_root: "~/.local/share/opencode".into(), + host: None, file_accesses: vec![], }, ]; diff --git a/src/lib.rs b/src/lib.rs index b393efc9..69dfa666 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,7 @@ //! &cfg.hidden_agents, //! cfg.panels, //! &cfg.claude_config_dirs, +//! &cfg.remote_hosts, //! ); //! loop { //! app.tick_no_summaries(); // refresh without spawning `claude --print` @@ -75,7 +76,7 @@ use crossterm::terminal::{ use crossterm::ExecutableCommand; use ratatui::prelude::*; use std::io::{self, stdout}; -use std::time::Duration; +use std::time::{Duration, Instant}; /// Construct a headless `App` from loaded config + theme. Shared by the /// `--json` and `--once` entry points. @@ -85,9 +86,37 @@ fn build_app(theme: theme::Theme, cfg: &config::AppConfig) -> App { &cfg.hidden_agents, cfg.panels, &cfg.claude_config_dirs, + &cfg.remote_hosts, ) } +/// A `RemoteCollector` poll runs on its own background thread and is +/// checked on the next call to `collect()` — fine for the long-running TUI, +/// which ticks every 2s regardless, but `--json`/`--once` do exactly one +/// tick and exit immediately, so without this a freshly-started process +/// would always report remote hosts as empty (the first poll simply +/// wouldn't have landed yet). Re-ticks every 200ms until every configured +/// host has reported at least once (success or failure) or `timeout` +/// elapses; a no-op with no configured remote hosts. +fn wait_for_remote_hosts_with_timeout(app: &mut App, timeout: Duration) { + let deadline = Instant::now() + timeout; + loop { + let still_pending = app + .remote_host_statuses() + .iter() + .any(|s| !s.reachable && s.last_error.is_none()); + if !still_pending || Instant::now() >= deadline { + return; + } + std::thread::sleep(Duration::from_millis(200)); + app.tick_no_summaries(); + } +} + +fn wait_for_remote_hosts(app: &mut App) { + wait_for_remote_hosts_with_timeout(app, Duration::from_secs(10)); +} + pub fn run() -> io::Result<()> { // --version / -V flag: print version and exit if std::env::args().any(|a| a == "--version" || a == "-V") { @@ -154,6 +183,7 @@ pub fn run() -> io::Result<()> { demo::populate_demo(&mut app); } else { app.tick_no_summaries(); + wait_for_remote_hosts(&mut app); } match serde_json::to_string_pretty(&app.to_snapshot(2000)) { Ok(json) => { @@ -174,6 +204,7 @@ pub fn run() -> io::Result<()> { demo::populate_demo(&mut app); } else { app.tick(); + wait_for_remote_hosts(&mut app); // Wait for summaries: retry-aware budget (up to 30s total to allow 2 × 10s attempts + slack) let deadline = std::time::Instant::now() + Duration::from_secs(30); while std::time::Instant::now() < deadline { @@ -196,15 +227,7 @@ pub fn run() -> io::Result<()> { } let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; - let app_result = run_app( - &mut terminal, - demo_mode, - initial_theme, - exit_on_jump, - &cfg.hidden_agents, - cfg.panels, - &cfg.claude_config_dirs, - ); + let app_result = run_app(&mut terminal, demo_mode, initial_theme, exit_on_jump, &cfg); // Always attempt both cleanup steps regardless of app result let r1 = if mouse_capture { @@ -232,15 +255,14 @@ fn run_app( demo_mode: bool, initial_theme: Option, exit_on_jump: bool, - hidden_agents: &[String], - panels: config::PanelVisibility, - claude_config_dirs: &[std::path::PathBuf], + cfg: &config::AppConfig, ) -> io::Result<()> { let mut app = App::new_with_config_and_claude_dirs( initial_theme.unwrap_or_default(), - hidden_agents, - panels, - claude_config_dirs, + &cfg.hidden_agents, + cfg.panels, + &cfg.claude_config_dirs, + &cfg.remote_hosts, ); if demo_mode { demo::populate_demo(&mut app); @@ -612,4 +634,22 @@ mod tests { assert!(!text.contains("Broken pipe")); assert!(!text.contains("select-workspace")); } + + #[test] + fn wait_for_remote_hosts_is_a_noop_with_no_remote_hosts_configured() { + // No remote_hosts means remote_host_statuses() is always empty, so + // this must return immediately rather than sleeping out the full + // timeout on every --json/--once invocation. + let mut app = App::new_with_config( + theme::Theme::default(), + &[], + config::PanelVisibility::default(), + ); + let started = Instant::now(); + wait_for_remote_hosts_with_timeout(&mut app, Duration::from_secs(5)); + assert!( + started.elapsed() < Duration::from_secs(1), + "should return immediately, not wait out the timeout" + ); + } } diff --git a/src/model/session.rs b/src/model/session.rs index 72c0ea65..c09ecb45 100644 --- a/src/model/session.rs +++ b/src/model/session.rs @@ -56,7 +56,7 @@ pub struct RateLimitInfo { pub updated_at: Option, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum SessionStatus { /// Model is generating a response (last_user_ts_ms > 0) Thinking, @@ -79,7 +79,17 @@ impl SessionStatus { } } -#[derive(Debug, Clone, Serialize)] +impl Default for SessionStatus { + /// Used when a `RemoteCollector` deserializes a session that's missing + /// (or fails to parse) its `status` field: "recent, but process + /// ownership not confirmed" is the closest existing meaning to "unknown + /// status from the wire." + fn default() -> Self { + SessionStatus::Unknown + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChildProcess { pub pid: u32, pub command: String, @@ -134,7 +144,7 @@ pub const MAX_CHAT_MESSAGES: usize = 12; /// executable path in its command line. Always `Cli` for Codex and OpenCode /// sessions — no desktop-app or editor-extension equivalent is known for /// those tools yet. -#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub enum LaunchSurface { /// Plain CLI invocation: a terminal shell running an npm/homebrew/native /// install (or the auto-updater's `versions/` layout). @@ -145,6 +155,16 @@ pub enum LaunchSurface { Ide, } +impl Default for LaunchSurface { + /// Used when a `RemoteCollector` deserializes a session missing (or + /// failing to parse) its `launch_surface` field — same fallback + /// `ClaudeCollector::detect_launch_surface` uses when detection itself + /// is inconclusive. + fn default() -> Self { + LaunchSurface::Cli + } +} + impl LaunchSurface { /// Single-char suffix appended to the agent-label column in the sessions /// table. Empty for the default `Cli` case so existing rows/tests are @@ -223,9 +243,22 @@ pub struct AgentSession { /// For Claude Code: the active .claude* profile folder. For Codex: "~/.codex". /// For OpenCode: the data directory containing opencode.db. pub config_root: String, + /// Host this session was collected from, per `remote_hosts` config + /// (`[[remote_hosts]].name`). `None` for sessions collected locally. + /// PIDs are only unique per host — anything that acts on `pid` alone + /// (kill, tmux/terminal jump) must first check this is `None`. + pub host: Option, } impl AgentSession { + /// True when this session was collected on the local machine. Kill and + /// terminal-jump actions must only ever act on local sessions: PIDs are + /// only unique per host, so a remote session's PID may collide with an + /// unrelated local process. + pub fn is_local(&self) -> bool { + self.host.is_none() + } + pub fn total_tokens(&self) -> u64 { self.total_input_tokens + self.total_output_tokens @@ -333,6 +366,7 @@ mod tests { thinking_since_ms: 0, file_accesses: Vec::new(), config_root: String::new(), + host: None, } } @@ -347,4 +381,14 @@ mod tests { let session = make_session(100, 50, 200, 30); assert_eq!(session.active_tokens(), 180); // 100 + 50 + 30, excludes cache_read } + + #[test] + fn is_local_reflects_host_field() { + // Guards the invariant kill/jump code relies on: only a session with + // `host: None` is safe to act on by local PID. + let mut session = make_session(0, 0, 0, 0); + assert!(session.is_local()); + session.host = Some("devbox".to_string()); + assert!(!session.is_local()); + } } diff --git a/src/snapshot.rs b/src/snapshot.rs index 4a631b66..70b87585 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -20,9 +20,17 @@ use crate::model::{ use serde::Serialize; use std::time::{SystemTime, UNIX_EPOCH}; +/// Wire schema version of [`Snapshot`]. Bump on any breaking field +/// removal/rename so a consumer (e.g. a `RemoteCollector` parsing another +/// host's `--json` output) can reject a mismatched version with a clear +/// error instead of failing serde deserialization opaquely. +pub const SCHEMA_VERSION: u32 = 1; + /// Top-level snapshot returned by [`App::to_snapshot`]. #[derive(Debug, Clone, Serialize)] pub struct Snapshot { + /// Wire schema version; see [`SCHEMA_VERSION`]. + pub schema_version: u32, /// Unix-epoch milliseconds when this snapshot was built. pub generated_at_ms: u64, /// Host vitals (CPU / mem / load1). `None` on unsupported platforms or @@ -85,6 +93,9 @@ pub struct SubAgentView { /// A single session, flattened and curated for JSON consumers. #[derive(Debug, Clone, Serialize)] pub struct SessionView { + /// Host this session was collected from (`[[remote_hosts]].name` in + /// config.toml). `None` for sessions collected on the local machine. + pub host: Option, /// Owning CLI: "claude", "codex", "opencode". pub agent_cli: &'static str, /// Which surface launched this session (CLI / Claude desktop app / IDE @@ -205,6 +216,7 @@ impl App { .sessions .iter() .map(|s| SessionView { + host: s.host.clone(), agent_cli: s.agent_cli, launch_surface: s.launch_surface, pid: s.pid, @@ -279,6 +291,7 @@ impl App { .collect(); Snapshot { + schema_version: SCHEMA_VERSION, generated_at_ms: epoch_ms(now).unwrap_or(0), host: self.host_metrics, aggregate: self.agent_aggregate, @@ -318,6 +331,24 @@ mod tests { assert_eq!(tail(&Vec::::new(), 3), Vec::::new()); // empty input } + #[test] + fn to_snapshot_stamps_current_schema_version() { + // A RemoteCollector parsing another host's `--json` output checks + // this before trusting the rest of the payload. + let snap = demo_app().to_snapshot(2_000); + assert_eq!(snap.schema_version, SCHEMA_VERSION); + } + + #[test] + fn to_snapshot_local_sessions_have_no_host() { + // demo/local sessions must serialize `host: null`, matching + // `AgentSession::is_local()`; a RemoteCollector is the only producer + // of `Some(host)` sessions. + let snap = demo_app().to_snapshot(2_000); + assert!(!snap.sessions.is_empty()); + assert!(snap.sessions.iter().all(|s| s.host.is_none())); + } + #[test] fn epoch_ms_is_monotonic_and_zero_at_unix_epoch() { assert_eq!(epoch_ms(UNIX_EPOCH), Some(0)); diff --git a/src/ui/sessions.rs b/src/ui/sessions.rs index ce25f39b..1740f276 100644 --- a/src/ui/sessions.rs +++ b/src/ui/sessions.rs @@ -127,11 +127,27 @@ pub(crate) fn draw_sessions_panel_active( }; let tokens_w: u16 = if w >= 100 { 7 } else { 5 }; + // Per-host reachability for the `[host]` row prefix / stale-row + // treatment below. Looked up by name rather than carried inline on + // AgentSession because reachability is host-level, not session-level, + // and changes independently of any one session's own data. + let remote_statuses = app.remote_host_statuses(); + let remote_by_name: std::collections::HashMap<&str, &crate::collector::RemoteHostStatus> = + remote_statuses + .iter() + .map(|s| (s.name.as_str(), s)) + .collect(); + let visible = app.visible_indices(); for &i in &visible { let session = &app.sessions[i]; let selected = i == app.selected; let marker = if selected { "►" } else { " " }; + let remote_status = session + .host + .as_deref() + .and_then(|h| remote_by_name.get(h).copied()); + let is_stale = remote_status.is_some_and(|s| !s.reachable); let (agent_base, agent_color) = match session.agent_cli { "claude" => ("*CC", Color::Rgb(217, 119, 87)), // #D97757 terracotta @@ -168,7 +184,10 @@ pub(crate) fn draw_sessions_panel_active( .bg(theme.selected_bg) .fg(theme.selected_fg) .add_modifier(Modifier::BOLD) - } else if is_done { + } else if is_done || is_stale { + // A stale remote row (host unreachable, showing last-known-good + // data) gets the same dimming as a finished session — it's + // "not current," same as Done, for different reasons. Style::default().fg(theme.inactive_fg) } else { Style::default() @@ -193,8 +212,12 @@ pub(crate) fn draw_sessions_panel_active( ))); } cells.push(Cell::from(Span::styled( - truncate_str(&session.project_name, project_w as usize), - Style::default().fg(theme.title), + truncate_str(&project_display_name(session), project_w as usize), + Style::default().fg(if is_stale { + theme.inactive_fg + } else { + theme.title + }), ))); if show_session_id { cells.push(Cell::from(Span::styled( @@ -267,14 +290,23 @@ pub(crate) fn draw_sessions_panel_active( + show_tokens as usize + show_memory as usize + show_turn as usize; + // A stale remote row replaces the normal task line with how long + // ago the host was last reachable — the task text itself is + // last-known-good and would otherwise look like live status. + let stale_task_text = remote_stale_task_text( + is_stale, + remote_status.and_then(|s| s.seconds_since_success), + ); let task_cells: Vec = (0..total_cols) .map(|j| { if j == summary_idx { - let task_text = session - .current_tasks - .last() - .map(|s| s.as_str()) - .unwrap_or(""); + let task_text = stale_task_text.as_deref().unwrap_or_else(|| { + session + .current_tasks + .last() + .map(|s| s.as_str()) + .unwrap_or("") + }); Cell::from(Span::styled( task_row_text(task_text, w.saturating_sub(24) as usize), Style::default().fg(theme.graph_text), @@ -991,6 +1023,28 @@ fn task_row_text(task_text: &str, max_width: usize) -> String { truncate_str(&format!("└─ {task_text}"), max_width) } +/// Project-column text for a session: `[host] project` when it was +/// collected remotely, else just `project`. +fn project_display_name(session: &AgentSession) -> String { + match &session.host { + Some(host) => format!("[{host}] {}", session.project_name), + None => session.project_name.clone(), + } +} + +/// Task-line text for a remote session whose host is currently unreachable +/// — `None` for a local session, or a remote one that's currently +/// reachable, in which case the caller shows the normal task text instead. +fn remote_stale_task_text(is_stale: bool, seconds_since_success: Option) -> Option { + if !is_stale { + return None; + } + Some(match seconds_since_success { + Some(secs) => format!("stale, {secs}s ago"), + None => "unreachable".to_string(), + }) +} + pub(crate) fn shorten_model(model: &str, is_1m: bool) -> String { // "claude-opus-4-6" → "opus4.6", "claude-sonnet-4-6" → "sonnet4.6", "claude-haiku-4-5" → "haiku4.5" let s = model.strip_prefix("claude-").unwrap_or(model); @@ -1316,6 +1370,7 @@ mod tests { thinking_since_ms: 0, file_accesses: Vec::new(), config_root: String::new(), + host: None, }); let backend = TestBackend::new(120, 20); @@ -1477,6 +1532,72 @@ mod tests { assert_eq!(buffer[(1, 4)].symbol(), "►"); } + #[test] + fn session_table_prefixes_remote_sessions_with_host() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + let mut remote = test_session("remote11", "widgets"); + remote.host = Some("devbox".to_string()); + app.sessions = vec![test_session("local111", "abtop"), remote]; + + let backend = TestBackend::new(120, 14); + let mut terminal = Terminal::new(backend).unwrap(); + let area = Rect { + x: 0, + y: 0, + width: 120, + height: 14, + }; + terminal + .draw(|f| draw_sessions_panel(f, &app, area, &app.theme)) + .unwrap(); + + let text = format!("{}", terminal.backend()); + // The project column is narrow (8-14 chars depending on width) and + // truncates like everything else in this panel — "[devbox]" is what + // actually fits here, the project name itself often doesn't. See + // `project_display_name_prefixes_host_only_for_remote_sessions` + // below for the untruncated mapping. + assert!( + text.contains("[devbox]"), + "remote session should show its host prefix\n{text}" + ); + assert!( + text.contains("abtop") && !text.contains("] abtop"), + "local session's project name should render unprefixed\n{text}" + ); + } + + #[test] + fn project_display_name_prefixes_host_only_for_remote_sessions() { + let mut session = test_session("s", "abtop"); + assert_eq!(project_display_name(&session), "abtop"); + + session.host = Some("devbox".to_string()); + assert_eq!(project_display_name(&session), "[devbox] abtop"); + } + + #[test] + fn remote_stale_task_text_is_none_when_not_stale() { + assert_eq!(remote_stale_task_text(false, Some(30)), None); + assert_eq!(remote_stale_task_text(false, None), None); + } + + #[test] + fn remote_stale_task_text_reports_seconds_since_success() { + assert_eq!( + remote_stale_task_text(true, Some(42)), + Some("stale, 42s ago".to_string()) + ); + } + + #[test] + fn remote_stale_task_text_reports_never_reachable() { + assert_eq!( + remote_stale_task_text(true, None), + Some("unreachable".to_string()) + ); + } + fn test_session(session_id: &str, project_name: &str) -> AgentSession { AgentSession { agent_cli: "claude", @@ -1517,6 +1638,7 @@ mod tests { thinking_since_ms: 0, file_accesses: Vec::new(), config_root: "~/.claude".into(), + host: None, } } }