Skip to content

feat: remote/SSH session monitoring - #3

Open
ntung wants to merge 6 commits into
mainfrom
feat/remote-ssh-monitoring
Open

ntung wants to merge 6 commits into
mainfrom
feat/remote-ssh-monitoring

Conversation

@ntung

@ntung ntung commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Summary

Implements remote/SSH session monitoring, previously a v0.1 non-goal: abtop can now poll abtop --json on other hosts over SSH and merge their sessions into the local session list, tagged with a [host] prefix. Full design and rationale (including a few decisions that landed differently than the original sketch) in docs/design/remote-ssh-monitoring.md.

Implemented in four phases, one commit each:

  • Phase 0 — wire contract: schema_version on the --json Snapshot; host: Option<String> on AgentSession/SessionView (None = local).
  • Phase 1 — config: [[remote_hosts]] blocks in config.toml, parsed by extending the existing hand-rolled parser (no new toml dependency — remote_hosts is read-only from the app's perspective).
  • Phase 2 — RemoteCollector: one background thread per host runs ssh ... abtop --json, caches last-known-good sessions + reachability so a slow/hung SSH round trip never blocks the 2s tick loop. Reuses the existing --json flag as-is (no new CLI flag) via a dedicated, narrower Deserialize DTO. Found and fixed two real bugs while wiring this in: local git-stats recomputation and orphan-port tracking would otherwise run against remote paths/PIDs.
  • Phase 3 — safety guards + UI: kill_selected/jump_to_session refuse to act on a remote session's PID (only unique per host); sessions panel shows [host] project and grays out + shows "stale, Ns ago" for an unreachable host.

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, ran abtop --json non-interactively exactly as RemoteCollector does, and confirmed the session merged into a local abtop --json run tagged with the host, including its real git branch/added/modified counts. This found and fixed two more issues:

  • --json/--once always showed remote sessions as empty on a fresh process (the poll is async; a single tick raced past it) — fixed with a bounded wait loop.
  • The config file isn't at ~/.config/abtop/config.toml on macOS (dirs::config_dir() resolves elsewhere there) — a pre-existing README inaccuracy, now fixed.

Testing

  • 226 tests pass (cargo test), cargo clippy --all-targets -- -D warnings clean.
  • Manual end-to-end verification against a live remote host (above).

@ntung ntung self-assigned this Sep 13, 2026
@ntung ntung added the enhancement New feature or request label Sep 13, 2026
@ntung

ntung commented Sep 14, 2026

Copy link
Copy Markdown
Owner Author

Updated this branch now that #2 (= upstream graykode#175, launch_surface) has landed on main:

  • Merged main in — brings in launch_surface plus the other upstream commits merged since this branch was cut.
  • The merge itself was conflict-free (git/GitHub's 3-way merge doesn't understand semantics), but left a real gap: RemoteCollector's RemoteSessionDto predates launch_surface and didn't know the field existed, so every remote session would have silently defaulted to LaunchSurface::Cli regardless of how it was actually launched on the remote host. Fixed in 947a068 — LaunchSurface gains Deserialize/Default, threaded through RemoteSessionDto/into_agent_session, with 2 new tests.
  • 248 tests pass, clippy clean.

Write the implementation phasing into docs/design/remote-ssh-monitoring.md,
correcting a few assumptions against the current codebase: --json already
exists as its own flag (no schema_version yet), config.rs is a hand-rolled
key=value parser with no array-of-tables support, and the pid-keyed caches
in collector/mod.rs are local-only so the (host, pid) identity concern is
narrower than sketched -- it's really about guarding kill/jump call sites.

Phase 0 (wire contract):
- Add `schema_version` to the `--json` Snapshot, so a future RemoteCollector
  can reject a mismatched remote abtop version with a clear error.
- Add `host: Option<String>` to AgentSession/SessionView (None = local) and
  an `AgentSession::is_local()` helper that later kill/jump guards will use.

No behavior change: all existing constructors set `host: None`.
Decision: extend the hand-rolled key=value parser in config.rs rather than
add a toml dependency. remote_hosts is read-only from the app's perspective
(nothing writes it back, unlike theme/panels), so the existing
comment/unknown-key-preserving writer never needs to reproduce it -- pulling
in a real TOML crate for one narrow array-of-tables shape would just split
config.rs into two parsing styles for no correctness gain.

- RemoteHostConfig { name, ssh_target, ssh_opts, poll_interval_secs,
  allow_remote_kill }, defaults matching the design doc (10s poll, kill
  disabled).
- parse_config_body now tracks whether it's inside a [[remote_hosts]] block
  (bool, not a general section stack -- there's exactly one table shape
  today) and routes keys accordingly; any other table header ends the block.
- Entries missing name or ssh_target are dropped after parsing, keeping
  config loading infallible rather than surfacing a parse error.
- Tests: single/multiple blocks, field defaults, malformed-entry drop,
  inline comments on table headers, top-level keys still working alongside
  a block, and rewrite_kv_lines leaving a remote_hosts block untouched when
  saving an unrelated key (theme).
src/collector/remote.rs: one background thread per configured host runs
`ssh ... abtop --json`, caches last-known-good sessions + reachability so a
slow/hung SSH round trip never blocks the 2s tick loop (same idiom as
DesktopRolloutScanner's Codex desktop-app rollout scan). Registered in
MultiCollector::with_hidden_and_claude_config_dirs, gated on remote_hosts
being non-empty.

A few decisions landed differently than the design doc originally sketched
(doc updated to match):

- No new CLI flag: --json already exists, is fast (no up-to-30s summary
  wait), and is already redacted/versioned -- reused as-is over SSH instead
  of inventing `--once --json`.
- RemoteSnapshotDto/RemoteSessionDto (remote.rs) are a dedicated, narrower
  Deserialize view of the same --json output, not new derives on 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 instead of a parse
  failure. A few AgentSession fields aren't on the wire (context_history,
  mem_file_count/mem_line_count, pending/thinking_since_ms, file_accesses)
  and default to empty/zero for remote sessions.
- The remote's precomputed `summary` string is reused as `initial_prompt` so
  App::session_summary shows it directly; drain_and_retry_summaries and
  has_retryable_summaries now gate on host.is_none() so this never triggers
  a second, local `claude --print` over the same text.
- Two safety guards landed here rather than waiting for Phase 3: local
  git-stats recomputation and orphan-port tracking in
  MultiCollector::collect both now skip host.is_some() sessions. Without the
  first, `git -C <remote cwd>` 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. This also means OrphanPort never needs a `host` field,
  and kill_orphan_ports never needs its own guard in Phase 3.

model/session.rs: SessionStatus and ChildProcess gain Deserialize (plus a
Default for SessionStatus = Unknown) so RemoteSessionDto can deserialize
them directly instead of duplicating their shape.

lib.rs: run_app now takes `cfg: &config::AppConfig` instead of four
separate fields, both to thread remote_hosts through and to stay under
clippy's too-many-arguments threshold.

7 new tests in collector::remote (schema mismatch, non-JSON input, missing-
field defaulting, host tagging, agent_cli fallback, poll-interval/in-flight
gating). 217 tests pass, clippy clean.
Completes the remote-ssh-monitoring design (docs/design/remote-ssh-monitoring.md).

app.rs:
- kill_selected and jump_to_session now bail with a status message naming
  the host when session.host.is_some(), instead of acting on session.pid
  against this machine's ps/kill/terminal-jump -- PIDs are only unique per
  host. kill_orphan_ports needed no guard: Phase 2 already excludes remote
  sessions from feeding local orphan-port tracking at collection time.
- New App::remote_host_statuses(), delegating to MultiCollector, for the UI.

collector/mod.rs:
- MultiCollector now holds its RemoteCollector in a dedicated `remote`
  field instead of type-erased into `collectors: Vec<Box<dyn
  AgentCollector>>`, so remote_host_statuses() can be queried directly
  without downcasting a trait object. collect() still merges its sessions
  in exactly as before.

ui/sessions.rs:
- Project column shows "[host] project" for a remote session
  (project_display_name).
- A session whose host is currently unreachable gets the same row dimming
  as a Done session, and its task line switches to "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 8-14 chars depending on terminal width, so
  "[host]" can crowd out most of the actual project name on a narrow
  terminal -- accepted as the same graceful truncation the rest of this
  panel already does.

9 new tests (2 in app.rs for the kill/jump guards, 7 in ui/sessions.rs for
the host prefix and stale-text helpers + a render-level check). 224 tests
pass, clippy clean.
Verified this feature end-to-end against a live remote host (see the new
"Verified against a live host" section in
docs/design/remote-ssh-monitoring.md). That exercise found one real bug:

- --json and --once always showed remote sessions as empty 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 the single tick 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 -- covered by a new test) until every
  configured host has reported at least once, mirroring the existing
  summary-wait loop --once already had for a different subsystem.

