diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 056853f..1606690 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,10 +54,88 @@ jobs: tar -czvf ${{ matrix.artifact }}.tar.gz agenttop mv ${{ matrix.artifact }}.tar.gz ../../../ - - name: Upload Release Asset + - name: Compute SHA256 + run: | + if command -v sha256sum >/dev/null 2>&1; then + sha256sum ${{ matrix.artifact }}.tar.gz > ${{ matrix.artifact }}.tar.gz.sha256 + else + shasum -a 256 ${{ matrix.artifact }}.tar.gz > ${{ matrix.artifact }}.tar.gz.sha256 + fi + cat ${{ matrix.artifact }}.tar.gz.sha256 + + - name: Upload Release Assets uses: softprops/action-gh-release@v1 with: - files: ${{ matrix.artifact }}.tar.gz + files: | + ${{ matrix.artifact }}.tar.gz + ${{ matrix.artifact }}.tar.gz.sha256 + + publish-homebrew: + name: Publish Homebrew formula + runs-on: ubuntu-latest + needs: build-release + # Requires HOMEBREW_TAP_TOKEN secret: a PAT with `repo` scope on + # tech4242/homebrew-agenttop. Skips silently if the secret isn't set + # so releases can ship even before the tap repo is wired up. + if: ${{ github.event.release.tag_name != '' }} + steps: + - uses: actions/checkout@v4 + + - name: Resolve version + SHAs + id: meta + run: | + TAG="${{ github.event.release.tag_name }}" + VERSION="${TAG#v}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved version: $VERSION" + + mkdir -p /tmp/shas + for artifact in agenttop-darwin-arm64 agenttop-darwin-x86_64 agenttop-linux-aarch64 agenttop-linux-x86_64; do + url="https://github.com/${{ github.repository }}/releases/download/${TAG}/${artifact}.tar.gz.sha256" + echo "Fetching $url" + # The .sha256 file format is " ", take just the hash. + curl -fsSL "$url" | awk '{print $1}' > "/tmp/shas/${artifact}.sha" + done + + echo "darwin_arm64=$(cat /tmp/shas/agenttop-darwin-arm64.sha)" >> "$GITHUB_OUTPUT" + echo "darwin_x86_64=$(cat /tmp/shas/agenttop-darwin-x86_64.sha)" >> "$GITHUB_OUTPUT" + echo "linux_aarch64=$(cat /tmp/shas/agenttop-linux-aarch64.sha)" >> "$GITHUB_OUTPUT" + echo "linux_x86_64=$(cat /tmp/shas/agenttop-linux-x86_64.sha)" >> "$GITHUB_OUTPUT" + + - name: Render formula + run: | + mkdir -p /tmp/rendered + sed \ + -e "s/REPLACE_VERSION/${{ steps.meta.outputs.version }}/g" \ + -e "s/REPLACE_SHA256_DARWIN_ARM64/${{ steps.meta.outputs.darwin_arm64 }}/g" \ + -e "s/REPLACE_SHA256_DARWIN_X86_64/${{ steps.meta.outputs.darwin_x86_64 }}/g" \ + -e "s/REPLACE_SHA256_LINUX_AARCH64/${{ steps.meta.outputs.linux_aarch64 }}/g" \ + -e "s/REPLACE_SHA256_LINUX_X86_64/${{ steps.meta.outputs.linux_x86_64 }}/g" \ + homebrew/agenttop.rb > /tmp/rendered/agenttop.rb + echo "--- rendered formula ---" + cat /tmp/rendered/agenttop.rb + + - name: Push to tap repo + env: + TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + if [ -z "${TAP_TOKEN:-}" ]; then + echo "::warning::HOMEBREW_TAP_TOKEN not set — skipping tap update. Add the secret to publish." + exit 0 + fi + git clone "https://x-access-token:${TAP_TOKEN}@github.com/tech4242/homebrew-agenttop.git" /tmp/tap + mkdir -p /tmp/tap/Formula + cp /tmp/rendered/agenttop.rb /tmp/tap/Formula/agenttop.rb + cd /tmp/tap + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if git diff --quiet; then + echo "Formula unchanged — nothing to push." + exit 0 + fi + git add Formula/agenttop.rb + git commit -m "agenttop ${{ steps.meta.outputs.version }}" + git push # publish-crates: # name: Publish to crates.io diff --git a/.gitignore b/.gitignore index 48f34d9..0655294 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target examples* .DS_Store -.claude \ No newline at end of file +.claude +ROADMAP.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 54d60f0..8b04484 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agenttop" -version = "0.3.1" +version = "0.4.0" dependencies = [ "anyhow", "axum", @@ -26,6 +26,7 @@ dependencies = [ "prost-types", "ratatui", "regex", + "rusqlite", "serde", "serde_json", "sysinfo", @@ -788,7 +789,7 @@ dependencies = [ "cast", "fallible-iterator", "fallible-streaming-iterator", - "hashlink", + "hashlink 0.10.0", "libduckdb-sys", "num-integer", "rust_decimal", @@ -1043,6 +1044,15 @@ dependencies = [ "ahash 0.7.8", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1060,6 +1070,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hashlink" version = "0.10.0" @@ -1546,6 +1565,17 @@ dependencies = [ "redox_syscall 0.7.0", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2333,6 +2363,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.9.1", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust_decimal" version = "1.40.0" diff --git a/Cargo.toml b/Cargo.toml index c60396f..2c364cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agenttop" -version = "0.3.1" +version = "0.4.0" edition = "2024" description = "htop for AI coding agents - terminal observability dashboard" authors = ["tech4242"] @@ -48,6 +48,10 @@ regex = "1" # Process / host vitals scraping (cross-platform) sysinfo = "0.33" +# Read opencode's session DB at ~/.local/share/opencode/opencode.db. +# `bundled` ships sqlite3 statically so users don't need a system sqlite. +rusqlite = { version = "0.32", features = ["bundled"] } + # Build dependencies for protobuf [build-dependencies] prost-build = "0.13" diff --git a/README.md b/README.md index ef57588..557a277 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ [![Ratatui](https://img.shields.io/badge/TUI-Ratatui-blue.svg)](https://ratatui.rs/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[Installation](#installation) • [Usage](#usage) • [Features](#features) • [How It Works](#how-it-works) +[Quick Start](#quick-start) • [Features](#features) • [Configuration](#configuration) • [How It Works](#how-it-works) @@ -28,6 +28,23 @@ A terminal-native observability dashboard for AI coding agents. Real-time visibi +## Quick Start + +```bash +# macOS +brew install tech4242/agenttop/agenttop + +# Linux (downloads matching binary to ~/.local/bin) +curl -fsSL https://raw.githubusercontent.com/tech4242/agenttop/main/scripts/install.sh | sh + +# Run it — auto-configures Claude Code, opens the dashboard +agenttop +``` + +That's it. agenttop detects Claude Code, writes the right OTEL env vars into `~/.claude/settings.json` (with a `.bak` first), starts the OTLP receiver on port 4318, and renders the TUI. On the next prompt to Claude, tool calls + token usage start streaming in. + +Other agents (Codex, Gemini, Qwen, Cline, Copilot Chat, opencode) need a one-time setup — see [Configuration](#configuration). + ## Origin Story This is the spiritual successor of an MCP logging and monitoring tool that I was building over at https://github.com/tech4242/mcphawk. After realising that the tool needs to wrap every MCP server call in e.g. Claude configs and the fact that we can only log useful information for local calls due to various OS limitations (esp. on macOS), I gave it a rest. @@ -126,13 +143,19 @@ schema (please report). - **Productivity Metrics** - Lines of code, commits - **Cache Reuse Rate** - Prompt caching efficiency -## Installation +## Other install methods + +The [Quick Start](#quick-start) covers brew (macOS) and the `install.sh` script (Linux). If you want something else, the options below all work. -### Cargo +### Cargo (from source) + +```bash +cargo install --git https://github.com/tech4242/agenttop +``` -Not published yet but you can run `cargo install --git https://github.com/tech4242/agenttop` +Not published to crates.io yet — tracked on the roadmap. -### Pre-built Binaries +### Pre-built binaries (direct download) Download from [GitHub Releases](https://github.com/tech4242/agenttop/releases), or use curl: diff --git a/homebrew/agenttop.rb b/homebrew/agenttop.rb new file mode 100644 index 0000000..097d168 --- /dev/null +++ b/homebrew/agenttop.rb @@ -0,0 +1,44 @@ +# homebrew/agenttop.rb — source of truth for the Homebrew formula. +# +# The release workflow renders this template (substituting VERSION and the +# four SHA256s) and pushes the result to tech4242/homebrew-agenttop, so +# `brew install tech4242/agenttop/agenttop` installs the latest release. +# +# Placeholders below (REPLACE_*) are filled in by the release workflow. +# Hand-edits to this file should preserve the placeholder strings. +class Agenttop < Formula + desc "htop for AI coding agents — terminal observability for Claude, Codex, Gemini, and more" + homepage "https://github.com/tech4242/agenttop" + version "REPLACE_VERSION" + license "MIT" + + on_macos do + on_arm do + url "https://github.com/tech4242/agenttop/releases/download/v#{version}/agenttop-darwin-arm64.tar.gz" + sha256 "REPLACE_SHA256_DARWIN_ARM64" + end + on_intel do + url "https://github.com/tech4242/agenttop/releases/download/v#{version}/agenttop-darwin-x86_64.tar.gz" + sha256 "REPLACE_SHA256_DARWIN_X86_64" + end + end + + on_linux do + on_arm do + url "https://github.com/tech4242/agenttop/releases/download/v#{version}/agenttop-linux-aarch64.tar.gz" + sha256 "REPLACE_SHA256_LINUX_AARCH64" + end + on_intel do + url "https://github.com/tech4242/agenttop/releases/download/v#{version}/agenttop-linux-x86_64.tar.gz" + sha256 "REPLACE_SHA256_LINUX_X86_64" + end + end + + def install + bin.install "agenttop" + end + + test do + assert_match "agenttop #{version}", shell_output("#{bin}/agenttop --version") + end +end diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..7eca33c --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env sh +# agenttop install script — downloads the latest GitHub release for your +# OS/arch, verifies its SHA256 against the published checksum, and drops +# the binary into ~/.local/bin (or $AGENTTOP_INSTALL_DIR if set). +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/tech4242/agenttop/main/scripts/install.sh | sh +# +# Optional env vars: +# AGENTTOP_INSTALL_DIR — install destination (default: $HOME/.local/bin) +# AGENTTOP_VERSION — pin to a specific tag (default: latest release) + +set -eu + +REPO="tech4242/agenttop" +INSTALL_DIR="${AGENTTOP_INSTALL_DIR:-$HOME/.local/bin}" + +bold() { printf '\033[1m%s\033[0m\n' "$1"; } +red() { printf '\033[31m%s\033[0m\n' "$1" >&2; } +green(){ printf '\033[32m%s\033[0m\n' "$1"; } + +bold "agenttop installer" + +# ---- Detect OS + arch ---- +os="$(uname -s)" +arch="$(uname -m)" + +case "$os" in + Darwin) os_short="darwin" ;; + Linux) os_short="linux" ;; + *) red "Unsupported OS: $os (only macOS and Linux are supported)"; exit 1 ;; +esac + +case "$arch" in + x86_64|amd64) arch_short="x86_64" ;; + arm64|aarch64) arch_short="arm64" ;; + *) red "Unsupported architecture: $arch"; exit 1 ;; +esac + +# The release tarball naming convention from .github/workflows/release.yml. +# macOS uses arm64/x86_64; Linux uses x86_64/aarch64. +if [ "$os_short" = "linux" ] && [ "$arch_short" = "arm64" ]; then + arch_short="aarch64" +fi + +artifact="agenttop-${os_short}-${arch_short}.tar.gz" +echo "Platform: $os $arch → $artifact" + +# ---- Find required tools ---- +for tool in curl tar uname; do + if ! command -v "$tool" >/dev/null 2>&1; then + red "Required tool not found: $tool" + exit 1 + fi +done + +if command -v sha256sum >/dev/null 2>&1; then + SHA_CMD="sha256sum" +elif command -v shasum >/dev/null 2>&1; then + SHA_CMD="shasum -a 256" +else + red "Need sha256sum or shasum to verify the download — please install one." + exit 1 +fi + +# ---- Resolve version ---- +if [ -n "${AGENTTOP_VERSION:-}" ]; then + version="$AGENTTOP_VERSION" + echo "Using pinned version: $version" +else + echo "Resolving latest release..." + # Follow redirects on /releases/latest and grab the tag from the final URL. + version="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \ + "https://github.com/${REPO}/releases/latest" \ + | sed 's#.*/tag/##')" + if [ -z "$version" ]; then + red "Failed to resolve latest release tag." + exit 1 + fi + echo "Latest: $version" +fi + +# ---- Download tarball + checksum ---- +base_url="https://github.com/${REPO}/releases/download/${version}" +tarball_url="${base_url}/${artifact}" + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +echo "Downloading $tarball_url ..." +if ! curl -fsSL -o "${tmpdir}/${artifact}" "$tarball_url"; then + red "Download failed. The release may not have a build for $artifact." + red "Check: https://github.com/${REPO}/releases/${version}" + exit 1 +fi + +# Try to fetch a sibling .sha256 file. Not all release workflows publish +# one yet — when absent, fall back to computing & printing the hash so the +# user can eyeball it. (Hardening: future release.yml should always emit +# .sha256 files alongside each tarball.) +sha_url="${tarball_url}.sha256" +if curl -fsSL -o "${tmpdir}/${artifact}.sha256" "$sha_url" 2>/dev/null; then + expected="$(cut -d' ' -f1 "${tmpdir}/${artifact}.sha256")" + actual="$(${SHA_CMD} "${tmpdir}/${artifact}" | cut -d' ' -f1)" + if [ "$expected" != "$actual" ]; then + red "SHA256 mismatch — refusing to install." + red " expected: $expected" + red " got: $actual" + exit 1 + fi + green "SHA256 verified." +else + echo "(No published .sha256 for $version yet — recording the hash for your records:)" + ${SHA_CMD} "${tmpdir}/${artifact}" +fi + +# ---- Extract + install ---- +tar -xzf "${tmpdir}/${artifact}" -C "$tmpdir" +if [ ! -f "${tmpdir}/agenttop" ]; then + red "Tarball did not contain 'agenttop' at the root." + exit 1 +fi + +mkdir -p "$INSTALL_DIR" +mv "${tmpdir}/agenttop" "${INSTALL_DIR}/agenttop" +chmod +x "${INSTALL_DIR}/agenttop" + +green "Installed agenttop to ${INSTALL_DIR}/agenttop" + +# ---- PATH hint ---- +case ":${PATH}:" in + *":${INSTALL_DIR}:"*) ;; + *) + echo + bold "One more step — add ${INSTALL_DIR} to your PATH:" + echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.bashrc" + echo " # or your shell's equivalent (.zshrc, config.fish, etc.)" + echo + ;; +esac + +echo +bold "Next: run \`agenttop\`" +echo "Need help with a specific provider? Try \`agenttop --setup \`." diff --git a/src/scraper/mod.rs b/src/scraper/mod.rs index 7e554e3..967213d 100644 --- a/src/scraper/mod.rs +++ b/src/scraper/mod.rs @@ -24,6 +24,7 @@ use std::path::PathBuf; pub mod claude_sessions; pub mod host; +pub mod opencode_sessions; pub mod ports; pub mod process; pub mod rate_limits; @@ -246,14 +247,21 @@ impl Scraper { self.cached_ports = ports::scan_listening_ports(); } - // 3. Scrape live Claude Code sessions (the only agent we can scrape - // directly today — others are OTLP-only). + // 3. Scrape live agent sessions from local state. + // Claude Code: ~/.claude transcripts (rich state — status, current_task, ctx%). + // opencode: ~/.local/share/opencode/opencode.db (tokens + project). + // Other agents (Codex, Gemini, Qwen, Cline, Copilot) flow through OTLP only. let mut live_sessions = claude_sessions::scan(&self.sys, &self.cached_ports, &mut self.transcript_offsets); + live_sessions.extend(opencode_sessions::scan(&self.sys)); - // 4. Enrich with subagents (cheap — only reads files for live sessions). + // 4. Enrich Claude Code sessions with subagents (cheap; only reads + // files for live sessions). opencode doesn't have an analogous + // concept. for session in &mut live_sessions { - session.subagents = subagents::for_session(&session.session_id, &session.cwd); + if session.agent_id == "claude_code" { + session.subagents = subagents::for_session(&session.session_id, &session.cwd); + } } // 5. Rate limits from the Claude StatusLine sidecar file. diff --git a/src/scraper/opencode_sessions.rs b/src/scraper/opencode_sessions.rs new file mode 100644 index 0000000..d947778 --- /dev/null +++ b/src/scraper/opencode_sessions.rs @@ -0,0 +1,359 @@ +//! Live opencode (sst/opencode) sessions, scraped from its local SQLite DB. +//! +//! opencode persists conversations in `~/.local/share/opencode/opencode.db`. +//! We open it read-only and surface live sessions next to Claude Code in +//! the Live panel. PID liveness is matched by `cwd` against the running +//! process tree (sysinfo) — same approach abtop uses. +//! +//! Why local-scrape instead of OTLP: opencode upstream doesn't ship native +//! OTLP yet (just a community plugin, `DEVtheOPS/opencode-plugin-otel`), +//! so 99% of users get no telemetry. The DB is always there. +//! +//! Schema reverse-engineered from opencode 0.x: +//! - `session` (id, title, directory, version, time_created, time_updated, project_id) +//! - `project` (id, name, ...) +//! - `message` (id, session_id, data JSON, time_created) +//! +//! `message.data` is a JSON blob containing role, tokens.input/output/cache, +//! modelID, providerID. +//! +//! The schema isn't documented and may drift across opencode versions. We +//! wrap the query in defensive error handling so a schema change downgrades +//! to "no opencode sessions" rather than crashing the whole scraper. + +use std::path::{Path, PathBuf}; + +use rusqlite::{Connection, OpenFlags}; + +use super::process::ProcessScanner; +use super::{LiveSession, SessionStatus}; + +const SESSION_QUERY: &str = r#" +SELECT + s.id, + COALESCE(s.title, '') as title, + COALESCE(s.directory, '') as directory, + s.time_updated, + COALESCE(SUM(json_extract(m.data, '$.tokens.input')), 0) as input_tokens, + COALESCE(SUM(json_extract(m.data, '$.tokens.output')), 0) as output_tokens, + COALESCE(SUM(json_extract(m.data, '$.tokens.cache.read')), 0) as cache_read, + COALESCE(SUM(json_extract(m.data, '$.tokens.cache.write')), 0) as cache_write, + ( + SELECT json_extract(m2.data, '$.modelID') + FROM message m2 + WHERE m2.session_id = s.id + AND json_extract(m2.data, '$.role') = 'assistant' + ORDER BY m2.time_created DESC LIMIT 1 + ) as model +FROM session s +LEFT JOIN message m + ON m.session_id = s.id + AND json_extract(m.data, '$.role') = 'assistant' +GROUP BY s.id +ORDER BY s.time_updated DESC +LIMIT 20 +"#; + +#[derive(Debug, Clone)] +struct DbRow { + id: String, + #[allow(dead_code)] + title: String, + directory: String, + time_updated_ms: u64, + input_tokens: u64, + output_tokens: u64, + cache_read: u64, + cache_write: u64, + model: Option, +} + +/// Scan opencode's SQLite database and return live sessions whose `cwd` +/// matches a running opencode process. Returns an empty vec when the DB +/// doesn't exist (opencode not installed) or any query step fails. +pub fn scan(sys: &ProcessScanner) -> Vec { + let Some(db_path) = default_db_path() else { + return Vec::new(); + }; + scan_at(sys, &db_path) +} + +pub(crate) fn scan_at(sys: &ProcessScanner, db_path: &Path) -> Vec { + if !db_path.exists() { + return Vec::new(); + } + + let rows = match read_sessions(db_path) { + Ok(rows) => rows, + Err(e) => { + tracing::debug!("opencode DB read failed: {} (schema drift?)", e); + return Vec::new(); + } + }; + + // Index running opencode processes by cwd. cwd may not be available on + // all platforms; missing cwd just means we won't match that process. + let opencode_pids: Vec = sys + .snapshot() + .values() + .filter(|p| p.cmd.contains("opencode") || p.name.contains("opencode")) + .map(|p| p.pid) + .collect(); + let mut pid_by_cwd: std::collections::HashMap = std::collections::HashMap::new(); + for pid in &opencode_pids { + if let Some(proc) = sys.get(*pid) + && let Some(cwd) = proc.cwd.as_deref() + { + pid_by_cwd.insert(cwd.to_string(), *pid); + } + } + + let mut out = Vec::new(); + for row in rows { + // Only surface sessions whose directory matches a live process — + // otherwise we'd show every historical opencode session forever. + let Some(&pid) = pid_by_cwd.get(&row.directory) else { + continue; + }; + let proc = match sys.get(pid) { + Some(p) => p, + None => continue, + }; + + let project_name = Path::new(&row.directory) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| row.directory.clone()); + + out.push(LiveSession { + agent_id: "opencode", + pid, + session_id: row.id, + cwd: row.directory, + project_name, + started_at_ms: row.time_updated_ms, + // Status from a passive scrape is necessarily Waiting — we + // don't have a stream of events to tell us "right now the + // model is generating." If the user wants live status they + // need the community OTLP plugin. + status: SessionStatus::Waiting, + model: row.model.unwrap_or_default(), + // opencode's tokens.* fields are already cumulative — no model + // window detection (provider-dependent), so leave context_* + // empty. + context_percent: None, + context_window: None, + latest_context_tokens: 0, + current_task: String::new(), + input_tokens: row.input_tokens, + output_tokens: row.output_tokens, + cache_read_tokens: row.cache_read, + cache_creation_tokens: row.cache_write, + mem_mb: proc.rss_kb / 1024, + children: Vec::new(), + subagents: Vec::new(), + }); + } + out +} + +fn default_db_path() -> Option { + // XDG-style: $XDG_DATA_HOME or ~/.local/share/opencode/opencode.db. + let base = std::env::var("XDG_DATA_HOME") + .map(PathBuf::from) + .ok() + .or_else(|| dirs::home_dir().map(|h| h.join(".local/share")))?; + Some(base.join("opencode").join("opencode.db")) +} + +fn read_sessions(db_path: &Path) -> rusqlite::Result> { + let conn = Connection::open_with_flags( + db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + // Make the connection extra-safe: opencode runs concurrently and may + // be writing. SQLite WAL mode is normally enabled by opencode itself. + let _ = conn.execute_batch("PRAGMA query_only = ON;"); + + let mut stmt = conn.prepare(SESSION_QUERY)?; + let rows = stmt.query_map([], |row| { + Ok(DbRow { + id: row.get::<_, String>(0)?, + title: row.get::<_, String>(1).unwrap_or_default(), + directory: row.get::<_, String>(2).unwrap_or_default(), + time_updated_ms: row.get::<_, i64>(3).unwrap_or(0).max(0) as u64, + input_tokens: row.get::<_, i64>(4).unwrap_or(0).max(0) as u64, + output_tokens: row.get::<_, i64>(5).unwrap_or(0).max(0) as u64, + cache_read: row.get::<_, i64>(6).unwrap_or(0).max(0) as u64, + cache_write: row.get::<_, i64>(7).unwrap_or(0).max(0) as u64, + model: row.get::<_, Option>(8).unwrap_or(None), + }) + })?; + + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::params; + use tempfile::tempdir; + + /// Build a minimal opencode-shape SQLite DB with one assistant message + /// per session. Schema mirrors opencode 0.x (verified via abtop). + fn make_fixture_db(dir: &Path) -> PathBuf { + let path = dir.join("opencode.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + r#" + CREATE TABLE session ( + id TEXT PRIMARY KEY, + title TEXT, + directory TEXT, + version TEXT, + time_created INTEGER, + time_updated INTEGER, + project_id TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + time_created INTEGER NOT NULL + ); + "#, + ) + .unwrap(); + conn.execute( + "INSERT INTO session VALUES (?, ?, ?, ?, ?, ?, NULL)", + params![ + "sess-1", + "Building agenttop", + "/tmp/agenttop-test-cwd", + "0.1.0", + 1700000000_i64, + 1700001000_i64, + ], + ) + .unwrap(); + // One assistant message with tokens. + let assistant_data = serde_json::json!({ + "role": "assistant", + "modelID": "claude-sonnet-4-5", + "tokens": { + "input": 1500, + "output": 800, + "cache": { "read": 12000, "write": 200 } + } + }); + conn.execute( + "INSERT INTO message VALUES (?, ?, ?, ?)", + params![ + "msg-1", + "sess-1", + assistant_data.to_string(), + 1700000500_i64 + ], + ) + .unwrap(); + path + } + + #[test] + fn returns_empty_when_db_missing() { + let sys = ProcessScanner::new(); + let result = scan_at(&sys, Path::new("/nonexistent/opencode.db")); + assert!(result.is_empty()); + } + + #[test] + fn returns_empty_when_no_running_opencode_process() { + // DB has a session for /tmp/agenttop-test-cwd, but there's no + // running opencode process whose cwd matches → no live session. + let tmp = tempdir().unwrap(); + let db_path = make_fixture_db(tmp.path()); + let sys = ProcessScanner::new(); + let result = scan_at(&sys, &db_path); + assert!( + result.is_empty(), + "no matching live process should mean no live session" + ); + } + + #[test] + fn reads_session_and_aggregates_tokens() { + // We can't easily fake a running opencode process from a unit test, + // so this test goes through `read_sessions` directly to verify the + // SQL aggregation logic. The PID-matching step is tested by the + // returns_empty_when_no_running_opencode_process case. + let tmp = tempdir().unwrap(); + let db_path = make_fixture_db(tmp.path()); + let rows = read_sessions(&db_path).unwrap(); + assert_eq!(rows.len(), 1); + let r = &rows[0]; + assert_eq!(r.id, "sess-1"); + assert_eq!(r.directory, "/tmp/agenttop-test-cwd"); + assert_eq!(r.input_tokens, 1500); + assert_eq!(r.output_tokens, 800); + assert_eq!(r.cache_read, 12000); + assert_eq!(r.cache_write, 200); + assert_eq!(r.model.as_deref(), Some("claude-sonnet-4-5")); + } + + #[test] + fn ignores_user_role_messages_in_token_sum() { + let tmp = tempdir().unwrap(); + let path = tmp.path().join("opencode.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + r#" + CREATE TABLE session ( + id TEXT PRIMARY KEY, title TEXT, directory TEXT, version TEXT, + time_created INTEGER, time_updated INTEGER, project_id TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, + data TEXT NOT NULL, time_created INTEGER NOT NULL + ); + INSERT INTO session VALUES ('s1', '', '/x', '0', 0, 0, NULL); + INSERT INTO message VALUES ('m1', 's1', '{"role":"user","tokens":{"input":999}}', 1); + INSERT INTO message VALUES ('m2', 's1', '{"role":"assistant","tokens":{"input":100,"output":50}}', 2); + "#, + ) + .unwrap(); + drop(conn); + + let rows = read_sessions(&path).unwrap(); + assert_eq!(rows.len(), 1); + // user.tokens.input (999) must NOT appear in the assistant-only sum. + assert_eq!(rows[0].input_tokens, 100); + assert_eq!(rows[0].output_tokens, 50); + } + + #[test] + fn schema_drift_returns_empty_rather_than_crashing() { + // Build a DB with a totally different shape — read_sessions should + // error cleanly and scan_at should return empty, not panic. + let tmp = tempdir().unwrap(); + let path = tmp.path().join("opencode.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TABLE unrelated (id INTEGER); INSERT INTO unrelated VALUES (1);", + ) + .unwrap(); + drop(conn); + + // read_sessions returns Err. + let err = read_sessions(&path); + assert!(err.is_err()); + + // scan_at swallows it. + let sys = ProcessScanner::new(); + let result = scan_at(&sys, &path); + assert!(result.is_empty()); + } +}