feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)

Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
  Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
  with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
  Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
  its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
  status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
  reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
  ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files

Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
This commit is contained in:
2026-10-06 17:12:28 +07:00
parent 837da6e5de
commit 4aae61297f
146 changed files with 13565 additions and 537 deletions
+119 -5
View File
@@ -20,6 +20,8 @@ Complete REST API and WebSocket documentation for Agent Dashboard.
- [Workspaces](#workspaces)
- [Notifications](#notifications)
- [Remote Data Sources](#remote-data-sources)
- [Query Explorer](#query-explorer)
- [Scheduled reports](#scheduled-reports)
- [WebSocket API](#websocket-api)
- [Error Handling](#error-handling)
- [Rate Limiting](#rate-limiting)
@@ -1169,6 +1171,10 @@ since every call also emits a `PostToolUse` and counting both would double
every number. `status` filters by session lifecycle, same convention as
`GET /api/workflows`; omit it (or pass `all`) for no filter.
Both `GET /api/workflows` and `/skill-usage` also take `from=YYYY-MM-DD&to=YYYY-MM-DD`
(+ `tz_offset`): only the sessions STARTED in those local days. A bad date, or
`from` after `to`, is `400 INVALID_INPUT`.
```json
{
"skills": [
@@ -1328,7 +1334,7 @@ Returns all sessions, ordered by most recent activity.
| `awaiting` | `1` | - | Only sessions blocked on the user: `awaiting_input_since IS NOT NULL AND status = 'active'` (the UI's **Waiting** filter) |
| `from` / `to` | ISO 8601 | - | Inclusive bounds on `started_at`; an unparseable value is ignored. Like every filter here, applied before `limit`/`offset`, so `total` stays exact |
| `cwd` | string | - | Exact working directory |
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity` |
| `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity`. Also accepted on `/api/stats`, `/api/analytics`, `/api/workflows` (and `/workflows/skill-usage`) and `/api/pricing/cost`, where a malformed id is `400 INVALID_INPUT` |
| `lane` | integer | - | The sessions a workspace OWNS (`sessions.lane_id`): the ones its panes held. `400 INVALID_INPUT` when not an id |
| `archived` | `0` \| `1` | - | `0` = not archived, `1` = archived only; omitted = both |
| `sources` | string | - | Comma-separated data-source ids to include (the built-in local history is `local`; remote SSH machines use their `remote_sources.id`). Omit for all sources. Also accepted on `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`, and `/api/pricing/cost`. See [Remote Data Sources](#remote-data-sources) |
@@ -1800,6 +1806,7 @@ Response `Content-Type: text/plain; version=0.0.4; charset=utf-8`.
| `agenthub_websocket_clients` | gauge | — | Connected realtime clients |
| `agenthub_remote_sources` | gauge | `enabled` (`true`/`false`) | Configured Remote Data Sources |
| `agenthub_tokens_total` | counter | `kind` (`input`/`output`/`cache_read`/`cache_write`) | Cumulative token usage |
| `agenthub_cost_usd_total` | counter | `tool`, `model` | Cumulative token cost in USD, priced like `GET /api/pricing/cost` (code-execution container time excluded) |
Status series are always emitted (even at `0`) so a series never disappears from the exposition. The endpoint is mounted under `/api`, so it sits behind the same two guards as every other route: the **Host-header (DNS-rebinding) guard** and the optional **`DASHBOARD_TOKEN`** guard. A scraper that reaches the server as anything other than loopback (e.g. Prometheus in Docker hitting `host.docker.internal`) must be allowlisted with `DASHBOARD_ALLOWED_HOSTS`, or the scrape returns `403 EBADHOST`; if a token is set, the scrape must also send it.
@@ -1815,6 +1822,8 @@ scrape_configs:
# credentials: "<DASHBOARD_TOKEN>"
```
A ready-made Prometheus config, recording rules and three Grafana boards built on these series live in `monitoring/`, runnable with `docker-compose.monitoring.yml` — see [MONITORING.md](./MONITORING.md).
---
### Pricing
@@ -2003,13 +2012,16 @@ curl -X DELETE http://localhost:4820/api/pricing/gpt-5.1-codex
#### Total Cost
```http
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>&date=<YYYY-MM-DD>
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>&tool=<id>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>&date=<YYYY-MM-DD>
```
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's.
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown`, `by_tool` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's. `tool=<id>` narrows it to one AI tool's sessions (`sessions.tool`), and `by_tool: [{tool, cost}]` (most expensive first) splits the total per tool — each tool priced on its own rows, so the code-execution free allowance applies per tool. A Codex turn on the fast/priority service tier is booked as `speed = fast` (priced with the model's priority rates, which the pricing sync writes into the fast columns), one on the flex tier as `service_tier = flex` (half price, like batch).
`from` / `to` (local dates, inclusive, read with `tz_offset`; either end may be left open) narrow `total_cost`, `breakdown`, `by_tool`, `feature_costs` and `unpriced_models` to the tokens SPENT on those days (the same split as `daily_costs`). `daily_costs` itself stays the whole series, so a 30-day trend or a month-to-date pace is unaffected by the range. A malformed date, a date from year 9999, or `from` after `to` is `400 INVALID_INPUT`.
```bash
curl "http://localhost:4820/api/pricing/cost?tz_offset=-420"
curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2026-09-30"
```
#### Get Plan Quota
@@ -2019,6 +2031,39 @@ GET /api/pricing/quota[?force=1]
```
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex", active, label?, account?, expiresAt?, windows: [{ label, usedPercent, resetsAt }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's `auth.json` access-token `exp` (JWT claims read unverified; no token leaves the server). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time.
#### Analytics
```http
GET /api/analytics?tz_offset=<minutes>&sources=<csv>&tool=<id>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>
```
The Analytics bundle (`tokens`, `total_cost`, `tool_usage`, `daily_events`, `daily_sessions`, `agent_types`, `event_types`, `avg_events_per_session`, `total_subagents`, `overview`, `agents_by_status`, `sessions_by_status`). With `from`/`to` (same rules as above) the shape is unchanged and the numbers narrow: sessions and agents count by `started_at`, events by `created_at`, and `tokens` / `total_cost` come from the cost rows of the days in the range (`token_usage` has no date, so tokens follow the spend-day split). `daily_events` and `daily_sessions` keep their 365-day window.
#### Spend Budgets
A budget is an alert rule of type `budget`, created and edited through `/api/alerts/rules`:
```json
{
"name": "Monthly",
"rule_type": "budget",
"config": { "period": "month", "limit_usd": 200, "thresholds": [80, 100], "tool": "codex", "tz_offset": -420 }
}
```
`period` is `day` | `week` (from Monday) | `month`, in the local time of `tz_offset` (the browser's `getTimezoneOffset()`, saved with the rule); `thresholds` are 1–10 whole percents (default `[80, 100]`), each firing ONCE per period through the alerts feed, webhooks and push; `tool` / `lane` narrow it to one AI tool's or one workspace's sessions. Spend is priced like `GET /api/pricing/cost`, by the day the tokens were spent. `cost_threshold` (`{ "usd": 5 }`) is the per-session rule.
```http
GET /api/budgets/status
```
Every enabled budget with its current period: `{ budgets: [{ id, name, config, period_start, period_end, spend, projected, pct, crossed }] }` — `projected` carries the pace so far to the period's end, `crossed` lists the thresholds reached.
```http
POST /api/alerts/rules/preview
```
`{ rule_type, config, days?, cooldown_seconds? }` → what the rule WOULD have fired over the last `days` (1–90, default 7): `{ supported, window_days, fires, sessions, samples }`, and for a budget `periods` (this one and the five before). Nothing is saved, fired or broadcast; `status_duration` answers `supported: false` (agent status history is not stored).
---
@@ -2074,6 +2119,8 @@ The `/api/remote-sources/*` namespace configures **remote SSH machines** the das
"ssh_port": 22,
"identity_file": "~/.ssh/id_ed25519",
"remote_home": "~/.claude",
"sync_codex": true,
"remote_codex_home": null,
"enabled": true,
"status": "ok",
"last_error": null,
@@ -2091,7 +2138,9 @@ The `/api/remote-sources/*` namespace configures **remote SSH machines** the das
}
```
`ssh_port`, `identity_file`, `remote_home`, `last_error`, `last_sync_at`, and `last_sync_counts` are nullable. `status` is one of `idle`, `syncing`, `ok`, `error`.
`ssh_port`, `identity_file`, `remote_home`, `remote_codex_home`, `last_error`, `last_sync_at`, and `last_sync_counts` are nullable. `status` is one of `idle`, `syncing`, `ok`, `error`.
`sync_codex` (default `false`) also pulls the machine's Codex history: `<remote_codex_home>/sessions` (default `~/.codex`, `wsl:~/.codex` when `remote_home` is a `wsl:` path) and its `session_index.jsonl` are mirrored with their mtimes kept into `<data dir>/remote-sources/<id>/codex/` and read by the same journal reader as local Codex history (`server/lib/codex-usage.js`), so those sessions arrive as `tool = 'codex'` rows tagged with this source, with their tokens; `last_sync_counts` then carries `codex_files` / `codex_sessions`. With Codex on, a machine may have only one of the two CLIs: a sync that finds one is `ok` with the other's failure in `last_error`; finding neither is an `error`.
#### List Remote Sources
@@ -2201,6 +2250,24 @@ Pulls history from **every enabled** source sequentially (one SSH connection at
{ "ok": true, "synced": 2, "results": [{ "id": "src_a", "ok": true }, { "id": "src_b", "ok": false, "error": "ssh exited with code 255" }] }
```
#### Push ingest (`POST /api/hooks/ingest-batch`)
For a machine the dashboard cannot reach over SSH: it pushes its own hook payloads. Off until **Settings → Data → Push ingest** creates a token (`POST /api/settings/push-token`, returned once; only its SHA-256 is stored as `AGENTHUB_PUSH_TOKEN_SHA256`).
```http
POST /api/hooks/ingest-batch
Authorization: Bearer ahp_…
Content-Type: application/json
{ "source": "laptop", "events": [ { "hook_type": "SessionStart", "data": { "session_id": "…", "cwd": "/home/me/app" } } ] }
```
- **503** `EPUSHDISABLED` with no token, **401** `EUNAUTHORIZED` on a wrong one (compared in constant time), **400** `EBADSOURCE` / `EBADBATCH`.
- `source`: 1–64 of `[A-Za-z0-9._-]`; the sessions are tagged `push:<source>` and appear in the `sources` filter.
- 1–500 events, 1 MB per request. `tool` per event is the same allowlist as `/api/hooks/event` (`codex`, else Claude Code; an Antigravity hook is answered `ignored`, since it must name a file under this machine's agy directory).
- Each event takes the `/api/hooks/event` path, except: `transcript_path` fields are dropped (they name the sender's files), no workspace or pane is touched, and a session another source owns is refused per item (`EFOREIGN`). Privacy settings apply.
- Response: `{ ok, source, stored, results: [{ok} | {ok, ignored} | {ok, duplicate} | {ok: false, error}] }`. Token cost needs the transcripts, which only an SSH source brings.
#### The `sources` filter
`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics` accept an optional `sources` query parameter: a comma-separated list of source ids to include (omit for all). `GET /api/sessions/facets` correspondingly returns a `sources: string[]` array (alongside `cwds`) listing the distinct `sessions.source` values so the UI can build the filter dropdown.
@@ -2438,10 +2505,57 @@ DELETE /api/pets/:id Delete a pet (its folder under the data di
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, idle/asleep → idle). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, idle/asleep → idle); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves. Each row keeps its own pace with a longer hold on its last frame (idle ~220 ms a frame, then ~1.4 s). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
**Featured pets are downloaded, never bundled.** The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: `POST /api/pets/featured/:id` takes only an id from the fixed list in `server/lib/pets.js` (`404 ENOPET` for any other, with no request made) and fetches `https://codex-pets.net/api/pets/<id>` and `/spritesheet` — no URL from the request, no redirects followed. A pet id is always made by the server (`p-<12 hex>` for an upload, `cp-<featured id>`), every write is same-origin (`403 EBADORIGIN`), and at most 50 pets are kept (`409 ETOOMANY`). Other errors: `413 ETOOBIG`, `502 EFETCH` (codex-pets.net unreachable or refusing).
### Session snapshots
```
GET /api/snapshots { snapshots: [{ id, token, session_id, created_at, session_name, redacted, event_count, bytes }] } — newest first, no payload
POST /api/snapshots { session_id, redact?: { file_paths?: boolean } } → 201 { snapshot: { id, token, session_id, created_at } }
GET /api/snapshots/:token { snapshot, payload } — the frozen copy
DELETE /api/snapshots/:id { ok: true }
```
A snapshot freezes a session as it is at that moment into one immutable `session_snapshots` row (`server/routes/snapshots.js`): `payload` is `{ version: 1, captured_at, redacted: { file_paths }, session, agents, events, stats, cost }` — the session row, its agents (each with its own `cost`), every stored event oldest first (`events.data` exactly as stored), the `/api/sessions/:id/stats` body and the `/api/pricing/cost/:id` result priced at the session's start date. Its `token` is 24 random bytes in base64url (32 characters) and is the only way to read it; the page is `/snapshot/<token>`. With `redact.file_paths`, paths are replaced **before anything is stored**: every `cwd`, `transcript_path`, `agent_transcript_path`, `file_path`, `filePath`, `notebook_path` and `path` value (also inside `events.data` and `metadata` JSON) becomes `[redacted]`, and the session's cwd / the home directory inside any other string (a Bash command, a summary) become `<cwd>` / `~`. A row cannot be updated (a trigger aborts it), outlives its session, and survives **Clear Data** — it goes only through `DELETE` (Settings → **Snapshots**). Both writes are same-origin (`403 EBADORIGIN`); `400 INVALID_INPUT` without `session_id`, `404 NOT_FOUND` for an unknown session, token or id.
### Query Explorer
```http
GET /api/query?entity=sessions|events|agents&q=&tool=&status=&cwd=&from=&to=&tz_offset=&sources=&sort=&dir=asc|desc&limit=&offset=&format=csv|json
```
Filters one table and answers one page — `{ entity, columns, sortable, statuses, rows, total, limit, offset, export_cap }` (`limit` 1–500, default 50) — or, with `format=csv|json`, the WHOLE filtered set as a download (`Content-Disposition: attachment`) capped at `export_cap` = 50,000 rows; the headers `X-Total-Count` and `X-Truncated: 1|0` say whether it was cut, and the JSON export is `{ entity, total, truncated, cap, rows }`. `server/routes/query.js`.
| Entity | Columns | `sort` accepts | Date range bounds | `q` searches | `status` |
|---|---|---|---|---|---|
| `sessions` (default) | id, name, status, tool, model, cwd, started_at, ended_at | started_at (default), ended_at, name, status, tool, model, cwd | `started_at` | id, name, cwd | active, completed, error, abandoned |
| `events` | id, session_id, agent_id, event_type, tool_name, summary, tool, cwd, created_at | created_at (default), event_type, tool_name, session_id, tool | `created_at` | summary, event_type, tool_name, session_id | — |
| `agents` | id, session_id, name, type, subagent_type, status, task, tool, cwd, started_at, ended_at | started_at (default), ended_at, name, type, subagent_type, status, tool | `started_at` | id, name, task, subagent_type | working, waiting, completed, error |
`tool`, `cwd` and `sources` apply to the row's session; `cwd` matches the folder and everything under it. `q` is a literal substring (`%` and `_` are not wildcards). `sort` and `dir` (default `desc`, ties broken by id) must come from the table above — the SQL column and direction are taken from that allowlist, never from the request — and anything else (an unknown entity/sort/dir/status/format, a malformed tool or date) is `400 INVALID_INPUT`. CSV is RFC 4180 (CRLF, a cell with `"`, `,` or a line break quoted, `"` doubled) with a UTF-8 BOM, and a text cell starting with `=`, `+`, `-`, `@`, a tab or a CR gets a leading `'` so a spreadsheet never runs it as a formula.
```bash
curl -OJ "http://localhost:4820/api/query?entity=events&q=Bash&from=2026-09-01&format=csv"
```
### Scheduled reports
```
GET /api/reports { reports: [{ id, name, window_days, frequency, hour, weekday, tz_offset, formats, enabled, last_slot, created_at, last_run }] }
POST /api/reports { name, window_days, frequency, hour, weekday?, tz_offset?, formats, enabled? } → 201 { report }
PUT /api/reports/:id any of those fields (the rest kept) → { report }
DELETE /api/reports/:id { ok: true } — the definition, its runs and its files
POST /api/reports/:id/run { run } — run now (also when disabled)
GET /api/reports/:id/runs { runs: [{ id, report_id, created_at, trigger, status, window_from, window_to, stamp, files, error }] } — newest 100
GET /api/reports/:id/runs/:runId/:format the file (html | json), as an attachment
```
A report (`server/lib/reports.js`) covers the `window_days` (1–366) full local days BEFORE the day it runs, at its `tz_offset` (the browser's `getTimezoneOffset()`, −840…840). `frequency` is `daily` or `weekly` (on `weekday`, 0 = Sunday, default Monday) at local `hour` 0–23; `formats` is a non-empty subset of `html`, `json`. Its content is computed like `GET /api/analytics` and `GET /api/pricing/cost` over that window — sessions (with status counts), agents, events, tokens, cost by model and by AI tool, unpriced models, the top 10 tools — plus every enabled budget's CURRENT period from `GET /api/budgets/status`. Each run writes `<data dir>/reports/<id>/<stamp>.<format>` and a `report_runs` row (`trigger` schedule | manual, `status` ok | error with `error`); the download path is built from the two ids and the stored stamp only (`404 NOT_FOUND` for anything else). Every value in the HTML is escaped.
The scheduler (started with the server's background services, every `DASHBOARD_REPORTS_MS`, default 60000, `0` = off) runs a report when its latest scheduled instant is newer than `last_slot`, claiming it with one conditional UPDATE so two dashboards on one database never both run it; a slot missed while the server was down runs once at the next start. Creating, editing or re-enabling a report claims the slot already past, so it never runs at once for an hour gone by. Writes are same-origin (`403 EBADORIGIN`); `400 INVALID_INPUT` for a bad field. Definitions and runs survive **Clear Data**, like alert rules and snapshots.
---
## WebSocket API
+11 -1
View File
@@ -21,6 +21,7 @@ The complete guide to `agenthub`, the command-line interface to AgentHub — the
- [Import](#import)
- [Remote Sources](#remote-sources)
- [Workspaces](#workspaces)
- [Integrations](#integrations)
- [Administration](#administration)
- [Safety Model](#safety-model)
- [Output & Scripting](#output--scripting)
@@ -220,7 +221,7 @@ Manage the remote (SSH) machines this dashboard pulls Claude Code history from
| Command | Description |
| ------- | ----------- |
| `agenthub remote-sources` (alias `remotes`) | List configured sources with id, auto-sync on/off, status, label, host, **session count**, and last-sync time, followed by a totals line (sources / auto-syncing / sessions collected) |
| `agenthub remote-sources add --label <name> --host <user@host> [--port N] [--identity <path>] [--remote-home <path>] [--disabled]` | Add a source. `--host` is an ssh destination (`user@host`) or a `~/.ssh/config` alias; `--disabled` skips it in the background poller |
| `agenthub remote-sources add --label <name> --host <user@host> [--port N] [--identity <path>] [--remote-home <path>] [--codex] [--codex-home <path>] [--disabled]` | Add a source. `--host` is an ssh destination (`user@host`) or a `~/.ssh/config` alias; `--codex` also pulls its Codex history (from `--codex-home`, default `~/.codex`); `--disabled` skips it in the background poller |
| `agenthub remote-sources test <id>` | Probe SSH connectivity and check the remote `~/.claude/projects` exists; exits non-zero on failure |
| `agenthub remote-sources sync [id]` | Pull history now — one source by id, or **all** sources when the id is omitted. Prints imported / tagged counts |
| `agenthub remote-sources rm <id> [--purge]` | Remove a source (its imported sessions are detached back to `local` by default; `--purge` also **deletes** them) |
@@ -265,6 +266,15 @@ Resolution order, shared by every cwd-resolving command: `--workspace` (old name
Because a workspace's services are fully detached, restarting the dashboard (or `agenthub update`) never stops a running stack.
### Integrations
| Command | Description |
| ------- | ----------- |
| `agenthub mcp` | Run the read-only MCP server on stdio (`mcp/server.js`). Register it with `claude mcp add agenthub -- agenthub mcp`; never registered automatically. `AGENTHUB_URL` overrides the dashboard it asks — see [MCP.md](MCP.md) |
| `agenthub statusline` | Render the Claude Code statusline from the statusline JSON on stdin (what a plugin install's `statusLine` runs) — see [STATUSLINE.md](STATUSLINE.md) |
| `agenthub statusline install [--force]` | Write AgentHub's `statusLine` into `$CLAUDE_HOME/settings.json` (default `~/.claude/settings.json`). Refuses (exit 1) when another `statusLine` is there unless `--force`; the old file is backed up beside itself as `settings.json.agenthub-backup-<timestamp>` |
| `agenthub statusline uninstall [--force]` | Remove AgentHub's `statusLine` (another one only with `--force`), with the same backup |
### Administration
| Command | Description |
+74 -5
View File
@@ -142,6 +142,8 @@ erDiagram
text identity_file "Optional private-key path (NULL = SSH default)"
text remote_home "Optional remote Claude home (NULL = remote ~/.claude)"
integer enabled "1 = eligible for sync, 0 = disabled"
integer sync_codex "1 = also pull Codex history (default 0)"
text remote_codex_home "Optional remote Codex home (NULL = ~/.codex)"
text status "idle | syncing | ok | error"
text last_error "Last failure message, or NULL"
text last_sync_at "ISO8601 timestamp of last successful sync, or NULL"
@@ -210,8 +212,8 @@ CREATE TABLE sessions (
| `awaiting_input_since` | TEXT | YES | ISO 8601 stamp set when the session is **Waiting** (Stop, SessionStart with source `startup`/`resume`/`clear`, permission Notification, or watchdog user-interrupt/Esc recovery). NULL otherwise. A SessionStart with source `compact` (auto-compaction fires mid-turn while Claude is working) leaves this column untouched, so a genuinely-active session is not mislabeled Waiting |
| `awaiting_reason` | TEXT | YES | Why the row is waiting: `notification`, `stop`, `session_start`, or `interrupted`. Set/cleared in lock-step with `awaiting_input_since` (SessionStart→`session_start`, Stop→`stop`, permission/input Notification→`notification`, watchdog/Esc recovery→`interrupted`). NULL otherwise. Exception: a `compact`-source SessionStart preserves the existing value (neither stamps `session_start` nor clears it) |
| `transcript_path` | TEXT | YES | Absolute path to the session's JSONL transcript. Written by `routes/hooks.js` on the first event that carries it (subsequent events no-op via a SQL guard) and read by the periodic compaction sweep — so the sweep touches only active session rows instead of scanning the entire `events` table for `json_extract(data,'$.transcript_path')`. Backfilled once from `events` by the `db.js` migration |
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from. Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from, or `push:<name>` for a session another machine pushed to `POST /api/hooks/ingest-batch` (a pushed hook may only extend a session its own source created). Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. `'cursor'` for a Cursor agent transcript, ingested by `server/lib/cursor-usage.js` (see `cursor_transcripts`): no `transcript_path`, no `model` and no `token_usage` (the transcripts record none), `active` while its transcript was written in the last two minutes, else `completed`. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
| `lane_id` | INTEGER | YES | The workspace that OWNS the session — the only sessions a workspace lists: the lane whose dashboard pane held it, stamped by `bindRunSession` (`server/routes/hooks.js`) from the hook's `run_id` — first writer wins, a nested `claude -p` excluded — or, for a Codex/Antigravity session, by `claimToolSessions` (`server/lib/dashboard-runs.js`) from the same-tool run that was live in its folder when it started. NULL for a session no workspace started (a terminal, an import, a tool with no hooks). No FK: removing a workspace leaves its sessions. Backfilled once from `dashboard_runs`. Indexed by `idx_sessions_lane` |
| `archived_at` | TEXT | YES | When the user archived the session out of its workspace's lists (`PATCH /api/sessions/:id {archived}`). Hides only — nothing is deleted |
@@ -474,7 +476,10 @@ CREATE TABLE remote_sources (
last_sync_at TEXT,
last_sync_counts TEXT,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
-- added by migration (additive):
sync_codex INTEGER NOT NULL DEFAULT 0,
remote_codex_home TEXT
);
```
@@ -489,10 +494,12 @@ CREATE TABLE remote_sources (
| `identity_file` | TEXT | YES | Optional private-key path passed to ssh (`-i`); NULL to omit |
| `remote_home` | TEXT | YES | Optional remote Claude home to read transcripts from; NULL defaults to remote `~/.claude` |
| `enabled` | INTEGER | NO | `1` = eligible for scheduled/manual syncs, `0` = disabled (default `1`) |
| `sync_codex` | INTEGER | NO | `1` = a sync also mirrors the machine's Codex `sessions/` and reads it through `server/lib/codex-usage.js` into `tool = 'codex'` sessions tagged with this source (default `0`) |
| `remote_codex_home` | TEXT | YES | Optional remote CODEX_HOME; NULL = `~/.codex` (`wsl:~/.codex` when `remote_home` is a `wsl:` path) |
| `status` | TEXT | NO | Last sync status: `idle`, `syncing`, `ok`, or `error` (CHECK-constrained) |
| `last_error` | TEXT | YES | Error message from the last failed sync/test, or NULL |
| `last_sync_at` | TEXT | YES | ISO 8601 timestamp of the last successful sync, or NULL |
| `last_sync_counts` | TEXT | YES | JSON blob of the last sync's counters (imported/skipped/backfilled/errors/sessions_seen/sessions_tagged), or NULL |
| `last_sync_counts` | TEXT | YES | JSON blob of the last sync's counters (imported/skipped/backfilled/errors/sessions_seen/sessions_tagged, plus codex_files/codex_sessions with Codex on), or NULL |
| `created_at` | TEXT | YES | ISO 8601 creation timestamp |
| `updated_at` | TEXT | YES | ISO 8601 timestamp of the last edit |
@@ -508,7 +515,7 @@ The same buckets as `token_usage` (session, model, speed, inference_geo, service
### codex_rollouts
How far `server/lib/codex-usage.js` has read each Codex rollout journal (`$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<thread>.jsonl`). A file whose `size` and `mtime_ms` are unchanged is not read again; a grown one is read from `offset` to its last newline, at most 16 MB per pass (`state.more` marks a file with more to read); a shrunken one from 0. There is one row per `thread_id`: the same journal met under a new path (CODEX_HOME moved, or reached through a symlink) replaces its old row instead of being counted twice. Because it lives in the DB, a Codex session the user deleted stays gone until Codex writes to that journal again, while a fresh database re-imports every journal. `POST /api/settings/clear-data` empties this table (and `antigravity_conversations`) with the sessions, so a cleared session is imported again whole instead of never, or half on its next write.
How far `server/lib/codex-usage.js` has read each Codex rollout journal (`$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<thread>.jsonl`). A file whose `size` and `mtime_ms` are unchanged is not read again; a grown one is read from `offset` to its last newline, at most 16 MB per pass (`state.more` marks a file with more to read); a shrunken one from 0. There is one row per `thread_id`: the same journal met under a new path (CODEX_HOME moved, or reached through a symlink) replaces its old row instead of being counted twice. Because it lives in the DB, a Codex session the user deleted stays gone until Codex writes to that journal again, while a fresh database re-imports every journal. `POST /api/settings/clear-data` empties this table (and `antigravity_conversations`, `cursor_transcripts`) with the sessions, so a cleared session is imported again whole instead of never, or half on its next write.
```sql
CREATE TABLE codex_rollouts (
@@ -540,6 +547,68 @@ CREATE TABLE antigravity_conversations (
);
```
### cursor_transcripts
Which Cursor agent transcripts `server/lib/cursor-usage.js` has read (`~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl`), at what mtime. An unchanged file is not read again (only its `active` session settled once quiet); a changed one rewrites its session row. As with `codex_rollouts`, a session the user deleted stays gone until Cursor writes to that transcript again. Additive: `CREATE TABLE IF NOT EXISTS`.
```sql
CREATE TABLE cursor_transcripts (
path TEXT PRIMARY KEY, -- absolute transcript path
session_id TEXT NOT NULL, -- the file name's id
mtime_ms INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
```
### session_snapshots
Frozen copies of a session (`server/routes/snapshots.js`, `POST /api/snapshots`). `payload` is one JSON document — `{version, captured_at, redacted: {file_paths}, session, agents, events, stats, cost}` — read back only by `token`. A trigger refuses every `UPDATE`; `session_id` has no foreign key, so a snapshot outlives its session and survives Clear Data (deleted only by `DELETE /api/snapshots/:id`). Additive: `CREATE TABLE IF NOT EXISTS`.
```sql
CREATE TABLE session_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE, -- crypto.randomBytes(24) base64url
session_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
payload TEXT NOT NULL
);
CREATE TRIGGER session_snapshots_immutable BEFORE UPDATE ON session_snapshots
BEGIN SELECT RAISE(ABORT, 'session snapshots are immutable'); END;
```
### report_definitions / report_runs
Scheduled reports (`server/lib/reports.js`, `/api/reports`). A definition names the window (`window_days` full local days before the run day), the schedule (`frequency` daily | weekly, `weekday` 0 = Sunday, local `hour`, at `tz_offset` = the browser's `getTimezoneOffset()`), the `formats` (JSON array of `html` / `json`) and `enabled`. `last_slot` is the scheduled instant last claimed — the scheduler claims a slot with one conditional `UPDATE … WHERE last_slot IS NULL OR last_slot < ?`, so of two dashboards on one database only one runs it. Each run is a `report_runs` row; its files are `<data dir>/reports/<report_id>/<stamp>.<format>` (`files` lists the formats written). Runs cascade with their definition. Both tables survive Clear Data. Additive: `CREATE TABLE IF NOT EXISTS`.
```sql
CREATE TABLE report_definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
window_days INTEGER NOT NULL DEFAULT 7,
frequency TEXT NOT NULL DEFAULT 'weekly' CHECK(frequency IN ('daily','weekly')),
hour INTEGER NOT NULL DEFAULT 8,
weekday INTEGER NOT NULL DEFAULT 1,
tz_offset INTEGER NOT NULL DEFAULT 0,
formats TEXT NOT NULL DEFAULT '["html"]',
enabled INTEGER NOT NULL DEFAULT 1,
last_slot TEXT, -- ISO instant of the last claimed slot
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE report_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id INTEGER NOT NULL REFERENCES report_definitions(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
trigger TEXT NOT NULL, -- schedule | manual
status TEXT NOT NULL, -- ok | error
window_from TEXT, -- local YYYY-MM-DD, inclusive
window_to TEXT,
stamp TEXT, -- file name stem, made by the server
files TEXT NOT NULL DEFAULT '[]',
error TEXT
);
CREATE INDEX idx_report_runs_report ON report_runs(report_id, id);
```
### lane_sources (and `lanes.instructions`)
A workspace's own context, as on its home page (`server/lib/lane-sources.js`). `lanes.instructions` (TEXT, nullable, ≤ 16 000 characters) is appended to every new pane's system prompt; `lane_sources` holds its extra **folders** (the user's directories, never deleted by AgentHub) and uploaded context **files** (AgentHub's own copies under `<data dir>/workspace-context/<lane id>/`, deleted with the row and with the workspace). Both are read at spawn from these rows only (`launchExtras`), never from a request. Both are additive migrations; the rows cascade when the lane row is deleted.
+13 -14
View File
@@ -189,7 +189,8 @@ npm run dev:server
# Terminal 2: Client only
npm run dev:client
# Terminal 3: MCP server (optional)
# Terminal 3: MCP server (optional; normally Claude Code starts it — docs/MCP.md)
node mcp/server.js
```
---
@@ -257,6 +258,8 @@ DASHBOARD_CODEX_POLL_MS=15000 # Codex rollout journals → codex lane
CODEX_HOME= # where Codex keeps sessions/ (default ~/.codex)
DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity sessions/tokens
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens)
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
# Pricing and saved settings
AGENTHUB_PRICING_SYNC= # 0|false|off disables automatic pricing from LiteLLM (default on)
@@ -532,21 +535,17 @@ graph TB
### Monitoring Metrics
```javascript
// Add to server/index.js for metrics endpoint
app.get('/metrics', (req, res) => {
const metrics = {
uptime: process.uptime(),
memory: process.memoryUsage(),
cpu: process.cpuUsage(),
sessions: db.prepare('SELECT COUNT(*) as count FROM sessions').get(),
agents: db.prepare('SELECT COUNT(*) as count FROM agents').get(),
websocket_clients: wss.clients.size
};
res.json(metrics);
});
The server already exposes Prometheus metrics at `GET /api/metrics`
([API.md § Prometheus exposition](./API.md#prometheus-exposition)). A scrape
config, recording rules and three Grafana boards ship in `monitoring/`:
```bash
DASHBOARD_ALLOWED_HOSTS=host.docker.internal npm start
GF_SECURITY_ADMIN_PASSWORD='choose-one' docker compose -f docker-compose.monitoring.yml up -d
```
See [MONITORING.md](./MONITORING.md).
---
## Backup & Recovery
+2
View File
@@ -23,6 +23,8 @@ Comprehensive guide to integrating with Claude Code's hook system for real-time
Claude Code provides a hook system that allows external tools to receive real-time events during agent execution. Agent Dashboard uses these hooks to capture session lifecycle, tool executions, and notifications.
> **Privacy (Settings → Data → Privacy, `server/lib/privacy.js`):** a hook whose `cwd` is an ignored folder (`AGENTHUB_IGNORE_CWD`, or under one — path-boundary match) is answered `{ok: true, ignored: true}` and nothing is stored; with **Redact secrets** on (`AGENTHUB_REDACT_SECRETS=1`) the stored `events.data` and `events.summary` have API keys, tokens, bearer headers, private keys and `password=`-style values replaced by `[redacted]` (`trim_event_data` / `redact_text` in `server/db.js`). The hook route still reads the raw payload in memory, and the transcript on disk is untouched. Hooks from another machine can be pushed to `POST /api/hooks/ingest-batch` with a token (see [API.md → Push ingest](./API.md#push-ingest-post-apihooksingest-batch)).
> **Cursor (informational):** Live hooks fire from Claude Code. **Cursor** sessions that only exist as JSONL under `~/.claude` (Cursor uses the same paths locally) are still counted — they appear via startup import, continuous project sync, or remote SSH sync, not via hooks.
```mermaid
+2 -2
View File
@@ -390,11 +390,11 @@ A missing source `.env` falls back to `.env.example` with a loud warning, never
The dashboard web UI merges workspaces and runs into a single **Workspace** page accessible at `/run` (the old `/lanes` route now redirects here). Top to bottom:
- **No page header.** The page title and the four counters (`lanes`, `running`, `needs you`, `dead`) are gone: the sidebar's workspace list shows every workspace with its own status dot, and the status bar at the bottom of every page counts running and need-you workspaces. `GET /api/lanes` still returns `counts` for other callers.
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar. Drag the expanded sidebar's right edge (or focus it and press ←/→) to widen it from 240 px up to 480 px — never more than 40% of the window; the width is kept in `agenthub.sidebar.width` and a double-click on the edge resets it (`client/src/components/Layout.tsx`).
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a session in slots 1–9 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
- **Row menu** — right-click a row, or its `⋯` button: Rename, the Model (AI Router) select, New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Router, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), whose half-typed text is kept per workspace in `agenthub.workspace.draft.<lane id>` (localStorage, so it survives leaving the page or switching workspace; cleared once sent — attached images are not kept), and whose **Start** opens a NEW session in the first slot with no live run (0–9, `POST /api/lanes/:id/start {slot}`; all ten busy is refused) and then shows it in a tab of its own, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
+86
View File
@@ -0,0 +1,86 @@
# AgentHub MCP server
`mcp/server.js` is a small **read-only** [Model Context Protocol](https://modelcontextprotocol.io)
server. It lets a Claude Code session (or any MCP client) ask the running
AgentHub dashboard about sessions, costs, analytics, workspaces and alerts.
- Plain Node, no build step and no dependency: JSON-RPC 2.0 over stdio, one JSON
message per line (`initialize`, `ping`, `tools/list`, `tools/call`).
- Every tool is ONE `GET` against the dashboard's HTTP API, so its numbers are
the dashboard's own. It never opens the database and has no write tool.
- The dashboard must be running (`agenthub start`). A tool called while it is
down answers an `isError` result saying so.
## Enable it
The plugin does **not** register the server by itself — every registered MCP
server adds its tool list to every session's context, so it is opt-in:
```bash
# Plugin install or checkout with `agenthub` on PATH
claude mcp add agenthub -- agenthub mcp
# Checkout without the CLI linked
claude mcp add agenthub -- node /path/to/AgentHub/mcp/server.js
```
Add `--scope user` to make it available in every project. Remove it with
`claude mcp remove agenthub`. On a plugin install, `agenthub` is the
`~/.local/bin/agenthub` launcher the bootstrap keeps pointed at the current
build, so the registration survives plugin updates.
## Which dashboard it talks to
| Priority | Source |
|---|---|
| 1 | `AGENTHUB_URL` (e.g. `http://127.0.0.1:4821`) |
| 2 | `CLAUDE_DASHBOARD_PORT` / `DASHBOARD_PORT` |
| 3 | `~/.claude/.agenthub.json` — the live-dashboard discovery file the CLI uses (`server/lib/dashboard-url.js`) |
| 4 | `http://127.0.0.1:4820` |
When the dashboard sets `DASHBOARD_TOKEN`, give the MCP process the same
variable (`claude mcp add agenthub -e DASHBOARD_TOKEN=… -- agenthub mcp`); it is
sent as `Authorization: Bearer`.
## Tools
Every result is the API's JSON, pretty-printed as text. `tz_offset` is minutes
from UTC as JavaScript's `getTimezoneOffset()` gives them (UTC+7 → `-420`); when
left out, the MCP process's own time zone is used.
| Tool | Arguments | API |
|---|---|---|
| `get_health` | — | `GET /api/health` |
| `get_info` | — | `GET /api/settings/info` |
| `get_stats` | `tz_offset`, `tool` | `GET /api/stats` |
| `get_total_cost` | `tz_offset`, `lane`, `tool` | `GET /api/pricing/cost` |
| `get_session_cost` | `session_id`*, `tz_offset` | `GET /api/pricing/cost/:id` |
| `get_lane_cost` | `lane`*, `tz_offset` | `GET /api/pricing/cost?lane=` |
| `list_sessions` | `q`, `status`, `tool`, `lane`, `limit` | `GET /api/sessions` |
| `get_session` | `session_id`*, `include_events` | `GET /api/sessions/:id` (`?events=0` unless `include_events`) |
| `get_session_stats` | `session_id`* | `GET /api/sessions/:id/stats` |
| `get_analytics` | `tz_offset`, `tool` | `GET /api/analytics` |
| `get_workflows` | `status`, `tool` | `GET /api/workflows` |
| `list_alerts` | `unacked`, `limit` | `GET /api/alerts` |
| `list_lanes` | — | `GET /api/lanes` (workspaces) |
| `list_pricing` | — | `GET /api/pricing` |
| `get_budget_status` | — | `GET /api/budgets/status` |
`*` = required. `tool` filters by AI CLI (`claude`, `codex`, …) on a dashboard
that supports the filter and is ignored by one that does not. A route the
dashboard does not have (`/api/budgets/status` before budgets ship, or any tool
against an older AgentHub) answers `Not available on this dashboard` — this
covers both a 404 and the production server's SPA page, which is served with
200 for any unknown path.
## Errors
- Dashboard unreachable, an API error (`HTTP 404: Session not found`), a missing
required argument or an unknown tool → a tool result with `isError: true`.
- Unknown JSON-RPC method → error `-32601`; a line that is not JSON → `-32700`.
## Tests
`server/__tests__/mcp-server.test.js` drives the server over stdio against a
fake API; `server/__tests__/mcp-server-live.test.js` calls every tool against a
real in-test dashboard, so a tool whose path drifts from the API fails there.
+60
View File
@@ -0,0 +1,60 @@
# Prometheus and Grafana for AgentHub
AgentHub exposes its live counters at `GET /api/metrics` in the Prometheus text
format (see [API.md § Prometheus exposition](API.md#prometheus-exposition)).
`monitoring/` holds a scrape config, recording rules and three Grafana boards
built on those series, and `docker-compose.monitoring.yml` runs both tools with
everything provisioned.
## Files
| Path | What |
|---|---|
| `monitoring/prometheus/prometheus.yml` | Scrape config: job `agenthub`, `metrics_path: /api/metrics`, target `host.docker.internal:4820` |
| `monitoring/prometheus/agenthub-rules.yml` | Recording rules `agenthub:*` (sessions, agents, event and token rates, cost) |
| `monitoring/grafana/dashboards/agenthub-overview.json` | **Overview**: up, version, active sessions, working agents, tokens and spend over 24 h, sessions/agents by status, events/min, token burn, RSS, uptime, websocket clients, remote sources |
| `monitoring/grafana/dashboards/agenthub-sessions-agents.json` | **Sessions & agents**: counts by status, errored sessions, agents waiting on you, sessions finished per hour |
| `monitoring/grafana/dashboards/agenthub-tokens-cost.json` | **Tokens & cost**: tokens per hour by kind, burn rate, cache-read share, spend all-time / 24 h, spend per hour by tool, spend by tool, top models by spend |
| `monitoring/grafana/provisioning/` | The Prometheus datasource and the dashboard provider for the compose stack |
| `docker-compose.monitoring.yml` | Prometheus + Grafana, both on loopback only |
The boards choose their datasource through a `ds` variable, so they also import
as they are into an existing Grafana (Dashboards → New → Import).
## Metrics used
`agenthub_up`, `agenthub_build_info{version}`, `agenthub_process_uptime_seconds`,
`agenthub_process_resident_memory_bytes`, `agenthub_sessions{status}`,
`agenthub_agents{status}`, `agenthub_events_total`, `agenthub_websocket_clients`,
`agenthub_remote_sources{enabled}`, `agenthub_tokens_total{kind}`.
The spend panels and the `agenthub:cost_usd:*` rules read
**`agenthub_cost_usd_total{tool,model}`** (estimated USD, only ever rising
except when pricing is recomputed). A dashboard that does not export it yet
leaves those panels empty; each one says so in its description.
`server/__tests__/monitoring.test.js` fails if a board or rule queries any other
metric `server/routes/metrics.js` does not export.
## Run it with Docker
```bash
# 1. The dashboard on the host must accept the container's Host header
DASHBOARD_ALLOWED_HOSTS=host.docker.internal agenthub start # or npm start
# 2. Prometheus + Grafana
GF_SECURITY_ADMIN_PASSWORD='choose-one' docker compose -f docker-compose.monitoring.yml up -d
# 3. Grafana: http://127.0.0.1:3000 (admin / the password above), folder "AgentHub"
# Prometheus: http://127.0.0.1:9090
```
Without step 1 every scrape gets `403 EBADHOST` (the DNS-rebinding guard).
When the dashboard sets `DASHBOARD_TOKEN`, put the token in
`monitoring/prometheus/dashboard-token` and uncomment the `authorization` block in
`prometheus.yml`. Stop with `docker compose -f docker-compose.monitoring.yml down`
(`-v` also drops the stored series).
## Without Docker
Point an existing Prometheus at the dashboard — copy the `agenthub` job from
`prometheus.yml` with the target changed to `127.0.0.1:4820` (no
`DASHBOARD_ALLOWED_HOSTS` needed for loopback), add `agenthub-rules.yml` to its
`rule_files`, and import the three JSON boards into Grafana.
+2 -2
View File
@@ -1,6 +1,6 @@
# AgentHub — Plugin Marketplace
Official Claude Code plugins for the Agent Monitor dashboard. The **`agenthub`** plugin _is_ the dashboard — hooks, server and the `agenthub` CLI, no checkout required. (It shipped an MCP server until `c7dbaf2`, which dropped it; the dashboard reads Claude Code's MCP configuration in the config explorer but no longer serves one.) On top of it, **10 focused plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
Official Claude Code plugins for the Agent Monitor dashboard. The **`agenthub`** plugin _is_ the dashboard — hooks, server and the `agenthub` CLI, no checkout required. (It ships a small read-only MCP server again — `agenthub mcp`, see [MCP.md](./MCP.md) — but does not register it: run `claude mcp add agenthub -- agenthub mcp` to opt in. The same goes for the statusline, `agenthub statusline install`, see [STATUSLINE.md](./STATUSLINE.md).) On top of it, **10 focused plugins** extend Claude Code with skills, agents, slash commands, hooks, and CLI tools for deep analytics, cost guardrails, productivity automation, developer tools, AI-powered insights, session forensics, workflow/fleet intelligence, reliability & SLOs, config & memory governance, and dashboard connectivity.
Every plugin is powered by the local Agent Monitor REST API at `http://localhost:4820`. They are read-only advisors unless a skill explicitly documents a mutating endpoint (and those preview + confirm before acting).
@@ -632,7 +632,7 @@ These plugins query the Agent Monitor API at `http://localhost:4820`. Key data s
11 datasets: `stats`, `orchestration` (DAG), `toolFlow` (transitions), `effectiveness` (subagent success), `patterns` (recurring sequences), `modelDelegation`, `errorPropagation` (by depth), `concurrency` (lanes), `complexity` (score), `compaction` (impact), `cooccurrence` (agent pairs)
### Alert Rules (`/api/alerts/rules`)
Rule types: `token_threshold` (`{ total_tokens }` — the spend-relevant guardrail), `event_pattern`, `inactivity`, `status_duration`.
Rule types: `budget` (`{ period: day|week|month, limit_usd, thresholds: [80, 100], tool?, lane?, tz_offset? }` — a spend limit in dollars, each threshold firing once per period; `GET /api/budgets/status` reports the current period), `cost_threshold` (`{ usd }` per session), `token_threshold` (`{ total_tokens }`), `event_pattern`, `inactivity`, `status_duration`. `POST /api/alerts/rules/preview` dry-runs any of them over recorded history.
### Config Explorer (`/api/cc-config/*`)
Read every Claude Code surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, settings, keybindings, statusline, memory). A plugin card's contribution chips are clickable: each one expands to name the skills / subagents / commands / output styles that plugin ships (from `contributes.items`), so the plugin's contents are readable without opening its install directory. `memory` includes the per-project file-based store with `scope: "auto-memory"` (carrying `project`, `name`, `isIndex`, `frontmatter`); those files plus `CLAUDE.md` are mutable via `PUT`/`DELETE /api/cc-config/file` with always-on timestamped backups. The page also **manages plugins and marketplaces**: paste a GitHub `owner/repo`, URL or local path to register a marketplace, then install / enable / disable / update / uninstall its plugins — each button runs the corresponding `claude plugin …` command on the dashboard host (argv array, never a shell string). Hooks and `settings.json` keys are editable per layer (user / project / project-local) through `PATCH /api/cc-config/settings`, a per-key merge that leaves every untouched key — including redacted secrets — exactly as it was.
+4 -1
View File
@@ -47,7 +47,9 @@ graph TB
| [API.md](./API.md) | REST API endpoints (sessions, agents, events, stats, analytics, hooks, pricing, workflows, settings, import history, **cc-config**, **run**), WebSocket protocol (including `run_stream` / `run_status` / `run_input_ack` for the Run page) | Integration developers |
| [DATABASE.md](./DATABASE.md) | SQLite schema, queries, performance | Database administrators |
| [HOOKS.md](./HOOKS.md) | Claude Code hook system integration | Hook developers |
| [MCP.md](./MCP.md) | MCP server setup and tool reference | MCP integrators |
| [MCP.md](./MCP.md) | The read-only MCP server (`agenthub mcp`): setup and tool reference | MCP integrators |
| [STATUSLINE.md](./STATUSLINE.md) | The Claude Code statusline (`agenthub statusline install`) | Claude Code users |
| [MONITORING.md](./MONITORING.md) | Prometheus rules + Grafana boards for `/api/metrics`, `docker-compose.monitoring.yml` | Operators |
| [I18N.md](./I18N.md) | Language architecture, locale strategy, and rollout checklist | Frontend and product teams |
| [CLI.md](./CLI.md) | `agenthub` command reference — monitoring, browsing, insights, alerts, pricing, import, administration | Terminal users and CI scripting |
@@ -139,6 +141,7 @@ graph TB
- [API.md](./API.md) - Complete API reference
- [MCP.md](./MCP.md) - MCP server integration
- [MONITORING.md](./MONITORING.md) - Prometheus + Grafana
- [HOOKS.md](./HOOKS.md) - Custom hook integration
---
+54
View File
@@ -0,0 +1,54 @@
# AgentHub statusline for Claude Code
`statusline/statusline.js` is a Claude Code [statusline](https://docs.claude.com/en/docs/claude-code/statusline)
command. It prints one line under the prompt:
```
Opus 5.5 · main · ███░░░░░░░ 34% · $1.23 · today $45.68
```
| Part | Source |
|---|---|
| model | `model.display_name` from Claude Code's statusline JSON |
| branch | `git symbolic-ref --short HEAD` in the session's cwd (short sha when detached; 500 ms cap) |
| context bar | `context_window.used_percentage`, else `current_usage` (input + cache) ÷ `context_window_size`; green < 50 %, yellow < 80 %, red above |
| `$1.23` | this session, Claude Code's own `cost.total_cost_usd` |
| `today $…` | every session today, from the dashboard: `GET /api/pricing/cost?tz_offset=<local>` → today's `daily_costs` row |
Today's cost has a **1-second timeout** and is cached for 30 s in
`<os tmpdir>/agenthub-statusline-<uid>.json` — a failed fetch is cached too, so a
stopped dashboard costs one timeout per 30 s, not one per prompt. When the
dashboard is down the part is simply left out. The script never throws, stops
itself after 2.5 s and always exits 0. The dashboard is found like the CLI finds
it (`AGENTHUB_URL`, then `CLAUDE_DASHBOARD_PORT`/`DASHBOARD_PORT`, then
`~/.claude/.agenthub.json`); `DASHBOARD_TOKEN` is sent when set.
## Install / uninstall
Nothing is installed automatically. Run:
```bash
agenthub statusline install # adds statusLine to ~/.claude/settings.json
agenthub statusline install --force # also replaces a statusLine that is not AgentHub's
agenthub statusline uninstall # removes AgentHub's statusLine only
agenthub statusline uninstall --force # removes whatever statusLine is there
```
- The file is `$CLAUDE_HOME/settings.json` (default `~/.claude/settings.json`);
every other key is kept.
- A `statusLine` that is not AgentHub's is refused (exit 1) unless `--force`.
- Before any write the old file is copied beside itself as
`settings.json.agenthub-backup-<timestamp>`.
- A plugin install registers `"~/.local/bin/agenthub" statusline` (absolute
path) — the launcher the bootstrap repoints at every update, because the plugin
cache directory is replaced on update. A checkout registers
`node "<checkout>/statusline/statusline.js"`.
`agenthub statusline` with no subcommand renders the line from stdin, which is
what the plugin-install registration runs.
## Tests
`server/__tests__/statusline.test.js`: the formatter, today's cost against a fake
dashboard (cache, cached failure, timeout), the script end to end on stdin, and
install/uninstall against a temp `HOME`.
+234
View File
@@ -0,0 +1,234 @@
# Analytics parity with the upstream agent monitor (hoangsonww)
Baseline compared: upstream `hoangsonww's upstream agent monitor` at
`523170e` (= tag v2.2.6, 2026-10-04) plus its open PRs, against AgentHub `main`
at `151dc8a`. Agreed with the user on 2026-10-05: phases 1–6 in full, plus
Cursor CLI, a small MCP server, a statusline script and Grafana boards. NOT
doing: the deep workflow charts and the web Kanban (dropped on purpose in
`4a0f995`), SSE (the websocket covers it), extra UI locales.
Each phase ships on its own, with tests, docs (`update-project-docs`) and the
CLAUDE.md marker. Nothing is committed without the user's ask.
## Phase 1 — data hygiene ✅
- `events.data` trimmed at every insert through the SQL function
`trim_event_data(?)` (`server/lib/event-payload.js`, registered in
`server/db.js`): Edit/MultiEdit/NotebookEdit/Write drop `originalFile`,
`oldString`/`newString` (Write also `content`); Read drops `file.content` and
`file.base64`; every string in `tool_input`/`tool_response` over 8 KB keeps a
6 KB head + 2 KB tail; `structuredPatch` and everything outside those two
fields stay whole; `_trimmed` names what was dropped. The full text stays in
the transcript and its snapshot under `<data dir>/transcripts/`.
- One-time sweep of stored rows (`sweepEventPayloads`, `user_version` 4, after
marker 3), 200 rows per write transaction. No VACUUM (user's choice).
Measured on the user's DB: 18,518 rows, 595 MB → 86 MB.
- The session list prices every column `/api/pricing/cost/:id` does
(`tokensForSessions` in `server/routes/sessions.js`).
## Phase 2 — cost by tool ✅
- `?tool=` on `/api/pricing/cost`, `/api/analytics`, `/api/stats`,
`/api/workflows` (+ `/skill-usage`): `parseTool`/`parseScope` in
`server/lib/source-filter.js` (id-shaped, else 400), `scoped-stats.js` now
takes a `{where, params}` predicate over `sessions`.
- `by_tool` in the cost response; Analytics gets an AI-tool select (applies to
every sub-tab, Workflow included) and a **Cost by AI tool** card.
- `/api/analytics`'s `total_cost` now equals `/api/pricing/cost` (it had
ignored the compaction baselines and used UTC start dates).
- Codex service tier read from `thread_settings_applied`: fast/priority →
`speed = fast` priced with LiteLLM's `*_priority` rates (synced into the fast
columns for non-Claude models), flex → `service_tier = flex` at half price.
- Prometheus: `agenthub_cost_usd_total{tool,model}`.
- DEFERRED (with the reason in `pricing-sync.js`): long-context (>200k/>272k)
rates. No current Claude model has one, Codex here runs a 258k window
(0 of 17,701 turns over 272k), so a `token_usage` primary-key rebuild would
change no number today.
## Phase 3 — budgets, burn rate, forecast ✅ (budgets as alert rules)
- `budgets` table: name, period (day/week/month), limit USD, scope
(all / tool / workspace), thresholds (%), enabled. `/api/budgets` CRUD +
`GET /api/budgets/status` (spend this period, %, projected end-of-period).
- Evaluated on the alert beat; a crossed threshold fires once per period
through the existing alerts feed → webhooks → push.
- Alert rule type `cost_threshold` (USD per session / per day).
- `POST /api/alerts/rules/preview`: dry-run a rule over recorded history.
- UI: Settings → Budgets page; burn rate + month-end forecast tiles on
Analytics → Cost; budget chip in the status bar when ≥ a threshold.
- **As built:** a budget is an alert rule of type `budget` (`server/lib/budgets.js`;
config `period` / `limit_usd` / `thresholds` / `tool` / `lane` / `tz_offset`),
so there is no `budgets` table, no `/api/budgets` CRUD and no Settings →
Budgets page: budgets are created and edited with the other rules in
Settings → Notifications → Alert rules (editable in place since v2.3), and
`GET /api/budgets/status` is the one budgets route. `cost_threshold`, the
preview, the Spend pace + Budgets cards and the status-bar chip are as planned.
## Phase 4 — analytics UX ✅
- **Date range.** `parseRange` (`server/lib/source-filter.js`): `from`/`to` are
local `YYYY-MM-DD`, inclusive, read with `tz_offset`, either end open; a bad
date (or year 9999, or `from` > `to`) is `400 INVALID_INPUT`.
`GET /api/pricing/cost` keeps only the cost rows of those spend days for
`total_cost` / `breakdown` / `by_tool` (the existing spend-day split);
`daily_costs` stays the whole series so the 30-day charts and the spend pace
keep their windows. `GET /api/analytics` counts sessions/agents by
`started_at` and events by `created_at` (an optional `range` on the
`scoped-stats.js` functions), and takes tokens and cost from the same cost
rows (`token_usage` has no date); `daily_events`/`daily_sessions` keep 365
days. `GET /api/workflows` (+ `/skill-usage`): sessions started in the range.
The bodies are now `computeAnalytics` / `computeCost` (exported). Client:
a range select on the Analytics toolbar beside the AI-tool select — 7d /
30d / 90d / All (default) / Custom with two `<input type="date">` — in
`?range=` (+ `from`/`to`), `client/src/lib/dateRange.ts`; it also reaches
the Workflow sub-tab.
- **Query Explorer.** `GET /api/query` (`server/routes/query.js`): entity
sessions / events / agents, `q` (literal substring), `tool`, `status`,
`from`/`to`, `cwd` (folder subtree), `sources`; `sort`/`dir` from a
per-entity allowlist (anything else 400 — no request text reaches SQL),
ties by id; pages of ≤ 500; `format=csv|json` exports the whole filter
capped at 50,000 rows (`X-Total-Count`, `X-Truncated`). CSV: RFC 4180,
CRLF, BOM, `'` before a cell starting with = + - @ tab CR. Page `/query`
(`client/src/pages/QueryExplorer.tsx`), sidebar entry **Query Explorer**
(the sidebar has no analytics item of its own; it sits under Sessions).
Column headers show the raw field names, which are also the CSV header.
- **Scheduled reports.** Tables `report_definitions` / `report_runs`;
`server/lib/reports.js` + `/api/reports` (CRUD, `POST /:id/run`, runs,
`GET /:id/runs/:runId/:format` with the path built from ids + the stored
stamp). A run covers the `window_days` full local days BEFORE the run day,
computed with `computeAnalytics` + `computeCost`, plus the current budget
status; files `<data dir>/reports/<id>/<stamp>.{html,json}`, HTML fully
escaped. Scheduler: unref'd interval in `startBackgroundServices`
(`DASHBOARD_REPORTS_MS`, default 60 s, 0 = off), each slot claimed by one
conditional UPDATE (two dashboards on one DB run it once); a slot missed
while down runs once on the next start; create/edit/re-enable claims the
slot already past. Weekly reports have a `weekday` (default Monday) —
needed for "weekly", not in the original list. Writes same-origin.
Definitions AND runs survive Clear Data (like alert rules and snapshots).
UI: Settings → **Reports** (`components/settings/ReportSettings.tsx`).
- Not done: OpenAPI entries for the new routes (snapshots/budgets have none
either); report runs are never pruned (one file per run per format).
- Upstream's PR branches (#286/#172 `feat/query-explorer`, #174
`feat/scheduled-reports`) were not in the reference clone; built from the
plan above.
- Tests: `analytics-range`, `query-explorer`, `reports` in
`server/__tests__/`; client `dateRange`, `QueryExplorer`, `ReportSettings`,
and a `?range=` case in `screens.snapshot.test.tsx`.
## Phase 5 — sessions ✅
- Replay: a **Replay** tab on `/sessions/:id` beside Agents / Conversation /
Timeline (`client/src/components/EventReplay.tsx`). It reads the session's
events through `GET /api/events?session_id=` in pages of 500 (the newest
5,000 at most, with a note when there are more), oldest first, on the tab's
first visit. A range slider over the event SEQUENCE (not the wall clock — an
hour-long pause does not stall it), play/pause at 0.5×/1×/2×/5× (one event
per 500 ms at 1×), step back/forward, jump start/end, keys ←/→/Home/End/Space,
a mini-map of lifecycle markers (SessionStart, UserPromptSubmit, Compaction,
SubagentStop, APIError, Stop) that jump the cursor, a state panel derived up
to the cursor (status, tool in flight, prompts, errors, compactions, elapsed,
tool-call counts) and the selected event through `EventDetail`. The pure
derivations live in `client/src/lib/replay.ts` (unit-tested).
- Snapshots: `POST /api/snapshots {session_id, redact?: {file_paths?}}` freezes
`{session, agents, events, stats, cost}` into an immutable
`session_snapshots` row (a trigger refuses UPDATE) under a 24-byte base64url
token; `GET /api/snapshots` (no payload), `GET /api/snapshots/:token`,
`DELETE /api/snapshots/:id`; writes behind `sameOriginGuard`. Redaction runs
at capture: path-valued keys → `[redacted]`, the session cwd / home dir in
any other string → `<cwd>` / `~`. Snapshots survive Clear Data, like alert
rules. UI: a **Snapshot** action in the session header (confirm with a
"redact file paths" box, default on; copies the link), the read-only page
`/snapshot/:token` (`client/src/pages/SnapshotView.tsx`: SessionOverview fed
the frozen stats via its new `stats` prop, the agents, Replay and Timeline —
the Timeline list now `client/src/components/EventTimeline.tsx`, shared with
the analysis page), and Settings → **Snapshots** (`/settings/snapshots`,
`components/settings/SnapshotSettings.tsx`: list, open, copy link, delete
with confirm, the same on a right-click menu).
- Upstream's PR branches (#265 `feat/session-replay`, #173
`feat/session-snapshots`) were not in the reference clone; built from the
plan above.
## Phase 6 — ingest breadth ✅
- **Remote Codex.** A remote source has an opt-in `sync_codex` (+ optional
`remote_codex_home`, default `~/.codex`, `wsl:~/.codex` beside a `wsl:` Claude
home) — two additive columns on `remote_sources`, a checkbox in the source
form, `--codex`/`--codex-home` on `agenthub remote-sources add`. A sync mirrors
`sessions/` with mtimes kept (`scp -r -p`, or tar from WSL) plus
`session_index.jsonl` into `<data dir>/remote-sources/<id>/codex/`, then runs
`ingestCodexUsage({home, source, activeMs})` — the same journal reader as local
Codex (`server/lib/codex-usage.js`), so sessions arrive as `tool = 'codex'`
rows tagged with the source id, with tokens and cost. A pass only ever writes
rows of its own source; the local settle never touches a remote row; the
remote activity window is `DASHBOARD_REMOTE_ACTIVE_WINDOW_MS`. With Codex on, a
machine may have only one CLI: one half failing is `ok` + `last_error`.
Differs from upstream (which always tries both): opt-in, and no separate
Codex import pipeline — AgentHub's journal reader is reused.
- **Push ingest.** `POST /api/hooks/ingest-batch` — body `{source, events:
[{hook_type, data, tool?}]}`, each event through the SAME function as
`POST /api/hooks/event` (`ingestHook` in `server/routes/hooks.js`). Off (503)
until Settings → Data → **Push ingest** creates a token: 32 random bytes,
shown once, stored only as its SHA-256 (`AGENTHUB_PUSH_TOKEN_SHA256` in the
settings file), checked in constant time; rotate/revoke; `GET` never returns
it. Sessions tagged `push:<source>`; a pushed hook may only extend a session
its own source created (`EFOREIGN`); `transcript_path`s are dropped (they
name the sender's files); no lane/pane binding; 500 events / 1 MB. The UI
shows a forwarder script keeping the token in a curl header file, not argv.
Differs from upstream: upstream's wire format is a collector's own session/
token/turn records; AgentHub takes plain hook payloads, so a pushed session
has events and live status but no token cost (that needs transcripts).
- **Live Codex overlay — not built.** Upstream's `include_transient`
(`codex-process-overlay.js`) scans `ps` for an interactive `codex` TUI that
has not written a rollout yet, i.e. one started but never prompted. In
AgentHub a Codex pane started from the dashboard already shows (its tab, and
**New session** in the sidebar from its run), and anything that has done work
has a journal the 15 s poll reads (or a SessionStart hook with Codex hooks
on). The only row it would add is an idle Codex in an outside terminal with
no prompt and no cost — not worth a per-tick process scan.
- **Privacy** (`server/lib/privacy.js`, Settings → Data → **Privacy**,
`GET/PUT /api/settings/privacy`, both off by default, persisted to the
settings file, env-pinnable): (a) **Redact secrets** (`AGENTHUB_REDACT_SECRETS=1`)
— chained in front of the trim in `trim_event_data` (so a key is redacted
before a trim could cut it) plus a new `redact_text(?)` on every events
`summary` insert; known key shapes (Anthropic, OpenAI, GitHub, GitLab, Slack,
AWS, Google, npm, JWT), bearer/basic headers, PEM private keys,
`password=`/`*_TOKEN:`-style values, and any string under a secret-named JSON
key → `[redacted]`, with `_redacted: <n>` on the payload. New data only; the
transcript is untouched; session NAMES (first prompt) are not redacted.
(b) **Ignored folders** (`AGENTHUB_IGNORE_CWD`, `path.delimiter`-separated,
path-boundary match): hooks dropped whole (`ignored: true`), and the history
import and the Codex reader skip those sessions too.
## Phase 7 — extras chosen by the user
- Cursor CLI: session + token ingest from `~/.cursor`, Cursor pricing rows.
- MCP server (stdio, read-only, ~15 tools: stats, cost, sessions, session
detail, analytics, workflows, budgets, alerts).
- `statusline/` script for Claude Code (model, branch, context %, session $,
today $ from the dashboard).
- `monitoring/` Grafana dashboards + Prometheus rules for `/api/metrics`.
### Phase 7 status — MCP, statusline, monitoring ✅; Cursor ✅ (no cost)
- **MCP** (`mcp/server.js`, `agenthub mcp`, docs/MCP.md): JSON-RPC over stdio
written directly (no SDK), 15 GET-only tools (health, info, stats, total /
session / lane cost, sessions, session, session stats, analytics, workflows,
alerts, lanes, pricing, budget status). `AGENTHUB_URL`, else the CLI's
discovery, now shared as `server/lib/dashboard-url.js`. A missing route —
404 or the production SPA page served with 200 — reads "Not available on
this dashboard", which is what `get_budget_status` says until Phase 3 lands.
NOT auto-registered by the plugin: `claude mcp add agenthub -- agenthub mcp`.
- **Statusline** (`statusline/statusline.js`, `agenthub statusline
install|uninstall [--force]`, docs/STATUSLINE.md): model · branch · context
bar · session $ · today $ (`/api/pricing/cost`, 1 s timeout, 30 s cache in
the OS temp dir, failures cached). Install refuses a foreign `statusLine`
without `--force` and backs settings.json up first; a plugin install
registers the `~/.local/bin/agenthub` launcher, not the cache dir.
- **Monitoring** (`monitoring/`, `docker-compose.monitoring.yml`,
docs/MONITORING.md): scrape config, `agenthub:*` recording rules, boards
Overview / Sessions & agents / Tokens & cost; the spend panels read Phase 2's
`agenthub_cost_usd_total{tool,model}` and say so.
- Tests: `mcp-server`, `mcp-server-live`, `statusline`, `monitoring` in
`server/__tests__/`.
- **Cursor** (`server/lib/cursor-usage.js`): agent transcripts under
`~/.cursor/projects/<slug>/agent-transcripts/` become `tool = 'cursor'`
sessions (`cursor_transcripts` table, `DASHBOARD_CURSOR_POLL_MS`,
`AGENTHUB_CURSOR_DIR`). NOT built: tokens, model and cost — the transcripts
record none, and `state.vscdb` (which has them) also holds auth tokens, so
it is never read; hence no Cursor pricing rows either.
## Ported onto main
Phases 2–7 were built in a separate worktree on 138e706 and ported onto v2.3.1
on 2026-10-06 (phase 1 had shipped on its own), released as v2.4.0.