Also documents remote_hosts in README's Configuration section (with a full
[[remote_hosts]] example), and fixes a pre-existing, unrelated inaccuracy I
hit while testing: the config file is not at ~/.config/abtop/config.toml on
macOS (dirs::config_dir() resolves to ~/Library/Application Support/abtop/
there) -- README now lists the actual path per platform. AGENTS.md's
Non-Goals and architecture tree updated to reflect that remote monitoring
has shipped.

226 tests pass, clippy clean.
…+PR#3

The automatic merge of feature/claude-launch-surface (PR#2) and
feat/remote-ssh-monitoring (PR#3) succeeded without conflict markers, but
left one real gap git couldn't have caught: PR#2 added
AgentSession.launch_surface (and already put it on the --json wire via
SessionView), but RemoteCollector's RemoteSessionDto -- new code from PR#3
that PR#2 never touched -- didn't know the field existed, so every remote
session would have silently defaulted to LaunchSurface::Cli regardless of
how it was actually launched on the remote host.

- LaunchSurface gains Deserialize + a Default (Cli, matching
  ClaudeCollector::detect_launch_surface's own inconclusive-detection
  fallback), the same treatment SessionStatus already got for the same
  reason in RemoteSessionDto.
- RemoteSessionDto/into_agent_session now parse and carry launch_surface
  through, so a remote session's desktop-app/IDE badge reflects the remote
  host's own process, not a merge-time default.
- 2 new tests: launch_surface round-trips through parse_remote_snapshot,
  and a session object missing the field still degrades to Cli rather than
  failing to parse.

232 tests pass, clippy clean.
@ntung
ntung force-pushed the feat/remote-ssh-monitoring branch from 947a068 to 018de17 Compare September 14, 2026 10:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant