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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "agenttop"
version = "0.3.0"
version = "0.3.1"
edition = "2024"
description = "htop for AI coding agents - terminal observability dashboard"
authors = ["tech4242"]
Expand Down Expand Up @@ -63,3 +63,5 @@ codegen-units = 1

[dev-dependencies]
tempfile = "3.24.0"
# Drives the axum router directly in tests without binding a TCP socket.
tower = { version = "0.5", features = ["util"] }
61 changes: 40 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ If you want to contribute, please let me know!
| Agent | OTLP Support | Signals | MCP Tools | Key Metrics |
|-------|--------------|---------|-----------|-------------|
| **Claude Code** | ✅ Full | Metrics, Logs | Full names (auto-enabled via `OTEL_LOG_TOOL_DETAILS=1`) | tokens, cost, tools, LOC, compaction |
| **OpenAI Codex CLI** | ⚠️ Partial | Logs, Traces | Full names | tokens, tools, prompts (interactive only — see caveat) |
| **OpenAI Codex CLI** | ✅ Full (since Feb 2026) | Logs, Traces | Full names | tokens, tools, prompts |
| **Gemini CLI** | ✅ Full | Metrics, Logs | Full names + `tool_type` | 40+ metrics |
| **Qwen Code** | ✅ Full | Metrics, Logs | Supported | tokens, diff stats |
| **Cline** | ✅ Full (Cline Enterprise) | Logs, Metrics | via `use_mcp_tool` | `cline.turns.total`, tool calls |
Expand All @@ -59,30 +59,49 @@ If you want to contribute, please let me know!
### Some notes on Limitations

#### MCP Tool Names (Claude Code)
Claude Code 2.1.2+ anonymizes MCP tool names by default. The opt-in env var
`OTEL_LOG_TOOL_DETAILS=1` makes the per-server names available again — Claude Code
emits them in `tool_parameters` as `{"mcp_server_name": "...", "mcp_tool_name": "..."}`,
and agenttop reconstitutes the full `mcp__<server>__<tool>` form so MCP usage
shows up grouped by server in the TUI. `agenttop --setup claude` writes this
env var for you.

History: this was tracked as https://github.com/anthropics/claude-code/issues/17046
(closed Jan 2026).
Claude Code 2.1.128+ emits full MCP tool names (e.g. `mcp__context7__resolve-library-id`)
on `tool_result` events natively. The earlier limitation (tracked as
[anthropic/claude-code#17046](https://github.com/anthropics/claude-code/issues/17046))
was resolved on 2026-03-25. agenttop still sets `OTEL_LOG_TOOL_DETAILS=1` for
older versions, and the OTLP parser keeps the `tool_parameters` reconstitution
path as a fallback.

`tool_decision` events still emit a generic `tool_name = "mcp_tool"` (separate
upstream code path). agenttop reconciles decisions back to the correct MCP
name via `tool_use_id` when computing approval rates, so APR% is accurate per
MCP server even though the raw decision event isn't.

#### Context Window Usage
Claude Code still does NOT expose live context window usage in telemetry.
However, **compaction events** (`event.name = "claude_code.compaction"`) are now
emitted with `pre_tokens`/`post_tokens`, and agenttop surfaces a count + last
delta in the header so you can see when compaction kicked in.

#### OpenAI Codex CLI caveat
As of 2026-Q1, `codex exec` and `codex mcp-server` emit no telemetry — only
interactive `codex` sessions populate OTLP. See
https://github.com/openai/codex/issues/12913.
Claude Code's OTLP stream still doesn't carry live context-window usage,
but agenttop now scrapes it locally from `~/.claude/projects/.../*.jsonl`
and shows a `used/window` ratio in the **Live sessions** panel. **Compaction
events** (`event.name = "claude_code.compaction"`) are also tracked and
surfaced in the header with pre→post token deltas.

For the opus 200k-vs-1M variants (the 1M context is selected via API beta
header and not encoded in the transcript model name), agenttop auto-bumps
the window to 1M when observed usage exceeds 200k.

#### OpenAI Codex CLI
Historically `codex exec` and `codex mcp-server` emitted no telemetry
([openai/codex#12913](https://github.com/openai/codex/issues/12913)) — that
issue was closed as *completed* on 2026-02-28. We haven't independently
verified the new behavior end-to-end; if you hit gaps with your specific
Codex version, please open an issue with a sample event.

#### Approval Rate
The `decision` attribute for tool approval tracking is not consistently present
in all Claude Code versions. APR% may show as 100% when data is unavailable.
Tool approval data is split across two Claude Code event types:
- `tool_result.decision_type = "accept"` is emitted for every accepted tool
call (which is the only kind that actually executes and produces a result).
- `tool_decision.decision` is emitted for both `accept` and `reject` — and
it's the *only* place rejections show up, because rejected tools never
fire a `tool_result`.

agenttop combines both streams to compute APR%. Auto-approved tools (Read,
Glob, Grep, etc.) have no `tool_decision` events at all — those show 100%
APR by convention. If you see persistent 100% APR for a tool you actually
get prompted on, your Claude Code version may be on an older telemetry
schema (please report).


## Features
Expand Down
12 changes: 9 additions & 3 deletions src/otlp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,20 @@ pub mod parser;

pub use parser::*;

pub async fn start_receiver(storage: StorageHandle) -> Result<()> {
let app = Router::new()
/// Build the OTLP receiver router. Exposed publicly so integration tests
/// can drive the receiver via `tower::ServiceExt::oneshot` without binding
/// a real TCP socket.
pub fn build_router(storage: StorageHandle) -> Router {
Router::new()
.route("/v1/metrics", post(handle_metrics))
.route("/v1/logs", post(handle_logs))
.route("/v1/traces", post(handle_traces))
.layer(CorsLayer::permissive())
.with_state(storage);
.with_state(storage)
}

pub async fn start_receiver(storage: StorageHandle) -> Result<()> {
let app = build_router(storage);
let listener = tokio::net::TcpListener::bind("127.0.0.1:4318").await?;
tracing::info!("OTLP receiver listening on http://127.0.0.1:4318");

Expand Down
62 changes: 37 additions & 25 deletions src/providers/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
use super::{Provider, TOKEN_CACHE_READ, TOKEN_CACHE_WRITE, TOKEN_INPUT, TOKEN_OUTPUT};
use anyhow::{Context, Result};
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};

const OTLP_ENDPOINT: &str = "http://localhost:4318";

/// Built-in Claude Code tools
/// Built-in Claude Code tools.
///
/// Keep this list in sync with the tool names that ship with Claude Code
/// 2.x. Missing entries cause tools to render as `mcp` in the TUI's TYPE
/// column, even though they're not MCP. Last verified against real
/// telemetry on 2026-05-18 (Claude Code 2.1.128).
const BUILTIN_TOOLS: &[&str] = &[
"Read",
"Write",
Expand All @@ -29,7 +34,14 @@ const BUILTIN_TOOLS: &[&str] = &[
"KillShell",
"EnterPlanMode",
"ExitPlanMode",
"TaskCreate",
"TaskUpdate",
"TaskOutput",
"TaskStop",
"TaskList",
"TaskGet",
"ToolSearch",
"TestRead",
];

/// Claude Code provider
Expand Down Expand Up @@ -107,15 +119,22 @@ impl Provider for ClaudeCodeProvider {
let settings_path = self
.settings_path()
.ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
self.ensure_configured_at(&settings_path)
}
}

impl ClaudeCodeProvider {
/// Testable variant that takes the settings.json path explicitly.
/// Production callers should use the trait method `ensure_configured`.
pub fn ensure_configured_at(&self, settings_path: &Path) -> Result<bool> {
if !settings_path.exists() {
// Create directory if needed
if let Some(parent) = settings_path.parent() {
fs::create_dir_all(parent)?;
}

// Create new settings file with OTEL enabled via env block.
// OTEL_LOG_TOOL_DETAILS=1 opts in to per-MCP-server tool names (Claude Code 2.1.2+).
// OTEL_LOG_TOOL_DETAILS=1 opts in to per-MCP-server tool names
// (Claude Code 2.1.2+; the upstream fix lands proper names in
// tool_result.tool_name natively as of 2.1.128).
let settings = serde_json::json!({
"enableTelemetry": true,
"env": {
Expand All @@ -128,30 +147,26 @@ impl Provider for ClaudeCodeProvider {
}
});

fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
fs::write(settings_path, serde_json::to_string_pretty(&settings)?)?;
tracing::info!(
"Created Claude Code settings with OTEL enabled at {:?}",
settings_path
);
return Ok(true);
}

// Read existing settings
let content =
fs::read_to_string(&settings_path).context("Failed to read Claude settings")?;

fs::read_to_string(settings_path).context("Failed to read Claude settings")?;
let mut settings: serde_json::Value =
serde_json::from_str(&content).context("Failed to parse Claude settings")?;

let mut modified = false;

// Check if enableTelemetry is set
if settings.get("enableTelemetry") != Some(&serde_json::Value::Bool(true)) {
settings["enableTelemetry"] = serde_json::Value::Bool(true);
modified = true;
}

// Check if env block exists and has correct OTEL settings
let env_block = settings.get("env");
let needs_env_update = match env_block {
None => true,
Expand All @@ -170,7 +185,6 @@ impl Provider for ClaudeCodeProvider {
};

if needs_env_update {
// Create or update env block
if settings.get("env").is_none() {
settings["env"] = serde_json::json!({});
}
Expand All @@ -188,27 +202,25 @@ impl Provider for ClaudeCodeProvider {
modified = true;
}

// Remove old-style telemetry block if present (migrate to env format)
// Migrate from old-style top-level `telemetry` block to the env block.
if settings.get("telemetry").is_some() && settings.as_object_mut().is_some() {
settings.as_object_mut().unwrap().remove("telemetry");
modified = true;
tracing::info!("Migrated from old telemetry format to env block format");
}

if modified {
// Backup existing settings
let backup_path = settings_path.with_extension("json.bak");
fs::copy(&settings_path, &backup_path)?;
tracing::info!("Backed up settings to {:?}", backup_path);

// Write updated settings
fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
tracing::info!("Updated Claude Code settings with OTEL env configuration");
return Ok(true);
if !modified {
tracing::debug!("Claude Code OTEL already configured correctly");
return Ok(false);
}

tracing::debug!("Claude Code OTEL already configured correctly");
Ok(false)
let backup_path = settings_path.with_extension("json.bak");
fs::copy(settings_path, &backup_path)?;
tracing::info!("Backed up settings to {:?}", backup_path);

fs::write(settings_path, serde_json::to_string_pretty(&settings)?)?;
tracing::info!("Updated Claude Code settings with OTEL env configuration");
Ok(true)
}
}

Expand Down
34 changes: 21 additions & 13 deletions src/providers/copilot_chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use super::{Provider, TOKEN_INPUT, TOKEN_OUTPUT};
use anyhow::{Context, Result};
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};

const OTLP_ENDPOINT: &str = "http://localhost:4318";

Expand Down Expand Up @@ -53,7 +53,24 @@ impl Provider for CopilotChatProvider {
let Some(settings_path) = self.settings_path() else {
return Ok(false);
};
self.ensure_configured_at(&settings_path)
}

fn setup_instructions(&self) -> Option<&'static str> {
Some(
"Copilot Chat reads OTLP config from VSCode settings.json. agenttop\n\
auto-writes the keys with `agenttop --setup copilot`; reload VSCode\n\
after running it. If captured prompt/response content is desired, also\n\
set \"github.copilot.chat.otel.captureContent\": true (opt-in).",
)
}
}

impl CopilotChatProvider {
/// Testable variant — see GeminiCliProvider for the rationale. Returns
/// `Ok(false)` when the VSCode settings.json doesn't exist (Copilot Chat
/// needs to have been launched at least once to create the file).
pub fn ensure_configured_at(&self, settings_path: &Path) -> Result<bool> {
if !settings_path.exists() {
tracing::warn!(
"VSCode settings.json not found at {:?}; install VSCode and run Copilot Chat at least once before configuring",
Expand All @@ -63,7 +80,7 @@ impl Provider for CopilotChatProvider {
}

let content =
fs::read_to_string(&settings_path).context("Failed to read VSCode settings.json")?;
fs::read_to_string(settings_path).context("Failed to read VSCode settings.json")?;
let mut settings: serde_json::Value = serde_json::from_str(&content)
.context("Failed to parse VSCode settings.json (may contain trailing commas; edit manually if so)")?;

Expand All @@ -80,7 +97,7 @@ impl Provider for CopilotChatProvider {
}

let backup_path = settings_path.with_extension("json.bak");
fs::copy(&settings_path, &backup_path)?;
fs::copy(settings_path, &backup_path)?;
tracing::info!("Backed up VSCode settings to {:?}", backup_path);

if let Some(obj) = settings.as_object_mut() {
Expand All @@ -94,22 +111,13 @@ impl Provider for CopilotChatProvider {
);
}

fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
fs::write(settings_path, serde_json::to_string_pretty(&settings)?)?;
tracing::info!(
"Updated VSCode settings.json with Copilot Chat OTLP at {:?}",
settings_path
);
Ok(true)
}

fn setup_instructions(&self) -> Option<&'static str> {
Some(
"Copilot Chat reads OTLP config from VSCode settings.json. agenttop\n\
auto-writes the keys with `agenttop --setup copilot`; reload VSCode\n\
after running it. If captured prompt/response content is desired, also\n\
set \"github.copilot.chat.otel.captureContent\": true (opt-in).",
)
}
}

fn vscode_user_settings_path() -> Option<PathBuf> {
Expand Down
Loading
Loading