Compare commits

..

5 Commits

Author SHA1 Message Date
nntrivi2001 f8acd5fc29 fix: v2.5.2 — plugin bootstrap exports before it runs (v2.5.1 update crash)
v2.5.1's update died at "bootstrap" on every platform: boot.npmCommand is
not a function. Run as the entry, plugin-bootstrap.js calls bootstrap() →
plugin-open.js, which requires plugin-bootstrap back and got the module.exports
of that moment — {} — because it was assigned at the bottom, below the entry
block. plugin-open.js already exported first for the same reason.

- module.exports moved above the entry block in plugin-bootstrap.js
- A test checks both entry scripts assign module.exports before their entry
  block (it fails on v2.5.1's file); requiring the module, as the other tests
  do, never takes the entry path
2026-10-06 19:17:52 +07:00
nntrivi2001 be75572977 fix: v2.5.1 — plugin npm runs no longer depend on PATH (Windows update failure)
A Windows self-update failed at "bootstrap": 'npm.cmd' is not recognized.
The update chain runs with the dashboard server's environment, whose PATH
need not reach npm.cmd, and the bootstrap looked npm up through cmd.exe
(shell: true, which also printed DEP0190).

- npmCommand() in scripts/plugin-bootstrap.js runs the npm bundled with the
  running node (npm-cli.js beside process.execPath, Windows and Unix
  layouts) with no shell, and puts that node's directory first on PATH (kept
  under the env's own Path/PATH key) so npm scripts find the same node; npm
  on PATH only when that node ships none
- Used by the runtime install, the UI build (plugin-open.js) and the
  doctor's npm row (plugin-doctor.js)
2026-10-06 19:08:38 +07:00
nntrivi2001 03ecc9e29a feat: start-at-login is a Settings switch, off by default (v2.5.0)
- Settings → About → "Start AgentHub automatically" (AutostartSettings.tsx,
  GET/PUT /api/settings/autostart, server/lib/autostart.js). The switch is
  the systemd --user unit's / LaunchAgent's enabled state: on writes a unit
  for the running server when none exists and enables it, off disables it;
  neither starts or stops the server. A foreign unit is never touched
  (409 EFOREIGN)
- The plugin bootstrap no longer installs a unit on every install: it
  installs or starts one only while the user has it enabled, and a rewrite
  keeps the enabled state (writeUserService({ enable })). An install that
  already had the unit enabled stays on. A stale-but-enabled unit beside a
  live detached server is repointed, no longer started onto the bound port
- unitIsEnabled / setUnitEnabled in systemd-service.js and launchd-service.js

Also:
- fix(workspace): status bar no longer covers the chat box below md (#61)
2026-10-06 18:53:10 +07:00
nntrivi2001 4aae61297f 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.
2026-10-06 17:12:28 +07:00
nntrivi2001 837da6e5de fix: v2.3.1 — correct version, say why a plugin update failed, gitignored files in Files
- v2.3.0's tree reported 2.2.1, so every install saw an update forever;
  package.json, the lockfile, openapi.yaml, the CLAUDE.md marker and the
  snapshot now read 2.3.1
- A failed plugin-update step records the tail of the command's stdout and
  stderr plus its exit code or signal (failureText); plugin-bootstrap.js
  prints its refusal on stdout, which the verdict used to drop. Update all
  uses the same text
- updates.test.js never applies an update: it skips when the checkout is
  behind a release (apply ran npm run setup and pruned dev dependencies)
- Files tree lists gitignored files and folders, named in orange
  (addIgnored in git-diff.js); change-tree rows indent in whole steps
2026-10-06 16:22:53 +07:00
165 changed files with 14498 additions and 655 deletions
+14
View File
@@ -131,6 +131,20 @@
# liveness/stale sweeps (which skip them). (default: 600000 = 10min).
# DASHBOARD_REMOTE_ACTIVE_WINDOW_MS=600000
# ── Privacy and push ingest (Settings → Data) ────────────────────────────────
# All three are normally saved from Settings into the settings file; set here,
# they are pinned and the Settings page shows them read-only.
# Redact secrets (API keys, tokens, bearer headers, private keys, password=…)
# from stored event payloads and summaries: 1 = on (default: off).
# AGENTHUB_REDACT_SECRETS=1
# Folders whose hooks are dropped whole (and skipped by the history import and
# the Codex reader) — absolute paths, ':'-separated (';' on Windows); a folder
# and everything under it.
# AGENTHUB_IGNORE_CWD=/home/you/clients/secret:/home/you/private
# SHA-256 (hex) of the token POST /api/hooks/ingest-batch accepts. Unset = push
# ingest off. Settings → Data → Push ingest makes the token and stores only this.
# AGENTHUB_PUSH_TOKEN_SHA256=
# ── Lanes (durable parallel-work units and managed git worktrees) ────────────
# Directory dashboard-managed worktrees are provisioned under. It is also the
# boundary safety check 2 resolves every destructive lane path against: nothing
+3
View File
@@ -78,3 +78,6 @@ __pycache__/
# JetBrains IDE config — machine-specific, never shared
.idea/
# Prometheus bearer token for docker-compose.monitoring.yml (docs/MONITORING.md)
monitoring/prometheus/dashboard-token
+71 -8
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env sh
#
# Pre-commit: auto-format staged files, then run the full test suite.
# The commit is ABORTED unless every test passes.
# Pre-commit: auto-format staged files, then run the tests the commit needs —
# both full suites for a big or risky commit, only the related tests for a
# small one, none for docs alone (see step 2). The commit is ABORTED unless
# every test it runs passes.
#
# Activated via `core.hooksPath=.husky` (set by the package.json "prepare"
# script on `npm install`). No husky/lint-staged runtime required.
@@ -44,13 +46,13 @@ fi
# is deterministic: it fails both runs and still blocks the commit. This keeps
# the gate strict without making commits a dice roll on machine load.
run_suite() {
# $1 = human label, $2 = npm script
# $1 = human label, $2 = shell command
echo "🧪 Running $1 tests..."
if npm run "$2"; then
if sh -c "$2"; then
return 0
fi
echo "⚠️ $1 suite failed — retrying once to rule out machine-load flakiness..."
if npm run "$2"; then
if sh -c "$2"; then
echo "✅ $1 suite passed on retry — treating the first failure as a load flake."
echo " If this keeps happening, the flaky test above deserves a real fix."
return 0
@@ -59,7 +61,68 @@ run_suite() {
return 1
}
run_suite backend test:server
run_suite frontend test:client
# The frontend suite needs NODE_ENV=test: a shell exporting
# NODE_ENV=production gets React's production build, where act() throws.
CLIENT_FULL="NODE_ENV=test npm run test:client"
echo "✅ Formatting applied and all tests passed — proceeding with commit."
# How much to run. A small commit runs only the tests that touch what it
# stages; a big or risky one runs both full suites, as does
# AGENTHUB_FULL_TESTS=1. Docs alone (*.md, docs/) run nothing.
# client/ files → `vitest related`: vitest's own import graph.
# anything else → every server/__tests__ file that names the file's module
# (a string match: wide on purpose, `db` reaches most).
# ponytail: name matching misses a test that reaches a module only through
# another one; the full run before a release still catches that.
SMALL_MAX=10
CODE="$(git diff --cached --name-only --diff-filter=ACMRD | grep -vE '\.md$|^docs/' || true)"
COUNT=$(printf '%s\n' "$CODE" | grep -c . || true)
FULL=0
[ "${AGENTHUB_FULL_TESTS:-0}" = "1" ] && FULL=1
[ "$COUNT" -gt "$SMALL_MAX" ] && FULL=1
# A dependency, build/test config, this hook or a deleted file can break
# anything, whatever imports it.
if printf '%s\n' "$CODE" | grep -qE '(^|/)package(-lock)?\.json$|(^|/)(vite|vitest|tailwind|postcss|tsconfig)[^/]*$|^\.husky/|^scripts/test-server\.js$'; then
FULL=1
fi
if git diff --cached --name-only --diff-filter=D | grep -vE '\.md$|^docs/' | grep -q .; then
FULL=1
fi
if [ "$COUNT" -eq 0 ]; then
echo "📝 Docs only — no tests to run."
elif [ "$FULL" = "1" ]; then
echo "🧪 Full run ($COUNT code files staged)."
run_suite backend "npm run test:server"
run_suite frontend "$CLIENT_FULL"
else
echo "🧪 Small commit ($COUNT code files) — running the related tests only."
OTHER="$(printf '%s\n' "$CODE" | grep -v '^client/' || true)"
CLIENT="$(printf '%s\n' "$CODE" | grep '^client/' | sed 's|^client/||' || true)"
if [ -n "$OTHER" ]; then
RELATED="$(
printf '%s\n' "$OTHER" | while IFS= read -r f; do
case "$f" in
server/__tests__/*.test.js) echo "$f" ;;
*)
name="$(basename "$f")"
name="${name%.*}"
grep -lF -- "$name" server/__tests__/*.test.js 2>/dev/null || true
;;
esac
done | sort -u | tr '\n' ' '
)"
if [ -n "$RELATED" ]; then
run_suite "backend ($(echo $RELATED | wc -w) files)" "node scripts/test-server.js $RELATED"
else
echo "⏭️ No server test names a staged file — backend skipped."
fi
fi
if [ -n "$CLIENT" ]; then
LIST="$(printf '%s\n' "$CLIENT" | tr '\n' ' ')"
run_suite "frontend (related)" "cd client && NODE_ENV=test NODE_OPTIONS=--no-experimental-webstorage npx vitest related --run --passWithNoTests $LIST"
fi
fi
echo "✅ Formatting applied and the tests passed — proceeding with commit."
+30 -5
View File
@@ -325,8 +325,9 @@ graph TD
| `routes/analytics.js` | Extended analytics — token totals, tool usage counts, daily event/session trends, agent type distribution. The client-side analytics heatmap grid is aligned to a Sunday start for correct day-of-week positioning |
| `routes/pricing.js` | Model pricing CRUD (list/upsert/delete) and per-session / global cost calculation with pattern-based model matching. `PUT /api/pricing` upserts a rule and accepts optional time-limited **introductory** rates (`intro_*_per_mtok` + `intro_until`): usage on/before the cutoff date prices at the intro rate, after it at the standard rate — the calculator picks the effective rate per usage day (`ratesForBucket`), so a promo like Sonnet 5's launch discount is correct before AND after the cutoff, retroactively. Intro columns are written only when the caller sends them (a standard-rate edit never disturbs a promo). Cost is computed per token bucket — keyed by (model, speed, inference_geo, service_tier) — applying fast-mode premium, US data-residency (1.1x), and Batch (0.5x) modifiers, the 5m/1h cache-write split, plus server-tool surcharges (web search $10/1k; code execution estimated by container-time with the monthly free-hours allowance; web fetch free). `attachAgentCosts`/`agentOwnCost` reuse the same calculator to price each agent's `metadata.tokens` for the per-agent `cost` on agent-list responses. Feature rates + modifier math live in `lib/pricing-constants.js`; usage normalization in `lib/token-usage.js` |
| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks |
| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately |
| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Four rule types: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing |
| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`), and the read-only dry run `POST /api/alerts/rules/preview` (`{rule_type, config, days?, cooldown_seconds?}` → what `previewRule` says it would have fired over the last `days`, 1–90, default 7). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately |
| `routes/budgets.js` + `lib/budgets.js` | Spend budgets: a budget IS an alert rule of type `budget` (CRUD through `/api/alerts/rules`). `lib/budgets.js` computes a period's bounds in the user's local time (`tz_offset` saved with the rule; day / week from Monday / month), its spend priced like `GET /api/pricing/cost` by the day tokens were spent (via `dailyTokenRows` + `costWhere`, scoped to `tool`/`lane`), and the pace projection to the period's end; `GET /api/budgets/status` lists every enabled budget with `spend`, `pct`, `projected` and the thresholds `crossed` |
| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Six rule types — `cost_threshold` (a session's cost ≥ `usd`, on token-bearing events like `token_threshold`) and `budget` (on the sweep: each of `thresholds` % of `limit_usd` for the current `period` fires ONCE per period, deduped on `details.period_start` + `details.threshold` and bypassing the cooldown) plus the four original ones: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing |
| `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` |
| `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes |
| `lib/webhooks.js` | Universal webhook delivery engine driven by the provider registry. `buildRequest()` resolves the URL, formats the provider-native payload, and assembles headers (provider auth headers + generic-family custom headers + optional HMAC-SHA256 signature via `X-Webhook-Signature` / `X-Webhook-Timestamp`). `dispatchAlert()` fans a fired alert out to every enabled, in-scope target (optional per-rule scoping via `rule_ids`); each `deliver()` POSTs with an `AbortController` timeout and bounded retry/backoff (retries transport errors / 429 / 5xx, never other 4xx) and records the attempt-chain outcome in `webhook_deliveries` (pruned to the newest 2000 rows). Delivery is detached and fully fail-safe — it never throws into the alert path. Enabled targets are cached like alert rules; tunables (`WEBHOOK_TIMEOUT_MS`, `WEBHOOK_MAX_ATTEMPTS`, `WEBHOOK_RETRY_BASE_MS`) are env-overridable. `sendTest()` awaits a synthetic delivery for the test endpoint |
@@ -351,8 +352,8 @@ graph TD
| `server/lib/archive.js` | Safe archive extraction: `.zip` via `adm-zip`, `.tar`/`.tar.gz`/`.tgz` via `tar`, plain `.gz` via `zlib` in streaming mode. Every entry is validated through `safeJoin` which rejects absolute paths and `..` traversal before any bytes are written. Enforces a hard extraction cap (`MAX_EXTRACT_BYTES`, default 4 GB, tunable via `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES`) with `ExtractionLimitError` surfaced as HTTP 413 from the upload route — defense against zip/tar/gzip bombs. Also provides `detectKind` for filename-based dispatch and `mkTempDir`/`rmTempDir` helpers |
| `server/lib/remote-sync.js` | **Remote Data Sources** — live remote/multi-machine data collection over SSH. Mirrors the remote's `~/.claude/projects` via **scp** (or `wsl.exe` + `tar` for WSL-hosted Claude on Windows SSH) into a **sandboxed per-source staging dir** under the data dir, then feeds it through the **same** importer used for local history (`importFromDirectory` from `scripts/import-history.js`) and tags every imported session with the source id (`sessions.source`). Also runs the connectivity probe behind the test route. Auth **defers entirely to the host SSH stack** (ssh-agent / `~/.ssh/config` / identity file) — **no secrets are ever stored**; every shell-out uses `execFile`/`spawn` argument arrays (never a shell string), and `StrictHostKeyChecking` is left at its SSH default. Background poller `DASHBOARD_REMOTE_SYNC_MS` (default 15000); add/re-enable triggers immediate pull. Sync timeout `DASHBOARD_REMOTE_SYNC_TIMEOUT_MS` (default 600000); connectivity-test timeout `DASHBOARD_REMOTE_TEST_TIMEOUT_MS` (default 15000). Broadcasts `remote_source.status`, `remote_data.updated`, and per-session `session_created`/`session_updated` on successful sync. After each pull, `reconcileRemoteSessionStatus` sets each source session's live status from the mirror — newest JSONL event timestamp within `DASHBOARD_REMOTE_ACTIVE_WINDOW_MS` (default 600000 = 10 min) ⇒ `active`, otherwise `completed`. Remote sessions get no live hooks and are excluded from every local liveness/stale heuristic (all gated on `source = 'local'`), so this reconciliation is the sole owner of their active/completed lifecycle |
| `server/routes/remote-sources.js` | HTTP surface for **Remote Data Sources**: `GET /api/remote-sources` (list), `POST /api/remote-sources` (create), `PATCH /api/remote-sources/:id`, `DELETE /api/remote-sources/:id` (`?purge=true` also deletes that source's imported sessions), `POST /api/remote-sources/:id/test` (SSH connectivity probe), and `POST /api/remote-sources/:id/sync` (on-demand pull). Delegates the pull/validation to `remote-sync.js`; broadcasts `remote_source.status` on every transition |
| `server/lib/source-filter.js` | Parses the optional `?sources=` query param (comma-separated source ids) into SQL predicates + bind params, shared by the data endpoints (`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`) so a client **data-scope** selection narrows every query consistently. No filter → the existing unscoped queries run unchanged |
| `server/lib/scoped-stats.js` | Source-scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set |
| `server/lib/source-filter.js` | Parses the optional `?sources=` query param (comma-separated source ids) and `?tool=` (an AI tool id; `parseScope` folds both into one predicate over `sessions`) into SQL predicates + bind params, shared by the data endpoints (`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`) so a client **data-scope** selection narrows every query consistently. No filter → the existing unscoped queries run unchanged |
| `server/lib/scoped-stats.js` | Scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` or `?tool=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set |
| `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), subagents (`<root>/agents/*.md`), slash commands (`<root>/commands/*.md`), output styles (`<root>/output-styles/*.md`), plugins (`<CLAUDE_HOME>/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` counts **and** `contributes.items`, which names every skill / subagent / command / output style the plugin ships — each surface read from the conventional install subdirectory *and* from whatever path `plugin.json` declares for that key, with declared paths outside the plugin's own install dir dropped by `isUnder()`, so a plugin that lists its commands explicitly and keeps `commands/` empty no longer reports zero + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`<CLAUDE_HOME>/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`<CLAUDE_HOME>/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects/<slug>/memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines |
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
| `lib/cc-reload.js` | Types `/reload-plugins` / `/reload-skills` into the Claude Code sessions the dashboard already has open, over `tmux send-keys` (reusing `tmux.js`'s `sendText`). Both slash commands are scoped BY CLAUDE CODE to the session that runs them, so a session cannot be reloaded from the outside and no background process can do it on a session's behalf — typing into a live pane is the only real mechanism. Deliberately spawns NO `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history, so this module creates no sessions at all. Skips panes sitting at a shell prompt (the text would run as a shell command) and addresses `claude` lanes only, since Codex has no such command. Sends to a busy pane on purpose — tmux queues the keystrokes into the REPL's input buffer, so a late reload still lands, where skipping busy panes would leave exactly the sessions in active use running stale code. The command set is a fixed constant, never caller-supplied: a pane's stdin is an arbitrary-code boundary. Never throws |
@@ -367,6 +368,7 @@ graph TD
| `lib/codex-hooks.js` | AgentHub's hooks in Codex's `$CODEX_HOME/hooks.json`, installed/removed only on a click (`GET/POST/DELETE /api/settings/codex-hooks`): one group per event through the stable forwarder `$CODEX_HOME/agenthub-hook.sh`, whose content `refreshScript()` rewrites at every start; trust is read from Codex's `config.toml`, never written. See **Codex hooks** under Codex Lane Status, Sessions and Hooks. |
| `lib/antigravity-home.js` | Where agy keeps its data: `~/.gemini` (`AGENTHUB_GEMINI_DIR` overrides it), the CLI's `antigravity-cli/` under it. agy has no home override of its own. |
| `lib/antigravity-usage.js` | Antigravity CLI sessions and tokens from agy's `conversations/<id>.db` (read-only, every `DASHBOARD_ANTIGRAVITY_POLL_MS`): protobuf `ModelUsageStats` per `gen_metadata` row, the workspace from `trajectory_metadata_blob`, name/start from the transcript. Also sets a quiet session's lane idle. See **Antigravity Sessions and Hooks**. |
| `lib/cursor-usage.js` | Cursor sessions (no tokens: Cursor's transcripts carry none) from `~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl`, every `DASHBOARD_CURSOR_POLL_MS`. See **Cursor Sessions**. |
| `lib/antigravity-hooks.js` | AgentHub's named hook `agenthub` in `~/.gemini/config/hooks.json` (`GET/POST/DELETE /api/settings/antigravity-hooks`): PreInvocation, PostToolUse and Stop through the stable forwarder `~/.gemini/config/agenthub-hook.sh`, which answers `{}`. Never PreToolUse. |
| `routes/codex-config.js` | HTTP surface for the Codex Config panel. Read endpoints for every surface (skills, agents, rules, memory, config, mcp, plugins, marketplaces, file, overview) plus mutation endpoints (`PUT /file`, `DELETE /file` delegating to `codex-mutate.js`; `POST /mcp/{add,remove}`, `/plugins/action`, `/marketplaces/{add,action}` delegating to `codex-cli.js`), plus `GET /backups`. Every successful mutation broadcasts `codex_config_changed` over the WebSocket so an open Codex settings tab refetches without polling. All errors return structured `{error: {code, message}}` shapes |
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
@@ -850,7 +852,7 @@ erDiagram
alert_rules {
TEXT id PK "UUID"
TEXT name "User-facing rule name"
TEXT rule_type "event_pattern|inactivity|status_duration|token_threshold"
TEXT rule_type "event_pattern|inactivity|status_duration|token_threshold|cost_threshold|budget (no CHECK; validated in lib/alerts.js)"
TEXT config "Type-specific JSON config"
INTEGER enabled "1|0"
INTEGER cooldown_seconds "Per-scope dedup window, default 300"
@@ -1731,6 +1733,29 @@ is ever inferred.
---
## Cursor Sessions
`server/lib/cursor-usage.js` (`startCursorUsagePoll`, every
`DASHBOARD_CURSOR_POLL_MS`, default 15000, `0` disables) reads the agent
transcripts Cursor writes — the CLI (`cursor-agent`) and the editor's agent both —
at `~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl`
(`AGENTHUB_CURSOR_DIR` overrides `~/.cursor`). Each becomes a `sessions` row with
`tool = 'cursor'`: id = file name, name = the first `<user_query>` line (60
chars), start = the file's birth time (mtime where there is none), `active`
while it was written in the last two minutes, else `completed` at its last
write. The cwd is the project slug walked back onto the filesystem (every
non-alphanumeric character is a `-` in the slug, so only a path that still
exists resolves); otherwise it stays NULL. `cursor_transcripts` holds the mtime
read, so a session the user deleted stays deleted until Cursor writes to it again.
**No tokens, no model, no cost.** A transcript line is `{role, message:{content}}`
or `{type: "turn_ended"}` — no usage, no model id, no timestamps — so a Cursor
session has no `token_usage` rows. The editor's `state.vscdb` keeps a
`tokenCount` per chat bubble and a display model name (`GPT`, `default`, a BYOK
name); AgentHub does not read it. No Cursor hooks are installed.
---
## Agent Extension Layer
The repository includes a triple extension strategy:
+8 -3
View File
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -117,9 +117,10 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin put
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
pins the node that installed it and falls back to `node` on `PATH` when a
`brew upgrade` or `nvm` switch deletes that binary.
- **Starts at login by itself (LaunchAgent).** The plugin bootstrap installs
`~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout: `agenthub
service install`), so the dashboard comes back after every login with no
- **Starts at login once you turn it on (LaunchAgent).** Off by default; with
Settings → About → **Start AgentHub automatically** on, the plugin bootstrap
installs `~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout:
the same switch, or `agenthub service install`), so the dashboard comes back after every login with no
Claude Code session, and launchd restarts it if it dies. Lane panes survive
a restart of the server. Its output goes to `~/Library/Logs/agenthub.log`.
A LaunchAgent needs a login at the Mac itself; over SSH only, or when
+26 -2
View File
@@ -86,6 +86,12 @@ Internal build — all rights reserved.
notification type can pick its own.
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
a UI in English, Vietnamese, Chinese and Korean.
- **Outside the dashboard, all opt-in:** a read-only MCP server
(`claude mcp add agenthub -- agenthub mcp`, [docs/MCP.md](docs/MCP.md)), a
Claude Code statusline with session and today's cost
(`agenthub statusline install`, [docs/STATUSLINE.md](docs/STATUSLINE.md)), and
Prometheus rules + Grafana boards for `/api/metrics`
(`docker-compose.monitoring.yml`, [docs/MONITORING.md](docs/MONITORING.md)).
## Prerequisites
@@ -197,7 +203,10 @@ agenthub service uninstall # disable and remove it
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
starts on a cold boot, before anyone logs in; if that needs elevated rights on
your machine it prints the `sudo loginctl enable-linger <user>` command to run
once yourself. Plugin installs get the same persistence automatically — see
once yourself. The same is a switch in the dashboard — Settings → About →
**Start AgentHub automatically** (off by default; it enables or disables the
unit and never stops the running server). Plugin installs get it only once
that switch is on — see
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`.
## The CLI
@@ -268,6 +277,13 @@ for a session the hooks report on (a long tool call writes nothing, and after a
Stop agy may still be waiting for you), after two minutes for one without
hooks. Antigravity workspaces carry no pipeline either.
**Cursor** sessions — the Cursor CLI's and the Cursor editor's agent chats — are
read from Cursor's agent transcripts
(`~/.cursor/projects/*/agent-transcripts/`, every `DASHBOARD_CURSOR_POLL_MS` ms,
default 15000) and appear in Sessions with a **Cursor CLI** badge. Cursor writes
no token counts or model ids there, so a Cursor session shows no tokens and no
cost.
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
@@ -314,7 +330,13 @@ npm run test:server # node:test
npm run test:client # Vitest
```
Both must be green before a commit; the pre-commit hook runs them plus Prettier.
Both must be green before a commit. The pre-commit hook (`.husky/pre-commit`)
runs Prettier, then the tests the commit needs: both full suites for a big or
risky commit (over 10 code files, a dependency, build/test config or the hook
itself, a deleted file), only the related tests for a small one (`vitest
related` for `client/`, the server test files that name each other staged
module), nothing for docs alone. `AGENTHUB_FULL_TESTS=1 git commit …` forces
both full suites.
## Layout
@@ -323,6 +345,8 @@ Both must be green before a commit; the pre-commit hook runs them plus Prettier.
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
| `client/` | React 18 + Vite + Tailwind dashboard |
| `bin/agenthub.js` | CLI |
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
| `docs/` | Architecture, API, workspaces, database |
| `plugins/` | Claude Code plugins shipped with the dashboard |
+11 -3
View File
@@ -176,7 +176,10 @@ agenthub service uninstall # disable và gỡ bỏ
`agenthub service install` cũng thử chạy `loginctl enable-linger` để service khởi
động được cả khi cold boot (chưa ai login); nếu máy cần quyền cao hơn, lệnh
này sẽ in ra `sudo loginctl enable-linger <user>` để bạn tự chạy một lần.
Bản cài qua plugin cũng được tự khởi động tương tự — xem
Trong dashboard cũng có công tắc tương ứng — Settings → About → **Tự động khởi
động AgentHub** (mặc định tắt; chỉ enable/disable unit, không bao giờ dừng
server đang chạy). Bản cài qua plugin chỉ được tự khởi động khi công tắc này
bật — xem
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) trong
`docs/PLUGINS.md`.
@@ -269,8 +272,13 @@ npm run test:server # node:test
npm run test:client # Vitest
```
Cả hai phải xanh (pass) trước khi commit; pre-commit hook chạy chúng cộng
thêm Prettier.
Cả hai phải xanh (pass) trước khi commit. Pre-commit hook (`.husky/pre-commit`)
chạy Prettier, rồi chạy những test mà commit cần: cả hai bộ đầy đủ cho commit
lớn hoặc rủi ro (hơn 10 file code, đổi dependency, cấu hình build/test hay
chính hook, có xoá file), chỉ test liên quan cho commit nhỏ (`vitest related`
cho `client/`, các file test server có nhắc tới module được stage), và không
chạy test nào nếu chỉ sửa tài liệu. `AGENTHUB_FULL_TESTS=1 git commit …` buộc
chạy cả hai bộ đầy đủ.
## Cấu trúc thư mục
+66 -14
View File
@@ -141,21 +141,10 @@ function termWidth() {
/**
* Resolve the dashboard base URL. Env override first (matches the hook
* handler's contract), then the live-server discovery file, then the default
* port. The discovery module is best-effort and never throws.
* port — shared with the MCP server and the statusline (`dashboard-url.js`).
*/
function baseUrl() {
const envPort = process.env.CLAUDE_DASHBOARD_PORT || process.env.DASHBOARD_PORT;
if (envPort) return `http://127.0.0.1:${envPort}`;
try {
const { resolveDashboardPort } = require(
path.join(REPO_ROOT, "server", "lib", "server-info.js")
);
const port = resolveDashboardPort();
if (port) return `http://127.0.0.1:${port}`;
} catch {
/* discovery unavailable — fall through to the default */
}
return "http://127.0.0.1:4820";
return require(path.join(REPO_ROOT, "server", "lib", "dashboard-url.js")).dashboardUrl();
}
/** Thrown when the dashboard server does not answer — the dispatcher decides
@@ -1037,6 +1026,8 @@ async function cmdRemoteSources(flags, positional) {
ssh_port: flags.port != null ? Number(flags.port) : null,
identity_file: flags.identity != null ? String(flags.identity) : null,
remote_home: flags["remote-home"] != null ? String(flags["remote-home"]) : null,
sync_codex: !!flags.codex,
remote_codex_home: flags["codex-home"] != null ? String(flags["codex-home"]) : null,
enabled: flags.disabled ? false : true,
};
const r = await post("/api/remote-sources", body);
@@ -2797,7 +2788,7 @@ const COMMAND_GROUPS = [
[
"remote-sources add",
"",
"--label X --host user@host [--port N --identity path --remote-home path]",
"--label X --host user@host [--port N --identity path --remote-home path --codex --codex-home path]",
],
["remote-sources test <id>", "", "Probe SSH connectivity to a source"],
["remote-sources sync [id]", "", "Pull history now (all sources if id omitted)"],
@@ -2917,6 +2908,22 @@ const COMMAND_GROUPS = [
],
],
],
[
"Integrations",
[
[
"mcp",
"",
"Run the read-only MCP server on stdio (claude mcp add agenthub -- agenthub mcp)",
],
["statusline", "", "Render the Claude Code statusline from the JSON on stdin"],
[
"statusline install|uninstall",
"[--force]",
"Add/remove AgentHub's statusLine in ~/.claude/settings.json (--force: replace or remove another one)",
],
],
],
[
"Administration",
[
@@ -3664,8 +3671,43 @@ const SUBCOMMANDS = {
pricing: ["set", "delete", "reset"],
webhooks: ["test"],
import: ["rescan", "path"],
statusline: ["install", "uninstall"],
};
/** `agenthub statusline install|uninstall [--force]` — the user's settings.json. */
function cmdStatusline(sub, flags) {
const { install, uninstall } = require(path.join(REPO_ROOT, "statusline", "install.js"));
const { getSettingsPath } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
const file = getSettingsPath();
const force = Boolean(flags.force);
const r = sub === "install" ? install(file, { force }) : uninstall(file, { force });
const prev = r.previous ? JSON.stringify(r.previous) : "";
switch (r.status) {
case "installed":
console.log(`${c.green("✔")} statusLine set in ${file}: ${r.command}`);
if (prev) console.log(c.dim(` replaced ${prev} (backup beside the file)`));
console.log(c.dim(" Claude Code picks it up on the next prompt."));
return;
case "unchanged":
console.log(`statusLine already set in ${file}: ${r.command}`);
return;
case "removed":
console.log(`${c.green("✔")} statusLine removed from ${file} (backup beside the file)`);
return;
case "absent":
console.log(`No statusLine in ${file}.`);
return;
case "foreign":
console.error(c.red(`✖ ${file} has a statusLine that is not AgentHub's: ${prev}`));
console.error(
c.dim(
` Re-run with --force to ${sub === "install" ? "replace" : "remove"} it (a backup is kept).`
)
);
process.exitCode = 1;
}
}
/** Run one parsed command. Returns the handler's promise; may throw
* ServerDownError (for the caller's offline routing) or call process.exit
* for usage/validation failures. `cmd === "repl"` is handled by the entry
@@ -3800,6 +3842,16 @@ async function runCommand(argv) {
process.exitCode = 1;
return;
}
case "mcp":
return require(path.join(REPO_ROOT, "mcp", "server.js")).main();
case "statusline":
if (rest[0] === "install" || rest[0] === "uninstall") return cmdStatusline(rest[0], flags);
if (rest[0]) {
console.error("usage: agenthub statusline [install|uninstall [--force]]");
process.exitCode = 1;
return;
}
return require(path.join(REPO_ROOT, "statusline", "statusline.js")).main();
case "open":
return cmdOpen();
case "version":
+6 -2
View File
@@ -199,13 +199,16 @@ client/
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
│ │ │ ├── ReportSettings.tsx # Settings → Reports: scheduled report definitions, Run now, run history + downloads
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
│ │
│ ├── pages/ # Route pages
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs; Analytics' date range (Today/This week from Monday/This month from the 1st/7d/30d/90d/All/Custom, `?range=`, lib/dateRange.ts) narrows its headline, cost and token cards
│ │ ├── QueryExplorer.tsx # /query: filter sessions / events / agents, sort, page, export CSV/JSON (GET /api/query)
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers)
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers) + Replay tab (EventReplay) + Snapshot action
│ │ ├── SnapshotView.tsx # Read-only /snapshot/:token page over a frozen session payload
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
│ │ ├── Workflows.tsx # Workflow analytics, rendered in Dashboard → Analytics → Workflow
│ │ ├── Settings.tsx
@@ -501,6 +504,7 @@ function App() {
<Route index element={<Dashboard />} />
<Route path="sessions" element={<History />} />
<Route path="sessions/:id" element={<SessionDetail />} />
<Route path="query" element={<QueryExplorer />} />
{/* legacy path - the activity feed is now a tab on /sessions */}
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
+22
View File
@@ -82,6 +82,12 @@ const SettingsHub = lazy(() =>
const SessionDetail = lazy(() =>
import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail }))
);
const SnapshotView = lazy(() =>
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
);
const QueryExplorer = lazy(() =>
import("./pages/QueryExplorer").then((m) => ({ default: m.QueryExplorer }))
);
function PageFallback() {
const { t } = useTranslation();
@@ -158,6 +164,22 @@ export default function App() {
</Page>
}
/>
<Route
path="snapshot/:token"
element={
<Page>
<SnapshotView />
</Page>
}
/>
<Route
path="query"
element={
<Page>
<QueryExplorer />
</Page>
}
/>
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route
path="workflows"
+252 -12
View File
@@ -70,6 +70,8 @@ import {
BellOff,
Check,
CheckCheck,
ChevronDown,
Eye,
ListChecks,
Pencil,
Plus,
@@ -87,9 +89,10 @@ import { ConfirmModal } from "./ConfirmModal";
import { Checkbox } from "./Checkbox";
import { FieldHelp } from "./FieldHelp";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { timeAgo } from "../lib/format";
import { fmtCost, timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
import { useAiTools } from "../hooks/useAiTools";
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
const PAGE_SIZE = 25;
@@ -124,6 +127,8 @@ const RULE_TYPES: AlertRuleType[] = [
"inactivity",
"status_duration",
"token_threshold",
"cost_threshold",
"budget",
];
type TabKey = "rules" | "channels" | "activity";
@@ -139,6 +144,11 @@ interface RuleFormState {
minutes: string;
status: "working" | "waiting";
total_tokens: string;
usd: string;
period: "day" | "week" | "month";
limit_usd: string;
thresholds: string;
budget_tool: string;
cooldown_seconds: string;
}
@@ -153,6 +163,11 @@ const EMPTY_FORM: RuleFormState = {
minutes: "10",
status: "working",
total_tokens: "1000000",
usd: "5",
period: "month",
limit_usd: "100",
thresholds: "80, 100",
budget_tool: "",
cooldown_seconds: "300",
};
@@ -171,12 +186,28 @@ function formFromRule(rule: AlertRule): RuleFormState {
minutes: str(c.minutes, EMPTY_FORM.minutes),
status: c.status === "waiting" ? "waiting" : "working",
total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens),
usd: str(c.usd, EMPTY_FORM.usd),
period: c.period === "day" || c.period === "week" ? c.period : "month",
limit_usd: str(c.limit_usd, EMPTY_FORM.limit_usd),
thresholds: Array.isArray(c.thresholds) ? c.thresholds.join(", ") : EMPTY_FORM.thresholds,
budget_tool: str(c.tool, ""),
cooldown_seconds: String(rule.cooldown_seconds),
};
}
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
/** "80, 100" → [80, 100]; null when any part is not a whole percent 1..1000. */
function parseThresholds(text: string): number[] | null {
const parts = text
.split(",")
.map((p) => p.trim())
.filter(Boolean);
const nums = parts.map((p) => Number(p));
if (nums.length === 0 || nums.length > 10) return null;
return nums.every((n) => Number.isInteger(n) && n > 0 && n <= 1000) ? nums : null;
}
function buildConfig(form: RuleFormState): AlertRule["config"] {
switch (form.rule_type) {
case "event_pattern": {
@@ -198,6 +229,18 @@ function buildConfig(form: RuleFormState): AlertRule["config"] {
return { status: form.status, minutes: parseFloat(form.minutes) };
case "token_threshold":
return { total_tokens: parseInt(form.total_tokens, 10) };
case "cost_threshold":
return { usd: parseFloat(form.usd) };
case "budget": {
const config: AlertRule["config"] = {
period: form.period,
limit_usd: parseFloat(form.limit_usd),
thresholds: parseThresholds(form.thresholds) ?? [80, 100],
tz_offset: new Date().getTimezoneOffset(),
};
if (form.budget_tool) config.tool = form.budget_tool;
return config;
}
}
}
@@ -225,6 +268,14 @@ function describeRule(rule: AlertRule, t: (key: string, opts?: Record<string, un
return t("ruleDesc.statusDuration", { status: c.status, minutes: c.minutes });
case "token_threshold":
return t("ruleDesc.tokenThreshold", { tokens: (c.total_tokens ?? 0).toLocaleString() });
case "cost_threshold":
return t("ruleDesc.costThreshold", { usd: fmtCost(c.usd ?? 0) });
case "budget":
return t("ruleDesc.budget", {
limit: fmtCost(c.limit_usd ?? 0),
period: t(`periods.${c.period ?? "month"}`),
thresholds: (c.thresholds ?? []).join("%, ") + "%",
});
}
}
@@ -244,6 +295,9 @@ export function AlertsNotifications() {
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
const [preview, setPreview] = useState<RulePreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const aiTools = useAiTools();
const ruleCtx = useContextMenu<AlertRule>();
// A rule-menu item: close the menu, then act on the rule it was opened on.
const pickRule = (fn: (rule: AlertRule) => unknown) => () => {
@@ -322,10 +376,35 @@ export function AlertsNotifications() {
});
}, [loadAlerts]);
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch }));
// Any edit makes a shown preview stale.
const set = (patch: Partial<RuleFormState>) => {
setPreview(null);
setForm((prev) => ({ ...prev, ...patch }));
};
const onPreview = async () => {
if (previewing) return;
setPreviewing(true);
setFormError(null);
try {
const cooldown = parseInt(form.cooldown_seconds, 10);
setPreview(
await api.alerts.rules.preview({
rule_type: form.rule_type,
config: buildConfig(form),
cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300,
})
);
} catch (err) {
setFormError(err instanceof Error ? err.message : String(err));
} finally {
setPreviewing(false);
}
};
const closeForm = () => {
setForm(EMPTY_FORM);
setPreview(null);
setEditingId(null);
setFormOpen(false);
setFormError(null);
@@ -333,6 +412,7 @@ export function AlertsNotifications() {
const openEditRule = (rule: AlertRule) => {
setForm(formFromRule(rule));
setPreview(null);
setEditingId(rule.id);
setFormError(null);
setFormOpen(true);
@@ -417,7 +497,10 @@ export function AlertsNotifications() {
(countVal <= 1 || (Number.isFinite(windowVal) && windowVal > 0)))) &&
((form.rule_type !== "inactivity" && form.rule_type !== "status_duration") ||
(Number.isFinite(minutesVal) && minutesVal > 0)) &&
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0));
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0)) &&
(form.rule_type !== "cost_threshold" || parseFloat(form.usd) > 0) &&
(form.rule_type !== "budget" ||
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
{
@@ -680,6 +763,100 @@ export function AlertsNotifications() {
</div>
)}
{form.rule_type === "cost_threshold" && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.usd")}
<FieldHelp description={t("rules.help.usd")} />
</span>
<input
type="number"
min={0}
step="0.01"
value={form.usd}
onChange={(e) => set({ usd: e.target.value })}
className="input mt-1 w-full"
/>
</label>
</div>
)}
{form.rule_type === "budget" && (
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.period")}
<FieldHelp description={t("rules.help.period")} />
</span>
<div className="relative mt-1">
<select
value={form.period}
onChange={(e) => set({ period: e.target.value as RuleFormState["period"] })}
className="input w-full appearance-none pr-8"
>
{(["day", "week", "month"] as const).map((p) => (
<option key={p} value={p}>
{t(`periods.${p}`)}
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.limitUsd")}
<FieldHelp description={t("rules.help.limitUsd")} />
</span>
<input
type="number"
min={0}
step="0.01"
value={form.limit_usd}
onChange={(e) => set({ limit_usd: e.target.value })}
className="input mt-1 w-full"
/>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.thresholds")}
<FieldHelp description={t("rules.help.thresholds")} />
</span>
<input
type="text"
value={form.thresholds}
onChange={(e) => set({ thresholds: e.target.value })}
placeholder="80, 100"
className="input mt-1 w-full"
/>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.budgetTool")}
<FieldHelp description={t("rules.help.budgetTool")} />
</span>
<div className="relative mt-1">
<select
value={form.budget_tool}
onChange={(e) => set({ budget_tool: e.target.value })}
className="input w-full appearance-none pr-8"
>
<option value="">{t("rules.form.allTools")}</option>
{aiTools.tools.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
</div>
)}
{preview && <RulePreviewResult preview={preview} />}
<div className="flex flex-wrap items-end justify-between gap-3">
<label className="block text-xs text-fg-secondary">
<span className="mb-1.5 flex items-center gap-1">
@@ -701,14 +878,29 @@ export function AlertsNotifications() {
</span>
)}
</label>
<button
onClick={onSaveRule}
disabled={!canSubmit || saving}
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingId ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
</button>
<div className="flex items-center gap-2">
<button
onClick={onPreview}
disabled={!canSubmit || previewing}
title={t("rules.previewHint")}
className="btn-ghost border border-border inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Eye className="w-3.5 h-3.5" />
{previewing ? t("rules.previewing") : t("rules.preview")}
</button>
<button
onClick={onSaveRule}
disabled={!canSubmit || saving}
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingId ? (
<Check className="w-3.5 h-3.5" />
) : (
<Plus className="w-3.5 h-3.5" />
)}
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
</button>
</div>
</div>
{formError && <p className="text-xs text-status-danger">{formError}</p>}
</div>
@@ -927,3 +1119,51 @@ export function AlertsNotifications() {
</div>
);
}
/** What a rule would have fired, from POST /api/alerts/rules/preview. */
function RulePreviewResult({ preview }: { preview: RulePreview }) {
const { t } = useTranslation("alerts");
if (!preview.supported) {
return <p className="text-xs text-fg-muted">{t("rules.previewUnsupported")}</p>;
}
if (preview.periods) {
return (
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
<p className="text-fg-secondary">{t("rules.previewBudget")}</p>
{preview.periods.map((p) => (
<div key={p.period_start} className="flex justify-between gap-3 font-mono">
<span className="text-fg-muted">{p.period_start}</span>
<span className="text-fg-secondary">
{fmtCost(p.spend)} · {Math.round(p.pct)}%
</span>
<span className={p.crossed.length ? "text-status-warning" : "text-fg-muted"}>
{p.crossed.length ? p.crossed.map((c) => `${c}%`).join(" ") : "—"}
</span>
</div>
))}
</div>
);
}
return (
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
<p className="text-fg-secondary">
{t("rules.previewResult", {
fires: preview.fires,
sessions: preview.sessions,
days: preview.window_days,
})}
</p>
{preview.samples.map((s, i) => (
<div key={i} className="flex justify-between gap-3 font-mono text-fg-muted">
<Link
to={`/sessions/${encodeURIComponent(s.session_id)}`}
className="truncate hover:text-accent"
>
{s.session_id.slice(0, 8)}
</Link>
<span>{timeAgo(s.at)}</span>
</div>
))}
</div>
);
}
+2
View File
@@ -28,6 +28,8 @@ export function DocumentTitle() {
title = t("workspace");
} else if (path.startsWith("/settings")) {
title = t("settings");
} else if (path.startsWith("/query")) {
title = t("query");
} else if (path !== "/") {
title = t("notFound");
}
+289
View File
@@ -0,0 +1,289 @@
/**
* @file EventReplay.tsx
* @description The session Replay tab: a scrubber over a session's recorded
* events (play/pause, 0.5×–5×, step, jump, arrow keys), a lifecycle mini-map
* whose markers jump the cursor, the state derived up to the cursor
* (`lib/replay.ts`) and the selected event through EventDetail. Fed a list of
* events, so the live analysis page and a frozen snapshot share it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pause, Play, SkipBack, SkipForward, StepBack, StepForward } from "lucide-react";
import { api } from "../lib/api";
import {
chronological,
lifecycleMarkers,
replayState,
LIFECYCLE_TYPES,
REPLAY_SPEEDS,
REPLAY_STEP_MS,
} from "../lib/replay";
import { buildEventTitle } from "../lib/event-grouping";
import type { AgentInfo } from "../lib/event-grouping";
import { formatDateTimeFull, formatMs } from "../lib/format";
import type { DashboardEvent } from "../lib/types";
import { AgentStatusBadge } from "./StatusBadge";
import { EventDetail } from "./EventDetail";
/** The newest this many events are replayed; older ones are left out. */
export const REPLAY_MAX_EVENTS = 5000;
const PAGE = 500;
/** A session's events in playback order (the newest REPLAY_MAX_EVENTS). */
export async function loadReplayEvents(
sessionId: string
): Promise<{ events: DashboardEvent[]; total: number }> {
const all: DashboardEvent[] = [];
let total = 0;
do {
const r = await api.events.list({ session_id: sessionId, limit: PAGE, offset: all.length });
total = r.total;
if (r.events.length === 0) break;
all.push(...r.events);
} while (all.length < Math.min(total, REPLAY_MAX_EVENTS));
return { events: chronological(all.slice(0, REPLAY_MAX_EVENTS)), total };
}
const MARKER_COLOR: Record<string, string> = {
SessionStart: "bg-status-success",
UserPromptSubmit: "bg-accent",
Compaction: "bg-status-warning",
SubagentStop: "bg-violet-500",
APIError: "bg-status-danger",
Stop: "bg-fg-muted",
};
const btn =
"inline-flex h-8 w-8 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed";
export function EventReplay({
events: raw,
total,
agentInfoById,
sessionNameById,
}: {
events: DashboardEvent[];
/** The session's full event count, when more than `events` were recorded. */
total?: number;
agentInfoById: Map<string, AgentInfo>;
sessionNameById: Map<string, string>;
}) {
const { t } = useTranslation("sessions");
const events = useMemo(() => chronological(raw), [raw]);
const markers = useMemo(() => lifecycleMarkers(events), [events]);
const last = events.length - 1;
const [cursor, setCursor] = useState(0);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState<number>(1);
const at = Math.min(cursor, Math.max(0, last));
const state = useMemo(() => replayState(events, at), [events, at]);
const event = events[at];
useEffect(() => {
if (!playing) return;
if (at >= last) {
setPlaying(false);
return;
}
const timer = setTimeout(() => setCursor((c) => Math.min(c + 1, last)), REPLAY_STEP_MS / speed);
return () => clearTimeout(timer);
}, [playing, at, last, speed]);
if (!event) return <p className="text-sm text-fg-muted">{t("detail.noEvents")}</p>;
const go = (i: number) => setCursor(Math.max(0, Math.min(last, i)));
const toggle = () => {
if (!playing && at >= last) setCursor(0);
setPlaying((p) => !p);
};
const onKey = (e: KeyboardEvent) => {
// The slider steps itself on arrow keys; only the rest is ours there.
const onSlider = e.target instanceof HTMLInputElement;
if (e.key === " " && !(e.target instanceof HTMLButtonElement)) {
e.preventDefault();
toggle();
} else if (onSlider) return;
else if (e.key === "ArrowLeft") go(at - 1);
else if (e.key === "ArrowRight") go(at + 1);
else if (e.key === "Home") go(0);
else if (e.key === "End") go(last);
else return;
e.preventDefault();
};
const markerLabel = (type: string) => t(`replay.marker.${type}`);
const present = LIFECYCLE_TYPES.filter((k) => markers.some((m) => m.type === k));
return (
<div
className="space-y-4 outline-none"
tabIndex={0}
onKeyDown={onKey}
role="group"
aria-label={t("replay.label")}
data-testid="event-replay"
>
{total != null && total > events.length && (
<p className="text-xs text-fg-muted">
{t("replay.truncated", { shown: events.length, total })}
</p>
)}
<div className="card space-y-3 p-4">
<div className="flex flex-wrap items-center gap-1">
<button type="button" className={btn} onClick={() => go(0)} title={t("replay.start")}>
<SkipBack className="h-4 w-4" />
</button>
<button
type="button"
className={btn}
onClick={() => go(at - 1)}
disabled={at <= 0}
title={t("replay.back")}
>
<StepBack className="h-4 w-4" />
</button>
<button
type="button"
className={`${btn} bg-accent/15 text-accent`}
onClick={toggle}
title={playing ? t("replay.pause") : t("replay.play")}
aria-pressed={playing}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<button
type="button"
className={btn}
onClick={() => go(at + 1)}
disabled={at >= last}
title={t("replay.forward")}
>
<StepForward className="h-4 w-4" />
</button>
<button type="button" className={btn} onClick={() => go(last)} title={t("replay.end")}>
<SkipForward className="h-4 w-4" />
</button>
<div
className="ml-2 flex items-center gap-0.5"
role="group"
aria-label={t("replay.speed")}
>
{REPLAY_SPEEDS.map((s) => (
<button
key={s}
type="button"
onClick={() => setSpeed(s)}
aria-pressed={speed === s}
className={`rounded px-2 py-1 text-xs font-mono ${
speed === s
? "bg-accent/15 text-accent"
: "text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{s}×
</button>
))}
</div>
<span className="ml-auto font-mono text-xs text-fg-muted">
{t("replay.position", { at: at + 1, n: events.length })} ·{" "}
{formatDateTimeFull(event.created_at)}
</span>
</div>
<input
type="range"
min={0}
max={last}
value={at}
onChange={(e) => go(Number(e.target.value))}
aria-label={t("replay.scrubber")}
className="w-full accent-[rgb(var(--accent))]"
/>
<div
className="relative h-4 rounded bg-surface-2"
role="group"
aria-label={t("replay.minimap")}
>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-fg-primary"
style={{ left: `${last > 0 ? (at / last) * 100 : 0}%` }}
/>
{markers.map((m) => (
<button
key={m.index}
type="button"
onClick={() => go(m.index)}
title={`${markerLabel(m.type)} · #${m.index + 1}`}
aria-label={`${markerLabel(m.type)} · #${m.index + 1}`}
className={`absolute inset-y-0 w-1.5 -translate-x-1/2 rounded-sm opacity-80 hover:opacity-100 ${MARKER_COLOR[m.type]}`}
style={{ left: `${m.pct}%` }}
/>
))}
</div>
{present.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-fg-muted">
{present.map((k) => (
<span key={k} className="inline-flex items-center gap-1">
<span className={`h-2 w-2 rounded-sm ${MARKER_COLOR[k]}`} />
{markerLabel(k)}
</span>
))}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-6">
<Tile label={t("replay.state.status")}>
{state.status ? <AgentStatusBadge status={state.status} /> : "-"}
</Tile>
<Tile label={t("replay.state.tool")}>
<span className="font-mono">{state.currentTool ?? "-"}</span>
</Tile>
<Tile label={t("replay.state.prompts")}>{state.prompts}</Tile>
<Tile label={t("replay.state.errors")}>{state.errors}</Tile>
<Tile label={t("replay.state.compactions")}>{state.compactions}</Tile>
<Tile label={t("replay.state.elapsed")}>{formatMs(state.elapsedMs)}</Tile>
</div>
{state.toolCounts.length > 0 && (
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={t("replay.state.toolCounts")}
>
{state.toolCounts.map((x) => (
<span
key={x.tool}
className="rounded bg-surface-2 px-2 py-0.5 font-mono text-[11px] text-fg-secondary"
>
{x.tool} ×{x.count}
</span>
))}
</div>
)}
<div className="card overflow-hidden">
<div className="border-b border-border px-5 py-3 text-sm text-fg-secondary">
<span className="mr-2 font-mono text-xs text-fg-muted">#{at + 1}</span>
{buildEventTitle(event)}
</div>
<EventDetail
event={event}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
</div>
</div>
);
}
function Tile({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-fg-muted">{label}</div>
<div className="mt-1 text-sm font-semibold text-fg-primary">{children}</div>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
/**
* @file EventTimeline.tsx
* @description A session's events as a flat, expandable list — one row per
* event (age, status badge, origin · title, tool), opening into EventDetail.
* Used by the session analysis page's Timeline tab and the read-only snapshot
* page.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AgentStatusBadge } from "./StatusBadge";
import { EventDetail } from "./EventDetail";
import {
agentOriginLabel,
buildEventTitle,
buildOriginLabel,
projectFromCwd,
projectFromEvent,
statusFromEventType,
} from "../lib/event-grouping";
import type { AgentInfo } from "../lib/event-grouping";
import { timeAgo } from "../lib/format";
import type { DashboardEvent } from "../lib/types";
export function EventTimeline({
events,
agentInfoById,
sessionNameById,
fallbackCwd,
}: {
events: DashboardEvent[];
agentInfoById: Map<string, AgentInfo>;
sessionNameById: Map<string, string>;
/** The session's cwd, for events that carry none of their own. */
fallbackCwd?: string | null;
}) {
const { t } = useTranslation("sessions");
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => new Set());
function toggleEvent(id: number) {
setExpandedEvents((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
// Precompute project per event so flat-row rendering doesn't re-parse JSON.
const projectByEventId = useMemo(() => {
const map = new Map<number, string | null>();
for (const e of events) map.set(e.id, projectFromEvent(e));
return map;
}, [events]);
return (
<div className="card overflow-hidden">
<div className="divide-y divide-border max-h-[600px] overflow-y-auto overflow-x-auto">
{events.map((event, i) => {
const key = event.id ?? i;
const isOpen = event.id != null && expandedEvents.has(event.id);
return (
// Off-screen rows skip layout/paint (a long timeline is not virtualized).
<div
key={key}
style={
isOpen
? undefined
: { contentVisibility: "auto", containIntrinsicSize: "auto 45px" }
}
>
<button
type="button"
onClick={() => event.id != null && toggleEvent(event.id)}
aria-expanded={isOpen}
aria-label={
isOpen ? t("common:eventDetail.collapse") : t("common:eventDetail.expand")
}
className="w-full text-left px-5 py-3 flex items-center gap-4 hover:bg-surface-4 transition-colors min-w-0 cursor-pointer"
>
<span
className={`text-fg-muted text-[10px] w-3 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
aria-hidden="true"
>
▶
</span>
<div className="w-16 text-[11px] text-fg-muted font-mono flex-shrink-0">
{timeAgo(event.created_at)}
</div>
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
{(() => {
// Session is implicit on this page - project is
// still shown so the row identifies the working
// directory when you share / search. Events without
// their own cwd (e.g. TurnDuration) fall back to the
// session's cwd so every row carries the dir prefix.
const project =
projectByEventId.get(event.id) ?? projectFromCwd(fallbackCwd) ?? null;
const origin = buildOriginLabel(
project,
null,
agentOriginLabel(event.agent_id, agentInfoById)
);
return (
<span className="text-sm text-fg-secondary flex-1 truncate">
{origin && (
<span className="text-fg-muted mr-1" title={event.agent_id ?? undefined}>
{origin} ·
</span>
)}
{buildEventTitle(event)}
</span>
);
})()}
{event.tool_name && (
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono">
{event.tool_name}
</span>
)}
</button>
{isOpen && (
<EventDetail
event={event}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
)}
</div>
);
})}
</div>
</div>
);
}
+78 -2
View File
@@ -12,6 +12,11 @@
* written back on every toggle. Failures to access storage are swallowed so a
* private-browsing quota error never breaks the UI.
*
* ## Sidebar width
* The expanded sidebar's right edge drags (or takes ←/→) between
* {@link SIDEBAR_MIN} and {@link SIDEBAR_MAX} px — never past 40% of the
* window — kept in `agenthub.sidebar.width`; a double-click resets it.
*
* ## Narrow viewports
* With no stored preference the sidebar starts collapsed below `lg` (see
* {@link loadCollapsed}). Below `md` it leaves the flow entirely: a menu
@@ -79,6 +84,26 @@ import { UpdateNotifier } from "./UpdateNotifier";
import { Sagi } from "./Sagi/Sagi";
import { StatusBar } from "./StatusBar";
const WIDTH_KEY = "agenthub.sidebar.width";
/** The default width, and the narrowest the edge drags to. */
export const SIDEBAR_MIN = 240;
/** Wide enough for long workspace names on a large screen, no wider. */
export const SIDEBAR_MAX = 480;
/** A width inside the bounds, and at most 40% of the window. */
export function clampSidebar(px: number): number {
const max = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, window.innerWidth * 0.4));
return Math.round(Math.min(max, Math.max(SIDEBAR_MIN, px)));
}
function loadWidth(): number {
try {
const n = Number(localStorage.getItem(WIDTH_KEY));
if (n) return clampSidebar(n);
} catch {}
return SIDEBAR_MIN;
}
/** Below Tailwind's `md`: the sidebar becomes an off-canvas drawer. */
export const DRAWER_QUERY = "(max-width: 767.98px)";
@@ -112,7 +137,17 @@ export function Layout({ wsConnected }: LayoutProps) {
const [drawerOpen, setDrawerOpen] = useState(false);
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
const { pathname, search } = useLocation();
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : "15rem";
const [width, setWidth] = useState(loadWidth);
const [resizing, setResizing] = useState(false);
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : `${width}px`;
const saveWidth = useCallback((px: number) => {
const next = clampSidebar(px);
setWidth(next);
try {
localStorage.setItem(WIDTH_KEY, String(next));
} catch {}
}, []);
// A pick in the drawer navigates; the drawer has done its job.
useEffect(closeDrawer, [pathname, search, closeDrawer]);
@@ -153,11 +188,52 @@ export function Layout({ wsConnected }: LayoutProps) {
connectionOpen={connectionOpen}
onConnectionOpenChange={setConnectionOpen}
onUpdateAvailableChange={setUpdateAvailable}
width={width}
resizing={resizing}
/>
{!drawer && !collapsed && (
<div
role="separator"
tabIndex={0}
aria-orientation="vertical"
aria-valuenow={width}
aria-valuemin={SIDEBAR_MIN}
aria-valuemax={SIDEBAR_MAX}
aria-label={t("resizeSidebar")}
title={t("resizeSidebar")}
data-testid="sidebar-resize"
className={`fixed top-0 bottom-0 z-30 w-1.5 -translate-x-1/2 cursor-col-resize outline-none transition-colors hover:bg-accent/40 focus-visible:bg-accent/60 ${
resizing ? "bg-accent/60" : ""
}`}
style={{ left: width }}
onPointerDown={(e) => {
e.preventDefault();
e.currentTarget.setPointerCapture?.(e.pointerId);
setResizing(true);
}}
onPointerMove={(e) => {
if (resizing) setWidth(clampSidebar(e.clientX));
}}
onPointerUp={(e) => {
if (!resizing) return;
setResizing(false);
saveWidth(e.clientX);
}}
onPointerCancel={() => setResizing(false)}
onDoubleClick={() => saveWidth(SIDEBAR_MIN)}
onKeyDown={(e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
saveWidth(width + (e.key === "ArrowRight" ? 16 : -16));
}}
/>
)}
<main
id="main-content"
tabIndex={-1}
className="min-h-screen min-w-0 pb-6 transition-[margin-left,width] duration-200 outline-none"
className={`min-h-screen min-w-0 pb-6 duration-200 outline-none ${
resizing ? "" : "transition-[margin-left,width]"
}`}
style={{
marginLeft: sidebarWidth,
width: `calc(100% - ${sidebarWidth})`,
+48 -3
View File
@@ -1,7 +1,8 @@
/**
* @file RemoteSources.tsx
* @description Settings UI for the Remote Data Sources feature: manage the SSH
* machines this dashboard pulls Claude Code history from, and choose the global
* machines this dashboard pulls Claude Code (and, opted in per source, Codex)
* history from, and choose the global
* "data scope" (which machines' data the whole app shows).
*
* Backs `server/routes/remote-sources.js` via {@link api.remoteSources} and the
@@ -97,6 +98,8 @@ const EMPTY_FORM: RemoteSourceInput = {
ssh_port: null,
identity_file: "",
remote_home: "",
sync_codex: false,
remote_codex_home: "",
enabled: true,
};
@@ -219,6 +222,8 @@ export function RemoteSources() {
ssh_port: s.ssh_port,
identity_file: s.identity_file || "",
remote_home: s.remote_home || "",
sync_codex: !!s.sync_codex,
remote_codex_home: s.remote_codex_home || "",
enabled: s.enabled,
});
setEditingId(s.id);
@@ -242,6 +247,8 @@ export function RemoteSources() {
ssh_port: form.ssh_port ? Number(form.ssh_port) : null,
identity_file: form.identity_file?.trim() ? form.identity_file.trim() : null,
remote_home: form.remote_home?.trim() ? form.remote_home.trim() : null,
sync_codex: !!form.sync_codex,
remote_codex_home: form.remote_codex_home?.trim() ? form.remote_codex_home.trim() : null,
enabled: form.enabled,
};
try {
@@ -591,6 +598,31 @@ export function RemoteSources() {
</p>
</div>
</div>
<Checkbox
checked={!!form.sync_codex}
onChange={(v) => setForm((f) => ({ ...f, sync_codex: v }))}
label={t("remoteSources.fieldSyncCodex", "Also pull Codex sessions")}
labelClassName="text-sm text-fg-secondary"
/>
{form.sync_codex && (
<div>
<label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldRemoteCodexHome", "Remote Codex home (optional)")}
</label>
<input
className="input w-full font-mono"
placeholder="~/.codex"
value={form.remote_codex_home ?? ""}
onChange={(e) => setForm((f) => ({ ...f, remote_codex_home: e.target.value }))}
/>
<p className="mt-1 text-[11px] text-fg-muted leading-snug">
{t(
"remoteSources.fieldRemoteCodexHomeHint",
"Default ~/.codex (wsl:~/.codex when the Claude home is in WSL). Its sessions/ folder is copied, and the sessions show up as Codex sessions of this source, with their tokens and cost."
)}
</p>
</div>
)}
<Checkbox
checked={!!form.enabled}
onChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
@@ -674,6 +706,9 @@ export function RemoteSources() {
{s.host}
{s.ssh_port ? `:${s.ssh_port}` : ""}
{s.remote_home ? ` · ${s.remote_home}` : ""}
{s.sync_codex
? ` · ${t("remoteSources.codexOn", "+ Codex")}${s.remote_codex_home ? ` (${s.remote_codex_home})` : ""}`
: ""}
</div>
<div className="text-[11px] text-fg-muted mt-1">
{s.last_sync_at
@@ -689,9 +724,19 @@ export function RemoteSources() {
` · ${t("remoteSources.syncOnRemote", "{{n}} on remote", {
n: s.last_sync_counts.sessions_tagged,
})}`}
{s.last_sync_counts?.codex_sessions != null &&
` · ${t("remoteSources.syncCodex", "{{n}} Codex", {
n: s.last_sync_counts.codex_sessions,
})}`}
</div>
{s.status === "error" && s.last_error && (
<div className="text-[11px] text-status-danger mt-1 break-words">
{/* A sync that pulled one CLI's history but not the other's is
`ok` with the other's failure in last_error. */}
{s.last_error && (s.status === "error" || s.status === "ok") && (
<div
className={`text-[11px] mt-1 break-words ${
s.status === "error" ? "text-status-danger" : "text-status-warning"
}`}
>
{s.last_error}
</div>
)}
+41 -8
View File
@@ -5,7 +5,10 @@
* animation state (idle, run right, run left, waving, jumping, failed,
* waiting, running, review) — played one row at a time by Sagi's mood; an
* uploaded image is shown still and bobs with the same `data-mood` CSS as
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
* Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right
* or waving row over the mood's, like Codex's own pet. Each row steps at its
* own pace and holds its last frame longer, so a loop breathes instead of
* spinning. Reduced motion holds the row's first frame. A sprite that fails to
* load (deleted from another browser) calls `onBroken`, so the mascot falls
* back to Sagi instead of an empty box.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -30,11 +33,28 @@ const ROW: Record<Mood, number> = {
worried: 5, // failed
disconnected: 5,
};
/** What the user is doing to the pet, which outranks its mood. */
export type PetAction = "run-left" | "run-right" | "wave";
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
/** Per row: ms per frame, and ms the last frame is held before the loop restarts. */
const PACE: [number, number][] = [
[220, 1400], // idle: a breath, then a pause
[120, 120], // run right — keeps up with the drag
[120, 120], // run left
[180, 400], // waving
[160, 700], // jumping
[200, 1200], // failed
[220, 900], // waiting
[170, 400], // running (working)
[220, 900], // review
];
const SLEEP_PACE: [number, number] = [450, 2000];
export function PetSprite({
id,
kind,
mood,
action,
size,
reducedMotion,
onBroken,
@@ -42,23 +62,36 @@ export function PetSprite({
id: string;
kind: "codex" | "image";
mood: Mood;
action?: PetAction | null;
size: number;
reducedMotion: boolean;
onBroken: () => void;
}) {
const url = api.pets.spriteUrl(id);
const row = ROW[mood] ?? 0;
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
const sleeping = !action && mood === "sleeping";
const [frame, setFrame] = useState(0);
useEffect(() => {
setFrame(0);
if (kind !== "codex" || reducedMotion) return;
const timer = window.setInterval(
() => setFrame((f) => (f + 1) % (FRAMES[row] ?? 1)),
mood === "sleeping" ? 400 : 125
);
return () => window.clearInterval(timer);
}, [kind, row, mood, reducedMotion]);
const count = FRAMES[row] ?? 1;
const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
let f = 0;
let timer = 0;
const tick = () => {
timer = window.setTimeout(
() => {
f = (f + 1) % count;
setFrame(f);
tick();
},
f === count - 1 ? hold : step
);
};
tick();
return () => window.clearTimeout(timer);
}, [kind, row, sleeping, reducedMotion]);
// A background image reports no error of its own: probe it once.
useEffect(() => {
+5
View File
@@ -203,6 +203,7 @@ export function Sagi() {
const [open, setOpen] = useState(false);
// The right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false);
const [hover, setHover] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const navigate = useNavigate();
const brain = useSagiBrain();
@@ -331,6 +332,8 @@ export function Sagi() {
className="sagi-avatar-btn"
data-dragging={place.dragging ? "1" : "0"}
style={{ left: place.left, top: place.top, width: size, height: size }}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
onPointerDown={place.onPointerDown}
onPointerMove={place.onPointerMove}
onPointerUp={place.onPointerUp}
@@ -358,6 +361,8 @@ export function Sagi() {
id={pet.id}
kind={pet.kind}
mood={brain.mood}
// Dragged → runs that way; hovered → waves (Codex's pet does both).
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
size={size}
reducedMotion={reducedMotion}
// Deleted elsewhere, or unreadable: back to Sagi.
@@ -94,6 +94,8 @@ describe("PetSprite", () => {
expect(sprite.dataset.row).toBe("7");
expect(sprite.style.backgroundPosition).toBe("0px -728px");
act(() => vi.advanceTimersByTime(130));
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // slower than 125 ms
act(() => vi.advanceTimersByTime(50));
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
rerender(
<PetSprite
@@ -108,6 +110,45 @@ describe("PetSprite", () => {
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("5");
});
it("runs the way it is dragged and waves on hover, over its mood", () => {
const props = {
id: "cp-goku",
kind: "codex" as const,
mood: "thinking" as const,
size: 208,
reducedMotion: false,
onBroken: () => {},
};
const { rerender } = render(<PetSprite {...props} action="run-left" />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("2");
rerender(<PetSprite {...props} action="run-right" />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("1");
rerender(<PetSprite {...props} action="wave" />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("3");
rerender(<PetSprite {...props} action={null} />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
});
it("holds a loop's last frame before starting over", () => {
render(
<PetSprite
id="cp-goku"
kind="codex"
mood="idle"
size={208}
reducedMotion={false}
onBroken={() => {}}
/>
);
const sprite = screen.getByTestId("pet-sprite");
act(() => vi.advanceTimersByTime(5 * 220)); // idle: 6 frames → on the last one
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
act(() => vi.advanceTimersByTime(1000));
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
act(() => vi.advanceTimersByTime(400));
expect(sprite.style.backgroundPosition).toBe("0px 0px");
});
it("holds the first frame under reduced motion", () => {
render(
<PetSprite
@@ -5,7 +5,16 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, act, cleanup, within } from "@testing-library/react";
import {
render,
renderHook,
screen,
fireEvent,
act,
cleanup,
within,
} from "@testing-library/react";
import { useSagiPosition } from "../useSagiPosition";
import { MemoryRouter } from "react-router-dom";
import { Sagi } from "../Sagi";
import { eventBus } from "../../../lib/eventBus";
@@ -192,3 +201,22 @@ describe("Sagi widget", () => {
expect(JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}").side).toBe("left");
});
});
describe("useSagiPosition moving", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("names the drag's direction and settles once the pointer rests", () => {
const { result } = renderHook(() => useSagiPosition(68));
const target = { setPointerCapture() {}, releasePointerCapture() {} };
const ev = (x: number) =>
({ button: 0, pointerId: 1, clientX: x, clientY: 300, currentTarget: target }) as never;
act(() => result.current.onPointerDown(ev(500)));
act(() => result.current.onPointerMove(ev(480)));
expect(result.current.moving).toBe("left");
act(() => result.current.onPointerMove(ev(520)));
expect(result.current.moving).toBe("right");
act(() => vi.advanceTimersByTime(400));
expect(result.current.moving).toBeNull();
});
});
+30 -3
View File
@@ -7,6 +7,8 @@
* viewport fraction so it survives resizes). A small movement threshold tells
* a drag apart from a tap so dragging never opens the panel. The arrow keys
* move a focused avatar too (WCAG 2.5.7): ←/→ dock it to an edge, ↑/↓ step it.
* `moving` names the way it is heading — while dragged (cleared once the
* pointer rests) and during the glide to its edge — so a pet can run that way.
* It never rests over the fixed bottom status bar; by default it docks
* bottom-right just above it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -83,6 +85,8 @@ const DRAG_THRESHOLD = 5;
const STATUS_BAR = 24;
// One arrow-key step, in px.
const KEY_STEP = 40;
// A held but unmoving drag stops running after this; also the snap glide's length.
const MOVE_SETTLE_MS = 300;
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
@@ -109,6 +113,8 @@ export interface SagiPlacement {
/** True when the avatar sits in the lower half - flyouts open upward. */
openUp: boolean;
dragging: boolean;
/** The way the avatar is moving horizontally right now, if it is. */
moving: "left" | "right" | null;
onPointerDown: (e: ReactPointerEvent) => void;
onPointerMove: (e: ReactPointerEvent) => void;
onPointerUp: (e: ReactPointerEvent) => void;
@@ -123,6 +129,15 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
const [, force] = useState(0); // re-derive resting coords on resize
const [moving, setMoving] = useState<"left" | "right" | null>(null);
const moveTimer = useRef(0);
const lastX = useRef(0);
const heading = useCallback((dir: "left" | "right" | null) => {
window.clearTimeout(moveTimer.current);
setMoving(dir);
if (dir) moveTimer.current = window.setTimeout(() => setMoving(null), MOVE_SETTLE_MS);
}, []);
useEffect(() => () => window.clearTimeout(moveTimer.current), []);
const draggedRef = useRef(false);
const startRef = useRef<{ px: number; py: number; left: number; top: number } | null>(null);
@@ -152,6 +167,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
/* capture unsupported - window-free fallback still works via props */
}
startRef.current = { px: e.clientX, py: e.clientY, left: screen.left, top: screen.top };
lastX.current = e.clientX;
movedRef.current = false;
},
[screen.left, screen.top]
@@ -172,8 +188,14 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
);
liveRef.current = { left, top };
setDrag({ left, top });
// Ignore a pixel of jitter; a steady push keeps re-arming the settle timer.
const stepX = e.clientX - lastX.current;
if (Math.abs(stepX) >= 2) {
lastX.current = e.clientX;
heading(stepX > 0 ? "right" : "left");
}
},
[size]
[size, heading]
);
const onPointerUp = useCallback(
@@ -193,12 +215,15 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
sagiPrefs.setPos(next);
setPos(next);
setDrag(null); // leave drag mode; resting coords (with transition) take over
// Run along the glide to the edge, when there is a real distance to cover.
const glide = restingScreen(next, size).left - live.left;
heading(Math.abs(glide) > size / 2 ? (glide > 0 ? "right" : "left") : null);
}
liveRef.current = null;
startRef.current = null;
movedRef.current = false;
},
[size]
[size, heading]
);
const onKeyDown = useCallback(
@@ -214,10 +239,11 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
}
if (!next) return;
e.preventDefault();
if (next.side !== pos.side) heading(next.side);
sagiPrefs.setPos(next);
setPos(next);
},
[pos, size]
[pos, size, heading]
);
const consumeDrag = useCallback(() => {
@@ -233,6 +259,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
side: pos.side,
openUp: screen.top + size / 2 > vh() / 2,
dragging: drag !== null,
moving,
onPointerDown,
onPointerMove,
onPointerUp,
+9 -4
View File
@@ -76,6 +76,8 @@ import type { Agent, Session, SessionStats } from "../lib/types";
interface SessionOverviewProps {
session: Session;
agents: Agent[];
/** Frozen stats (a session snapshot): shown as given, never fetched or refreshed. */
stats?: SessionStats;
}
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
@@ -160,18 +162,19 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
);
}
export function SessionOverview({ session, agents }: SessionOverviewProps) {
export function SessionOverview({ session, agents, stats: frozen }: SessionOverviewProps) {
const { t } = useTranslation("sessions");
const [stats, setStats] = useState<SessionStats | null>(null);
const [fetched, setStats] = useState<SessionStats | null>(null);
const stats = frozen ?? fetched;
const fetchingRef = useRef(false);
// Tick clock every 30s so a still-active session's "duration" tile stays current.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (session.status !== "active") return;
if (frozen || session.status !== "active") return;
const id = window.setInterval(() => setNow(Date.now()), 30_000);
return () => window.clearInterval(id);
}, [session.status]);
}, [frozen, session.status]);
// A refresh asked for while one is in flight is not dropped: it runs once
// when the current one settles (that is the throttle's trailing call).
@@ -198,6 +201,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// Initial load + reload when the session id changes.
useEffect(() => {
if (frozen) return;
fetchStats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.id]);
@@ -206,6 +210,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// stream still refreshes) and again after a reconnect.
useLiveRefresh(() => void fetchStats(), {
throttleMs: REFRESH_THROTTLE_MS,
enabled: !frozen,
match: (msg) => {
if (isRemoteDataRefreshMessage(msg)) return true;
if (
+10 -1
View File
@@ -69,6 +69,7 @@ import {
ChevronUp,
ChevronDown,
FolderTree,
TableProperties,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
@@ -83,6 +84,7 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
const NAV_KEYS = [
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
{ to: "/query", icon: TableProperties, key: "nav:query" },
] as const;
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
@@ -170,6 +172,10 @@ interface SidebarProps {
onUpdateAvailableChange?: (available: boolean) => void;
/** Set on a narrow viewport: the sidebar is an off-canvas drawer. */
drawer?: { open: boolean; onClose: () => void };
/** Expanded width in px, chosen by dragging its edge (see Layout). */
width?: number;
/** True while that edge is dragged: no width transition lagging the pointer. */
resizing?: boolean;
}
export function Sidebar({
@@ -180,6 +186,8 @@ export function Sidebar({
onConnectionOpenChange,
onUpdateAvailableChange,
drawer,
width = 240,
resizing = false,
}: SidebarProps) {
const { t } = useTranslation();
const asideRef = useRef<HTMLElement>(null);
@@ -423,8 +431,9 @@ export function Sidebar({
? "visible translate-x-0 shadow-2xl transition-transform"
: "invisible -translate-x-full transition-[transform,visibility]"
}`
: `z-30 transition-[width] duration-200 ${collapsed ? "w-[4.25rem]" : "w-60"}`
: `z-30 duration-200 ${resizing ? "" : "transition-[width]"} ${collapsed ? "w-[4.25rem]" : ""}`
}`}
style={drawer || collapsed ? undefined : { width }}
>
<div className="px-3 py-4 border-b border-border flex-shrink-0">
<div className="flex flex-col items-center justify-center">
+84
View File
@@ -0,0 +1,84 @@
/**
* @file SnapshotButton.tsx
* @description The session analysis page's **Snapshot** action: confirms with a
* "redact file paths" choice, freezes the session through `POST
* /api/snapshots`, copies the `/snapshot/<token>` link and offers it inline.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Camera } from "lucide-react";
import { api } from "../lib/api";
import { ConfirmModal } from "./ConfirmModal";
import { Checkbox } from "./Checkbox";
import { CopyButton } from "./event-views/primitives";
export function snapshotUrl(token: string): string {
return `${window.location.origin}/snapshot/${token}`;
}
export function SnapshotButton({ sessionId }: { sessionId: string }) {
const { t } = useTranslation("sessions");
const [open, setOpen] = useState(false);
const [redact, setRedact] = useState(true);
const [busy, setBusy] = useState(false);
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const create = async () => {
setBusy(true);
setError(null);
try {
const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact });
setToken(snapshot.token);
navigator.clipboard?.writeText(snapshotUrl(snapshot.token)).catch(() => undefined);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
setOpen(false);
}
};
return (
<div className="flex flex-wrap items-center justify-end gap-1">
{token && (
<span className="inline-flex items-center gap-1 text-xs text-fg-secondary">
<Link to={`/snapshot/${token}`} className="text-accent hover:underline">
{t("snapshot.open")}
</Link>
<CopyButton text={snapshotUrl(token)} />
</span>
)}
<button
type="button"
onClick={() => setOpen(true)}
title={t("snapshot.hint")}
className="btn-ghost text-xs"
>
<Camera className="h-4 w-4" />
{t("snapshot.create")}
</button>
{error && (
<p role="alert" className="basis-full text-right text-[11px] text-status-danger">
{error}
</p>
)}
<ConfirmModal
open={open}
title={t("snapshot.title")}
message={t("snapshot.body")}
confirmLabel={t("snapshot.confirm")}
cancelLabel={t("snapshot.cancel")}
destructive={false}
busy={busy}
onConfirm={() => void create()}
onCancel={() => setOpen(false)}
>
<Checkbox checked={redact} onChange={setRedact} label={t("snapshot.redactPaths")} />
</ConfirmModal>
</div>
);
}
+25 -2
View File
@@ -23,10 +23,10 @@
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import type { CostResult, ToolQuota } from "../lib/types";
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
@@ -342,6 +342,8 @@ export function StatusBar({
const [laneCost, setLaneCost] = useState<CostResult | null>(null);
const [sessionCost, setSessionCost] = useState<CostResult | null>(null);
const [runs, setRuns] = useState<RunHandle[]>([]);
// The budget furthest past one of its thresholds, if any.
const [budget, setBudget] = useState<BudgetStatus | null>(null);
const location = useLocation();
const shown = selectedFrom(location.pathname, location.search);
const lane = shown.lane;
@@ -381,6 +383,13 @@ export function StatusBar({
.totalCost({ date: localDate() })
.then((r) => live && setToday(r))
.catch(() => undefined);
api.budgets
.status()
.then(({ budgets }) => {
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
if (live) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
@@ -470,6 +479,20 @@ export function StatusBar({
r={sessionCost}
/>
)}
{budget && (
<Link
to="/?tab=analytics&sub=cost"
data-testid="status-bar-budget"
title={t("statusBar.budgetTitle", {
name: budget.name,
spend: usd(budget.spend),
limit: usd(budget.config.limit_usd ?? 0),
})}
className={`tabular-nums hover:underline ${budget.pct >= 100 ? "text-status-danger" : "text-status-warning"}`}
>
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
</Link>
)}
<QuotaChips />
</footer>
);
@@ -0,0 +1,102 @@
/**
* @file Tests for the alert rule form's spend rules: a budget rule is built
* from period / limit / thresholds / AI tool (plus the browser's timezone
* offset), Preview shows what it would have fired without saving it, and
* Create saves the same config.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
vi.mock("../../lib/api", () => ({
api: {
alerts: {
list: vi.fn().mockResolvedValue({ alerts: [], total: 0, unacked: 0, limit: 50, offset: 0 }),
rules: {
list: vi.fn().mockResolvedValue({ rules: [] }),
create: vi.fn().mockResolvedValue({ rule: {} }),
preview: vi.fn().mockResolvedValue({
supported: true,
window_days: 7,
fires: 1,
sessions: 0,
samples: [],
periods: [
{
period_start: "2026-09-01",
period_end: "2026-09-30",
spend: 120,
pct: 120,
crossed: [80, 100],
},
{
period_start: "2026-10-01",
period_end: "2026-10-31",
spend: 10,
pct: 10,
crossed: [],
},
],
}),
},
},
aiTools: {
list: vi.fn().mockResolvedValue({
tools: [{ id: "codex", label: "Codex" }],
default: "claude",
}),
},
},
}));
import { api } from "../../lib/api";
import { AlertsNotifications } from "../AlertsNotifications";
describe("AlertsNotifications — spend rules", () => {
it("previews and creates a budget rule", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<AlertsNotifications />
</MemoryRouter>
);
await user.click(await screen.findByText("Add rule"));
await user.type(screen.getByPlaceholderText("e.g. Error burst"), "Monthly");
const selects = screen.getAllByRole("combobox");
await user.selectOptions(selects[0]!, "budget");
await user.selectOptions(screen.getByDisplayValue("month"), "week");
const limit = screen.getByDisplayValue("100");
await user.clear(limit);
await user.type(limit, "50");
await screen.findByRole("option", { name: "Codex" });
await user.selectOptions(screen.getByDisplayValue("All AI tools"), "codex");
await user.click(screen.getByRole("button", { name: /Preview/ }));
const config = {
period: "week",
limit_usd: 50,
thresholds: [80, 100],
tz_offset: new Date().getTimezoneOffset(),
tool: "codex",
};
expect(api.alerts.rules.preview).toHaveBeenCalledWith({
rule_type: "budget",
config,
cooldown_seconds: 300,
});
expect(await screen.findByText("2026-09-01")).toBeInTheDocument();
expect(screen.getByText("80% 100%")).toBeInTheDocument();
expect(api.alerts.rules.create).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /Create rule/ }));
expect(api.alerts.rules.create).toHaveBeenCalledWith({
name: "Monthly",
rule_type: "budget",
config,
cooldown_seconds: 300,
});
});
});
@@ -0,0 +1,93 @@
/**
* @file EventReplay.test.tsx
* @description The Replay tab's controls: step, jump, arrow keys, a lifecycle
* marker jumping the cursor, playback advancing on a timer, and the derived
* state following the cursor.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { EventReplay } from "../EventReplay";
import type { DashboardEvent } from "../../lib/types";
function ev(id: number, event_type: string, tool_name: string | null = null): DashboardEvent {
return {
id,
session_id: "s1",
agent_id: "a1",
event_type,
tool_name,
summary: `${event_type} ${tool_name ?? ""}`.trim(),
data: null,
created_at: new Date(Date.UTC(2026, 9, 1, 10, 0, id)).toISOString(),
} as DashboardEvent;
}
// Given newest first, as the events API answers: the replay orders them itself.
const events = [
ev(4, "Stop"),
ev(3, "PostToolUse", "Bash"),
ev(2, "PreToolUse", "Bash"),
ev(1, "SessionStart"),
];
function renderReplay() {
return render(
<MemoryRouter>
<EventReplay events={events} agentInfoById={new Map()} sessionNameById={new Map()} />
</MemoryRouter>
);
}
const position = () => screen.getByText(/^\d+ \/ 4/);
afterEach(() => vi.useRealTimers());
describe("EventReplay", () => {
it("starts at the first event and steps with the buttons", () => {
renderReplay();
expect(position().textContent).toMatch(/^1 \/ 4/);
fireEvent.click(screen.getByTitle("Step forward (→)"));
fireEvent.click(screen.getByTitle("Step forward (→)"));
expect(position().textContent).toMatch(/^3 \/ 4/);
expect(screen.getByText("Bash ×1")).toBeTruthy();
fireEvent.click(screen.getByTitle("Jump to end (End)"));
expect(position().textContent).toMatch(/^4 \/ 4/);
fireEvent.click(screen.getByTitle("Jump to start (Home)"));
expect(position().textContent).toMatch(/^1 \/ 4/);
});
it("moves on arrow keys and jumps from a lifecycle marker", () => {
renderReplay();
const root = screen.getByTestId("event-replay");
fireEvent.keyDown(root, { key: "ArrowRight" });
expect(position().textContent).toMatch(/^2 \/ 4/);
// The in-flight tool at the PreToolUse.
expect(screen.getByText("Running tool").nextSibling?.textContent).toBe("Bash");
fireEvent.keyDown(root, { key: "End" });
expect(position().textContent).toMatch(/^4 \/ 4/);
fireEvent.click(screen.getByRole("button", { name: "Session start · #1" }));
expect(position().textContent).toMatch(/^1 \/ 4/);
});
it("plays to the end at the chosen speed", () => {
vi.useFakeTimers();
renderReplay();
fireEvent.click(screen.getByRole("button", { name: "5×" }));
fireEvent.click(screen.getByTitle("Play (Space)"));
act(() => {
vi.advanceTimersByTime(100);
});
expect(position().textContent).toMatch(/^2 \/ 4/);
// One step per tick: each step schedules the next.
for (let i = 0; i < 3; i++) {
act(() => {
vi.advanceTimersByTime(100);
});
}
expect(position().textContent).toMatch(/^4 \/ 4/);
expect(screen.getByTitle("Play (Space)")).toBeTruthy();
});
});
@@ -3,7 +3,8 @@
* @description Responsive shell: with no stored preference the sidebar starts
* collapsed below `lg`, and below `md` it becomes an off-canvas drawer opened
* from a menu button — a modal dialog that Escape and the backdrop close,
* handing focus back to the button.
* handing focus back to the button. The expanded sidebar's edge resizes it
* within its bounds and remembers the width.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,7 +17,7 @@ vi.mock("../UpdateNotifier", () => ({ UpdateNotifier: () => null }));
vi.mock("../StatusBar", () => ({ StatusBar: () => null }));
vi.mock("../WorkspaceTree", () => ({ WorkspaceTree: () => null }));
import { Layout, DRAWER_QUERY } from "../Layout";
import { Layout, DRAWER_QUERY, SIDEBAR_MIN, SIDEBAR_MAX } from "../Layout";
import { loadCollapsed, SIDEBAR_STORAGE_KEY } from "../Sidebar";
function viewport(width: number) {
@@ -34,6 +35,7 @@ function viewport(width: number) {
afterEach(() => {
vi.unstubAllGlobals();
localStorage.removeItem(SIDEBAR_STORAGE_KEY);
localStorage.removeItem("agenthub.sidebar.width");
});
describe("loadCollapsed", () => {
@@ -95,3 +97,47 @@ describe("Layout drawer", () => {
expect(screen.getByRole("main").style.marginLeft).toBe("4.25rem");
});
});
describe("Layout sidebar width", () => {
it("the edge drags within bounds, is remembered, and a double-click resets it", () => {
viewport(1440);
vi.stubGlobal("innerWidth", 2560);
render(
<MemoryRouter>
<Layout wsConnected />
</MemoryRouter>
);
const edge = screen.getByTestId("sidebar-resize");
const main = screen.getByRole("main");
expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`);
fireEvent.pointerDown(edge, { pointerId: 1 });
fireEvent.pointerMove(edge, { clientX: 360 });
fireEvent.pointerUp(edge, { clientX: 360 });
expect(main.style.marginLeft).toBe("360px");
expect(screen.getByTestId("sidebar").style.width).toBe("360px");
expect(localStorage.getItem("agenthub.sidebar.width")).toBe("360");
fireEvent.pointerDown(edge, { pointerId: 1 });
fireEvent.pointerUp(edge, { clientX: 2000 });
expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX}px`);
fireEvent.keyDown(edge, { key: "ArrowLeft" });
expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX - 16}px`);
fireEvent.doubleClick(edge);
expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`);
});
it("never wider than 40% of the window", () => {
viewport(1100);
vi.stubGlobal("innerWidth", 1100);
localStorage.setItem("agenthub.sidebar.width", "480");
render(
<MemoryRouter>
<Layout wsConnected />
</MemoryRouter>
);
expect(screen.getByRole("main").style.marginLeft).toBe("440px");
});
});
@@ -111,6 +111,21 @@ vi.mock("../../lib/api", () => ({
daily_costs: [],
})),
},
budgets: {
status: vi.fn().mockResolvedValue({
budgets: [
{ id: "b1", name: "Quiet", config: { limit_usd: 100 }, spend: 10, pct: 10, crossed: [] },
{
id: "b2",
name: "Monthly",
config: { limit_usd: 100 },
spend: 85,
pct: 85,
crossed: [80],
},
],
}),
},
},
}));
@@ -142,6 +157,14 @@ describe("StatusBar", () => {
expect(screen.getByRole("status")).toHaveTextContent("Live · 1 need you");
});
it("flags the budget furthest past a threshold, linking to the cost analytics", async () => {
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
const chip = await screen.findByTestId("status-bar-budget");
expect(chip.textContent).toBe("Budget 85%");
expect(chip.getAttribute("href")).toBe("/?tab=analytics&sub=cost");
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
});
it("says Disconnected when the socket is down", () => {
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
@@ -248,6 +248,7 @@ function TreeNodes({
type="button"
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
data-tree-kept={isKept || undefined}
data-tree-ignored={e.ignored || undefined}
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
@@ -255,11 +256,13 @@ function TreeNodes({
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
isKept
? "bg-kept/10 text-kept"
: mark
? mark.tone
: e.changed
? "text-status-warning"
: "text-fg-primary"
: e.ignored
? "text-kept"
: mark
? mark.tone
: e.changed
? "text-status-warning"
: "text-fg-primary"
} ${active === e.path ? "!bg-surface-4" : ""}`}
>
{e.dir ? (
@@ -495,7 +498,9 @@ function ChangeRow({
return (
<li
className="group flex items-baseline gap-1"
style={depth ? { paddingLeft: `${depth * 0.9}rem` } : undefined}
// A file has no chevron: reserve its 1rem so the letter lines up
// with a sibling folder's icon, one step inside the parent folder.
style={{ paddingLeft: `${depth + 1}rem` }}
onContextMenu={(e) => onMenu(e, f)}
>
<button
@@ -571,10 +576,7 @@ function ChangeTreeNodes({
const open = !collapsed.has(key);
return (
<li key={d.path}>
<div
className="group flex items-center gap-1"
style={{ paddingLeft: `${depth * 0.9}rem` }}
>
<div className="group flex items-center gap-1" style={{ paddingLeft: `${depth}rem` }}>
<button
type="button"
aria-expanded={open}
+17 -1
View File
@@ -125,7 +125,23 @@ function HomeBody({
const entry = aiTools.byId(tool);
const claude = tool === "claude";
const codex = tool === "codex";
const [prompt, setPrompt] = useState("");
// The half-typed prompt survives leaving the page or the workspace.
const draftKey = `agenthub.workspace.draft.${lane.id}`;
const [prompt, setPrompt] = useState(() => {
try {
return localStorage.getItem(draftKey) ?? "";
} catch {
return "";
}
});
useEffect(() => {
try {
if (prompt) localStorage.setItem(draftKey, prompt);
else localStorage.removeItem(draftKey);
} catch {
/* storage unavailable: the draft just does not survive */
}
}, [draftKey, prompt]);
const [images, setImages] = useState<PromptImage[]>([]);
const [router, setRouter] = useSessionRouter();
const routers = useAiRouters();
@@ -193,7 +193,9 @@ describe("freeSlot / paneRunId", () => {
it("picks the first slot no live run of this lane holds", () => {
expect(freeSlot(1, [])).toBe(0);
expect(freeSlot(1, runs)).toBe(1);
const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle);
const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(
(slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle
);
expect(freeSlot(1, all)).toBeNull();
expect(freeSlot(1, all.slice(0, 4))).toBe(4);
expect(freeSlot(2, all)).toBe(0);
@@ -222,6 +224,23 @@ describe("WorkspaceHome", () => {
);
});
it("keeps a half-typed prompt per workspace across a remount, and drops it once sent", async () => {
const user = userEvent.setup();
const first = renderHome();
await screen.findByTestId("workspace-recent-s-live");
await user.type(screen.getByLabelText("How can I help you today?"), "long draft");
first.unmount();
const other = renderHome(lane({ id: 2 }));
await screen.findByTestId("workspace-recent-s-live");
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("");
other.unmount();
renderHome();
await screen.findByTestId("workspace-recent-s-live");
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("long draft");
await user.click(screen.getByTestId("workspace-home-start"));
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
});
it("keeps AI and Model (with effort) in the box, Router/Permission/Worktree under it", async () => {
renderHome();
await screen.findByTestId("workspace-recent-s-live");
@@ -0,0 +1,58 @@
/**
* @file AutostartSettings — Settings → About's "Start automatically" switch:
* whether the dashboard starts at boot/login through the per-user service
* (systemd `--user` unit, or a LaunchAgent on macOS), over
* `GET/PUT /api/settings/autostart`. Off by default; a change never starts or
* stops the running server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type AutostartStatus } from "../../lib/api";
import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function AutostartSettings() {
const { t } = useTranslation("settings");
const [status, setStatus] = useState<AutostartStatus | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
api.settings.autostart
.get()
.then(setStatus)
.catch(() => {});
}, []);
const change = async (enabled: boolean) => {
setSaving(true);
try {
setStatus(await api.settings.autostart.set(enabled));
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
};
const note = !status
? undefined
: !status.available
? t("autostart.unavailable")
: status.foreign
? t("autostart.foreign")
: t(`autostart.desc_${status.kind}`);
return (
<SettingsSection id="autostart" title={t("autostart.title")}>
<SettingsSwitchRow
label={t("autostart.label")}
description={note}
checked={!!status?.enabled}
disabled={!status?.available || status.foreign || saving}
onChange={(v) => void change(v)}
/>
</SettingsSection>
);
}
@@ -0,0 +1,111 @@
/**
* @file PrivacySettings.tsx
* @description Settings → Data → Privacy: the two opt-in ingest controls of
* `server/lib/privacy.js` — redact secrets (API keys, tokens, bearer headers,
* private keys, `password=…`) from the payloads and summaries AgentHub stores,
* and a list of folders whose hooks are dropped whole (one absolute path per
* line; a folder and everything under it). Both persist through
* `PUT /api/settings/privacy`; one the environment set is shown read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ShieldCheck } from "lucide-react";
import { api } from "../../lib/api";
import type { PrivacySettings as PrivacyState } from "../../lib/api";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
const lines = (text: string) =>
text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
export function PrivacySettings() {
const { t } = useTranslation("settings");
const [state, setState] = useState<PrivacyState | null>(null);
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const apply = (s: PrivacyState) => {
setState(s);
setDraft(s.ignore_cwd.join("\n"));
};
useEffect(() => {
api.settings.privacy
.get()
.then(apply)
.catch(() => undefined);
}, []);
async function save(patch: { redact_secrets?: boolean; ignore_cwd?: string[] }) {
setSaving(true);
setError(null);
try {
apply(await api.settings.privacy.save(patch));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
const listChanged = !!state && lines(draft).join("\n") !== state.ignore_cwd.join("\n");
return (
<div id="privacy" className="scroll-mt-24">
<SettingsSection
title={
<span className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-fg-muted" />
{t("privacy.title")}
</span>
}
description={t("privacy.description")}
>
<SettingsSwitchRow
label={t("privacy.redact")}
description={t("privacy.redactHint")}
checked={!!state?.redact_secrets}
disabled={!state || saving || state.env_owned.redact_secrets}
onChange={(v) => void save({ redact_secrets: v })}
/>
<div className="py-3 space-y-2">
<label htmlFor="privacy-ignore" className="block text-sm text-fg-secondary">
{t("privacy.ignore")}
</label>
<p className="text-xs text-fg-muted">{t("privacy.ignoreHint")}</p>
<textarea
id="privacy-ignore"
className="input w-full font-mono text-xs min-h-[72px]"
placeholder={"/home/you/clients/secret-project\n~/private"}
value={draft}
disabled={!state || state.env_owned.ignore_cwd}
onChange={(e) => {
setDraft(e.target.value);
setError(null);
}}
/>
<button
type="button"
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving || !listChanged}
onClick={() => void save({ ignore_cwd: lines(draft) })}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
{error && <p className="text-xs text-status-danger">{error}</p>}
{(state?.env_owned.redact_secrets || state?.env_owned.ignore_cwd) && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("privacy.envOwned")}
</p>
)}
</div>
</SettingsSection>
</div>
);
}
@@ -0,0 +1,170 @@
/**
* @file PushIngestSettings.tsx
* @description Settings → Data → Push ingest: the bearer token that opens
* `POST /api/hooks/ingest-batch`, for a machine this dashboard cannot reach
* over SSH, so the machine pushes its hooks instead. Off until a token is
* created; the token is shown once, right after it is made (only its hash is
* stored), and can be rotated or revoked. Also shows a forwarder script for
* the other machine — its token in a header file, never on a command line.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, Copy, KeyRound, RefreshCw, Trash2 } from "lucide-react";
import { api } from "../../lib/api";
import type { PushTokenStatus } from "../../lib/api";
import { ConfirmModal } from "../ConfirmModal";
import { SettingsSection } from "./SettingsRow";
const copyText = (text: string) => navigator.clipboard?.writeText(text).catch(() => undefined);
/** The forwarder for the pushing machine; `origin` is how it reaches this dashboard. */
function forwarder(origin: string) {
return `#!/bin/sh
# ~/.claude/agenthub-push.sh <HookEvent> — a Claude Code hook command:
# "command": "sh ~/.claude/agenthub-push.sh PreToolUse"
# ~/.claude/agenthub-push.header (chmod 600) holds one line:
# Authorization: Bearer <token>
jq -c --arg t "$1" --arg s "$(hostname -s)" \\
'{source: $s, events: [{hook_type: $t, data: .}]}' |
curl -s -m 5 -o /dev/null -X POST "${origin}/api/hooks/ingest-batch" \\
-H "@$HOME/.claude/agenthub-push.header" \\
-H "Content-Type: application/json" --data-binary @-
exit 0`;
}
export function PushIngestSettings() {
const { t } = useTranslation("settings");
const [status, setStatus] = useState<PushTokenStatus | null>(null);
const [token, setToken] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Rotating a live token or revoking one asks first.
const [confirm, setConfirm] = useState<"rotate" | "revoke" | null>(null);
useEffect(() => {
api.settings.pushToken
.get()
.then(setStatus)
.catch(() => undefined);
}, []);
async function run(fn: () => Promise<void>) {
setConfirm(null);
setBusy(true);
setError(null);
try {
await fn();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
const rotate = () =>
run(async () => {
const r = await api.settings.pushToken.rotate();
setToken(r.token);
setStatus({ enabled: r.enabled, env_owned: r.env_owned });
});
const revoke = () =>
run(async () => {
setStatus(await api.settings.pushToken.revoke());
setToken(null);
});
const script = forwarder(window.location.origin);
return (
<div id="push-ingest" className="scroll-mt-24">
<SettingsSection
title={
<span className="flex items-center gap-2">
<KeyRound className="w-4 h-4 text-fg-muted" />
{t("pushIngest.title")}
</span>
}
description={t("pushIngest.description")}
>
<div className="space-y-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span
className={`text-xs ${status?.enabled ? "text-status-success" : "text-fg-muted"}`}
>
{status?.enabled ? t("pushIngest.on") : t("pushIngest.off")}
</span>
<button
type="button"
className="btn-primary text-xs disabled:opacity-40"
disabled={busy || !status || status.env_owned}
onClick={() => (status?.enabled ? setConfirm("rotate") : void rotate())}
>
<RefreshCw className="w-3.5 h-3.5" />
{status?.enabled ? t("pushIngest.rotate") : t("pushIngest.create")}
</button>
{status?.enabled && (
<button
type="button"
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
disabled={busy || status.env_owned}
onClick={() => setConfirm("revoke")}
>
<Trash2 className="w-3.5 h-3.5" />
{t("pushIngest.revoke")}
</button>
)}
</div>
{token && (
<div className="rounded-lg border border-status-warning/25 bg-status-warning/10 p-3 space-y-2">
<p className="text-xs text-status-warning">{t("pushIngest.shownOnce")}</p>
<div className="flex items-center gap-2">
<code className="flex-1 min-w-0 break-all text-xs text-fg-primary">{token}</code>
<button
type="button"
className="btn-ghost text-xs"
title={t("pushIngest.copy")}
onClick={() => void copyText(token)}
>
<Copy className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
{status?.env_owned && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("pushIngest.envOwned")}
</p>
)}
<p className="text-xs text-fg-muted">{t("pushIngest.reachHint")}</p>
<div className="relative">
<pre className="text-[11px] font-mono bg-surface-1 border border-border rounded-lg p-3 overflow-x-auto text-fg-secondary">
{script}
</pre>
<button
type="button"
className="btn-ghost text-xs absolute top-1.5 right-1.5"
title={t("pushIngest.copy")}
onClick={() => void copyText(script)}
>
<Copy className="w-3.5 h-3.5" />
</button>
</div>
<p className="text-xs text-fg-muted">{t("pushIngest.limits")}</p>
{error && <p className="text-xs text-status-danger">{error}</p>}
</div>
<ConfirmModal
open={confirm !== null}
title={t(confirm === "revoke" ? "pushIngest.revokeConfirm" : "pushIngest.rotateConfirm")}
confirmLabel={t(confirm === "revoke" ? "pushIngest.revoke" : "pushIngest.rotate")}
cancelLabel={t("common:cancel")}
destructive={confirm === "revoke"}
busy={busy}
onConfirm={() => void (confirm === "revoke" ? revoke() : rotate())}
onCancel={() => setConfirm(null)}
/>
</SettingsSection>
</div>
);
}
@@ -0,0 +1,394 @@
/**
* @file ReportSettings.tsx
* @description Settings → Reports: scheduled report definitions (`/api/reports`)
* — create/edit (name, window, daily/weekly + weekday, local hour, HTML/JSON),
* enable switch, Run now, a confirmed Delete, and each report's run history
* with download links. Every row action is also on a right-click menu. The
* browser's timezone offset is saved with the definition, so its hour is the
* user's local hour.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, History, Pencil, Play, Plus, Trash2 } from "lucide-react";
import { api } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import type { ReportDefinition, ReportInput, ReportRun } from "../../lib/types";
import { Checkbox } from "../Checkbox";
import { ConfirmModal } from "../ConfirmModal";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { Select } from "../Select";
import { Switch } from "../Switch";
import { SettingsSection } from "./SettingsRow";
const EMPTY: ReportInput = {
name: "",
window_days: 7,
frequency: "weekly",
hour: 8,
weekday: 1,
tz_offset: 0,
formats: ["html"],
enabled: true,
};
const HOURS = Array.from({ length: 24 }, (_, h) => h);
const pad = (n: number) => String(n).padStart(2, "0");
export function ReportSettings() {
const { t, i18n } = useTranslation("settings");
const [items, setItems] = useState<ReportDefinition[] | null>(null);
const [error, setError] = useState<string | null>(null);
/** The form: a new report (id null) or the one being edited. */
const [form, setForm] = useState<{ id: number | null; value: ReportInput } | null>(null);
const [deleting, setDeleting] = useState<ReportDefinition | null>(null);
const [busy, setBusy] = useState<number | "form" | null>(null);
const [history, setHistory] = useState<{ id: number; runs: ReportRun[] } | null>(null);
const ctx = useContextMenu<ReportDefinition>();
const weekday = (d: number) =>
new Intl.DateTimeFormat(i18n.resolvedLanguage ?? i18n.language, { weekday: "long" }).format(
new Date(Date.UTC(2026, 0, 4 + d))
);
const fail = (err: unknown) => setError(err instanceof Error ? err.message : String(err));
const load = useCallback(() => {
api.reports
.list()
.then((r) => setItems(r.reports))
.catch(fail);
}, []);
useEffect(load, [load]);
const showHistory = async (id: number) => {
try {
const r = await api.reports.runs(id);
setHistory({ id, runs: r.runs });
} catch (err) {
fail(err);
}
};
const save = async () => {
if (!form) return;
setBusy("form");
const value = { ...form.value, tz_offset: new Date().getTimezoneOffset() };
try {
if (form.id === null) await api.reports.create(value);
else await api.reports.update(form.id, value);
setForm(null);
setError(null);
load();
} catch (err) {
fail(err);
} finally {
setBusy(null);
}
};
const toggle = async (r: ReportDefinition, enabled: boolean) => {
try {
await api.reports.update(r.id, { enabled });
load();
} catch (err) {
fail(err);
}
};
const runNow = async (r: ReportDefinition) => {
setBusy(r.id);
try {
const { run } = await api.reports.run(r.id);
if (run.status === "error") setError(run.error);
load();
if (history?.id === r.id) await showHistory(r.id);
} catch (err) {
fail(err);
} finally {
setBusy(null);
}
};
const remove = async () => {
if (!deleting) return;
try {
await api.reports.remove(deleting.id);
if (history?.id === deleting.id) setHistory(null);
load();
} catch (err) {
fail(err);
} finally {
setDeleting(null);
}
};
const edit = (r: ReportDefinition) =>
setForm({
id: r.id,
value: {
name: r.name,
window_days: r.window_days,
frequency: r.frequency,
hour: r.hour,
weekday: r.weekday,
tz_offset: r.tz_offset,
formats: r.formats,
enabled: r.enabled,
},
});
const pick = (fn: (r: ReportDefinition) => void) => () => {
const r = ctx.menu?.item;
ctx.close();
if (r) fn(r);
};
const schedule = (r: ReportInput) =>
[
r.frequency === "daily"
? t("reports.everyDay", { time: `${pad(r.hour)}:00` })
: t("reports.everyWeek", { day: weekday(r.weekday), time: `${pad(r.hour)}:00` }),
t("reports.window", { count: r.window_days }),
r.formats.map((f) => f.toUpperCase()).join(", "),
].join(" · ");
const set = (patch: Partial<ReportInput>) =>
setForm((f) => (f ? { ...f, value: { ...f.value, ...patch } } : f));
const toggleFormat = (f: "html" | "json", on: boolean) =>
set({
formats: on
? [...(form?.value.formats ?? []), f]
: (form?.value.formats ?? []).filter((x) => x !== f),
});
return (
<SettingsSection title={t("reports.title")} description={t("reports.description")}>
{error && (
<p role="alert" className="py-2 text-xs text-status-danger">
{error}
</p>
)}
{form ? (
<div
className="my-2 space-y-3 rounded-md border border-border p-3"
data-testid="report-form"
>
<label className="block text-xs text-fg-muted">
{t("reports.name")}
<input
className="input mt-1 w-full text-sm"
value={form.value.name}
maxLength={200}
onChange={(e) => set({ name: e.target.value })}
/>
</label>
<div className="flex flex-wrap items-end gap-3">
<label className="text-xs text-fg-muted">
{t("reports.windowDays")}
<input
type="number"
min={1}
max={366}
className="input mt-1 block w-24 text-sm"
value={form.value.window_days}
onChange={(e) => set({ window_days: Math.trunc(Number(e.target.value)) })}
/>
</label>
<div className="w-36">
<Select
value={form.value.frequency}
onChange={(v) => set({ frequency: v })}
label={t("reports.frequency")}
options={[
{ value: "daily", label: t("reports.daily") },
{ value: "weekly", label: t("reports.weekly") },
]}
/>
</div>
{form.value.frequency === "weekly" && (
<div className="w-40">
<Select
value={String(form.value.weekday)}
onChange={(v) => set({ weekday: Number(v) })}
label={t("reports.weekday")}
options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({
value: String(d),
label: weekday(d),
}))}
/>
</div>
)}
<div className="w-28">
<Select
value={String(form.value.hour)}
onChange={(v) => set({ hour: Number(v) })}
label={t("reports.hour")}
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
<Checkbox
checked={form.value.formats.includes("html")}
onChange={(v) => toggleFormat("html", v)}
label="HTML"
/>
<Checkbox
checked={form.value.formats.includes("json")}
onChange={(v) => toggleFormat("json", v)}
label="JSON"
/>
</div>
<p className="text-[11px] text-fg-muted">{t("reports.windowHint")}</p>
<div className="flex gap-2">
<button
type="button"
className="btn-primary text-xs"
disabled={
busy === "form" || !form.value.name.trim() || form.value.formats.length === 0
}
onClick={() => void save()}
>
{t("reports.save")}
</button>
<button type="button" className="btn-ghost text-xs" onClick={() => setForm(null)}>
{t("reports.cancel")}
</button>
</div>
</div>
) : (
<button
type="button"
className="btn-ghost my-2 text-xs"
onClick={() => setForm({ id: null, value: { ...EMPTY, name: t("reports.defaultName") } })}
>
<Plus className="h-3.5 w-3.5" />
{t("reports.add")}
</button>
)}
{items?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("reports.empty")}</p>}
{items && items.length > 0 && (
<ul className="divide-y divide-border/60" data-testid="report-list">
{items.map((r) => (
<li key={r.id} onContextMenu={(e) => ctx.open(e, r)} className="py-2.5">
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-fg-secondary">{r.name}</p>
<p className="text-[11px] text-fg-muted">{schedule(r)}</p>
{r.last_run && (
<p
className={`text-[11px] ${
r.last_run.status === "error" ? "text-status-danger" : "text-fg-muted"
}`}
>
{t("reports.lastRun", { at: formatDateTime(r.last_run.created_at) })}
{r.last_run.status === "error" && ` · ${r.last_run.error ?? ""}`}
</p>
)}
</div>
<Switch
checked={r.enabled}
onChange={(v) => void toggle(r, v)}
label={t("reports.enabled")}
title={t("reports.enabled")}
/>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy === r.id}
onClick={() => void runNow(r)}
title={t("reports.runNow")}
aria-label={t("reports.runNow")}
>
<Play className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn-ghost text-xs"
onClick={() => (history?.id === r.id ? setHistory(null) : void showHistory(r.id))}
title={t("reports.history")}
aria-label={t("reports.history")}
>
<History className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn-ghost text-xs"
onClick={() => edit(r)}
title={t("reports.edit")}
aria-label={t("reports.edit")}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn-ghost text-xs text-status-danger"
onClick={() => setDeleting(r)}
title={t("reports.delete")}
aria-label={t("reports.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{history?.id === r.id && (
<ul
className="mt-2 space-y-1 border-l border-border pl-3"
data-testid="report-runs"
>
{history.runs.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("reports.noRuns")}</li>
)}
{history.runs.map((run) => (
<li key={run.id} className="flex flex-wrap items-center gap-2 text-[11px]">
<span className="text-fg-secondary">{formatDateTime(run.created_at)}</span>
<span className="text-fg-muted">
{t(`reports.trigger.${run.trigger}`)} · {run.window_from} → {run.window_to}
</span>
{run.status === "error" ? (
<span className="text-status-danger">{run.error}</span>
) : (
run.files.map((f) => (
<a
key={f}
href={api.reports.fileUrl(r.id, run.id, f)}
download
className="inline-flex items-center gap-0.5 text-accent hover:underline"
title={t("reports.download", { format: f.toUpperCase() })}
>
<Download className="h-3 w-3" />
{f.toUpperCase()}
</a>
))
)}
</li>
))}
</ul>
)}
</li>
))}
</ul>
)}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="report-menu">
<MenuItem onSelect={pick((r) => void runNow(r))}>{t("reports.runNow")}</MenuItem>
<MenuItem onSelect={pick((r) => void showHistory(r.id))}>{t("reports.history")}</MenuItem>
<MenuItem onSelect={pick(edit)}>{t("reports.edit")}</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pick(setDeleting)}>
{t("reports.delete")}
</MenuItem>
</ContextMenu>
)}
<ConfirmModal
open={deleting !== null}
title={t("reports.deleteTitle")}
message={t("reports.deleteBody")}
confirmLabel={t("reports.delete")}
cancelLabel={t("reports.cancel")}
onConfirm={() => void remove()}
onCancel={() => setDeleting(null)}
/>
</SettingsSection>
);
}
@@ -0,0 +1,142 @@
/**
* @file SnapshotSettings.tsx
* @description Settings → Snapshots: every frozen session (`GET
* /api/snapshots`), each with Open, Copy link and a confirmed Delete — the
* buttons and the same actions on a right-click menu. Snapshots survive Clear
* Data, so this is the one place they go away.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ExternalLink, ShieldCheck, Trash2 } from "lucide-react";
import { api } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import type { SessionSnapshotInfo } from "../../lib/types";
import { ConfirmModal } from "../ConfirmModal";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { CopyButton } from "../event-views/primitives";
import { snapshotUrl } from "../SnapshotButton";
import { SettingsSection } from "./SettingsRow";
export function SnapshotSettings() {
const { t } = useTranslation("settings");
const [items, setItems] = useState<SessionSnapshotInfo[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState<SessionSnapshotInfo | null>(null);
const [busy, setBusy] = useState(false);
const ctx = useContextMenu<SessionSnapshotInfo>();
const navigate = useNavigate();
const load = useCallback(() => {
api.snapshots
.list()
.then((r) => setItems(r.snapshots))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, []);
useEffect(load, [load]);
const remove = async () => {
if (!deleting) return;
setBusy(true);
try {
await api.snapshots.remove(deleting.id);
setItems((xs) => xs?.filter((x) => x.id !== deleting.id) ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
setDeleting(null);
}
};
const pick = (fn: (s: SessionSnapshotInfo) => void) => () => {
const s = ctx.menu?.item;
ctx.close();
if (s) fn(s);
};
return (
<SettingsSection title={t("snapshots.title")} description={t("snapshots.description")}>
{error && (
<p role="alert" className="py-2 text-xs text-status-danger">
{error}
</p>
)}
{items?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("snapshots.empty")}</p>}
{items && items.length > 0 && (
<ul className="divide-y divide-border/60" data-testid="snapshot-list">
{items.map((s) => (
<li
key={s.id}
onContextMenu={(e) => ctx.open(e, s)}
className="flex items-center gap-3 py-2.5"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-fg-secondary">
{s.session_name || s.session_id.slice(0, 8)}
</p>
<p className="flex items-center gap-2 text-[11px] text-fg-muted">
<span>{formatDateTime(s.created_at)}</span>
<span>· {t("snapshots.events", { n: s.event_count ?? 0 })}</span>
{s.redacted && (
<span
className="inline-flex items-center gap-0.5"
title={t("snapshots.redacted")}
>
<ShieldCheck className="h-3 w-3" />
{t("snapshots.redacted")}
</span>
)}
</p>
</div>
<Link
to={`/snapshot/${s.token}`}
className="btn-ghost text-xs"
title={t("snapshots.open")}
>
<ExternalLink className="h-3.5 w-3.5" />
</Link>
<CopyButton text={snapshotUrl(s.token)} />
<button
type="button"
onClick={() => setDeleting(s)}
className="btn-ghost text-xs text-status-danger"
title={t("snapshots.delete")}
aria-label={t("snapshots.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="snapshot-menu">
<MenuItem onSelect={pick((s) => navigate(`/snapshot/${s.token}`))}>
{t("snapshots.open")}
</MenuItem>
<MenuItem
onSelect={pick((s) => void navigator.clipboard?.writeText(snapshotUrl(s.token)))}
>
{t("snapshots.copyLink")}
</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pick(setDeleting)}>
{t("snapshots.delete")}
</MenuItem>
</ContextMenu>
)}
<ConfirmModal
open={deleting !== null}
title={t("snapshots.deleteTitle")}
message={t("snapshots.deleteBody")}
confirmLabel={t("snapshots.delete")}
cancelLabel={t("snapshots.cancel")}
busy={busy}
onConfirm={() => void remove()}
onCancel={() => setDeleting(null)}
/>
</SettingsSection>
);
}
@@ -0,0 +1,61 @@
/**
* @file PushIngestSettings.test.tsx
* @description Settings → Data → Push ingest and Privacy: a created token is
* shown once (a reload shows only that push is on), and the ignored-folder
* list is saved one path per line with blank lines dropped.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
const { pushToken, privacy } = vi.hoisted(() => ({
pushToken: { get: vi.fn(), rotate: vi.fn(), revoke: vi.fn() },
privacy: { get: vi.fn(), save: vi.fn() },
}));
vi.mock("../../../lib/api", () => ({ api: { settings: { pushToken, privacy } } }));
import { PushIngestSettings } from "../PushIngestSettings";
import { PrivacySettings } from "../PrivacySettings";
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
});
describe("PushIngestSettings", () => {
it("shows a new token once, and never on a fresh load", async () => {
const user = userEvent.setup();
pushToken.get.mockResolvedValue({ enabled: false, env_owned: false });
pushToken.rotate.mockResolvedValue({ token: "ahp_secret", enabled: true, env_owned: false });
const view = render(<PushIngestSettings />);
await user.click(await screen.findByRole("button", { name: /Create token/ }));
expect(await screen.findByText("ahp_secret")).toBeInTheDocument();
view.unmount();
pushToken.get.mockResolvedValue({ enabled: true, env_owned: false });
render(<PushIngestSettings />);
expect(await screen.findByRole("button", { name: /Rotate token/ })).toBeInTheDocument();
expect(screen.queryByText("ahp_secret")).toBeNull();
});
});
describe("PrivacySettings", () => {
it("saves the ignored folders one per line", async () => {
const user = userEvent.setup();
const state = {
redact_secrets: false,
ignore_cwd: [],
env_owned: { redact_secrets: false, ignore_cwd: false },
};
privacy.get.mockResolvedValue(state);
privacy.save.mockImplementation((p) => Promise.resolve({ ok: true, ...state, ...p }));
render(<PrivacySettings />);
const box = await screen.findByLabelText("Ignored folders");
await user.type(box, "/a/b{enter}{enter}/c");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(privacy.save).toHaveBeenCalledWith({ ignore_cwd: ["/a/b", "/c"] }));
});
});
@@ -0,0 +1,97 @@
/**
* @file ReportSettings.test.tsx
* @description Settings → Reports: a new report is saved with the browser's
* timezone offset, Run now runs it, and History lists the runs with download
* links built from the report and run ids.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
const { reports } = vi.hoisted(() => ({
reports: {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
run: vi.fn(),
runs: vi.fn(),
fileUrl: (id: number, runId: number, f: string) => `/api/reports/${id}/runs/${runId}/${f}`,
},
}));
vi.mock("../../../lib/api", () => ({ api: { reports } }));
import { ReportSettings } from "../ReportSettings";
const DEF = {
id: 3,
name: "Weekly summary",
window_days: 7,
frequency: "weekly" as const,
hour: 8,
weekday: 1,
tz_offset: 0,
formats: ["html" as const, "json" as const],
enabled: true,
created_at: "2026-10-01T00:00:00Z",
last_slot: null,
last_run: null,
};
const RUN = {
id: 9,
report_id: 3,
created_at: "2026-10-05T01:00:00Z",
trigger: "manual" as const,
status: "ok" as const,
window_from: "2026-09-28",
window_to: "2026-10-04",
files: ["html", "json"],
error: null,
};
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
});
describe("ReportSettings", () => {
it("creates a report with the browser's timezone offset", async () => {
const user = userEvent.setup();
reports.list.mockResolvedValueOnce({ reports: [] }).mockResolvedValue({ reports: [DEF] });
reports.create.mockResolvedValue({ report: DEF });
render(<ReportSettings />);
await user.click(await screen.findByRole("button", { name: /New report/ }));
await user.click(screen.getByRole("checkbox", { name: "JSON" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(reports.create).toHaveBeenCalledWith({
name: "Weekly summary",
window_days: 7,
frequency: "weekly",
hour: 8,
weekday: 1,
tz_offset: new Date().getTimezoneOffset(),
formats: ["html", "json"],
enabled: true,
})
);
expect(await screen.findByText(/last 7 days · HTML, JSON/)).toBeInTheDocument();
});
it("runs a report now and lists its runs with download links", async () => {
const user = userEvent.setup();
reports.list.mockResolvedValue({ reports: [DEF] });
reports.run.mockResolvedValue({ run: RUN });
reports.runs.mockResolvedValue({ runs: [RUN] });
render(<ReportSettings />);
await user.click(await screen.findByRole("button", { name: "Run now" }));
expect(reports.run).toHaveBeenCalledWith(3);
await user.click(screen.getByRole("button", { name: "History" }));
const link = await screen.findByRole("link", { name: /HTML/ });
expect(link).toHaveAttribute("href", "/api/reports/3/runs/9/html");
expect(screen.getByText(/2026-09-28 → 2026-10-04/)).toBeInTheDocument();
});
});
+33 -5
View File
@@ -7,20 +7,26 @@
"event_pattern": "Event pattern",
"inactivity": "Inactivity",
"status_duration": "Stuck agent",
"token_threshold": "Token threshold"
"token_threshold": "Token threshold",
"cost_threshold": "Cost threshold",
"budget": "Spend budget"
},
"ruleTypeHints": {
"event_pattern": "Fires when an ingested event matches the pattern. Set a count above 1 to require N matching events within a time window (e.g. more than 5 errors in 2 minutes).",
"inactivity": "Fires when an active session has received no events for the configured number of minutes.",
"status_duration": "Fires when an agent has remained in the selected status with no activity for the configured number of minutes.",
"token_threshold": "Fires when a session's total token usage (input + output + cache) exceeds the threshold."
"token_threshold": "Fires when a session's total token usage (input + output + cache) exceeds the threshold.",
"cost_threshold": "Fires when a session's cost reaches the amount, priced like the session's own cost.",
"budget": "Fires once per period at each threshold of a spend limit — per day, week or month in your local time, optionally for one AI tool."
},
"ruleDesc": {
"eventPattern": "Matches {{pattern}}",
"eventPatternCount": "≥{{count}} × {{pattern}} within {{window}} min",
"inactivity": "No session activity for {{minutes}} min",
"statusDuration": "Agent stuck in {{status}} for {{minutes}} min",
"tokenThreshold": "Session tokens exceed {{tokens}}"
"tokenThreshold": "Session tokens exceed {{tokens}}",
"costThreshold": "Session cost reaches {{usd}}",
"budget": "{{limit}} per {{period}} · alerts at {{thresholds}}"
},
"rules": {
"title": "Alert rules",
@@ -48,7 +54,13 @@
"agentStatus": "Agent status",
"minutes": "Minutes",
"totalTokens": "Total tokens",
"cooldown": "Cooldown (seconds)"
"cooldown": "Cooldown (seconds)",
"usd": "Cost (USD)",
"period": "Period",
"limitUsd": "Limit (USD)",
"thresholds": "Alert at (%)",
"budgetTool": "AI tool",
"allTools": "All AI tools"
},
"deleteTitle": "Delete alert rule?",
"help": {
@@ -63,8 +75,19 @@
"minutesInactivity": "Fire when an active session has had no events for this many minutes (a stalled or forgotten session).",
"minutesStatus": "Fire when an agent stays in the selected status with no activity for this many minutes (a stuck agent).",
"totalTokens": "Fire when a session's cumulative tokens (input + output + cache read + cache write) cross this number. e.g. 1000000 = 1M tokens.",
"cooldown": "Minimum seconds between repeat alerts for the same rule + session + agent. Prevents noisy duplicates. Default 300 (5 min); 0 = no cooldown."
"cooldown": "Minimum seconds between repeat alerts for the same rule + session + agent. Prevents noisy duplicates. Default 300 (5 min); 0 = no cooldown.",
"usd": "A session reaching this many US dollars fires the alert.",
"period": "Day starts at your local midnight, week on Monday, month on the 1st.",
"limitUsd": "The spend limit for one period, in US dollars.",
"thresholds": "Percents of the limit, comma-separated, e.g. 50, 80, 100. Each fires once per period.",
"budgetTool": "Count only this AI tool's sessions."
},
"preview": "Preview",
"previewing": "Checking…",
"previewHint": "What this rule would have fired over the last 7 days — nothing is saved or sent",
"previewResult": "Would have fired {{fires}} times in {{sessions}} sessions over the last {{days}} days.",
"previewBudget": "Spend per period (this one and the five before it):",
"previewUnsupported": "This rule type cannot be replayed from history.",
"edit": "Edit rule",
"save": "Save rule",
"typeFixed": "A rule's type cannot be changed — create a new rule instead.",
@@ -81,6 +104,11 @@
"emptyDescription": "Alerts fired by your rules will appear here in real time.",
"emptyUnacked": "Everything is acknowledged. New alerts will appear here as they fire."
},
"periods": {
"day": "day",
"week": "week",
"month": "month"
},
"errors": {
"loadRules": "Could not load alert rules: {{error}}",
"loadAlerts": "Could not load alerts: {{error}}",
+55
View File
@@ -51,6 +51,61 @@
"last7Days": "Last 7 days",
"less": "Less",
"more": "More",
"toolFilter": "AI tool",
"allTools": "All AI tools",
"costByTool": "Cost by AI tool",
"spendPace": "Spend pace",
"burnRate": "Burn rate (7-day avg / day)",
"burnRateHint": "Average cost per day over the last 7 days, today included",
"monthToDate": "Month to date",
"forecastMonth": "Forecast for this month",
"forecastHint": "Month to date plus the 7-day pace for each day left",
"budgets": "Budgets",
"noBudgets": "No spend budget yet.",
"addBudget": "Add one in Settings → Notifications",
"projected": "projected",
"projectedHint": "This period's spend at the current pace, carried to its end",
"range": "Date range",
"rangePreset": {
"today": "Today",
"week": "This week",
"month": "This month",
"7d": "Last 7 days",
"30d": "Last 30 days",
"90d": "Last 90 days",
"all": "All time",
"custom": "Custom"
},
"rangeFrom": "From",
"rangeTo": "To",
"query": {
"title": "Query Explorer",
"subtitle": "Filter sessions, events and agents, then export what you see.",
"entity": "Data",
"entities": {
"sessions": "Sessions",
"events": "Events",
"agents": "Agents"
},
"search": "Search",
"searchPlaceholder": "Search text…",
"status": "Status",
"allStatuses": "All statuses",
"cwd": "Folder",
"cwdPlaceholder": "/path/to/project",
"from": "From",
"to": "To",
"sortBy": "Sort by {{column}}",
"exportCsv": "Export CSV",
"exportJson": "Export JSON",
"exportNote": "An export holds the whole filter, up to {{cap}} rows.",
"exportTruncated": "{{total}} rows match; the export holds the first {{cap}}.",
"rows": "{{from}}–{{to}} of {{total}}",
"prev": "Previous page",
"next": "Next page",
"empty": "Nothing matches these filters.",
"reset": "Reset filters"
},
"tokensExCacheReads": "Tokens (excl. cache reads)",
"cacheReadsSub": "+{{reads}} cache reads · {{pct}}% hit rate",
"avgPerWeekday30d": "Average per weekday, last 30 days"
+8 -1
View File
@@ -67,6 +67,8 @@
"today": "Today {{cost}}",
"workspace": "Workspace {{cost}}",
"session": "Session {{cost}}",
"budget": "Budget {{pct}}%",
"budgetTitle": "{{name}}: {{spend}} of {{limit}} this period",
"tokens": "({{tokens}} Tokens)",
"quotaUsed": "{{pct}}% used",
"quotaTitle": "Usage",
@@ -80,10 +82,15 @@
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
"quotaSignInExpires": "Sign-in expires in {{time}}",
"quotaSignInExpired": "Sign-in expired",
"quotaWindow": { "wk": "Week", "mo": "Month" }
"quotaWindow": {
"wk": "Week",
"mo": "Month"
}
},
"version": "Version",
"query": "Query Explorer",
"openMenu": "Open menu",
"resizeSidebar": "Resize sidebar (double-click to reset)",
"closeMenu": "Close menu",
"sidebar": "Sidebar",
"now": "now"
+49
View File
@@ -54,6 +54,55 @@
"copyId": "Copy session ID",
"copyFolder": "Copy folder path"
},
"replay": {
"tab": "Replay",
"loading": "Loading events…",
"label": "Session replay",
"truncated": "Replaying the newest {{shown}} of {{total}} events.",
"start": "Jump to start (Home)",
"back": "Step back (←)",
"play": "Play (Space)",
"pause": "Pause (Space)",
"forward": "Step forward (→)",
"end": "Jump to end (End)",
"speed": "Playback speed",
"position": "{{at}} / {{n}}",
"scrubber": "Event position",
"minimap": "Lifecycle markers",
"marker": {
"SessionStart": "Session start",
"UserPromptSubmit": "Prompt",
"Compaction": "Compaction",
"SubagentStop": "Subagent done",
"APIError": "API error",
"Stop": "Turn end"
},
"state": {
"status": "Status",
"tool": "Running tool",
"prompts": "Prompts",
"errors": "Errors",
"compactions": "Compactions",
"elapsed": "Elapsed",
"toolCounts": "Tool calls so far"
}
},
"snapshot": {
"create": "Snapshot",
"hint": "Freeze this session into a read-only copy with a share link",
"title": "Create a snapshot",
"body": "Freezes this session as it is now — its agents, events, stats and cost — into a read-only copy under a private link. Later activity does not change it, and it survives Clear Data.",
"redactPaths": "Redact file paths (working directory, transcript and file paths)",
"confirm": "Create and copy link",
"cancel": "Cancel",
"open": "Open snapshot",
"notFound": "Snapshot not found",
"banner": "Snapshot taken {{date}}",
"bannerBody": "A read-only copy of the session as it was then. Nothing here updates.",
"redacted": "Paths redacted",
"openLive": "Open live session",
"timeline": "Timeline ({{n}})"
},
"exportCsv": "CSV",
"exportJson": "JSON",
"exportCapped": "The file holds {{shown}} of {{total}} sessions (the export limit) — narrow the filters to export the rest.",
+94
View File
@@ -303,6 +303,14 @@
"typeToConfirm": "Type “{{phrase}}” to confirm:",
"phrase": "clear all data"
},
"autostart": {
"title": "Startup",
"label": "Start AgentHub automatically",
"desc_systemd": "Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.",
"desc_launchd": "Run the dashboard as a LaunchAgent, so it starts at login without Claude Code being opened. Turning it off keeps the running server up.",
"unavailable": "Not available here: needs systemd --user on Linux, or a GUI login on macOS.",
"foreign": "A service named agenthub that AgentHub did not write already exists — AgentHub leaves it alone."
},
"about": {
"title": "About",
"description": "Server runtime information.",
@@ -561,6 +569,11 @@
"deleteAndPurge": "Remove + delete data",
"removeKeep": "Remove (keep data)",
"remoteSourceBadgeTitle": "Collected from a remote machine",
"fieldSyncCodex": "Also pull Codex sessions",
"fieldRemoteCodexHome": "Remote Codex home (optional)",
"fieldRemoteCodexHomeHint": "Default ~/.codex (wsl:~/.codex when the Claude home is in WSL). Its sessions/ folder is copied, and the sessions show up as Codex sessions of this source, with their tokens and cost.",
"codexOn": "+ Codex",
"syncCodex": "{{n}} Codex",
"fieldLabelPlaceholder": "Dev box"
},
"cursorPathsNote": "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).",
@@ -802,6 +815,14 @@
"about": {
"title": "About",
"description": "Server version, uptime and runtime."
},
"snapshots": {
"title": "Snapshots",
"description": "Frozen, read-only copies of sessions, each behind its own link."
},
"reports": {
"title": "Reports",
"description": "Analytics summaries written on a schedule, as HTML or JSON."
}
},
"backPrevious": "Back",
@@ -839,6 +860,79 @@
"enable": "Enable",
"disable": "Disable"
},
"snapshots": {
"title": "Session snapshots",
"description": "Created from a session's page with Snapshot. They survive Clear Data; delete them here.",
"empty": "No snapshots yet.",
"events": "{{n}} events",
"redacted": "Paths redacted",
"open": "Open",
"copyLink": "Copy link",
"delete": "Delete",
"deleteTitle": "Delete this snapshot?",
"deleteBody": "Its link stops working. The session itself is not touched.",
"cancel": "Cancel"
},
"pushIngest": {
"title": "Push ingest",
"description": "For a machine this dashboard cannot reach over SSH: it pushes its own hooks to POST /api/hooks/ingest-batch with a token made here. Its sessions are tagged with the name it sends (push:<name>) and show live status and events; their token cost needs the transcripts, which only an SSH source brings.",
"on": "On — a token exists",
"off": "Off — no token yet",
"create": "Create token",
"rotate": "Rotate token",
"revoke": "Revoke",
"rotateConfirm": "Make a new token? The current one stops working at once.",
"revokeConfirm": "Revoke the token and turn push ingest off?",
"shownOnce": "Copy this token now: only its hash is stored, so it is never shown again.",
"copy": "Copy",
"envOwned": "AGENTHUB_PUSH_TOKEN_SHA256 is set by the environment the dashboard started in; change it there.",
"reachHint": "The other machine must reach this dashboard: start it with DASHBOARD_HOST (and DASHBOARD_ALLOWED_HOSTS for its name). Over plain http the token crosses the network readable — use it only on a network you trust, or behind TLS. The forwarder below keeps the token in a header file, never on a command line:",
"limits": "At most 500 events and 1 MB per request. A hook for a session another source owns is refused; transcript paths are dropped (they name files on the sender). Codex hooks are taken; Antigravity hooks are not."
},
"privacy": {
"title": "Privacy",
"description": "What AgentHub keeps from hooks and imported history. Both are off by default and apply to new data only.",
"redact": "Redact secrets before storing",
"redactHint": "API keys and tokens of known shapes, bearer headers, private keys and password=/TOKEN= values in event payloads and summaries become [redacted]. The transcripts on disk are the CLIs' own files and are not changed.",
"ignore": "Ignored folders",
"ignoreHint": "One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.",
"envOwned": "A value here is set by the environment the dashboard started in, and cannot be changed from this page."
},
"reports": {
"title": "Scheduled reports",
"description": "A summary of the last days — sessions, events, tokens, cost by model and by AI tool, top tools, budgets — written as HTML or JSON on a schedule. Reports and their history survive Clear Data.",
"add": "New report",
"defaultName": "Weekly summary",
"empty": "No scheduled report yet.",
"name": "Name",
"windowDays": "Days covered",
"frequency": "Frequency",
"daily": "Daily",
"weekly": "Weekly",
"weekday": "Day",
"hour": "Time",
"windowHint": "A report covers the full days before the day it runs, at this browser's time zone.",
"save": "Save",
"cancel": "Cancel",
"enabled": "Enabled",
"runNow": "Run now",
"history": "History",
"edit": "Edit",
"delete": "Delete",
"everyDay": "Every day at {{time}}",
"everyWeek": "Every {{day}} at {{time}}",
"window_one": "last {{count}} day",
"window_other": "last {{count}} days",
"lastRun": "Last run {{at}}",
"noRuns": "No runs yet.",
"trigger": {
"schedule": "Scheduled",
"manual": "Manual"
},
"download": "Download {{format}}",
"deleteTitle": "Delete this report?",
"deleteBody": "Its schedule, its run history and the files it wrote are deleted."
},
"prefsBackup": {
"title": "This browser's preferences",
"description": "Theme, language, notification settings, pet, sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.",
+33 -5
View File
@@ -7,20 +7,26 @@
"event_pattern": "Mẫu sự kiện",
"inactivity": "Không hoạt động",
"status_duration": "Agent bị kẹt",
"token_threshold": "Ngưỡng token"
"token_threshold": "Ngưỡng token",
"cost_threshold": "Ngưỡng chi phí",
"budget": "Ngân sách chi tiêu"
},
"ruleTypeHints": {
"event_pattern": "Kích hoạt khi một sự kiện khớp với mẫu. Đặt số lần lớn hơn 1 để yêu cầu N sự kiện khớp trong khung thời gian (ví dụ: hơn 5 lỗi trong 2 phút).",
"inactivity": "Kích hoạt khi một phiên đang hoạt động không nhận được sự kiện nào trong số phút đã cấu hình.",
"status_duration": "Kích hoạt khi một agent giữ nguyên trạng thái đã chọn mà không có hoạt động trong số phút đã cấu hình.",
"token_threshold": "Kích hoạt khi tổng mức dùng token của phiên (đầu vào + đầu ra + bộ nhớ đệm) vượt ngưỡng."
"token_threshold": "Kích hoạt khi tổng mức dùng token của phiên (đầu vào + đầu ra + bộ nhớ đệm) vượt ngưỡng.",
"cost_threshold": "Kích hoạt khi chi phí của một phiên chạm mức này, tính giá như chi phí riêng của phiên.",
"budget": "Kích hoạt một lần mỗi kỳ ở từng ngưỡng của hạn mức chi tiêu — theo ngày, tuần hoặc tháng theo giờ địa phương, có thể chỉ cho một công cụ AI."
},
"ruleDesc": {
"eventPattern": "Khớp {{pattern}}",
"eventPatternCount": "≥{{count}} × {{pattern}} trong {{window}} phút",
"inactivity": "Phiên không hoạt động trong {{minutes}} phút",
"statusDuration": "Agent kẹt ở trạng thái {{status}} trong {{minutes}} phút",
"tokenThreshold": "Token của phiên vượt {{tokens}}"
"tokenThreshold": "Token của phiên vượt {{tokens}}",
"costThreshold": "Chi phí phiên chạm {{usd}}",
"budget": "{{limit}} mỗi {{period}} · cảnh báo ở {{thresholds}}"
},
"rules": {
"title": "Quy tắc cảnh báo",
@@ -48,7 +54,13 @@
"agentStatus": "Trạng thái agent",
"minutes": "Phút",
"totalTokens": "Tổng token",
"cooldown": "Thời gian chờ (giây)"
"cooldown": "Thời gian chờ (giây)",
"usd": "Chi phí (USD)",
"period": "Kỳ",
"limitUsd": "Hạn mức (USD)",
"thresholds": "Cảnh báo ở (%)",
"budgetTool": "Công cụ AI",
"allTools": "Mọi công cụ AI"
},
"deleteTitle": "Xóa quy tắc cảnh báo?",
"help": {
@@ -63,8 +75,19 @@
"minutesInactivity": "Kích hoạt khi một phiên đang hoạt động không có sự kiện nào trong bấy nhiêu phút (phiên bị treo hoặc bị quên).",
"minutesStatus": "Kích hoạt khi agent ở trạng thái đã chọn mà không có hoạt động trong bấy nhiêu phút (agent bị kẹt).",
"totalTokens": "Kích hoạt khi tổng token của phiên (input + output + cache read + cache write) vượt số này. vd 1000000 = 1 triệu token.",
"cooldown": "Số giây tối thiểu giữa các cảnh báo lặp cho cùng quy tắc + phiên + agent. Tránh trùng lặp gây nhiễu. Mặc định 300 (5 phút); 0 = không có thời gian chờ."
"cooldown": "Số giây tối thiểu giữa các cảnh báo lặp cho cùng quy tắc + phiên + agent. Tránh trùng lặp gây nhiễu. Mặc định 300 (5 phút); 0 = không có thời gian chờ.",
"usd": "Một phiên chạm số đô la này sẽ kích hoạt cảnh báo.",
"period": "Ngày bắt đầu lúc nửa đêm giờ địa phương, tuần từ thứ Hai, tháng từ ngày 1.",
"limitUsd": "Hạn mức chi tiêu cho một kỳ, tính bằng đô la Mỹ.",
"thresholds": "Phần trăm hạn mức, cách nhau bằng dấu phẩy, vd 50, 80, 100. Mỗi ngưỡng kích hoạt một lần mỗi kỳ.",
"budgetTool": "Chỉ tính các phiên của công cụ AI này."
},
"preview": "Xem trước",
"previewing": "Đang kiểm tra…",
"previewHint": "Quy tắc này lẽ ra đã kích hoạt những gì trong 7 ngày qua — không lưu, không gửi gì",
"previewResult": "Lẽ ra đã kích hoạt {{fires}} lần ở {{sessions}} phiên trong {{days}} ngày qua.",
"previewBudget": "Chi tiêu mỗi kỳ (kỳ này và năm kỳ trước):",
"previewUnsupported": "Loại quy tắc này không phát lại được từ lịch sử.",
"edit": "Sửa quy tắc",
"save": "Lưu quy tắc",
"typeFixed": "Không đổi được loại của quy tắc — hãy tạo quy tắc mới.",
@@ -81,6 +104,11 @@
"emptyDescription": "Cảnh báo do các quy tắc của bạn kích hoạt sẽ hiển thị tại đây theo thời gian thực.",
"emptyUnacked": "Tất cả đã được xác nhận. Cảnh báo mới sẽ hiển thị tại đây khi được kích hoạt."
},
"periods": {
"day": "ngày",
"week": "tuần",
"month": "tháng"
},
"errors": {
"loadRules": "Không tải được quy tắc cảnh báo: {{error}}",
"loadAlerts": "Không tải được cảnh báo: {{error}}",
+55
View File
@@ -51,6 +51,61 @@
"last7Days": "7 ngày gần nhất",
"less": "Ít",
"more": "Nhiều",
"toolFilter": "Công cụ AI",
"allTools": "Mọi công cụ AI",
"costByTool": "Chi phí theo công cụ AI",
"spendPace": "Nhịp chi tiêu",
"burnRate": "Tốc độ đốt (TB 7 ngày / ngày)",
"burnRateHint": "Chi phí trung bình mỗi ngày trong 7 ngày qua, tính cả hôm nay",
"monthToDate": "Từ đầu tháng",
"forecastMonth": "Dự báo cả tháng",
"forecastHint": "Từ đầu tháng cộng nhịp 7 ngày cho mỗi ngày còn lại",
"budgets": "Ngân sách",
"noBudgets": "Chưa có ngân sách chi tiêu.",
"addBudget": "Thêm trong Cài đặt → Thông báo",
"projected": "dự kiến",
"projectedHint": "Chi tiêu kỳ này theo nhịp hiện tại, kéo đến cuối kỳ",
"range": "Khoảng thời gian",
"rangePreset": {
"today": "Hôm nay",
"week": "Tuần này",
"month": "Tháng này",
"7d": "7 ngày qua",
"30d": "30 ngày qua",
"90d": "90 ngày qua",
"all": "Toàn bộ",
"custom": "Tùy chọn"
},
"rangeFrom": "Từ ngày",
"rangeTo": "Đến ngày",
"query": {
"title": "Truy vấn dữ liệu",
"subtitle": "Lọc phiên, sự kiện và agent, rồi xuất đúng những gì đang thấy.",
"entity": "Dữ liệu",
"entities": {
"sessions": "Phiên",
"events": "Sự kiện",
"agents": "Agent"
},
"search": "Tìm kiếm",
"searchPlaceholder": "Tìm văn bản…",
"status": "Trạng thái",
"allStatuses": "Mọi trạng thái",
"cwd": "Thư mục",
"cwdPlaceholder": "/duong/dan/du-an",
"from": "Từ ngày",
"to": "Đến ngày",
"sortBy": "Sắp xếp theo {{column}}",
"exportCsv": "Xuất CSV",
"exportJson": "Xuất JSON",
"exportNote": "Tệp xuất chứa toàn bộ kết quả lọc, tối đa {{cap}} dòng.",
"exportTruncated": "{{total}} dòng khớp; tệp xuất chỉ chứa {{cap}} dòng đầu.",
"rows": "{{from}}–{{to}} / {{total}}",
"prev": "Trang trước",
"next": "Trang sau",
"empty": "Không có dữ liệu khớp bộ lọc.",
"reset": "Đặt lại bộ lọc"
},
"tokensExCacheReads": "Token (trừ đọc cache)",
"cacheReadsSub": "+{{reads}} token đọc cache · tỉ lệ trúng {{pct}}%",
"avgPerWeekday30d": "Trung bình theo thứ trong tuần, 30 ngày qua"
+8 -1
View File
@@ -67,6 +67,8 @@
"today": "Hôm nay {{cost}}",
"workspace": "Workspace {{cost}}",
"session": "Session {{cost}}",
"budget": "Ngân sách {{pct}}%",
"budgetTitle": "{{name}}: {{spend}} / {{limit}} kỳ này",
"tokens": "({{tokens}} Tokens)",
"quotaUsed": "đã dùng {{pct}}%",
"quotaTitle": "Mức dùng",
@@ -80,10 +82,15 @@
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
"quotaSignInExpired": "Đăng nhập đã hết hạn",
"quotaWindow": { "wk": "Tuần", "mo": "Tháng" }
"quotaWindow": {
"wk": "Tuần",
"mo": "Tháng"
}
},
"version": "Phiên bản",
"query": "Truy vấn",
"openMenu": "Mở menu",
"resizeSidebar": "Kéo để đổi độ rộng sidebar (nhấp đúp để đặt lại)",
"closeMenu": "Đóng menu",
"sidebar": "Thanh bên",
"now": "bây giờ"
+49
View File
@@ -53,6 +53,55 @@
"copyId": "Sao chép ID session",
"copyFolder": "Sao chép đường dẫn thư mục"
},
"replay": {
"tab": "Phát lại",
"loading": "Đang tải sự kiện…",
"label": "Phát lại phiên",
"truncated": "Đang phát lại {{shown}} sự kiện mới nhất trong {{total}}.",
"start": "Về đầu (Home)",
"back": "Lùi một bước (←)",
"play": "Phát (Space)",
"pause": "Tạm dừng (Space)",
"forward": "Tiến một bước (→)",
"end": "Đến cuối (End)",
"speed": "Tốc độ phát",
"position": "{{at}} / {{n}}",
"scrubber": "Vị trí sự kiện",
"minimap": "Mốc vòng đời",
"marker": {
"SessionStart": "Bắt đầu phiên",
"UserPromptSubmit": "Prompt",
"Compaction": "Nén ngữ cảnh",
"SubagentStop": "Subagent xong",
"APIError": "Lỗi API",
"Stop": "Kết thúc lượt"
},
"state": {
"status": "Trạng thái",
"tool": "Công cụ đang chạy",
"prompts": "Prompt",
"errors": "Lỗi",
"compactions": "Lần nén",
"elapsed": "Đã trôi qua",
"toolCounts": "Số lần gọi công cụ đến lúc này"
}
},
"snapshot": {
"create": "Snapshot",
"hint": "Đóng băng phiên này thành một bản chỉ đọc kèm liên kết chia sẻ",
"title": "Tạo snapshot",
"body": "Đóng băng phiên như hiện tại — agent, sự kiện, thống kê và chi phí — thành một bản chỉ đọc dưới một liên kết riêng. Hoạt động sau đó không làm nó thay đổi, và nó vẫn còn sau khi Xóa dữ liệu.",
"redactPaths": "Che đường dẫn (thư mục làm việc, transcript và đường dẫn tệp)",
"confirm": "Tạo và sao chép liên kết",
"cancel": "Hủy",
"open": "Mở snapshot",
"notFound": "Không tìm thấy snapshot",
"banner": "Snapshot chụp lúc {{date}}",
"bannerBody": "Bản chỉ đọc của phiên tại thời điểm đó. Không có gì ở đây được cập nhật.",
"redacted": "Đã che đường dẫn",
"openLive": "Mở phiên đang có",
"timeline": "Dòng thời gian ({{n}})"
},
"exportCsv": "CSV",
"exportJson": "JSON",
"exportCapped": "Tệp chỉ gồm {{shown}} trên {{total}} phiên (giới hạn xuất) — hãy thu hẹp bộ lọc để xuất phần còn lại.",
+94
View File
@@ -303,6 +303,14 @@
"typeToConfirm": "Gõ “{{phrase}}” để xác nhận:",
"phrase": "xóa toàn bộ dữ liệu"
},
"autostart": {
"title": "Khởi động",
"label": "Tự động khởi động AgentHub",
"desc_systemd": "Chạy dashboard bằng systemd --user service để nó tự bật khi khởi động máy, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
"desc_launchd": "Chạy dashboard bằng LaunchAgent để nó tự bật khi đăng nhập, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
"unavailable": "Không khả dụng ở đây: cần systemd --user trên Linux, hoặc phiên đăng nhập GUI trên macOS.",
"foreign": "Đã có một service tên agenthub không do AgentHub tạo — AgentHub không đụng vào."
},
"about": {
"title": "Giới thiệu",
"description": "Thông tin runtime của máy chủ.",
@@ -561,6 +569,11 @@
"deleteAndPurge": "Xóa + xóa dữ liệu",
"removeKeep": "Xóa (giữ dữ liệu)",
"remoteSourceBadgeTitle": "Được thu thập từ một máy từ xa",
"fieldSyncCodex": "Kéo cả phiên Codex",
"fieldRemoteCodexHome": "Thư mục Codex trên máy từ xa (tùy chọn)",
"fieldRemoteCodexHomeHint": "Mặc định ~/.codex (wsl:~/.codex khi thư mục Claude nằm trong WSL). Thư mục sessions/ của nó được sao chép về, và các phiên hiện ra như phiên Codex của nguồn này, kèm token và chi phí.",
"codexOn": "+ Codex",
"syncCodex": "{{n}} Codex",
"fieldLabelPlaceholder": "Máy dev"
},
"cursorPathsNote": "Chỉ mang tính thông tin: phiên Cursor cũng được tính — Cursor tình cờ dùng cùng đường dẫn ~/.claude với Claude Code (cục bộ và trên máy từ xa đã đồng bộ).",
@@ -802,6 +815,14 @@
"about": {
"title": "Giới thiệu",
"description": "Phiên bản máy chủ, thời gian hoạt động và môi trường chạy."
},
"snapshots": {
"title": "Snapshot",
"description": "Các bản sao chỉ đọc, đã đóng băng của phiên, mỗi bản có liên kết riêng."
},
"reports": {
"title": "Báo cáo",
"description": "Bản tóm tắt phân tích ghi ra theo lịch, dạng HTML hoặc JSON."
}
},
"backPrevious": "Quay lại",
@@ -839,6 +860,79 @@
"enable": "Bật",
"disable": "Tắt"
},
"snapshots": {
"title": "Snapshot phiên",
"description": "Tạo từ trang của phiên bằng nút Snapshot. Chúng vẫn còn sau khi Xóa dữ liệu; xóa chúng ở đây.",
"empty": "Chưa có snapshot nào.",
"events": "{{n}} sự kiện",
"redacted": "Đã che đường dẫn",
"open": "Mở",
"copyLink": "Sao chép liên kết",
"delete": "Xóa",
"deleteTitle": "Xóa snapshot này?",
"deleteBody": "Liên kết của nó sẽ không dùng được nữa. Bản thân phiên không bị ảnh hưởng.",
"cancel": "Hủy"
},
"pushIngest": {
"title": "Đẩy dữ liệu về (push ingest)",
"description": "Cho máy mà dashboard không SSH tới được: máy đó tự đẩy hook của nó tới POST /api/hooks/ingest-batch bằng token tạo ở đây. Các phiên được gắn tên máy gửi lên (push:<tên>) và có trạng thái, sự kiện trực tiếp; chi phí token cần transcript, thứ chỉ nguồn SSH mang về.",
"on": "Đang bật — đã có token",
"off": "Đang tắt — chưa có token",
"create": "Tạo token",
"rotate": "Đổi token",
"revoke": "Thu hồi",
"rotateConfirm": "Tạo token mới? Token hiện tại ngừng hoạt động ngay.",
"revokeConfirm": "Thu hồi token và tắt push ingest?",
"shownOnce": "Hãy sao chép token này ngay: chỉ bản băm của nó được lưu, nên nó không bao giờ hiện lại.",
"copy": "Sao chép",
"envOwned": "AGENTHUB_PUSH_TOKEN_SHA256 được đặt bởi môi trường khởi động dashboard; hãy đổi nó ở đó.",
"reachHint": "Máy kia phải truy cập được dashboard này: khởi động với DASHBOARD_HOST (và DASHBOARD_ALLOWED_HOSTS cho tên của nó). Qua http thường, token đi trên mạng ở dạng đọc được — chỉ dùng trong mạng tin cậy, hoặc sau TLS. Script chuyển tiếp dưới đây giữ token trong một tệp header, không bao giờ trên dòng lệnh:",
"limits": "Tối đa 500 sự kiện và 1 MB mỗi yêu cầu. Hook cho phiên thuộc nguồn khác bị từ chối; đường dẫn transcript bị bỏ (chúng trỏ tới tệp trên máy gửi). Nhận hook Codex; không nhận hook Antigravity."
},
"privacy": {
"title": "Quyền riêng tư",
"description": "Những gì AgentHub giữ lại từ hook và lịch sử nhập vào. Cả hai mặc định tắt và chỉ áp dụng cho dữ liệu mới.",
"redact": "Che bí mật trước khi lưu",
"redactHint": "Khóa API và token có dạng đã biết, header bearer, khóa riêng và giá trị password=/TOKEN= trong payload và tóm tắt sự kiện được thay bằng [redacted]. Transcript trên đĩa là tệp của chính CLI và không bị thay đổi.",
"ignore": "Thư mục bỏ qua",
"ignoreHint": "Mỗi dòng một đường dẫn tuyệt đối. Hook từ các thư mục này, và mọi thứ bên trong, bị bỏ hoàn toàn; việc nhập lịch sử và bộ đọc Codex cũng bỏ qua phiên của chúng. AGENTHUB_IGNORE_CWD đặt cùng danh sách này.",
"envOwned": "Một giá trị ở đây do môi trường khởi động dashboard đặt, và không thể đổi từ trang này."
},
"reports": {
"title": "Báo cáo định kỳ",
"description": "Bản tóm tắt những ngày gần nhất — phiên, sự kiện, token, chi phí theo model và theo công cụ AI, công cụ dùng nhiều nhất, ngân sách — ghi ra HTML hoặc JSON theo lịch. Báo cáo và lịch sử chạy được giữ lại khi Xóa dữ liệu.",
"add": "Báo cáo mới",
"defaultName": "Tóm tắt hằng tuần",
"empty": "Chưa có báo cáo định kỳ nào.",
"name": "Tên",
"windowDays": "Số ngày",
"frequency": "Tần suất",
"daily": "Hằng ngày",
"weekly": "Hằng tuần",
"weekday": "Ngày",
"hour": "Giờ",
"windowHint": "Báo cáo gồm các ngày trọn vẹn trước ngày chạy, theo múi giờ của trình duyệt này.",
"save": "Lưu",
"cancel": "Hủy",
"enabled": "Bật",
"runNow": "Chạy ngay",
"history": "Lịch sử",
"edit": "Sửa",
"delete": "Xóa",
"everyDay": "Mỗi ngày lúc {{time}}",
"everyWeek": "Mỗi {{day}} lúc {{time}}",
"window_one": "{{count}} ngày gần nhất",
"window_other": "{{count}} ngày gần nhất",
"lastRun": "Lần chạy gần nhất {{at}}",
"noRuns": "Chưa chạy lần nào.",
"trigger": {
"schedule": "Theo lịch",
"manual": "Thủ công"
},
"download": "Tải {{format}}",
"deleteTitle": "Xóa báo cáo này?",
"deleteBody": "Lịch chạy, lịch sử chạy và các tệp nó đã ghi sẽ bị xóa."
},
"prefsBackup": {
"title": "Tùy chọn của trình duyệt này",
"description": "Giao diện, ngôn ngữ, cài đặt thông báo, thú cưng, thanh bên và bố cục không gian làm việc được lưu trong trình duyệt này. Lưu chúng ra tệp để chuyển sang trình duyệt khác, hoặc khôi phục từ tệp.",
+26
View File
@@ -0,0 +1,26 @@
/**
* @file Tests for spendPace — the 7-day burn rate, month to date and the
* month-end forecast built from the daily cost series.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { spendPace } from "../burnRate";
describe("spendPace", () => {
it("averages the last 7 days and carries the pace to month end", () => {
const daily = [
{ date: "2026-09-28", cost: 70 }, // before the 7-day window (09-29..10-05)
{ date: "2026-09-30", cost: 7 }, // inside the window, last month
{ date: "2026-10-01", cost: 7 },
{ date: "2026-10-05", cost: 14 },
{ date: "2026-10-06", cost: 99 }, // after today: ignored
];
const p = spendPace(daily, "2026-10-05");
expect(p.perDay7).toBe(4); // (7 + 7 + 14) / 7
expect(p.monthToDate).toBe(21);
expect(p.forecastMonth).toBe(21 + 4 * 26); // 26 days left in October
});
it("is all zero with no spend", () => {
expect(spendPace([], "2026-02-28")).toEqual({ perDay7: 0, monthToDate: 0, forecastMonth: 0 });
});
});
@@ -0,0 +1,47 @@
/**
* @file dateRange.test.ts
* @description The Analytics range presets: `?range=` parsing, a preset's
* local bounds (week from Monday, month from the 1st), a custom range, and the query params `applyRange` sets.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { applyRange, parsePreset, rangeDates } from "../dateRange";
describe("dateRange", () => {
const today = new Date(2026, 9, 5, 12); // local 2026-10-05
it("parses presets, defaulting to all", () => {
expect(parsePreset("30d")).toBe("30d");
expect(parsePreset("1y")).toBe("all");
expect(parsePreset(null)).toBe("all");
});
it("gives a preset's local bounds, today included", () => {
expect(rangeDates("7d", {}, today)).toEqual({ from: "2026-09-29", to: "2026-10-05" });
expect(rangeDates("90d", {}, today).from).toBe("2026-07-08");
expect(rangeDates("all", {}, today)).toEqual({});
});
it("starts the week on Monday and the month on the 1st", () => {
const wed = new Date(2026, 9, 7, 12);
const sun = new Date(2026, 9, 11, 12);
expect(rangeDates("today", {}, wed)).toEqual({ from: "2026-10-07", to: "2026-10-07" });
expect(rangeDates("week", {}, wed)).toEqual({ from: "2026-10-05", to: "2026-10-07" });
expect(rangeDates("week", {}, sun)).toEqual({ from: "2026-10-05", to: "2026-10-11" });
expect(rangeDates("week", {}, today)).toEqual({ from: "2026-10-05", to: "2026-10-05" });
expect(rangeDates("month", {}, wed)).toEqual({ from: "2026-10-01", to: "2026-10-07" });
expect(rangeDates("custom", { from: "2026-01-01", to: "" }, today)).toEqual({
from: "2026-01-01",
to: undefined,
});
});
it("sets from/to and tz_offset only when there is a bound", () => {
expect(applyRange(new URLSearchParams(), {}).toString()).toBe("");
const qs = applyRange(new URLSearchParams(), { from: "2026-01-01" });
expect(qs.get("from")).toBe("2026-01-01");
expect(qs.has("to")).toBe(false);
expect(qs.get("tz_offset")).toBe(String(new Date().getTimezoneOffset()));
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* @file replay.test.ts
* @description Unit tests for the Replay tab's pure derivations: playback
* order, lifecycle markers and the session state at a cursor.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { chronological, lifecycleMarkers, replayState } from "../replay";
import type { DashboardEvent } from "../types";
let nextId = 1;
function ev(event_type: string, sec: number, tool_name: string | null = null, agent = "main") {
return {
id: nextId++,
session_id: "s",
agent_id: agent,
event_type,
tool_name,
summary: null,
data: null,
created_at: new Date(Date.UTC(2026, 9, 1, 10, 0, sec)).toISOString(),
} as unknown as DashboardEvent;
}
const events = [
ev("SessionStart", 0),
ev("UserPromptSubmit", 1),
ev("PreToolUse", 2, "Read"),
ev("PostToolUse", 3, "Read"),
ev("PreToolUse", 4, "Bash"),
ev("PreToolUse", 5, "Read", "sub"),
ev("APIError", 6),
ev("PostToolUse", 7, "Bash"),
ev("Stop", 8),
];
describe("chronological", () => {
it("orders by time, then id", () => {
const tie = [ev("Stop", 9), ev("Stop", 9)];
const out = chronological([tie[1]!, events[2]!, tie[0]!, events[0]!]);
expect(out.map((e) => e.id)).toEqual([events[0]!.id, events[2]!.id, tie[0]!.id, tie[1]!.id]);
});
});
describe("lifecycleMarkers", () => {
it("marks lifecycle events at their sequence position", () => {
const m = lifecycleMarkers(events);
expect(m.map((x) => x.type)).toEqual(["SessionStart", "UserPromptSubmit", "APIError", "Stop"]);
expect(m[0]!.pct).toBe(0);
expect(m[3]!.pct).toBe(100);
expect(m[2]!.index).toBe(6);
});
it("handles a single event", () => {
expect(lifecycleMarkers([events[0]!])).toEqual([{ index: 0, type: "SessionStart", pct: 0 }]);
});
});
describe("replayState", () => {
it("is empty for no events", () => {
const s = replayState([], 0);
expect(s.status).toBeNull();
expect(s.currentTool).toBeNull();
expect(s.toolCounts).toEqual([]);
});
it("tracks the tool in flight and counts up to the cursor", () => {
const at4 = replayState(events, 4);
expect(at4.currentTool).toBe("Bash");
expect(at4.status).toBe("working");
expect(at4.prompts).toBe(1);
expect(at4.toolCounts).toEqual([
{ tool: "Bash", count: 1 },
{ tool: "Read", count: 1 },
]);
expect(at4.elapsedMs).toBe(4000);
const at6 = replayState(events, 6);
expect(at6.currentTool).toBe("Read"); // the subagent's, newest still open
expect(at6.errors).toBe(1);
expect(at6.status).toBe("error");
expect(at6.toolCounts[0]).toEqual({ tool: "Read", count: 2 });
});
it("closes a finished tool and an ended turn", () => {
expect(replayState(events, 3).currentTool).toBeNull();
// Stop on main leaves only the subagent's Read open.
expect(replayState(events, 8).currentTool).toBe("Read");
expect(replayState(events, 8).status).toBe("waiting");
});
it("clamps a cursor past the end", () => {
expect(replayState(events, 99).elapsedMs).toBe(8000);
});
});
+168 -3
View File
@@ -387,6 +387,8 @@ import type {
AlertEvent,
AlertRule,
Analytics,
BudgetStatus,
RulePreview,
CodexHooksStatus,
AntigravityHooksStatus,
CostResult,
@@ -424,10 +426,17 @@ import type {
WorkflowRun,
WorkflowRunsResponse,
WorkflowRunDetail,
SessionSnapshotInfo,
SessionSnapshotPayload,
QueryPage,
ReportDefinition,
ReportInput,
ReportRun,
} from "./types";
import { activeSourcesParam } from "./dataScope";
import i18n from "i18next";
import { applyRange, type DateRange } from "./dateRange";
// Root path all endpoint paths are appended to. Kept relative (no host) so the
// same client bundle works behind the Vite dev proxy and in same-origin prod.
@@ -979,10 +988,15 @@ export const api = {
* day, the viewer's timezone offset is sent so the daily buckets align to
* the user's local midnight.
*
* @param opts.tool Only sessions of this AI tool (`sessions.tool`).
* @param opts.range Local `from`/`to` days: counts, tokens and cost follow
* it; the daily series keep their own window.
* @returns {@link Analytics} — the chart-ready analytics payload.
*/
get: () => {
get: (opts: { tool?: string; range?: DateRange } = {}) => {
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
if (opts.tool) qs.set("tool", opts.tool);
applyRange(qs, opts.range);
applyScope(qs);
return request<Analytics>(`/analytics?${qs.toString()}`);
},
@@ -1100,6 +1114,41 @@ export const api = {
/** DELETE /api/settings/codex-hooks — AgentHub's own hooks only */
remove: () => request<CodexHooksStatus>("/settings/codex-hooks", { method: "DELETE" }),
},
/** Start the dashboard at boot/login (systemd `--user` / LaunchAgent) — off
* by default; a change never starts or stops the running server. */
autostart: {
/** GET /api/settings/autostart */
get: () => request<AutostartStatus>("/settings/autostart"),
/** PUT /api/settings/autostart */
set: (enabled: boolean) =>
request<AutostartStatus>("/settings/autostart", {
method: "PUT",
body: JSON.stringify({ enabled }),
}),
},
/** Opt-in secret redaction of stored payloads and the folders whose hooks
* are dropped (`server/lib/privacy.js`). */
privacy: {
/** GET /api/settings/privacy */
get: () => request<PrivacySettings>("/settings/privacy"),
/** PUT /api/settings/privacy — any subset of the two fields */
save: (data: { redact_secrets?: boolean; ignore_cwd?: string[] }) =>
request<PrivacySettings & { ok: boolean }>("/settings/privacy", {
method: "PUT",
body: JSON.stringify(data),
}),
},
/** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is
* stored: `rotate` returns the new token this once, `get` never does. */
pushToken: {
/** GET /api/settings/push-token */
get: () => request<PushTokenStatus>("/settings/push-token"),
/** POST /api/settings/push-token — a new token; the old one stops working */
rotate: () =>
request<PushTokenStatus & { token: string }>("/settings/push-token", { method: "POST" }),
/** DELETE /api/settings/push-token — push ingest off */
revoke: () => request<PushTokenStatus>("/settings/push-token", { method: "DELETE" }),
},
/** AgentHub's named hook in `~/.gemini/config/hooks.json` for agy — changed
* only on a click; agy runs it with no trust step. */
antigravityHooks: {
@@ -1256,6 +1305,8 @@ export const api = {
* "all" (which means "no filter"), keeping the default URL param-free.
*
* @param status Optional lifecycle filter; "all" (or omitted) means no filter.
* @param tool Optional AI tool id (`sessions.tool`).
* @param range Optional local date range: sessions started in it.
* @param sections Optional subset of the bundle to compute (`?sections=`);
* the server skips the rest, so a page reading a few cards asks for those.
* @returns {@link WorkflowData} — the aggregated workflow-intelligence panel,
@@ -1263,10 +1314,14 @@ export const api = {
*/
get: <K extends keyof WorkflowData = keyof WorkflowData>(
status?: string,
tool?: string,
range?: DateRange,
sections?: readonly K[]
) => {
const qs = new URLSearchParams();
if (status && status !== "all") qs.set("status", status);
if (tool) qs.set("tool", tool);
applyRange(qs, range);
if (sections?.length) qs.set("sections", sections.join(","));
const q = qs.toString();
return request<Pick<WorkflowData, K>>(`/workflows${q ? `?${q}` : ""}`);
@@ -1381,15 +1436,18 @@ export const api = {
*
* @returns {@link CostResult} — the aggregate cost breakdown across sessions.
*/
totalCost: (opts: { lane?: number; date?: string } = {}) => {
totalCost: (opts: { lane?: number; date?: string; tool?: string; range?: DateRange } = {}) => {
// Scope the aggregate to the active data-scope, exactly like the sessions /
// stats / analytics endpoints — otherwise switching the Data scope selector
// left the Dashboard "total cost" showing the un-narrowed global total.
// `lane`: only the sessions that workspace owns.
// `lane`: only the sessions that workspace owns; `tool`: one AI tool's.
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
if (opts.lane !== undefined) qs.set("lane", String(opts.lane));
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
if (opts.date) qs.set("date", opts.date);
if (opts.tool) qs.set("tool", opts.tool);
// `range`: total, breakdown and by_tool for those local days only.
applyRange(qs, opts.range);
applyScope(qs);
return request<CostResult>(`/pricing/cost?${qs.toString()}`);
},
@@ -2066,6 +2124,24 @@ export const api = {
{ method: "DELETE" }
),
},
/** Frozen session copies (`/api/snapshots`, `server/routes/snapshots.js`). */
snapshots: {
list: () => request<{ snapshots: SessionSnapshotInfo[] }>("/snapshots"),
/** Freeze a session; `filePaths` redacts paths before anything is stored. */
create: (sessionId: string, opts?: { filePaths?: boolean }) =>
request<{ snapshot: SessionSnapshotInfo }>("/snapshots", {
method: "POST",
body: JSON.stringify({
session_id: sessionId,
redact: { file_paths: opts?.filePaths === true },
}),
}),
get: (token: string) =>
request<{ snapshot: SessionSnapshotInfo; payload: SessionSnapshotPayload }>(
`/snapshots/${encodeURIComponent(token)}`
),
remove: (id: number) => request<{ ok: true }>(`/snapshots/${id}`, { method: "DELETE" }),
},
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
pets: {
list: () => request<PetList>("/pets"),
@@ -2329,9 +2405,65 @@ export const api = {
*/
remove: (id: string) =>
request<{ ok: true }>(`/alerts/rules/${encodeURIComponent(id)}`, { method: "DELETE" }),
/**
* POST /api/alerts/rules/preview - what a rule WOULD have fired over the
* last `days` (default 7) of recorded history. Saves and fires nothing.
*/
preview: (body: {
rule_type: AlertRule["rule_type"];
config: AlertRule["config"];
cooldown_seconds?: number;
days?: number;
}) =>
request<RulePreview>("/alerts/rules/preview", {
method: "POST",
body: JSON.stringify(body),
}),
},
},
/** Spend budgets — `budget` alert rules — with their current-period spend. */
budgets: {
/** GET /api/budgets/status */
status: () => request<{ budgets: BudgetStatus[] }>("/budgets/status"),
},
/** Query Explorer (`GET /api/query`, `server/routes/query.js`). */
query: {
/** One page of the filtered rows; `params` are the page's filters. */
page: (params: URLSearchParams) =>
request<QueryPage>(`/query?${applyScope(new URLSearchParams(params)).toString()}`),
/** A download link for the whole filtered set (≤ `export_cap` rows). */
exportUrl: (params: URLSearchParams, format: "csv" | "json") => {
const qs = applyScope(new URLSearchParams(params));
qs.delete("limit");
qs.delete("offset");
qs.set("format", format);
return `${BASE}/query?${qs.toString()}`;
},
},
/** Scheduled reports (`/api/reports`, `server/lib/reports.js`). */
reports: {
list: () => request<{ reports: ReportDefinition[] }>("/reports"),
create: (body: ReportInput) =>
request<{ report: ReportDefinition }>("/reports", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: number, body: Partial<ReportInput>) =>
request<{ report: ReportDefinition }>(`/reports/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
remove: (id: number) => request<{ ok: true }>(`/reports/${id}`, { method: "DELETE" }),
run: (id: number) => request<{ run: ReportRun }>(`/reports/${id}/run`, { method: "POST" }),
runs: (id: number) => request<{ runs: ReportRun[] }>(`/reports/${id}/runs`),
/** A run's file, as a download link. */
fileUrl: (id: number, runId: number, format: string) =>
`${BASE}/reports/${id}/runs/${runId}/${encodeURIComponent(format)}`,
},
// ───────────────────────────────── Webhooks API ─────────────────────────────
/** Outbound webhook target CRUD, provider metadata, test sends, and the
* per-target delivery log. */
@@ -4172,6 +4304,10 @@ export interface RemoteSource {
identity_file: string | null;
/** Optional remote CLAUDE_HOME (default `~/.claude`). */
remote_home: string | null;
/** Whether Codex history (`<remote_codex_home>/sessions`) is pulled too. */
sync_codex?: boolean;
/** Optional remote CODEX_HOME (default `~/.codex`). */
remote_codex_home?: string | null;
/** Whether the background poller pulls this source. */
enabled: boolean;
/** Last known sync state. */
@@ -4188,6 +4324,9 @@ export interface RemoteSource {
errors?: number;
sessions_seen?: number;
sessions_tagged?: number;
/** Codex journals read / sessions written, when Codex is pulled. */
codex_files?: number;
codex_sessions?: number;
} | null;
/** Live number of sessions currently attributed to this source. */
session_count?: number;
@@ -4202,9 +4341,35 @@ export interface RemoteSourceInput {
ssh_port?: number | null;
identity_file?: string | null;
remote_home?: string | null;
sync_codex?: boolean;
remote_codex_home?: string | null;
enabled?: boolean;
}
/** GET /api/settings/autostart. `available` false: no systemd `--user` /
* launchd GUI session here; `foreign`: a unit named agenthub AgentHub did not
* write, which it never touches. */
export interface AutostartStatus {
available: boolean;
kind: "systemd" | "launchd";
enabled: boolean;
foreign: boolean;
}
/** GET /api/settings/privacy. A field the environment set is `env_owned`
* and cannot be saved from the dashboard. */
export interface PrivacySettings {
redact_secrets: boolean;
ignore_cwd: string[];
env_owned: { redact_secrets: boolean; ignore_cwd: boolean };
}
/** GET /api/settings/push-token — whether push ingest is on; never the token. */
export interface PushTokenStatus {
enabled: boolean;
env_owned: boolean;
}
/** Result of a connectivity probe (POST /:id/test). */
export interface RemoteSourceTestResult {
ok: boolean;
+40
View File
@@ -0,0 +1,40 @@
/**
* @file Spend pace from the daily cost series (`daily_costs` of GET
* /api/pricing/cost, already in local dates): the average cost per day over
* the last 7 days, the month to date, and a month-end forecast that carries
* that pace over the days left in the month.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export interface SpendPace {
/** Average cost per day over the 7 days ending today (today included). */
perDay7: number;
/** Spend since the 1st of this month. */
monthToDate: number;
/** monthToDate + perDay7 × the days left after today. */
forecastMonth: number;
}
/**
* @param daily `{date: "YYYY-MM-DD", cost}` per local day, any order.
* @param today Today's local date, `YYYY-MM-DD`.
*/
export function spendPace(daily: Array<{ date: string; cost: number }>, today: string): SpendPace {
const [y, m, d] = today.split("-").map(Number) as [number, number, number];
const day = (offset: number) => {
const t = new Date(Date.UTC(y, m - 1, d + offset));
return t.toISOString().slice(0, 10);
};
const weekStart = day(-6);
const monthStart = today.slice(0, 8) + "01";
let week = 0;
let mtd = 0;
for (const r of daily) {
if (r.date > today) continue;
if (r.date >= weekStart) week += r.cost;
if (r.date >= monthStart) mtd += r.cost;
}
const daysInMonth = new Date(Date.UTC(y, m, 0)).getUTCDate();
const perDay7 = week / 7;
return { perDay7, monthToDate: mtd, forecastMonth: mtd + perDay7 * (daysInMonth - d) };
}
+64
View File
@@ -0,0 +1,64 @@
/**
* @file dateRange — the Analytics date range: the preset ids kept in `?range=`
* (today / this week from Monday / this month from the 1st / 7d / 30d / 90d /
* all / custom), and `rangeDates`, which turns a preset (or
* a custom from/to) into the local `YYYY-MM-DD` bounds sent as `from`/`to`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const RANGE_PRESETS = [
"today",
"week",
"month",
"7d",
"30d",
"90d",
"all",
"custom",
] as const;
export type RangePreset = (typeof RANGE_PRESETS)[number];
/** Local, inclusive bounds; an absent end is open. */
export interface DateRange {
from?: string;
to?: string;
}
/** A `?range=` value, or `all` for anything else. */
export function parsePreset(v: string | null): RangePreset {
return (RANGE_PRESETS as readonly string[]).includes(v ?? "") ? (v as RangePreset) : "all";
}
/** `d` as a local `YYYY-MM-DD`. */
export function localDate(d: Date): string {
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
/** The bounds of a preset: `7d` = today and the 6 days before it; `week`
* starts on Monday, `month` on the 1st, both ending today. */
export function rangeDates(
preset: RangePreset,
custom: DateRange = {},
today: Date = new Date()
): DateRange {
if (preset === "all") return {};
if (preset === "custom") {
return { from: custom.from || undefined, to: custom.to || undefined };
}
const start = new Date(today);
if (preset === "week") start.setDate(today.getDate() - ((today.getDay() + 6) % 7));
else if (preset === "month") start.setDate(1);
else if (preset !== "today") start.setDate(today.getDate() - (parseInt(preset, 10) - 1));
return { from: localDate(start), to: localDate(today) };
}
/** Set `from`/`to` (and `tz_offset`, which reads them) on a query string. */
export function applyRange(qs: URLSearchParams, range?: DateRange): URLSearchParams {
if (range?.from) qs.set("from", range.from);
if (range?.to) qs.set("to", range.to);
if ((range?.from || range?.to) && !qs.has("tz_offset")) {
qs.set("tz_offset", String(new Date().getTimezoneOffset()));
}
return qs;
}
+124
View File
@@ -0,0 +1,124 @@
/**
* @file replay.ts
* @description Pure derivations behind the session Replay tab: events in
* playback order, the lifecycle markers on its mini-map, and the state a
* session was in at a cursor (status, tool in flight, tool counts).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { AgentStatus, DashboardEvent } from "./types";
import { statusFromEventType } from "./event-grouping";
export const REPLAY_SPEEDS = [0.5, 1, 2, 5] as const;
/** One event per this many ms at 1× — playback walks the sequence, not the
* wall clock, so an hour-long pause does not stall the replay. */
export const REPLAY_STEP_MS = 500;
/** Event types drawn on the mini-map, in legend order. */
export const LIFECYCLE_TYPES = [
"SessionStart",
"UserPromptSubmit",
"Compaction",
"SubagentStop",
"APIError",
"Stop",
] as const;
/** Oldest first; `id` breaks timestamp ties. */
export function chronological(events: DashboardEvent[]): DashboardEvent[] {
return [...events].sort(
(a, b) => a.created_at.localeCompare(b.created_at) || (a.id ?? 0) - (b.id ?? 0)
);
}
export interface ReplayMarker {
index: number;
type: string;
/** Position on the mini-map, 0–100, matching the slider's scale. */
pct: number;
}
export function lifecycleMarkers(events: DashboardEvent[]): ReplayMarker[] {
const kinds = new Set<string>(LIFECYCLE_TYPES);
const last = Math.max(1, events.length - 1);
const out: ReplayMarker[] = [];
events.forEach((e, index) => {
if (kinds.has(e.event_type)) out.push({ index, type: e.event_type, pct: (index / last) * 100 });
});
return out;
}
export interface ReplayState {
status: AgentStatus | null;
/** The newest tool started and not yet finished at the cursor. */
currentTool: string | null;
/** Tool calls (PreToolUse) so far, most used first. */
toolCounts: { tool: string; count: number }[];
prompts: number;
errors: number;
compactions: number;
/** From the first event to the cursor's. */
elapsedMs: number;
}
/** The session as it stood after `events[cursor]` (events in playback order). */
export function replayState(events: DashboardEvent[], cursor: number): ReplayState {
const end = Math.min(cursor, events.length - 1);
const open: { agent: string; tool: string }[] = [];
const counts = new Map<string, number>();
let prompts = 0;
let errors = 0;
let compactions = 0;
for (let i = 0; i <= end; i++) {
const e = events[i]!;
const agent = e.agent_id ?? "";
switch (e.event_type) {
case "PreToolUse":
if (e.tool_name) {
open.push({ agent, tool: e.tool_name });
counts.set(e.tool_name, (counts.get(e.tool_name) ?? 0) + 1);
}
break;
case "PostToolUse":
case "PostToolUseFailure": {
for (let j = open.length - 1; j >= 0; j--) {
if (open[j]!.agent === agent && open[j]!.tool === e.tool_name) {
open.splice(j, 1);
break;
}
}
if (e.event_type === "PostToolUseFailure") errors++;
break;
}
case "Stop":
case "SubagentStop":
case "SessionEnd":
// A turn that ended leaves nothing of that agent in flight.
for (let j = open.length - 1; j >= 0; j--) if (open[j]!.agent === agent) open.splice(j, 1);
break;
case "UserPromptSubmit":
prompts++;
break;
case "APIError":
errors++;
break;
case "Compaction":
compactions++;
break;
}
}
const first = events[0];
const at = end >= 0 ? events[end] : undefined;
return {
status: at ? statusFromEventType(at.event_type) : null,
currentTool: open[open.length - 1]?.tool ?? null,
toolCounts: [...counts]
.map(([tool, count]) => ({ tool, count }))
.sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)),
prompts,
errors,
compactions,
elapsedMs:
first && at ? Math.max(0, Date.parse(at.created_at) - Date.parse(first.created_at)) : 0,
};
}
+32
View File
@@ -39,6 +39,20 @@ export const SETTINGS_INDEX: {
to: "#language",
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
},
{
section: "data",
titleKey: "pushIngest.title",
descKey: "pushIngest.description",
keywords: ["token", "push", "remote", "batch"],
to: "#push-ingest",
},
{
section: "data",
titleKey: "privacy.title",
descKey: "privacy.description",
keywords: ["redact", "secrets", "ignore", "folder", "cwd"],
to: "#privacy",
},
{
section: "data",
titleKey: "lanesSection.title",
@@ -90,6 +104,12 @@ export const SETTINGS_INDEX: {
titleKey: "about.title",
keywords: ["version", "server", "database", "uptime"],
},
{
section: "about",
titleKey: "autostart.label",
keywords: ["autostart", "boot", "login", "systemd", "launchd", "service"],
to: "#autostart",
},
{
section: "appearance",
titleKey: "sagi.title",
@@ -105,6 +125,18 @@ export const SETTINGS_INDEX: {
keywords: ["codex-pets", "upload", "import"],
to: "#pet",
},
{
section: "snapshots",
titleKey: "snapshots.title",
descKey: "snapshots.description",
keywords: ["share", "freeze", "link", "redact"],
},
{
section: "reports",
titleKey: "reports.title",
descKey: "reports.description",
keywords: ["schedule", "report", "email", "summary", "html", "json", "export"],
},
{ section: "appearance", titleKey: "appearance.mode", keywords: ["dark", "light", "system"] },
{ section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] },
{ section: "appearance", titleKey: "appearance.lightThemes" },
+149 -3
View File
@@ -731,8 +731,8 @@ export interface Session {
source?: string;
/** The AI coding CLI that produced the session: `"claude"` (the default —
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
* journals and hooks) or `"antigravity"` (agy's conversation files and
* hooks). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
* journals and hooks), `"antigravity"` (agy's conversation files and
* hooks) or `"cursor"` (Cursor's agent transcripts, no tokens). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
tool?: string;
/** The workspace (lane id) that owns the session — the one whose pane held
* it; null for a session no workspace started.
@@ -1204,6 +1204,9 @@ export interface CostResult {
feature_costs?: CostFeatureCosts;
/** Present only when at least one model had usage but no pricing rule. */
unpriced_models?: UnpricedModel[];
/** Cost per AI tool (`sessions.tool`), most expensive first. Only
* GET /api/pricing/cost sends it, not the per-session route. */
by_tool?: Array<{ tool: string; cost: number }>;
}
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
@@ -1533,7 +1536,13 @@ export interface CodexConfigChangedPayload {
/** Kind of condition an {@link AlertRule} evaluates. "event_pattern" and
* "token_threshold" are checked on every hook ingest; "inactivity" and
* "status_duration" are checked on a periodic server-side sweep. */
export type AlertRuleType = "event_pattern" | "inactivity" | "status_duration" | "token_threshold";
export type AlertRuleType =
| "event_pattern"
| "inactivity"
| "status_duration"
| "token_threshold"
| "cost_threshold"
| "budget";
/**
* Rule-type-specific settings for an {@link AlertRule}. Which fields apply
@@ -1544,6 +1553,9 @@ export type AlertRuleType = "event_pattern" | "inactivity" | "status_duration" |
* - inactivity: `minutes` of session silence before firing.
* - status_duration: `status` held continuously for `minutes`.
* - token_threshold: cumulative `total_tokens` for a session.
* - cost_threshold: a session's cost reaching `usd`.
* - budget: spend per `period` against `limit_usd`, firing each of
* `thresholds` (percent) once per period, optionally for one `tool`/`lane`.
*/
export interface AlertRuleConfig {
/** event_pattern: exact `DashboardEvent.event_type` to match. */
@@ -1565,6 +1577,55 @@ export interface AlertRuleConfig {
status?: "working" | "waiting";
/** token_threshold: cumulative token count that triggers the alert. */
total_tokens?: number;
/** cost_threshold: a session's cost in USD that triggers the alert. */
usd?: number;
/** budget: the period spend is measured over (local time). */
period?: "day" | "week" | "month";
/** budget: the period's limit in USD. */
limit_usd?: number;
/** budget: percents of the limit that fire, each once per period. */
thresholds?: number[];
/** budget: only this AI tool's sessions. */
tool?: string;
/** budget: only the sessions this workspace owns. */
lane?: number;
/** budget: the browser's getTimezoneOffset() when the rule was saved. */
tz_offset?: number;
}
/** One budget from GET /api/budgets/status: its rule plus current-period spend. */
export interface BudgetStatus {
id: string;
name: string;
config: AlertRuleConfig;
/** Local dates (inclusive) of the current period. */
period_start: string;
period_end: string;
spend: number;
/** The spend at the current pace, carried to the period's end. */
projected: number;
/** Spend as a percent of `limit_usd`. */
pct: number;
/** Thresholds already reached this period, lowest first. */
crossed: number[];
}
/** POST /api/alerts/rules/preview — what a rule would have fired, read-only. */
export interface RulePreview {
/** False for a type history cannot replay (status_duration). */
supported: boolean;
window_days: number;
fires: number;
sessions: number;
samples: Array<{ session_id: string; at: string } & Record<string, unknown>>;
/** budget only: the current period and the five before it. */
periods?: Array<{
period_start: string;
period_end: string;
spend: number;
pct: number;
crossed: number[];
}>;
}
/** A user-defined alert rule from GET/POST/PATCH /api/alerts/rules. Couples a
@@ -1927,6 +1988,89 @@ export interface SessionStats {
};
}
/** A row of `GET /api/snapshots` — a frozen session, listed without its payload. */
export interface SessionSnapshotInfo {
id: number;
/** The unguessable read token: `/snapshot/<token>`. */
token: string;
session_id: string;
created_at: string;
session_name?: string | null;
/** File paths were replaced at capture. */
redacted?: boolean;
event_count?: number;
/** Size of the stored payload, bytes. */
bytes?: number;
}
export type QueryEntity = "sessions" | "events" | "agents";
/** `GET /api/query` — one page of an entity's filtered rows. */
export interface QueryPage {
entity: QueryEntity;
/** Column names, in order (also the export's header). */
columns: string[];
/** The columns `sort` accepts. */
sortable: string[];
/** The values `status` accepts (none for events). */
statuses: string[];
rows: Record<string, string | number | null>[];
total: number;
limit: number;
offset: number;
/** An export holds at most this many rows. */
export_cap: number;
}
/** A scheduled report definition (`/api/reports`). */
export interface ReportInput {
name: string;
/** Full local days before the run day. */
window_days: number;
frequency: "daily" | "weekly";
/** Local hour, 0–23. */
hour: number;
/** 0 = Sunday; weekly only. */
weekday: number;
/** getTimezoneOffset() of the browser that saved it. */
tz_offset: number;
formats: ("html" | "json")[];
enabled: boolean;
}
export interface ReportRun {
id: number;
report_id: number;
created_at: string;
trigger: "schedule" | "manual";
status: "ok" | "error";
window_from: string | null;
window_to: string | null;
/** The formats written. */
files: string[];
error: string | null;
}
export interface ReportDefinition extends ReportInput {
id: number;
created_at: string;
last_slot: string | null;
last_run?: ReportRun | null;
}
/** What `POST /api/snapshots` froze: the session as it stood at `captured_at`. */
export interface SessionSnapshotPayload {
version: number;
captured_at: string;
redacted: { file_paths: boolean };
session: Session;
agents: Agent[];
/** Oldest first. */
events: DashboardEvent[];
stats: SessionStats;
cost: CostResult;
}
// ───── Workflow intelligence (events-derived) ─────
// The analytics panels on the Workflows page. Every type below is COMPUTED by
// the server from `DashboardEvent`/`Agent` rows (server/routes/workflows.js) -
@@ -2764,6 +2908,8 @@ export interface LaneTreeEntry {
status?: string;
/** A folder with a changed or untracked file anywhere under it. */
changed?: true;
/** Ignored by git (`.gitignore`) — drawn orange in the Files tree. */
ignored?: true;
}
/** One row of a rendered diff — both gutters already resolved server-side. */
+186 -6
View File
@@ -107,9 +107,15 @@ import { AgentStatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
import { Tip } from "../components/Tip";
import { Skeleton } from "../components/Skeleton";
import { Select } from "../components/Select";
import { AiToolIcon } from "../components/AiToolIcon";
import { useAiTools } from "../hooks/useAiTools";
import { spendPace } from "../lib/burnRate";
import { RANGE_PRESETS, parsePreset, rangeDates } from "../lib/dateRange";
import { timeAgo, fmt, fmtCost, fmtCostFull, formatModelName } from "../lib/format";
import { statusFromEventType } from "../lib/event-grouping";
import type {
BudgetStatus,
Stats,
Agent,
DashboardEvent,
@@ -700,6 +706,7 @@ function AnalyticsTab() {
const { t, i18n } = useTranslation("analytics");
const [data, setData] = useState<AnalyticsData | null>(null);
const [costData, setCostData] = useState<CostResult | null>(null);
const [budgets, setBudgets] = useState<BudgetStatus[]>([]);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [loading, setLoading] = useState(true);
// `?sub=` deep-links a sub-tab (the old /workflows route lands on `workflow`).
@@ -723,6 +730,28 @@ function AnalyticsTab() {
);
// Global data scope; a change re-runs `load` (api injects the `sources` param).
const [scope] = useDataScope();
// One AI tool's sessions only ("" = every tool); a change re-runs `load`.
const [tool, setTool] = useState("");
const aiTools = useAiTools();
// The date range, kept in the URL (`?range=`, plus `from`/`to` when custom).
// Headline numbers, cost/token cards and by-model/by-tool follow it; the
// 52-week and 30-day charts keep their own windows.
const preset = parsePreset(params.get("range"));
const customFrom = params.get("from") ?? "";
const customTo = params.get("to") ?? "";
const { from: rangeFrom, to: rangeTo } = rangeDates(preset, { from: customFrom, to: customTo });
const range = useMemo(() => ({ from: rangeFrom, to: rangeTo }), [rangeFrom, rangeTo]);
const setRangeParam = (patch: Record<string, string>) =>
setParams(
(p) => {
for (const [k, v] of Object.entries(patch)) {
if (v) p.set(k, v);
else p.delete(k);
}
return p;
},
{ replace: true }
);
const [loadError, setLoadError] = useState<string | null>(null);
// Responses can land out of order (a slow poll after a fast refresh); only
@@ -731,14 +760,16 @@ function AnalyticsTab() {
const load = useCallback(async () => {
const seq = ++loadSeq.current;
try {
const [result, cost] = await Promise.all([
api.analytics.get(),
api.pricing.totalCost().catch(() => null),
const [result, cost, budgetRes] = await Promise.all([
api.analytics.get({ tool, range }),
api.pricing.totalCost({ tool, range }).catch(() => null),
api.budgets.status().catch(() => null),
]);
if (seq !== loadSeq.current) return;
setData(result);
setCostData(cost);
setLoadError(null);
if (budgetRes) setBudgets(budgetRes.budgets);
setLastUpdate(new Date());
} catch (err) {
if (seq === loadSeq.current)
@@ -746,7 +777,7 @@ function AnalyticsTab() {
} finally {
if (seq === loadSeq.current) setLoading(false);
}
}, [scope]);
}, [scope, tool, range]);
useEffect(() => {
load();
@@ -861,6 +892,7 @@ function AnalyticsTab() {
dailyCostLast30[0] ?? { date: "", cost: 0 }
);
const totalCost30d = dailyCostLast30.reduce((sum, day) => sum + day.cost, 0);
const pace = spendPace(dailyCostsLocal, localDateStr(today));
const costBreakdown = [...(costData?.breakdown ?? [])]
.filter((b) => b.cost > 0)
.sort((a, b) => b.cost - a.cost);
@@ -985,6 +1017,53 @@ function AnalyticsTab() {
{lastUpdate.toLocaleTimeString()}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="w-40">
<Select
value={preset}
onChange={(v) =>
setRangeParam(
v === "custom"
? { range: v, from: rangeFrom ?? "", to: rangeTo ?? "" }
: { range: v === "all" ? "" : v, from: "", to: "" }
)
}
label={t("range")}
options={RANGE_PRESETS.map((v) => ({ value: v, label: t(`rangePreset.${v}`) }))}
/>
</div>
{preset === "custom" && (
<>
<input
type="date"
className="input text-sm"
value={customFrom}
max={customTo || undefined}
onChange={(e) => setRangeParam({ from: e.target.value })}
aria-label={t("rangeFrom")}
title={t("rangeFrom")}
/>
<input
type="date"
className="input text-sm"
value={customTo}
min={customFrom || undefined}
onChange={(e) => setRangeParam({ to: e.target.value })}
aria-label={t("rangeTo")}
title={t("rangeTo")}
/>
</>
)}
<div className="w-44">
<Select
value={tool}
onChange={setTool}
label={t("toolFilter")}
options={[
{ value: "", label: t("allTools") },
...aiTools.tools.map((x) => ({ value: x.id, label: x.label })),
]}
/>
</div>
<button onClick={load} className="btn-ghost" disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
{t("common:refresh")}
@@ -1353,6 +1432,107 @@ function AnalyticsTab() {
)}
</div>
{/* Spend pace: burn rate, month to date, month-end forecast */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("spendPace")}</h3>
<div className="space-y-3">
{[
{ label: t("burnRate"), value: pace.perDay7, hint: t("burnRateHint") },
{ label: t("monthToDate"), value: pace.monthToDate },
{
label: t("forecastMonth"),
value: pace.forecastMonth,
hint: t("forecastHint"),
},
].map(({ label, value, hint }) => (
<div
key={label}
className="flex justify-between items-center py-2 border-b border-border last:border-0"
>
<span className="text-xs text-fg-secondary" title={hint}>
{label}
</span>
<span className="text-sm font-mono font-medium text-status-success">
<Tip raw={fmtCostFull(value)}>{fmtCost(value)}</Tip>
</span>
</div>
))}
</div>
</div>
{/* Budgets (budget alert rules) */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("budgets")}</h3>
{budgets.length === 0 ? (
<p className="text-sm text-fg-muted">
{t("noBudgets")}{" "}
<Link to="/settings/notifications" className="text-accent hover:underline">
{t("addBudget")}
</Link>
</p>
) : (
<div className="space-y-4">
{budgets.map((b) => {
const over = b.pct >= 100;
const warn = b.crossed.length > 0;
return (
<div key={b.id} className="space-y-1.5">
<div className="flex justify-between text-xs gap-2">
<span className="text-fg-secondary truncate" title={b.name}>
{b.name}
</span>
<span className="font-mono text-fg-secondary flex-shrink-0">
{fmtCost(b.spend)} / {fmtCost(b.config.limit_usd ?? 0)}
</span>
</div>
<div className="bg-surface-3 rounded-full h-2">
<div
className={`h-2 rounded-full ${over ? "bg-status-danger" : warn ? "bg-status-warning" : "bg-status-success"}`}
style={{ width: `${Math.min(100, Math.max(2, b.pct))}%` }}
/>
</div>
<div className="flex justify-between text-[11px] text-fg-muted">
<span>
{b.period_start} → {b.period_end}
</span>
<span title={t("projectedHint")}>
{t("projected")} {fmtCost(b.projected)}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Cost by AI tool */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("costByTool")}</h3>
{(costData?.by_tool?.length ?? 0) > 0 ? (
<div className="space-y-3">
{(costData?.by_tool ?? []).map((b) => (
<div key={b.tool} className="flex items-center gap-2">
<AiToolIcon
tool={{ id: b.tool, label: aiTools.label(b.tool) }}
className="h-5 w-5"
/>
<div className="flex-1 min-w-0">
<CostBarRow
label={aiTools.label(b.tool)}
cost={b.cost}
max={Math.max(costData?.by_tool?.[0]?.cost ?? 0, 0.0001)}
color="bg-accent"
/>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-fg-muted">{t("noCostData")}</p>
)}
</div>
{/* Cost by weekday */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-1">
@@ -1471,7 +1651,7 @@ function AnalyticsTab() {
{/* The former Workflows page, folded into this sub-tab. */}
<RouteErrorBoundary>
<Suspense fallback={<AnalyticsChartsSkeleton />}>
<Workflows embedded />
<Workflows embedded tool={tool || undefined} range={range} />
</Suspense>
</RouteErrorBoundary>
</div>
@@ -1605,7 +1785,7 @@ function SystemHealthTab() {
try {
const [infoRes, workflowRes] = await Promise.all([
api.settings.info(),
api.workflows.get(undefined, HEALTH_SECTIONS),
api.workflows.get(undefined, undefined, undefined, HEALTH_SECTIONS),
]);
setInfo(infoRes as any);
setWorkflow(workflowRes);
+341
View File
@@ -0,0 +1,341 @@
/**
* @file QueryExplorer.tsx
* @description `/query`: filter sessions, events or agents (free text, AI tool,
* status, local date range, folder subtree, plus the global data scope), sort
* by a column the server allows, page through the rows, and export the whole
* filtered set as CSV or JSON (`GET /api/query`, capped server-side).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Download, RotateCcw } from "lucide-react";
import { api } from "../lib/api";
import { applyRange } from "../lib/dateRange";
import { useDataScope } from "../lib/dataScope";
import { useAiTools } from "../hooks/useAiTools";
import { Select } from "../components/Select";
import type { QueryEntity, QueryPage } from "../lib/types";
const PAGE_SIZE = 50;
const ENTITIES: QueryEntity[] = ["sessions", "events", "agents"];
const STATUSES: Record<QueryEntity, string[]> = {
sessions: ["active", "completed", "error", "abandoned"],
events: [],
agents: ["working", "waiting", "completed", "error"],
};
export function QueryExplorer() {
const { t } = useTranslation("analytics");
const aiTools = useAiTools();
const [scope] = useDataScope();
const [entity, setEntity] = useState<QueryEntity>("sessions");
const [q, setQ] = useState("");
const [cwd, setCwd] = useState("");
const [typed, setTyped] = useState({ q: "", cwd: "" });
const [tool, setTool] = useState("");
const [status, setStatus] = useState("");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [sort, setSort] = useState("");
const [dir, setDir] = useState<"asc" | "desc">("desc");
const [offset, setOffset] = useState(0);
const [page, setPage] = useState<QueryPage | null>(null);
const [error, setError] = useState<string | null>(null);
// Text filters wait for a pause in typing.
useEffect(() => {
const h = setTimeout(() => {
const next = { q: q.trim(), cwd: cwd.trim() };
setTyped((prev) => (prev.q === next.q && prev.cwd === next.cwd ? prev : next));
setOffset(0);
}, 300);
return () => clearTimeout(h);
}, [q, cwd]);
/** Every filter change starts over at the first page. */
const fresh =
<T,>(set: (v: T) => void) =>
(v: T) => {
set(v);
setOffset(0);
};
const filters = useMemo(() => {
const p = new URLSearchParams({ entity });
if (typed.q) p.set("q", typed.q);
if (typed.cwd) p.set("cwd", typed.cwd);
if (tool) p.set("tool", tool);
if (status) p.set("status", status);
if (sort) {
p.set("sort", sort);
p.set("dir", dir);
}
return applyRange(p, { from: from || undefined, to: to || undefined });
}, [entity, typed, tool, status, sort, dir, from, to]);
useEffect(() => {
let live = true;
const p = new URLSearchParams(filters);
p.set("limit", String(PAGE_SIZE));
p.set("offset", String(offset));
api.query
.page(p)
.then((r) => {
if (!live) return;
setPage(r);
setError(null);
})
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
}, [filters, offset, scope]);
const switchEntity = (v: QueryEntity) => {
setEntity(v);
setStatus("");
setSort("");
setDir("desc");
setOffset(0);
setPage(null);
};
const reset = () => {
setQ("");
setCwd("");
setTool("");
setStatus("");
setFrom("");
setTo("");
setSort("");
setDir("desc");
setOffset(0);
};
const sortedBy = sort || page?.sortable[0] || "";
// A click on the current column flips it; another column starts descending.
const sortOn = (col: string) => {
setDir(col === sortedBy && dir === "desc" ? "asc" : "desc");
setSort(col);
setOffset(0);
};
const total = page?.total ?? 0;
const cap = page?.export_cap ?? 50_000;
const statuses = STATUSES[entity];
return (
<div className="animate-fade-in space-y-5">
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("query.title")}</h1>
<p className="text-sm text-fg-muted">{t("query.subtitle")}</p>
</div>
<div className="card p-4 flex flex-wrap items-end gap-3">
<div className="w-36">
<Select
value={entity}
onChange={switchEntity}
label={t("query.entity")}
options={ENTITIES.map((v) => ({ value: v, label: t(`query.entities.${v}`) }))}
/>
</div>
<input
className="input text-sm w-56"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("query.searchPlaceholder")}
aria-label={t("query.search")}
/>
<div className="w-40">
<Select
value={tool}
onChange={fresh(setTool)}
label={t("toolFilter")}
options={[
{ value: "", label: t("allTools") },
...aiTools.tools.map((x) => ({ value: x.id, label: x.label })),
]}
/>
</div>
{statuses.length > 0 && (
<div className="w-40">
<Select
value={status}
onChange={fresh(setStatus)}
label={t("query.status")}
options={[
{ value: "", label: t("query.allStatuses") },
...statuses.map((s) => ({ value: s, label: t(`common:status.${s}`) })),
]}
/>
</div>
)}
<input
type="date"
className="input text-sm"
value={from}
max={to || undefined}
onChange={(e) => fresh(setFrom)(e.target.value)}
aria-label={t("query.from")}
title={t("query.from")}
/>
<input
type="date"
className="input text-sm"
value={to}
min={from || undefined}
onChange={(e) => fresh(setTo)(e.target.value)}
aria-label={t("query.to")}
title={t("query.to")}
/>
<input
className="input text-sm w-56 font-mono"
value={cwd}
onChange={(e) => setCwd(e.target.value)}
placeholder={t("query.cwdPlaceholder")}
aria-label={t("query.cwd")}
title={t("query.cwd")}
/>
<button type="button" className="btn-ghost" onClick={reset} title={t("query.reset")}>
<RotateCcw className="w-4 h-4" />
{t("query.reset")}
</button>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-fg-muted">
{total > cap
? t("query.exportTruncated", {
total: total.toLocaleString(),
cap: cap.toLocaleString(),
})
: t("query.exportNote", { cap: cap.toLocaleString() })}
</p>
<div className="flex items-center gap-2">
<a className="btn-ghost" href={api.query.exportUrl(filters, "csv")} download>
<Download className="w-4 h-4" />
{t("query.exportCsv")}
</a>
<a className="btn-ghost" href={api.query.exportUrl(filters, "json")} download>
<Download className="w-4 h-4" />
{t("query.exportJson")}
</a>
</div>
</div>
{error && (
<p role="alert" className="text-sm text-status-danger">
{error}
</p>
)}
{page && page.rows.length === 0 ? (
<p className="py-10 text-center text-sm text-fg-muted">{t("query.empty")}</p>
) : (
page && (
<div className="card overflow-x-auto">
<table className="w-full text-sm" data-testid="query-table">
<thead>
<tr className="border-b border-border text-left">
{page.columns.map((c) => {
const sortable = page.sortable.includes(c);
return (
<th
key={c}
className="px-3 py-2 text-[11px] font-semibold text-fg-muted font-mono"
aria-sort={
c === sortedBy ? (dir === "asc" ? "ascending" : "descending") : undefined
}
>
{sortable ? (
<button
type="button"
onClick={() => sortOn(c)}
className="inline-flex items-center gap-1 hover:text-fg-primary"
title={t("query.sortBy", { column: c })}
>
{c}
{c === sortedBy &&
(dir === "asc" ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
))}
</button>
) : (
c
)}
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-border">
{page.rows.map((r, i) => (
<tr key={`${r.id}-${i}`} className="hover:bg-surface-4">
{page.columns.map((c) => {
const v = r[c];
const sid =
(entity === "sessions" && c === "id") || c === "session_id" ? v : null;
return (
<td
key={c}
className="px-3 py-1.5 max-w-[22rem] truncate text-fg-secondary"
title={v == null ? undefined : String(v)}
>
{sid ? (
<Link
to={`/sessions/${encodeURIComponent(String(sid))}`}
className="font-mono text-accent hover:underline"
>
{String(sid).slice(0, 8)}
</Link>
) : (
(v ?? "")
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)
)}
{page && total > 0 && (
<div className="flex items-center justify-end gap-2 text-xs text-fg-muted">
<span>
{t("query.rows", {
from: (offset + 1).toLocaleString(),
to: Math.min(offset + PAGE_SIZE, total).toLocaleString(),
total: total.toLocaleString(),
})}
</span>
<button
type="button"
className="btn-ghost"
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
title={t("query.prev")}
aria-label={t("query.prev")}
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
type="button"
className="btn-ghost"
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset(offset + PAGE_SIZE)}
title={t("query.next")}
aria-label={t("query.next")}
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
);
}
+56 -109
View File
@@ -1,6 +1,6 @@
/**
* @file SessionDetail.tsx
* @description Displays detailed information about a specific session, including its agents, events, and cost breakdown, with real-time updates and an expandable agent hierarchy view.
* @description Displays detailed information about a specific session, including its agents, events, and cost breakdown, with real-time updates and an expandable agent hierarchy view; a Replay tab scrubs through its events and a Snapshot action freezes it under a share link.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -28,7 +28,7 @@
* - `../components/conversation/ConversationView`
* - `../components/StatusBadge`
* - `../lib/types`
* - `../components/EventDetail`
* - `../components/EventTimeline`, `../components/EventReplay`, `../components/SnapshotButton`
* - `../components/EventFilters`
* - `../components/EventFiltersInfo`
* - `../components/Skeleton`
@@ -79,6 +79,7 @@ import {
Hourglass,
X,
Download,
Film,
} from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
@@ -89,9 +90,12 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { AgentCard } from "../components/AgentCard";
import { SessionOverview } from "../components/SessionOverview";
import { ConversationView } from "../components/conversation/ConversationView";
import { SessionStatusBadge, AgentStatusBadge, REASON_ICONS } from "../components/StatusBadge";
import { SessionStatusBadge, REASON_ICONS } from "../components/StatusBadge";
import { SessionToolBadge } from "../components/SessionToolBadge";
import { SessionResumeButton } from "../components/lanes/SessionResumeButton";
import { SnapshotButton } from "../components/SnapshotButton";
import { EventTimeline } from "../components/EventTimeline";
import { EventReplay, loadReplayEvents } from "../components/EventReplay";
import { CopyButton } from "../components/event-views/primitives";
import {
effectiveSessionStatus,
@@ -99,7 +103,6 @@ import {
sessionAwaitingReason,
AWAITING_REASON_CONFIG,
} from "../lib/types";
import { EventDetail } from "../components/EventDetail";
import {
EventFilters,
isEmptyFilters,
@@ -110,14 +113,6 @@ import {
import type { EventFiltersValue } from "../components/EventFilters";
import { EventFiltersInfo } from "../components/EventFiltersInfo";
import { Skeleton } from "../components/Skeleton";
import {
agentOriginLabel,
buildEventTitle,
buildOriginLabel,
projectFromCwd,
projectFromEvent,
statusFromEventType,
} from "../lib/event-grouping";
import type { AgentInfo } from "../lib/event-grouping";
import {
formatDateTime,
@@ -139,7 +134,7 @@ import type {
} from "../lib/types";
import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel";
type DetailTab = "agents" | "conversation" | "timeline";
type DetailTab = "agents" | "conversation" | "timeline" | "replay";
const EVENTS_INITIAL_BATCH = 50;
const EVENTS_MORE_BATCH = 500;
@@ -204,16 +199,18 @@ export function SessionDetail() {
const [pendingTranscriptId, setPendingTranscriptId] = useState<string | null>(null);
const [transcriptNotFound, setTranscriptNotFound] = useState(false);
const notFoundTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => new Set());
function toggleEvent(id: number) {
setExpandedEvents((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
// The Replay tab's own copy: every event, oldest first, read on first visit.
const [replay, setReplay] = useState<{ events: DashboardEvent[]; total: number } | null>(null);
useEffect(() => {
if (activeTab !== "replay" || replay || !id) return;
let live = true;
loadReplayEvents(id)
.then((r) => live && setReplay(r))
.catch(() => live && setReplay({ events: [], total: 0 }));
return () => {
live = false;
};
}, [activeTab, replay, id]);
// Refs let the websocket handler access latest state without re-subscribing.
const eventApiParamsRef = useRef<Record<string, unknown> | null>(null);
@@ -427,13 +424,6 @@ export function SessionDetail() {
return map;
}, [session?.id, session?.name]);
// Precompute project per event so flat-row rendering doesn't re-parse JSON.
const projectByEventId = useMemo(() => {
const map = new Map<number, string | null>();
for (const e of events) map.set(e.id, projectFromEvent(e));
return map;
}, [events]);
const loadEvents = useCallback(async () => {
if (!eventApiParams) return;
try {
@@ -680,6 +670,7 @@ export function SessionDetail() {
) : null;
})()}
<SessionResumeButton session={session} />
<SnapshotButton sessionId={session.id} />
<button
onClick={load}
className="btn-ghost"
@@ -822,6 +813,20 @@ export function SessionDetail() {
<List className="w-4 h-4" />
{t("detail.timeline", { shown: events.length, total: eventsTotal })}
</button>
<button
onClick={() => {
setActiveTab("replay");
setTranscriptNotFound(false);
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "replay"
? "border-violet-500 text-violet-700 dark:text-violet-400"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
<Film className="w-4 h-4" />
{t("replay.tab")}
</button>
</div>
{/* Tab Content */}
@@ -1139,85 +1144,12 @@ export function SessionDetail() {
{isEmptyFilters(filters) ? t("detail.noEvents") : t("common:eventFilters.noResults")}
</p>
) : (
<div className="card overflow-hidden">
<div className="divide-y divide-border max-h-[600px] overflow-y-auto overflow-x-auto">
{events.map((event, i) => {
const key = event.id ?? i;
const isOpen = event.id != null && expandedEvents.has(event.id);
return (
// Off-screen rows skip layout/paint (a long timeline is not virtualized).
<div
key={key}
style={
isOpen
? undefined
: { contentVisibility: "auto", containIntrinsicSize: "auto 45px" }
}
>
<button
type="button"
onClick={() => event.id != null && toggleEvent(event.id)}
aria-expanded={isOpen}
aria-label={
isOpen ? t("common:eventDetail.collapse") : t("common:eventDetail.expand")
}
className="w-full text-left px-5 py-3 flex items-center gap-4 hover:bg-surface-4 transition-colors min-w-0 cursor-pointer"
>
<span
className={`text-fg-muted text-[10px] w-3 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
aria-hidden="true"
>
▶
</span>
<div className="w-16 text-[11px] text-fg-muted font-mono flex-shrink-0">
{timeAgo(event.created_at)}
</div>
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
{(() => {
// Session is implicit on this page - project is
// still shown so the row identifies the working
// directory when you share / search. Events without
// their own cwd (e.g. TurnDuration) fall back to the
// session's cwd so every row carries the dir prefix.
const project =
projectByEventId.get(event.id) ?? projectFromCwd(session?.cwd) ?? null;
const origin = buildOriginLabel(
project,
null,
agentOriginLabel(event.agent_id, agentInfoById)
);
return (
<span className="text-sm text-fg-secondary flex-1 truncate">
{origin && (
<span
className="text-fg-muted mr-1"
title={event.agent_id ?? undefined}
>
{origin} ·
</span>
)}
{buildEventTitle(event)}
</span>
);
})()}
{event.tool_name && (
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono">
{event.tool_name}
</span>
)}
</button>
{isOpen && (
<EventDetail
event={event}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
)}
</div>
);
})}
</div>
</div>
<EventTimeline
events={events}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
fallbackCwd={session?.cwd}
/>
)}
{events.length < eventsTotal && (
<div className="flex items-center justify-between mt-3 px-1">
@@ -1238,6 +1170,21 @@ export function SessionDetail() {
)}
</div>
)}
{visitedTabs.has("replay") && (
<div hidden={activeTab !== "replay"}>
{replay ? (
<EventReplay
events={replay.events}
total={replay.total}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
) : (
<p className="text-sm text-fg-muted">{t("replay.loading")}</p>
)}
</div>
)}
</div>
);
}
+11 -1
View File
@@ -1,6 +1,6 @@
/**
* @file Settings.tsx
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — the start-at-login switch (`AutostartSettings`) and system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -105,6 +105,9 @@ import { notify, playSound } from "../lib/notify";
import { Tip } from "../components/Tip";
import { ImportHistory } from "../components/ImportHistory";
import { RemoteSources } from "../components/RemoteSources";
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
import { PrivacySettings } from "../components/settings/PrivacySettings";
import { AutostartSettings } from "../components/settings/AutostartSettings";
import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications";
import {
@@ -962,6 +965,10 @@ export function Settings({
<RemoteSources />
</section>
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
<PushIngestSettings />
<PrivacySettings />
{/* ─── SESSION CLEANUP ─── */}
<section id="cleanup" className="card p-5 space-y-4 scroll-mt-24">
<div className="flex items-center gap-3">
@@ -1115,6 +1122,9 @@ export function Settings({
{section === "about" && (
<section id="about" className="scroll-mt-24">
<div className="mb-4">
<AutostartSettings />
</div>
<p className="text-xs text-fg-muted mb-4">{t("about.description")}</p>
{sysInfo ? (
<div className="card p-5">
+18
View File
@@ -33,6 +33,8 @@ import {
Database,
Info,
Bot,
Camera,
CalendarClock,
Coins,
GitBranch,
KeyRound,
@@ -47,6 +49,8 @@ import { AiToolConfig } from "./AiToolConfig";
import { AiRouterConfig } from "./AiRouterConfig";
import { GitCredentialsConfig } from "./GitCredentialsConfig";
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
import { ReportSettings } from "../components/settings/ReportSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
@@ -223,6 +227,20 @@ export function SettingsHub() {
...s("appearance"),
render: () => <AppearanceSettings />,
},
{
id: "snapshots",
group: "general",
Icon: Camera,
...s("snapshots"),
render: () => <SnapshotSettings />,
},
{
id: "reports",
group: "general",
Icon: CalendarClock,
...s("reports"),
render: () => <ReportSettings />,
},
{
id: "notifications",
group: "general",
+191
View File
@@ -0,0 +1,191 @@
/**
* @file SnapshotView.tsx
* @description The read-only page `/snapshot/:token`: a session as it was frozen
* by `POST /api/snapshots` — header, SessionOverview fed the frozen stats, the
* agents, and the Replay / Timeline tabs over the frozen events. Nothing here
* reads live data.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Bot, Camera, Clock, DollarSign, Film, List, ShieldCheck } from "lucide-react";
import { api } from "../lib/api";
import type { AgentInfo } from "../lib/event-grouping";
import { formatDateTime, fmtCostFull } from "../lib/format";
import type { SessionSnapshotInfo, SessionSnapshotPayload } from "../lib/types";
import { SessionOverview } from "../components/SessionOverview";
import { AgentCard } from "../components/AgentCard";
import { EventTimeline } from "../components/EventTimeline";
import { EventReplay } from "../components/EventReplay";
import { SessionStatusBadge } from "../components/StatusBadge";
import { Skeleton } from "../components/Skeleton";
type Tab = "overview" | "replay" | "timeline";
export function SnapshotView() {
const { token = "" } = useParams<{ token: string }>();
const { t } = useTranslation("sessions");
const [data, setData] = useState<{
snapshot: SessionSnapshotInfo;
payload: SessionSnapshotPayload;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("overview");
useEffect(() => {
let live = true;
setData(null);
setError(null);
api.snapshots
.get(token)
.then((r) => live && setData(r))
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
return () => {
live = false;
};
}, [token]);
const payload = data?.payload;
const agentInfoById = useMemo(() => {
const map = new Map<string, AgentInfo>();
for (const a of payload?.agents ?? []) map.set(a.id, a);
return map;
}, [payload]);
const sessionNameById = useMemo(() => {
const s = payload?.session;
return new Map(s ? [[s.id, s.name?.trim() || `Session ${s.id.slice(0, 8)}`]] : []);
}, [payload]);
if (error) {
return (
<div className="py-20 text-center">
<p className="mb-2 text-status-danger">{t("snapshot.notFound")}</p>
<p className="text-xs text-fg-muted">{error}</p>
</div>
);
}
if (!payload) {
return (
<div className="space-y-4" aria-busy="true">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-24 w-full" />
</div>
);
}
const { session, agents, events, stats, cost } = payload;
const tabs: { id: Tab; label: string; Icon: typeof Bot }[] = [
{ id: "overview", label: `${t("detail.agents")} (${agents.length})`, Icon: Bot },
{ id: "replay", label: t("replay.tab"), Icon: Film },
{ id: "timeline", label: t("snapshot.timeline", { n: events.length }), Icon: List },
];
return (
<div className="animate-fade-in space-y-6">
<div className="flex items-start gap-3 rounded-lg border border-accent/30 bg-accent/10 px-4 py-2.5 text-sm">
<Camera className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<div className="font-medium text-fg-primary">
{t("snapshot.banner", { date: formatDateTime(payload.captured_at) })}
</div>
<div className="text-[11px] text-fg-muted">{t("snapshot.bannerBody")}</div>
</div>
{payload.redacted.file_paths && (
<span className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
<ShieldCheck className="h-3.5 w-3.5" />
{t("snapshot.redacted")}
</span>
)}
<Link to={`/sessions/${session.id}`} className="text-[11px] text-accent hover:underline">
{t("snapshot.openLive")}
</Link>
</div>
<div>
<div className="mb-2 flex items-center gap-3">
<h2 className="text-xl font-semibold text-fg-primary">
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</h2>
<SessionStatusBadge status={session.status} />
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span className="rounded bg-surface-2 px-2 py-1 font-mono text-xs text-fg-muted">
{session.id.slice(0, 16)}
</span>
<span className="inline-flex items-center gap-1.5 rounded bg-surface-2 px-2 py-1 text-xs text-fg-secondary">
<Clock className="h-3 w-3 text-fg-muted" />
{formatDateTime(session.started_at)}
</span>
{cost.total_cost > 0 && (
<span className="inline-flex items-center gap-1.5 rounded bg-status-success/10 px-2 py-1 text-xs font-medium text-status-success">
<DollarSign className="h-3 w-3" />
{fmtCostFull(cost.total_cost).slice(1)}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 border-b border-border">
{tabs.map(({ id, label, Icon }) => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
className={`flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
tab === id
? "border-violet-500 text-violet-700 dark:text-violet-400"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
{tab === "overview" && (
<div>
{/* Frozen: its duration ends at the capture, not at "now". */}
<SessionOverview
session={{ ...session, ended_at: session.ended_at ?? payload.captured_at }}
agents={agents}
stats={stats}
/>
<div className="space-y-2">
{[...agents]
.sort((a, b) => (a.started_at || "").localeCompare(b.started_at || ""))
.map((a) => (
<AgentCard
key={a.id}
agent={a}
session={{ ...session, cost: cost.total_cost }}
// Without a handler the card opens the LIVE session.
onClick={() => undefined}
/>
))}
</div>
</div>
)}
{tab === "replay" && (
<EventReplay
events={events}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
)}
{tab === "timeline" &&
(events.length === 0 ? (
<p className="text-sm text-fg-muted">{t("detail.noEvents")}</p>
) : (
<EventTimeline
events={[...events].reverse()}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
fallbackCwd={session.cwd}
/>
))}
</div>
);
}
+8 -3
View File
@@ -75,6 +75,7 @@ import {
import { useTranslation } from "react-i18next";
import { Workflow, RefreshCw, Download, AlertCircle, Info } from "lucide-react";
import { api } from "../lib/api";
import type { DateRange } from "../lib/dateRange";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import type { WorkflowData, WSMessage } from "../lib/types";
@@ -91,7 +92,11 @@ import { SkillUsagePanel } from "../components/workflows/SkillUsagePanel";
type StatusFilter = "all" | "active" | "completed";
export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
export function Workflows({
embedded = false,
tool,
range,
}: { embedded?: boolean; tool?: string; range?: DateRange } = {}) {
const { t } = useTranslation("workflows");
const [data, setData] = useState<WorkflowData | null>(null);
const [loading, setLoading] = useState(true);
@@ -108,7 +113,7 @@ export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
const fetchData = useCallback(async () => {
const seq = ++fetchSeq.current;
try {
const result = await api.workflows.get(statusFilter);
const result = await api.workflows.get(statusFilter, tool, range);
if (seq !== fetchSeq.current) return;
setError(null);
setData(result);
@@ -118,7 +123,7 @@ export function Workflows({ embedded = false }: { embedded?: boolean } = {}) {
} finally {
if (seq === fetchSeq.current) setLoading(false);
}
}, [statusFilter]);
}, [statusFilter, tool, range?.from, range?.to]);
const selectSession = useCallback((id: string | null) => {
setSelectedSessionId(id);
+4 -3
View File
@@ -925,10 +925,11 @@ export function Workspace() {
<div
className={
sessionView
? // Cancels Layout's page padding; the 1.5rem left is the status bar's.
"-m-5 flex h-[calc(100vh-1.5rem)] flex-col lg:-m-6"
? // Cancels Layout's page padding; the 1.5rem left is the status bar's,
// plus the drawer's 2.75rem menu bar below `md` (issue #61).
"-m-5 flex h-[calc(100vh-1.5rem)] flex-col max-md:h-[calc(100vh-4.25rem)] lg:-m-6"
: viewportLocked
? "h-[calc(100vh-4rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
? "h-[calc(100vh-4rem)] max-md:h-[calc(100vh-6.75rem)] lg:h-[calc(100vh-4.5rem)] flex flex-col gap-5"
: "space-y-5"
}
>
@@ -0,0 +1,95 @@
/**
* @file QueryExplorer.test.tsx
* @description The Query Explorer page: it renders a page of rows with session
* links, sorts by an allowed column on a header click (flipping on a second),
* switches entity, and points both export links at the current filter.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
const { query } = vi.hoisted(() => ({
query: {
page: vi.fn(),
exportUrl: (p: URLSearchParams, f: string) => `/api/query?${p.toString()}&format=${f}`,
},
}));
vi.mock("../../lib/api", () => ({ api: { query } }));
vi.mock("../../hooks/useAiTools", () => ({
useAiTools: () => ({ tools: [{ id: "codex", label: "Codex" }] }),
}));
import { QueryExplorer } from "../QueryExplorer";
const page = (entity: string, rows: Record<string, string>[]) => ({
entity,
columns: Object.keys(rows[0] ?? {}),
sortable: entity === "sessions" ? ["started_at", "name"] : ["created_at"],
statuses: [],
rows,
total: rows.length,
limit: 50,
offset: 0,
export_cap: 50000,
});
const lastParams = () => {
const calls = query.page.mock.calls;
return calls[calls.length - 1]![0] as URLSearchParams;
};
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
query.page.mockImplementation((p: URLSearchParams) =>
Promise.resolve(
p.get("entity") === "events"
? page("events", [{ id: "1", session_id: "sess-abcdef12", created_at: "2026-10-01" }])
: page("sessions", [{ id: "sess-abcdef12", name: "first", started_at: "2026-10-01" }])
)
);
});
describe("QueryExplorer", () => {
it("shows rows, sorts by an allowed column and exports the filter", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<QueryExplorer />
</MemoryRouter>
);
expect(await screen.findByText("first")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "sess-abc" })).toHaveAttribute(
"href",
"/sessions/sess-abcdef12"
);
await user.click(screen.getByRole("button", { name: "name" }));
await waitFor(() => expect(lastParams().get("sort")).toBe("name"));
expect(lastParams().get("dir")).toBe("desc");
await user.click(screen.getByRole("button", { name: "name" }));
await waitFor(() => expect(lastParams().get("dir")).toBe("asc"));
// A column outside `sortable` has no sort button.
expect(screen.queryByRole("button", { name: "id" })).toBeNull();
const csv = screen.getByRole("link", { name: /Export CSV/ });
expect(csv.getAttribute("href")).toContain("sort=name");
expect(csv.getAttribute("href")).toContain("format=csv");
});
it("switches entity and drops the status filter for events", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<QueryExplorer />
</MemoryRouter>
);
await screen.findByText("first");
expect(screen.getByRole("button", { name: /Status/ })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Data/ }));
await user.click(screen.getByRole("option", { name: "Events" }));
await waitFor(() => expect(lastParams().get("entity")).toBe("events"));
expect(screen.queryByRole("button", { name: /Status/ })).toBeNull();
});
});
+10 -2
View File
@@ -9,7 +9,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
@@ -44,6 +44,14 @@ vi.mock("../../lib/api", async (importOriginal) => {
clearData: () => clearData(),
gc: vi.fn(async () => ({ reaped: [], capped: [] })),
exportData: () => "/api/settings/export",
pushToken: { get: vi.fn(async () => ({ enabled: false, env_owned: false })) },
privacy: {
get: vi.fn(async () => ({
redact_secrets: false,
ignore_cwd: [],
env_owned: { redact_secrets: false, ignore_cwd: false },
})),
},
},
},
};
@@ -112,7 +120,7 @@ describe("Settings", () => {
const dialog = screen.getByRole("dialog", { name: /clear all data/i });
const yes = screen.getByRole("button", { name: /yes, clear all/i });
expect(yes).toBeDisabled();
await user.type(screen.getByRole("textbox"), "clear all data");
await user.type(within(dialog).getByRole("textbox"), "clear all data");
expect(yes).toBeEnabled();
await user.click(yes);
await waitFor(() => expect(clearData).toHaveBeenCalled());
@@ -0,0 +1,131 @@
/**
* @file SnapshotView.test.tsx
* @description The read-only `/snapshot/:token` page renders the frozen payload
* without reading live session data, and Settings → Snapshots lists and
* deletes snapshots behind a confirm.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { SnapshotView } from "../SnapshotView";
import { SnapshotSettings } from "../../components/settings/SnapshotSettings";
const stats = {
session_id: "s1",
total_events: 2,
events_by_type: [],
tools_used: [{ tool_name: "Read", count: 2 }],
error_count: 0,
first_event_at: "2026-10-01T10:00:00.000Z",
last_event_at: "2026-10-01T10:00:01.000Z",
agents: { total: 1, main: 1, subagent: 0, compaction: 0, by_status: {} },
subagent_types: [],
tokens: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
};
const sessionStats = vi.fn();
const remove = vi.fn(() => Promise.resolve({ ok: true }));
vi.mock("../../lib/api", () => ({
api: {
sessions: { stats: (...a: unknown[]) => sessionStats(...a) },
snapshots: {
get: vi.fn(() =>
Promise.resolve({
snapshot: { id: 1, token: "tok", session_id: "s1", created_at: "2026-10-02T00:00:00Z" },
payload: {
version: 1,
captured_at: "2026-10-02T00:00:00.000Z",
redacted: { file_paths: true },
session: {
id: "s1",
name: "Frozen session",
status: "completed",
cwd: "[redacted]",
model: null,
started_at: "2026-10-01T10:00:00.000Z",
ended_at: null,
metadata: null,
},
agents: [],
events: [
{
id: 1,
session_id: "s1",
agent_id: null,
event_type: "SessionStart",
tool_name: null,
summary: "start",
data: null,
created_at: "2026-10-01T10:00:00.000Z",
},
{
id: 2,
session_id: "s1",
agent_id: null,
event_type: "Stop",
tool_name: null,
summary: "stop",
data: null,
created_at: "2026-10-01T10:00:01.000Z",
},
],
stats,
cost: { total_cost: 0, breakdown: [] },
},
})
),
list: vi.fn(() =>
Promise.resolve({
snapshots: [
{
id: 7,
token: "tok7",
session_id: "s1",
created_at: "2026-10-02T00:00:00Z",
session_name: "Frozen session",
redacted: true,
event_count: 2,
},
],
})
),
remove: (...a: unknown[]) => remove(...(a as [])),
},
},
}));
describe("SnapshotView", () => {
it("renders the frozen session and its replay, never the live stats", async () => {
render(
<MemoryRouter initialEntries={["/snapshot/tok"]}>
<Routes>
<Route path="/snapshot/:token" element={<SnapshotView />} />
</Routes>
</MemoryRouter>
);
expect(await screen.findByText("Frozen session")).toBeTruthy();
expect(screen.getByText("Paths redacted")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Replay/ }));
expect(screen.getByTestId("event-replay")).toBeTruthy();
expect(sessionStats).not.toHaveBeenCalled();
});
});
describe("SnapshotSettings", () => {
it("lists snapshots and deletes one after confirming", async () => {
render(
<MemoryRouter>
<SnapshotSettings />
</MemoryRouter>
);
expect(await screen.findByText("Frozen session")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const buttons = screen.getAllByRole("button", { name: "Delete" });
fireEvent.click(buttons[buttons.length - 1]!);
await waitFor(() => expect(remove).toHaveBeenCalledWith(7));
await waitFor(() => expect(screen.queryByText("Frozen session")).toBeNull());
});
});
@@ -3133,6 +3133,92 @@ exports[`screen snapshots > Dashboard 1`] = `
<div
class="flex items-center gap-2 flex-shrink-0"
>
<div
class="w-40"
>
<div
class="relative"
>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Date range"
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
title="Date range"
type="button"
>
<span
class="flex min-w-0 items-center gap-2"
>
<span
class="truncate"
>
All time
</span>
</span>
<svg
aria-hidden="true"
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
<div
class="w-44"
>
<div
class="relative"
>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-label="AI tool"
class="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
title="AI tool"
type="button"
>
<span
class="flex min-w-0 items-center gap-2"
>
<span
class="truncate"
>
All AI tools
</span>
</span>
<svg
aria-hidden="true"
class="lucide lucide-chevron-down w-3.5 h-3.5 text-fg-muted flex-shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
</button>
</div>
</div>
<button
class="btn-ghost"
>
@@ -5296,6 +5382,110 @@ exports[`screen snapshots > Dashboard 1`] = `
No cost data yet.
</p>
</div>
<div
class="card p-5"
>
<h3
class="text-sm font-medium text-fg-secondary mb-5"
>
Spend pace
</h3>
<div
class="space-y-3"
>
<div
class="flex justify-between items-center py-2 border-b border-border last:border-0"
>
<span
class="text-xs text-fg-secondary"
title="Average cost per day over the last 7 days, today included"
>
Burn rate (7-day avg / day)
</span>
<span
class="text-sm font-mono font-medium text-status-success"
>
<span
class="relative inline-block cursor-default"
>
$0.00
</span>
</span>
</div>
<div
class="flex justify-between items-center py-2 border-b border-border last:border-0"
>
<span
class="text-xs text-fg-secondary"
>
Month to date
</span>
<span
class="text-sm font-mono font-medium text-status-success"
>
<span
class="relative inline-block cursor-default"
>
$0.00
</span>
</span>
</div>
<div
class="flex justify-between items-center py-2 border-b border-border last:border-0"
>
<span
class="text-xs text-fg-secondary"
title="Month to date plus the 7-day pace for each day left"
>
Forecast for this month
</span>
<span
class="text-sm font-mono font-medium text-status-success"
>
<span
class="relative inline-block cursor-default"
>
$0.00
</span>
</span>
</div>
</div>
</div>
<div
class="card p-5"
>
<h3
class="text-sm font-medium text-fg-secondary mb-5"
>
Budgets
</h3>
<p
class="text-sm text-fg-muted"
>
No spend budget yet.
<a
class="text-accent hover:underline"
href="/settings/notifications"
>
Add one in Settings → Notifications
</a>
</p>
</div>
<div
class="card p-5"
>
<h3
class="text-sm font-medium text-fg-secondary mb-5"
>
Cost by AI tool
</h3>
<p
class="text-sm text-fg-muted"
>
No cost data yet.
</p>
</div>
<div
class="card p-5"
>
@@ -5795,6 +5985,38 @@ exports[`screen snapshots > Session detail 1`] = `
</button>
</div>
</div>
<div
class="flex flex-wrap items-center justify-end gap-1"
>
<button
class="btn-ghost text-xs"
title="Freeze this session into a read-only copy with a share link"
type="button"
>
<svg
class="lucide lucide-camera h-4 w-4"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"
/>
<circle
cx="12"
cy="13"
r="3"
/>
</svg>
Snapshot
</button>
</div>
<button
aria-label="Refresh"
class="btn-ghost"
@@ -5930,6 +6152,52 @@ exports[`screen snapshots > Session detail 1`] = `
</svg>
Timeline (0/0)
</button>
<button
class="flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors border-transparent text-fg-muted hover:text-fg-secondary"
>
<svg
class="lucide lucide-film w-4 h-4"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="18"
rx="2"
width="18"
x="3"
y="3"
/>
<path
d="M7 3v18"
/>
<path
d="M3 7.5h4"
/>
<path
d="M3 12h18"
/>
<path
d="M3 16.5h4"
/>
<path
d="M17 3v18"
/>
<path
d="M17 7.5h4"
/>
<path
d="M17 16.5h4"
/>
</svg>
Replay
</button>
</div>
<div>
<div
@@ -6841,6 +7109,58 @@ exports[`screen snapshots > Settings — about 1`] = `
class="scroll-mt-24"
id="about"
>
<div
class="mb-4"
>
<section
class="card p-5 scroll-mt-24"
id="autostart"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
Startup
</h3>
<div
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Start AgentHub automatically
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
</p>
</div>
<div
class="shrink-0"
>
<button
aria-checked="false"
aria-label="Start AgentHub automatically"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
/>
</button>
</div>
</div>
</div>
</section>
</div>
<p
class="text-xs text-fg-muted mb-4"
>
@@ -6913,7 +7233,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v2.2.1 (rebuild client to match)
UI build v2.5.2 (rebuild client to match)
</p>
</div>
<div
@@ -8443,6 +8763,266 @@ exports[`screen snapshots > Settings — data 1`] = `
</div>
</div>
</section>
<div
class="scroll-mt-24"
id="push-ingest"
>
<section
class="card p-5"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
<span
class="flex items-center gap-2"
>
<svg
class="lucide lucide-key-round w-4 h-4 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"
/>
<circle
cx="16.5"
cy="7.5"
fill="currentColor"
r=".5"
/>
</svg>
Push ingest
</span>
</h3>
<p
class="mt-0.5 text-xs text-fg-muted"
>
For a machine this dashboard cannot reach over SSH: it pushes its own hooks to POST /api/hooks/ingest-batch with a token made here. Its sessions are tagged with the name it sends (push:&lt;name&gt;) and show live status and events; their token cost needs the transcripts, which only an SSH source brings.
</p>
<div
class="mt-2"
>
<div
class="space-y-3 py-2"
>
<div
class="flex flex-wrap items-center gap-2"
>
<span
class="text-xs text-fg-muted"
>
Off — no token yet
</span>
<button
class="btn-primary text-xs disabled:opacity-40"
type="button"
>
<svg
class="lucide lucide-refresh-cw w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"
/>
<path
d="M21 3v5h-5"
/>
<path
d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"
/>
<path
d="M8 16H3v5"
/>
</svg>
Create token
</button>
</div>
<p
class="text-xs text-fg-muted"
>
The other machine must reach this dashboard: start it with DASHBOARD_HOST (and DASHBOARD_ALLOWED_HOSTS for its name). Over plain http the token crosses the network readable — use it only on a network you trust, or behind TLS. The forwarder below keeps the token in a header file, never on a command line:
</p>
<div
class="relative"
>
<pre
class="text-[11px] font-mono bg-surface-1 border border-border rounded-lg p-3 overflow-x-auto text-fg-secondary"
>
#!/bin/sh
# ~/.claude/agenthub-push.sh &lt;HookEvent&gt; — a Claude Code hook command:
# "command": "sh ~/.claude/agenthub-push.sh PreToolUse"
# ~/.claude/agenthub-push.header (chmod 600) holds one line:
# Authorization: Bearer &lt;token&gt;
jq -c --arg t "$1" --arg s "$(hostname -s)" \\
'{source: $s, events: [{hook_type: $t, data: .}]}' |
curl -s -m 5 -o /dev/null -X POST "http://localhost/api/hooks/ingest-batch" \\
-H "@$HOME/.claude/agenthub-push.header" \\
-H "Content-Type: application/json" --data-binary @-
exit 0
</pre>
<button
class="btn-ghost text-xs absolute top-1.5 right-1.5"
title="Copy"
type="button"
>
<svg
class="lucide lucide-copy w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="14"
rx="2"
ry="2"
width="14"
x="8"
y="8"
/>
<path
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
/>
</svg>
</button>
</div>
<p
class="text-xs text-fg-muted"
>
At most 500 events and 1 MB per request. A hook for a session another source owns is refused; transcript paths are dropped (they name files on the sender). Codex hooks are taken; Antigravity hooks are not.
</p>
</div>
</div>
</section>
</div>
<div
class="scroll-mt-24"
id="privacy"
>
<section
class="card p-5"
>
<h3
class="text-sm font-semibold text-fg-primary"
>
<span
class="flex items-center gap-2"
>
<svg
class="lucide lucide-shield-check w-4 h-4 text-fg-muted"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"
/>
<path
d="m9 12 2 2 4-4"
/>
</svg>
Privacy
</span>
</h3>
<p
class="mt-0.5 text-xs text-fg-muted"
>
What AgentHub keeps from hooks and imported history. Both are off by default and apply to new data only.
</p>
<div
class="mt-2"
>
<div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
>
<div
class="min-w-0"
>
<p
class="text-sm text-fg-secondary"
>
Redact secrets before storing
</p>
<p
class="mt-0.5 text-xs text-fg-muted"
>
API keys and tokens of known shapes, bearer headers, private keys and password=/TOKEN= values in event payloads and summaries become [redacted]. The transcripts on disk are the CLIs' own files and are not changed.
</p>
</div>
<div
class="shrink-0"
>
<button
aria-checked="false"
aria-label="Redact secrets before storing"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
/>
</button>
</div>
</div>
<div
class="py-3 space-y-2"
>
<label
class="block text-sm text-fg-secondary"
for="privacy-ignore"
>
Ignored folders
</label>
<p
class="text-xs text-fg-muted"
>
One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.
</p>
<textarea
class="input w-full font-mono text-xs min-h-[72px]"
id="privacy-ignore"
placeholder="/home/you/clients/secret-project
~/private"
/>
<button
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
type="button"
>
Save
</button>
</div>
</div>
</section>
</div>
<section
class="card p-5 space-y-4 scroll-mt-24"
id="cleanup"
@@ -318,6 +318,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
totalCost: r(cost),
sessionCost: r(cost),
},
budgets: { status: r({ budgets: [] }) },
settings: {
info: r(settingsInfo),
claudeHome: {
@@ -328,6 +329,23 @@ vi.mock("../../lib/api", async (importOriginal) => {
reimport: r({ ok: true, imported: 0, skipped: 0, errors: 0 }),
reinstallHooks: r({ ok: true, hooks: { installed: true, hooks: {} } }),
resetPricing: r({ ok: true, pricing: [] }),
pushToken: {
get: r({ enabled: false, env_owned: false }),
rotate: r({ token: "t", enabled: true, env_owned: false }),
revoke: r({ enabled: false, env_owned: false }),
},
autostart: {
get: r({ available: true, kind: "systemd", enabled: false, foreign: false }),
set: r({ available: true, kind: "systemd", enabled: true, foreign: false }),
},
privacy: {
get: r({
redact_secrets: false,
ignore_cwd: [],
env_owned: { redact_secrets: false, ignore_cwd: false },
}),
save: r({ ok: true }),
},
exportData: () => "/api/settings/export",
cleanup: r({
ok: true,
@@ -483,6 +501,7 @@ vi.mock("../../lib/push", () => ({
}));
// Page components (imported after the mocks above; vi.mock is hoisted).
import { api } from "../../lib/api";
import { Dashboard } from "../Dashboard";
import { Sessions } from "../Sessions";
import { SessionDetail } from "../SessionDetail";
@@ -570,6 +589,19 @@ describe("screen snapshots", () => {
const headings = [...container.querySelectorAll("h2")].map((h) => h.textContent);
expect(headings).toContain(i18n.t("workflows:title"));
});
it("Dashboard Analytics reads its date range from ?range=", async () => {
localStorage.removeItem("dashboard_tab");
render(
<MemoryRouter initialEntries={["/?tab=analytics&range=7d"]}>
<Dashboard />
</MemoryRouter>
);
await settle();
const range = { from: "2026-06-04", to: "2026-06-10" };
expect(api.analytics.get).toHaveBeenCalledWith({ tool: "", range });
expect(api.pricing.totalCost).toHaveBeenCalledWith({ tool: "", range });
expect(screen.getByRole("button", { name: /Date range/ })).toHaveTextContent("Last 7 days");
});
it("Sessions", async () => {
await snapshot(<Sessions />, "/sessions");
});
+57
View File
@@ -0,0 +1,57 @@
# Prometheus + Grafana for AgentHub's /api/metrics (docs/MONITORING.md).
#
# DASHBOARD_ALLOWED_HOSTS=host.docker.internal agenthub start # or npm start
# GF_SECURITY_ADMIN_PASSWORD='<choose one>' docker compose -f docker-compose.monitoring.yml up -d
# open http://127.0.0.1:3000 (user admin)
#
# The dashboard runs on the HOST; Prometheus reaches it via host.docker.internal
# (monitoring/prometheus/prometheus.yml). The Prometheus datasource and the three
# AgentHub boards are provisioned, so there is nothing to click. Both UIs bind
# loopback only.
services:
prometheus:
image: prom/prometheus:v3.13.2@sha256:508729e0e2d18e11fd742a5a5ca70e557b940a93948c3c95fd0123a6fd538b69
container_name: agenthub-prometheus
restart: unless-stopped
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=90d
volumes:
- ./monitoring/prometheus:/etc/prometheus:ro
- prometheus-data:/prometheus
ports:
- "127.0.0.1:9090:9090"
# Linux needs the explicit mapping; Docker Desktop resolves it natively.
extra_hosts:
- "host.docker.internal:host-gateway"
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
grafana:
image: grafana/grafana:13.1.2@sha256:d177053ab62253815f130d81504f77063baf5fd4ca93299d6048453bd31e047a
container_name: agenthub-grafana
restart: unless-stopped
depends_on: [prometheus]
environment:
GF_SECURITY_ADMIN_USER: ${GF_SECURITY_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GF_SECURITY_ADMIN_PASSWORD:?set GF_SECURITY_ADMIN_PASSWORD}
GF_USERS_ALLOW_SIGN_UP: "false"
GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /var/lib/grafana/dashboards/agenthub-overview.json
GF_ANALYTICS_REPORTING_ENABLED: "false"
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
volumes:
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana-data:/var/lib/grafana
ports:
- "127.0.0.1:3000:3000"
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
volumes:
prometheus-data:
grafana-data:
+128 -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) |
@@ -1729,6 +1735,15 @@ POST /api/settings/cleanup
Body `{ abandon_hours?, purge_days?, dry_run? }`. `abandon_hours` marks active sessions silent that long as `abandoned` (and completes their working agents); `purge_days` deletes ended sessions (with their events and agents) that started more than that many days ago. `purge_days` must be a whole number ≥ 1, else `400 INVALID_PURGE_DAYS` — a fraction would purge almost everything. `dry_run: true` answers the same counts (`abandoned`, `purged_sessions`, `purged_events`, `purged_agents`, plus `dry_run: true`) and changes nothing.
#### Start at boot/login
```http
GET /api/settings/autostart
PUT /api/settings/autostart
```
Settings → About's **Start AgentHub automatically** switch. `GET` answers `{ available, kind, enabled, foreign }`: `kind` is `systemd` (a `--user` unit) or `launchd` (a LaunchAgent), `available` is false with no systemd `--user` / no macOS GUI login, `foreign` is true when a unit named `agenthub` exists that AgentHub did not write. `PUT { enabled: boolean }` (same-origin only) enables or disables the unit — writing one for the running server first when none exists — and answers the same shape. It never starts or stops the server. Off by default: a plugin install gets no unit until this is turned on. `400 EBADVALUE` for a non-boolean, `400 EUNAVAILABLE` with no service manager, `409 EFOREIGN` for a foreign unit.
---
### Tools
@@ -1800,6 +1815,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 +1831,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 +2021,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 +2040,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 +2128,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 +2147,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 +2259,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 +2514,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.
+16 -6
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).
@@ -126,12 +126,13 @@ waits on an install:
2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)).
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir, run as the npm bundled with the node running the bootstrap (`npmCommand`: its `npm-cli.js` beside `process.execPath`, no shell, that node's directory first on PATH; `npm` on PATH only when that node ships none) — the update chain runs with the dashboard server's environment, whose PATH need not reach `npm.cmd` on Windows. The UI build below and the doctor's npm row use the same npm (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
6. **Legacy hook cleanup** — see above.
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
9. **Server** — on Linux with `systemd --user` available, install/refresh a
`agenthub.service` unit — on macOS with a GUI login, the
9. **Server** — when the user has turned **Start AgentHub automatically** on
(Settings → About; OFF by default), install/refresh the
`agenthub.service` unit on Linux — on macOS with a GUI login, the
`io.smartgift.agenthub` LaunchAgent (see [Auto-start on boot](#auto-start-on-boot)) — instead
of a bare spawn, so the server is supervised and comes back after a reboot
with no Claude Code session involved. Everywhere else (Windows, no
@@ -242,7 +243,16 @@ throughout: a directory that will not delete is left alone, never an error.
### Auto-start on boot
On Linux, every plugin bootstrap (not just the first) installs/refreshes a
Opt-in, and off by default: Settings → About → **Start AgentHub automatically**
(`GET/PUT /api/settings/autostart`, `server/lib/autostart.js`). The switch IS
the unit's enabled state — on writes a unit for the running server when none
exists and enables it, off disables it; neither starts or stops the server. A
bootstrap only installs or starts a unit that is enabled, never changes that
state (`writeUserService({ enable })` keeps it on a rewrite), and with it off
the server is the detached spawn below. An install that already had the unit
enabled before this switch existed keeps it on.
While it is on, every plugin bootstrap (not just the first) installs/refreshes a
systemd `--user` service (`~/.config/systemd/user/agenthub.service`, managed via
`scripts/systemd-service.js` — shared with the checkout path's
`agenthub service install`, see the README). Its `ExecStart`/`WorkingDirectory`
@@ -632,7 +642,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.
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env node
/**
* @file AgentHub's MCP server — a small, READ-ONLY Model Context Protocol server
* over stdio (newline-delimited JSON-RPC 2.0: `initialize`, `ping`,
* `tools/list`, `tools/call`). Every tool is one GET against the running
* dashboard's HTTP API, so the numbers are the dashboard's own; nothing here
* opens the database or writes anything. The base URL is `AGENTHUB_URL`, else
* the CLI's discovery (`server/lib/dashboard-url.js`); `DASHBOARD_TOKEN`, when
* set, is sent as a bearer token. Run it as `agenthub mcp` or
* `node mcp/server.js`; see docs/MCP.md.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("node:path");
const readline = require("node:readline");
const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
const VERSION = (() => {
try {
return require(path.join(__dirname, "..", "package.json")).version;
} catch {
return "0.0.0";
}
})();
function baseUrl() {
if (process.env.AGENTHUB_URL) return process.env.AGENTHUB_URL.replace(/\/+$/, "");
return require(path.join(__dirname, "..", "server", "lib", "dashboard-url.js")).dashboardUrl();
}
// ── Tool table ───────────────────────────────────────────────────────────────
// Each tool: description, JSON-schema properties, and `path(args)` → the API
// path to GET. Only the listed query keys are ever forwarded.
const str = (description) => ({ type: "string", description });
const int = (description) => ({ type: "integer", description });
const TZ = int(
"Minutes from UTC as JavaScript's getTimezoneOffset() returns them (e.g. -420 for UTC+7). Defaults to this machine's."
);
const TOOL = str(
"AI CLI to scope to: claude, codex, antigravity, … (ignored by dashboards without the filter)"
);
const SESSION_ID = str("Session id");
const LANE = int("Workspace (lane) id, from list_lanes");
/** `?a=1&b=2` from the given keys of `args`, skipping empty ones. */
function query(args, keys) {
const q = new URLSearchParams();
for (const k of keys) {
const v = args[k];
if (v !== undefined && v !== null && v !== "") q.set(k, String(v));
}
const s = q.toString();
return s ? `?${s}` : "";
}
const withTz = (args) => ({ tz_offset: new Date().getTimezoneOffset(), ...args });
const id = (v) => encodeURIComponent(String(v));
const TOOLS = {
get_health: {
description: "Is the AgentHub dashboard up, and which version.",
properties: {},
path: () => "/api/health",
},
get_info: {
description:
"Dashboard system info: version, database size and row counts, server memory, hook status.",
properties: {},
path: () => "/api/settings/info",
},
get_stats: {
description: "Overview totals: sessions and agents by status, today's events, active sessions.",
properties: { tz_offset: TZ, tool: TOOL },
path: (a) => `/api/stats${query(withTz(a), ["tz_offset", "tool"])}`,
},
get_total_cost: {
description:
"Estimated USD cost across all sessions, with per-model breakdown and daily costs (daily_costs[].date in the given time zone).",
properties: { tz_offset: TZ, lane: LANE, tool: TOOL },
path: (a) => `/api/pricing/cost${query(withTz(a), ["tz_offset", "lane", "tool"])}`,
},
get_session_cost: {
description: "Estimated USD cost of one session, per model.",
properties: { session_id: SESSION_ID, tz_offset: TZ },
required: ["session_id"],
path: (a) => `/api/pricing/cost/${id(a.session_id)}${query(withTz(a), ["tz_offset"])}`,
},
get_lane_cost: {
description: "Estimated USD cost of the sessions one workspace (lane) owns.",
properties: { lane: LANE, tz_offset: TZ },
required: ["lane"],
path: (a) => `/api/pricing/cost${query(withTz(a), ["tz_offset", "lane"])}`,
},
list_sessions: {
description: "List sessions, newest first, with cost and token totals.",
properties: {
q: str("Substring of the session id, name or cwd"),
status: { type: "string", enum: ["active", "completed", "error", "abandoned"] },
tool: TOOL,
lane: LANE,
limit: int("Max rows (default 50)"),
},
path: (a) => `/api/sessions${query(a, ["q", "status", "tool", "lane", "limit"])}`,
},
get_session: {
description:
"One session: its row, agents (with each agent's own cost) and Workflow-tool runs. Events are left out unless include_events is true (they can be huge).",
properties: {
session_id: SESSION_ID,
include_events: { type: "boolean", description: "Also return every hook event" },
},
required: ["session_id"],
path: (a) => `/api/sessions/${id(a.session_id)}${a.include_events ? "" : "?events=0"}`,
},
get_session_stats: {
description: "Aggregated counts for one session: tools used, agents, errors, durations.",
properties: { session_id: SESSION_ID },
required: ["session_id"],
path: (a) => `/api/sessions/${id(a.session_id)}/stats`,
},
get_analytics: {
description:
"Token totals, tool usage, daily events/sessions, agent types — the Analytics page's data.",
properties: { tz_offset: TZ, tool: TOOL },
path: (a) => `/api/analytics${query(withTz(a), ["tz_offset", "tool"])}`,
},
get_workflows: {
description:
"Workflow intelligence: orchestration, tool flow, subagent effectiveness, patterns.",
properties: { status: { type: "string", enum: ["active", "completed"] }, tool: TOOL },
path: (a) => `/api/workflows${query(a, ["status", "tool"])}`,
},
list_alerts: {
description: "Fired alerts, newest first.",
properties: {
unacked: { type: "boolean", description: "Only unacknowledged ones" },
limit: int("Max rows (default 50, max 200)"),
},
path: (a) =>
`/api/alerts${query({ ...a, unacked: a.unacked ? "true" : undefined }, ["unacked", "limit"])}`,
},
list_lanes: {
description: "Workspaces (lanes): id, title, folder, AI tool, status and liveness.",
properties: {},
path: () => "/api/lanes",
},
list_pricing: {
description: "Model pricing rules (USD per million tokens) the cost estimates use.",
properties: {},
path: () => "/api/pricing",
},
get_budget_status: {
description: "Budgets: spend this period, percent of limit, projected end-of-period spend.",
properties: {},
path: () => "/api/budgets/status",
},
};
// ── HTTP ─────────────────────────────────────────────────────────────────────
/** GET a dashboard path; resolves to an MCP tool result, never rejects. */
async function callApi(pathname) {
const url = `${baseUrl()}${pathname}`;
const headers = process.env.DASHBOARD_TOKEN
? { Authorization: `Bearer ${process.env.DASHBOARD_TOKEN}` }
: {};
let res;
try {
res = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) });
} catch (err) {
return fail(
`AgentHub dashboard not reachable at ${baseUrl()} (${err?.cause?.code || err?.name || err}). Start it with \`agenthub start\`, or set AGENTHUB_URL.`
);
}
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = undefined;
}
// The API answers in JSON, its own 404s included. A non-JSON body is
// Express's "Cannot GET" 404, or — on a production dashboard — the SPA page
// served with 200 for any unknown path: either way, no such route.
if (data === undefined && (res.ok || res.status === 404)) {
return fail(
`Not available on this dashboard: GET ${pathname} does not exist (an older AgentHub?).`
);
}
if (!res.ok) {
return fail(
`GET ${pathname} → HTTP ${res.status}: ${data?.error?.message || text.slice(0, 300)}`
);
}
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
}
const fail = (text) => ({ content: [{ type: "text", text }], isError: true });
// ── JSON-RPC ────────────────────────────────────────────────────────────────
function toolList() {
return Object.entries(TOOLS).map(([name, t]) => ({
name,
description: t.description,
inputSchema: {
type: "object",
properties: t.properties,
...(t.required ? { required: t.required } : {}),
additionalProperties: false,
},
annotations: { readOnlyHint: true, openWorldHint: false },
}));
}
async function callTool(params) {
const tool = TOOLS[params?.name];
if (!tool) return fail(`Unknown tool: ${params?.name}`);
const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {};
const missing = (tool.required || []).filter((k) => args[k] === undefined || args[k] === "");
if (missing.length) return fail(`Missing required argument(s): ${missing.join(", ")}`);
return callApi(tool.path(args));
}
/** Answer one parsed message; resolves to the response, or null for a notification. */
async function handle(msg) {
const reply = (result) => ({ jsonrpc: "2.0", id: msg.id, result });
const error = (code, message) => ({
jsonrpc: "2.0",
id: msg.id ?? null,
error: { code, message },
});
if (!msg || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") {
return error(-32600, "Invalid Request");
}
const isNotification = msg.id === undefined;
let out;
switch (msg.method) {
case "initialize": {
const asked = msg.params?.protocolVersion;
out = reply({
protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
capabilities: { tools: {} },
serverInfo: { name: "agenthub", version: VERSION },
instructions:
"Read-only access to the local AgentHub dashboard: sessions, costs, analytics, workspaces, alerts.",
});
break;
}
case "ping":
out = reply({});
break;
case "tools/list":
out = reply({ tools: toolList() });
break;
case "tools/call":
out = reply(await callTool(msg.params));
break;
default:
out = error(-32601, `Method not found: ${msg.method}`);
}
return isNotification ? null : out;
}
function main() {
const send = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
const rl = readline.createInterface({ input: process.stdin });
rl.on("line", async (line) => {
if (!line.trim()) return;
let msg;
try {
msg = JSON.parse(line);
} catch {
return send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
}
try {
const out = await handle(msg);
if (out) send(out);
} catch (err) {
if (msg?.id !== undefined) {
send({
jsonrpc: "2.0",
id: msg.id,
error: { code: -32603, message: String(err?.message || err) },
});
}
}
});
}
if (require.main === module) main();
module.exports = { main, handle, TOOLS };
@@ -0,0 +1,666 @@
{
"uid": "agenthub-overview",
"title": "AgentHub — Overview",
"description": "Health, activity and spend of one AgentHub dashboard (/api/metrics).",
"tags": ["agenthub"],
"editable": true,
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": {
"from": "now-24h",
"to": "now"
},
"timezone": "browser",
"templating": {
"list": [
{
"name": "ds",
"label": "Prometheus",
"type": "datasource",
"query": "prometheus",
"current": {},
"hide": 0
}
]
},
"links": [
{
"title": "AgentHub boards",
"type": "dashboards",
"tags": ["agenthub"],
"asDropdown": true
}
],
"annotations": {
"list": []
},
"panels": [
{
"id": 1,
"type": "stat",
"title": "Dashboard up",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 0,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "max(agenthub_up)",
"legendFormat": "__auto",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"mappings": [
{
"type": "value",
"options": {
"0": {
"text": "DOWN",
"color": "red"
},
"1": {
"text": "UP",
"color": "green"
}
}
}
]
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
}
},
{
"id": 2,
"type": "stat",
"title": "Version",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 4,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "agenthub_build_info",
"legendFormat": "{{version}}",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "name",
"colorMode": "none",
"graphMode": "none"
}
},
{
"id": 3,
"type": "stat",
"title": "Active sessions",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 8,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(agenthub_sessions{status=\"active\"})",
"legendFormat": "__auto",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
}
},
{
"id": 4,
"type": "stat",
"title": "Working agents",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 12,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(agenthub_agents{status=\"working\"})",
"legendFormat": "__auto",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
}
},
{
"id": 5,
"type": "stat",
"title": "Tokens (24h)",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 16,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(increase(agenthub_tokens_total[24h]))",
"legendFormat": "__auto",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
}
},
{
"id": 6,
"type": "stat",
"title": "Spend (24h)",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 20,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(increase(agenthub_cost_usd_total[24h]))",
"legendFormat": "__auto",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "currencyUSD",
"decimals": 2
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
},
"description": "Requires agenthub_cost_usd_total{tool,model} — empty on a dashboard that does not export it yet."
},
{
"id": 7,
"type": "timeseries",
"title": "Sessions by status",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 0,
"y": 4,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum by (status) (agenthub_sessions)",
"legendFormat": "{{status}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"custom": {
"fillOpacity": 15,
"lineWidth": 2,
"stacking": {
"mode": "none"
}
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 8,
"type": "timeseries",
"title": "Agents by status",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 12,
"y": 4,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum by (status) (agenthub_agents)",
"legendFormat": "{{status}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"custom": {
"fillOpacity": 15,
"lineWidth": 2,
"stacking": {
"mode": "none"
}
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 9,
"type": "timeseries",
"title": "Events per minute",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 0,
"y": 12,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(rate(agenthub_events_total[5m])) * 60",
"legendFormat": "events/min"
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"custom": {
"fillOpacity": 15,
"lineWidth": 2,
"stacking": {
"mode": "none"
}
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 10,
"type": "timeseries",
"title": "Token burn (per second, by kind)",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 12,
"y": 12,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum by (kind) (rate(agenthub_tokens_total[5m]))",
"legendFormat": "{{kind}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"custom": {
"fillOpacity": 15,
"lineWidth": 2,
"stacking": {
"mode": "normal"
}
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 11,
"type": "timeseries",
"title": "Server memory (RSS)",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 0,
"y": 20,
"w": 8,
"h": 7
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "agenthub_process_resident_memory_bytes",
"legendFormat": "rss"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes",
"custom": {
"fillOpacity": 15,
"lineWidth": 2,
"stacking": {
"mode": "none"
}
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 12,
"type": "timeseries",
"title": "Server uptime",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 8,
"y": 20,
"w": 8,
"h": 7
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "agenthub_process_uptime_seconds",
"legendFormat": "uptime"
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"custom": {
"fillOpacity": 15,
"lineWidth": 2,
"stacking": {
"mode": "none"
}
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 13,
"type": "timeseries",
"title": "Realtime clients / remote sources",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 16,
"y": 20,
"w": 8,
"h": 7
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "agenthub_websocket_clients",
"legendFormat": "websocket clients"
},
{
"refId": "B",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum by (enabled) (agenthub_remote_sources)",
"legendFormat": "remote sources (auto-sync {{enabled}})"
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"custom": {
"fillOpacity": 15,
"lineWidth": 2,
"stacking": {
"mode": "none"
}
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
}
]
}
@@ -0,0 +1,490 @@
{
"uid": "agenthub-sessions-agents",
"title": "AgentHub — Sessions & agents",
"description": "Session and agent lifecycle counts from /api/metrics.",
"tags": ["agenthub"],
"editable": true,
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": {
"from": "now-24h",
"to": "now"
},
"timezone": "browser",
"templating": {
"list": [
{
"name": "ds",
"label": "Prometheus",
"type": "datasource",
"query": "prometheus",
"current": {},
"hide": 0
}
]
},
"links": [
{
"title": "AgentHub boards",
"type": "dashboards",
"tags": ["agenthub"],
"asDropdown": true
}
],
"annotations": {
"list": []
},
"panels": [
{
"id": 1,
"type": "stat",
"title": "Sessions (all)",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 0,
"y": 0,
"w": 6,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(agenthub_sessions)",
"legendFormat": "__auto",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
}
},
{
"id": 2,
"type": "stat",
"title": "Active",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 6,
"y": 0,
"w": 6,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(agenthub_sessions{status=\"active\"})",
"legendFormat": "__auto",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
}
},
{
"id": 3,
"type": "stat",
"title": "Errored sessions",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 12,
"y": 0,
"w": 6,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(agenthub_sessions{status=\"error\"})",
"legendFormat": "__auto",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
}
},
{
"id": 4,
"type": "stat",
"title": "Waiting agents (need you)",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 18,
"y": 0,
"w": 6,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(agenthub_agents{status=\"waiting\"})",
"legendFormat": "__auto",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
}
},
{
"id": 5,
"type": "bargauge",
"title": "Sessions now, by status",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 0,
"y": 4,
"w": 12,
"h": 7
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum by (status) (agenthub_sessions)",
"legendFormat": "{{status}}",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"options": {
"orientation": "horizontal",
"displayMode": "gradient",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
}
}
},
{
"id": 6,
"type": "bargauge",
"title": "Agents now, by status",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 12,
"y": 4,
"w": 12,
"h": 7
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum by (status) (agenthub_agents)",
"legendFormat": "{{status}}",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"options": {
"orientation": "horizontal",
"displayMode": "gradient",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
}
}
},
{
"id": 7,
"type": "timeseries",
"title": "Active sessions and working agents",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 0,
"y": 11,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(agenthub_sessions{status=\"active\"})",
"legendFormat": "active sessions"
},
{
"refId": "B",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(agenthub_agents{status=\"working\"})",
"legendFormat": "working agents"
},
{
"refId": "C",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(agenthub_agents{status=\"waiting\"})",
"legendFormat": "waiting agents"
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"custom": {
"fillOpacity": 15,
"lineWidth": 2,
"stacking": {
"mode": "none"
}
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 8,
"type": "timeseries",
"title": "Sessions finished per hour",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 12,
"y": 11,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum by (status) (clamp_min(delta(agenthub_sessions{status=~\"completed|error|abandoned\"}[1h]), 0))",
"legendFormat": "{{status}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"custom": {
"drawStyle": "bars",
"fillOpacity": 60,
"stacking": {
"mode": "normal"
}
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 9,
"type": "timeseries",
"title": "Events per minute",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 0,
"y": 19,
"w": 24,
"h": 7
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(rate(agenthub_events_total[5m])) * 60",
"legendFormat": "events/min"
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"custom": {
"fillOpacity": 15,
"lineWidth": 2,
"stacking": {
"mode": "none"
}
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
}
]
}
@@ -0,0 +1,462 @@
{
"uid": "agenthub-tokens-cost",
"title": "AgentHub — Tokens & cost",
"description": "Token usage by kind and estimated USD spend by tool and model.",
"tags": ["agenthub"],
"editable": true,
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": {
"from": "now-24h",
"to": "now"
},
"timezone": "browser",
"templating": {
"list": [
{
"name": "ds",
"label": "Prometheus",
"type": "datasource",
"query": "prometheus",
"current": {},
"hide": 0
}
]
},
"links": [
{
"title": "AgentHub boards",
"type": "dashboards",
"tags": ["agenthub"],
"asDropdown": true
}
],
"annotations": {
"list": []
},
"panels": [
{
"id": 1,
"type": "stat",
"title": "Tokens (all time)",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 0,
"y": 0,
"w": 6,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(agenthub_tokens_total)",
"legendFormat": "__auto",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
}
},
{
"id": 2,
"type": "stat",
"title": "Cache read share (24h)",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 6,
"y": 0,
"w": 6,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(increase(agenthub_tokens_total{kind=\"cache_read\"}[24h])) / sum(increase(agenthub_tokens_total{kind=~\"input|cache_read|cache_write\"}[24h]))",
"legendFormat": "__auto",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"decimals": 1
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
}
},
{
"id": 3,
"type": "stat",
"title": "Spend (all time)",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 12,
"y": 0,
"w": 6,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(agenthub_cost_usd_total)",
"legendFormat": "__auto",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "currencyUSD",
"decimals": 2
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
},
"description": "Requires agenthub_cost_usd_total{tool,model} — empty on a dashboard that does not export it yet."
},
{
"id": 4,
"type": "stat",
"title": "Spend (24h)",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 18,
"y": 0,
"w": 6,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum(increase(agenthub_cost_usd_total[24h]))",
"legendFormat": "__auto",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "currencyUSD",
"decimals": 2
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "area",
"textMode": "auto"
},
"description": "Requires agenthub_cost_usd_total{tool,model} — empty on a dashboard that does not export it yet."
},
{
"id": 5,
"type": "timeseries",
"title": "Tokens per hour, by kind",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 0,
"y": 4,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum by (kind) (increase(agenthub_tokens_total[1h]))",
"legendFormat": "{{kind}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"custom": {
"drawStyle": "bars",
"fillOpacity": 60,
"stacking": {
"mode": "normal"
}
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"interval": "1h"
},
{
"id": 6,
"type": "timeseries",
"title": "Token burn (per second, by kind)",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 12,
"y": 4,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum by (kind) (rate(agenthub_tokens_total[5m]))",
"legendFormat": "{{kind}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"custom": {
"fillOpacity": 15,
"lineWidth": 2,
"stacking": {
"mode": "normal"
}
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 7,
"type": "timeseries",
"title": "Spend per hour, by tool",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 0,
"y": 12,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum by (tool) (increase(agenthub_cost_usd_total[1h]))",
"legendFormat": "{{tool}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "currencyUSD",
"custom": {
"drawStyle": "bars",
"fillOpacity": 60,
"stacking": {
"mode": "normal"
}
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"interval": "1h",
"description": "Requires agenthub_cost_usd_total{tool,model} — empty on a dashboard that does not export it yet."
},
{
"id": 8,
"type": "piechart",
"title": "Spend by tool (all time)",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 12,
"y": 12,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "sum by (tool) (agenthub_cost_usd_total)",
"legendFormat": "{{tool}}",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "currencyUSD",
"decimals": 2
},
"overrides": []
},
"description": "Requires agenthub_cost_usd_total{tool,model} — empty on a dashboard that does not export it yet.",
"options": {
"pieType": "donut",
"legend": {
"displayMode": "table",
"placement": "right",
"values": ["value", "percent"]
},
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
}
}
},
{
"id": 9,
"type": "bargauge",
"title": "Top models by spend (24h)",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"gridPos": {
"x": 0,
"y": 20,
"w": 24,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "${ds}"
},
"expr": "topk(10, sum by (model) (increase(agenthub_cost_usd_total[24h])))",
"legendFormat": "{{model}}",
"instant": true,
"range": false
}
],
"fieldConfig": {
"defaults": {
"unit": "currencyUSD",
"decimals": 2
},
"overrides": []
},
"description": "Requires agenthub_cost_usd_total{tool,model} — empty on a dashboard that does not export it yet.",
"options": {
"orientation": "horizontal",
"displayMode": "gradient",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
}
}
}
]
}
@@ -0,0 +1,14 @@
# Loads the AgentHub boards (overview, sessions & agents, tokens & cost) from
# /var/lib/grafana/dashboards, the compose file's mount of monitoring/grafana/dashboards.
apiVersion: 1
providers:
- name: AgentHub
orgId: 1
folder: AgentHub
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards
@@ -0,0 +1,13 @@
# The Prometheus datasource for docker-compose.monitoring.yml. The boards pick
# their datasource through a `ds` variable, so they also import into any other
# Grafana as they are.
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
uid: agenthub-prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
+27
View File
@@ -0,0 +1,27 @@
# Recording rules over AgentHub's /api/metrics series (docs/MONITORING.md).
# The agenthub:cost_usd:* rules need agenthub_cost_usd_total{tool,model}; on a
# dashboard that does not export it they simply record nothing.
groups:
- name: agenthub_recording
interval: 30s
rules:
- record: agenthub:sessions:total
expr: sum(agenthub_sessions)
- record: agenthub:sessions:active
expr: sum(agenthub_sessions{status="active"})
- record: agenthub:agents:working
expr: sum(agenthub_agents{status="working"})
- record: agenthub:agents:waiting
expr: sum(agenthub_agents{status="waiting"})
- record: agenthub:events:rate5m
expr: sum(rate(agenthub_events_total[5m]))
- record: agenthub:tokens:rate5m
expr: sum by (kind) (rate(agenthub_tokens_total[5m]))
- record: agenthub:tokens:total
expr: sum(agenthub_tokens_total)
- record: agenthub:cost_usd:total
expr: sum(agenthub_cost_usd_total)
- record: agenthub:cost_usd:increase1h
expr: sum by (tool) (increase(agenthub_cost_usd_total[1h]))
- record: agenthub:cost_usd:increase24h
expr: sum by (tool, model) (increase(agenthub_cost_usd_total[24h]))
+26
View File
@@ -0,0 +1,26 @@
# Prometheus scrape config for AgentHub's /api/metrics (see docs/MONITORING.md).
#
# As shipped it is the config docker-compose.monitoring.yml mounts: Prometheus
# runs in a container and reaches the dashboard on the HOST through
# host.docker.internal. For a Prometheus running natively on the same machine,
# change the target to "127.0.0.1:4820".
#
# The dashboard's DNS-rebinding guard answers 403 EBADHOST to any Host header
# that is not loopback, so a container scrape needs the dashboard started with
# DASHBOARD_ALLOWED_HOSTS=host.docker.internal
# and, when DASHBOARD_TOKEN is set, the authorization block below.
global:
scrape_interval: 15s
scrape_timeout: 10s
rule_files:
- agenthub-rules.yml
scrape_configs:
- job_name: agenthub
metrics_path: /api/metrics
static_configs:
- targets: ["host.docker.internal:4820"]
# authorization:
# type: Bearer
# credentials_file: /etc/prometheus/dashboard-token
+85 -2
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.2.1
version: 2.5.2
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -230,7 +230,7 @@ components:
format: date-time
tool:
type: string
description: 'The AI coding CLI that produced the session: `claude` (Claude Code''s hooks and imported transcripts), `codex` (Codex''s rollout journals and its opt-in hooks) or `antigravity` (agy''s conversation files and its opt-in hooks).'
description: 'The AI coding CLI that produced the session: `claude` (Claude Code''s hooks and imported transcripts), `codex` (Codex''s rollout journals and its opt-in hooks) `antigravity` (agy''s conversation files and its opt-in hooks) or `cursor` (Cursor''s agent transcripts — sessions only, no tokens).'
agent_count:
type: integer
nullable: true
@@ -4787,6 +4787,7 @@ paths:
- claude
- codex
- antigravity
- cursor
description: Only the sessions of one CLI. Applied before `limit`, so a busy Codex lane cannot push Claude sessions off a page.
example: claude
- name: awaiting
@@ -12584,6 +12585,88 @@ paths:
results:
- id: src_a
ok: true
/api/settings/autostart:
get:
tags:
- Settings
summary: Whether the dashboard starts at boot/login
description: The per-user service's enabled state (a systemd `--user` unit, or a LaunchAgent on macOS). Off by default.
operationId: getAutostart
responses:
'200':
description: Start-at-login state
content:
application/json:
schema:
type: object
required:
- available
- kind
- enabled
- foreign
properties:
available:
type: boolean
description: False with no systemd --user (Linux) or GUI login (macOS)
kind:
type: string
enum:
- systemd
- launchd
enabled:
type: boolean
foreign:
type: boolean
description: A unit named agenthub exists that AgentHub did not write
put:
tags:
- Settings
summary: Turn start-at-login on or off
description: Enables or disables the unit, writing one for the running server first when none exists. Never starts or stops the server. Same-origin only.
operationId: setAutostart
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- enabled
properties:
enabled:
type: boolean
responses:
'200':
description: The new state
content:
application/json:
schema:
type: object
required:
- available
- kind
- enabled
- foreign
properties:
available:
type: boolean
description: False with no systemd --user (Linux) or GUI login (macOS)
kind:
type: string
enum:
- systemd
- launchd
enabled:
type: boolean
foreign:
type: boolean
description: A unit named agenthub exists that AgentHub did not write
'400':
description: 'EBADVALUE: `enabled` is not a boolean; EUNAVAILABLE: no systemd --user / launchd GUI session'
'403':
description: 'EBADORIGIN: another site asked'
'409':
description: 'EFOREIGN: a unit named agenthub that AgentHub did not write'
/api/settings/codex-hooks:
get:
tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.2.1",
"version": "2.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.2.1",
"version": "2.5.2",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.2.1",
"version": "2.5.2",
"bin": {
"agenthub": "bin/agenthub.js"
},
+6 -4
View File
@@ -26,6 +26,8 @@ const fs = require("fs");
const { rotateIfLarge } = require("./log-rotate");
const path = require("path");
const { execFileSync, spawnSync } = require("child_process");
// What a failed command printed — plugin-bootstrap.js says why on STDOUT.
const { failureText } = require("./plugin-update-helper");
const [, , runtimeDirArg, planJson] = process.argv;
@@ -229,7 +231,7 @@ function main() {
} catch (err) {
// A marketplace that can't be refreshed (offline, auth) does not stop the
// batch: the plugins may still have updates from the marketplaces that did.
finishStep(mktStep, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
finishStep(mktStep, "failed", { error: failureText(err).slice(0, 2000) });
}
const self = plan.selfPlugin;
@@ -243,7 +245,7 @@ function main() {
run(claude, ["plugin", "update", id, "-y"], 600_000);
finishStep(step, "ok");
} catch (err) {
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
finishStep(step, "failed", { error: failureText(err).slice(0, 2000) });
}
}
@@ -253,7 +255,7 @@ function main() {
try {
run(claude, ["plugin", "update", selfId, "-y"], 600_000);
} catch (err) {
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
finishStep(step, "failed", { error: failureText(err).slice(0, 2000) });
reloadPanes();
state.status = "failed";
state.finished_at = new Date().toISOString();
@@ -281,7 +283,7 @@ function main() {
} catch (err) {
log(`bootstrap failed: ${err.message}`);
state.status = "failed";
state.bootstrap_error = String(err.stderr || err.message).slice(0, 2000);
state.bootstrap_error = failureText(err).slice(0, 2000);
state.note = "the old server was stopped; run /agenthub-update to recover";
flush();
}
+9 -6
View File
@@ -30,6 +30,7 @@ const {
getTranscriptSnapshotDir,
} = require("../server/lib/claude-home");
const { extractFirstUserText } = require("../server/lib/transcript-cache");
const { isIgnoredCwd } = require("../server/lib/privacy");
const CLAUDE_DIR = getClaudeHome();
// The projects dir is read per call, not at load: CLAUDE_HOME can change while
// the dashboard runs (Settings → Claude Code → Overview).
@@ -562,7 +563,7 @@ function importCompactions(dbModule, sessionId, mainAgentId, compactions) {
if (!compactions || compactions.length === 0) return 0;
const { db, stmts } = dbModule;
const insertEvent = db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), ?)"
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), ?)"
);
let created = 0;
for (let i = 0; i < compactions.length; i++) {
@@ -620,7 +621,7 @@ function importSubagents(dbModule, sessionId, mainAgentId, toolUses) {
if (!toolUses || toolUses.length === 0) return 0;
const { stmts } = dbModule;
const insertEvent = dbModule.db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), ?)"
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), ?)"
);
let created = 0;
@@ -679,7 +680,7 @@ function importApiErrors(dbModule, sessionId, mainAgentId, apiErrors) {
if (!apiErrors || apiErrors.length === 0) return 0;
const { db } = dbModule;
const insertEvent = db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), ?)"
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), ?)"
);
let created = 0;
for (const err of apiErrors) {
@@ -996,7 +997,7 @@ function importSubagentFromJsonl(dbModule, sessionId, mainAgentId, subData) {
// importSession level (subagents have their own JSONL with separate usage).
const insertEvent = db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), ?)"
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), ?)"
);
// Spawn marker under the parent (main) agent — only emit once per subagent,
@@ -1185,6 +1186,8 @@ function reconcileSubagentParents(dbModule, sessionId, mainAgentId, parsedSubage
*/
function importSession(dbModule, session) {
const { db, stmts } = dbModule;
// A folder the user asked AgentHub to ignore (Settings → Privacy).
if (isIgnoredCwd(session.cwd)) return { skipped: true };
const existing = stmts.getSession.get(session.sessionId);
if (existing) {
const meta = existing.metadata ? JSON.parse(existing.metadata) : {};
@@ -1192,7 +1195,7 @@ function importSession(dbModule, session) {
const mainAgentId = `${session.sessionId}-main`;
const insertEvent = db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), ?)"
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), ?)"
);
const importedData = JSON.stringify({ imported: true });
let backfilled = false;
@@ -1591,7 +1594,7 @@ function importSession(dbModule, session) {
// Create synthetic events at actual message timestamps so the activity heatmap
// reflects when work actually happened, not just session start/end.
const insertEvent = db.prepare(
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, ?, trim_event_data(?), ?)"
"INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, ?, ?, redact_text(?), trim_event_data(?), ?)"
);
const importedData = JSON.stringify({ imported: true });
+26 -3
View File
@@ -105,6 +105,26 @@ function unitIsActive(name) {
return res.status === 0 && /^\s*state = running$/m.test(String(res.stdout || ""));
}
/** Is our plist set to load at login — the Settings "start automatically"
* switch. A `launchctl disable` override is what turns it off. */
function unitIsEnabled(name) {
if (!systemctlAvailable() || !unitIsOurs(name)) return false;
const res = launchctl(["print-disabled", domain()]);
const line = String(res.stdout || "")
.split("\n")
.find((l) => l.includes(`"${jobLabel(name)}"`));
return !line || !/=>\s*(disabled|true)/.test(line);
}
/** Enable or disable loading at login — never loads or unloads the job, so
* the server answering the switch stays up either way. */
function setUnitEnabled(name, on) {
if (!systemctlAvailable()) return "unavailable";
if (!unitIsOurs(name)) return "foreign";
const res = launchctl([on ? "enable" : "disable", `${domain()}/${jobLabel(name)}`]);
return res.status === 0 ? "ok" : "failed";
}
/**
* `argv` is the program and its arguments as separate strings (the systemd
* twin's `execStart` is one line; a path with a space would split here).
@@ -153,14 +173,15 @@ ${envLines}
}
/**
* Writes the plist and clears any "disabled" override — and starts nothing.
* Writes the plist and clears any "disabled" override (unless `enable:
* false`, which leaves it as it is) — and starts nothing.
* A loaded job keeps running its OLD definition until `restartUserService`;
* an unloaded one is picked up at the next login. Same split, same reason, as
* the systemd twin.
*
* @returns {"written"|"unchanged"|"foreign"|"unavailable"|"failed"}
*/
function writeUserService({ name, workingDir, argv, env = {} }) {
function writeUserService({ name, workingDir, argv, env = {}, enable = true }) {
if (!systemctlAvailable()) return "unavailable";
const body = unitContent({ name, workingDir, argv, env });
const target = unitPath(name);
@@ -173,7 +194,7 @@ function writeUserService({ name, workingDir, argv, env = {} }) {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.mkdirSync(path.dirname(logPath()), { recursive: true });
fs.writeFileSync(target, body, "utf8");
launchctl(["enable", `${domain()}/${jobLabel(name)}`]);
if (enable) launchctl(["enable", `${domain()}/${jobLabel(name)}`]);
return "written";
} catch {
return "failed";
@@ -229,6 +250,8 @@ module.exports = {
unitIsStale,
unitRoot,
unitIsActive,
unitIsEnabled,
setUnitEnabled,
writeUserService,
restartUserService,
installUserService,
+82 -49
View File
@@ -8,11 +8,11 @@
*
* The worker installs runtime dependencies, removes hook entries left behind by
* an older `npm run install-hooks` (they would double-count every event), puts
* the `agenthub` CLI on PATH, and starts the dashboard server — as a systemd
* `--user` service or a macOS LaunchAgent when available (see
* `service-manager.js`), so it survives
* reboots without Claude Code being opened first; otherwise a detached spawn
* scoped to this session, same as before.
* the `agenthub` CLI on PATH, and starts the dashboard server — through the
* systemd `--user` service or macOS LaunchAgent (see `service-manager.js`)
* only while the user has turned start-at-login on (Settings → About, OFF by
* default), so it survives reboots without Claude Code being opened first;
* otherwise a detached spawn scoped to this session.
*
* Everything writable lives under `~/.claude/agenthub/runtime/` — NOT in
* the plugin cache, which Claude Code replaces wholesale on every plugin
@@ -383,6 +383,29 @@ function linkCli(dir = cliDir(), root = PLUGIN_ROOT, nodePath = process.execPath
/* ------------------------------------------------------------- dependencies */
/**
* npm, run by the node executing this file: `{ file, args, env, shell }` for
* `spawnSync`. Its bundled `npm-cli.js` is found from `process.execPath`, so
* nothing depends on PATH — the self-update chain runs with the dashboard
* server's environment, and on Windows that PATH need not hold the directory
* of `npm.cmd` ("'npm.cmd' is not recognized"), even though node itself runs.
* PATH is still prefixed with that node's directory so npm's scripts (the UI
* build's `tsc`/`vite` shims) find the same node. A node with no bundled npm
* (a distro's split nodejs/npm packages) falls back to `npm` on PATH.
*/
function npmCommand(args, { execPath = process.execPath, env = process.env } = {}) {
const dir = path.dirname(execPath);
const pathKey = Object.keys(env).find((k) => k.toUpperCase() === "PATH") || "PATH";
const childEnv = { ...env, [pathKey]: [dir, env[pathKey]].filter(Boolean).join(path.delimiter) };
const cli = [
path.join(dir, "node_modules", "npm", "bin", "npm-cli.js"), // Windows layout
path.join(dir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), // Unix layout
].find((p) => fs.existsSync(p));
if (cli) return { file: execPath, args: [cli, ...args], env: childEnv, shell: false };
const win = process.platform === "win32";
return { file: win ? "npm.cmd" : "npm", args, env: childEnv, shell: win };
}
/**
* Install runtime dependencies into the runtime dir. `--ignore-scripts` keeps
* the root postinstall from pulling the whole Vite client toolchain; the
@@ -395,12 +418,13 @@ function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) {
const src = path.join(root, f);
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
}
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
const res = spawnSync(
npm,
["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
{ cwd: rt, stdio: "inherit", shell: process.platform === "win32" }
);
const npm = npmCommand(["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"]);
const res = spawnSync(npm.file, npm.args, {
cwd: rt,
stdio: "inherit",
env: npm.env,
shell: npm.shell,
});
return res.status === 0;
}
@@ -738,13 +762,17 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
// the restart is deferred to `pendingSystemdRestart` below, because
// starting it now would kill this process mid-bootstrap. Otherwise
// there is no cgroup to be reaped by, and install-and-start is fine.
const result = systemdOwned
? svc.writeUserService(unit)
: svc.systemctlAvailable()
? svc.installUserService(unit)
: "unavailable";
// Start-at-login is OPT-IN (Settings → About, `/api/settings/autostart`):
// the unit is installed or started only while the user has it enabled,
// and its enabled state is never changed here.
const autostart = svc.unitIsEnabled("agenthub");
let result = "off";
if (systemdOwned) result = svc.writeUserService({ ...unit, enable: autostart });
else if (autostart)
result = needsServer ? svc.installUserService(unit) : svc.writeUserService(unit);
else if (unitStale) svc.writeUserService({ ...unit, enable: false });
if (result === "written" || result === "unchanged") {
if (result === "written") svc.enableLinger();
if (result === "written" && autostart) svc.enableLinger();
// Only when the RUNNING process has to change. A stale unit beside a
// healthy server is now corrected on disk and left to take effect at
// the next start — restarting a working dashboard to fix a file that
@@ -825,6 +853,43 @@ function main() {
child.unref();
}
// Exported before the entry block below, not after it: `bootstrap()` requires
// plugin-open.js, which requires this module back (`npmCommand`, `runtimeDir`),
// and when this file is the entry that require resolves to whatever is on
// module.exports at that moment — an empty object if it is assigned last.
module.exports = {
MIN_NODE,
LOCK_STALE_MS,
KEEP_RETIRED_ROOTS,
supersededBy,
pruneRetiredPluginRoots,
installedPluginsPath,
pluginVersion,
runtimeDir,
statePath,
readState,
writeState,
depsHash,
fastPathOk,
nodeVersionOk,
lockPath,
lockIsStale,
acquireLock,
releaseLock,
isCheckoutHookEntry,
stripLegacyHooks,
hasLegacyHooks,
linkCli,
npmCommand,
installDeps,
serverIsLive,
liveServers,
livePids,
stopDashboard,
startDashboard,
bootstrap,
};
if (require.main === module) {
const force = process.argv.includes("--force");
if (force || process.argv.includes("--worker")) {
@@ -850,35 +915,3 @@ if (require.main === module) {
}
}
}
module.exports = {
MIN_NODE,
LOCK_STALE_MS,
KEEP_RETIRED_ROOTS,
supersededBy,
pruneRetiredPluginRoots,
installedPluginsPath,
pluginVersion,
runtimeDir,
statePath,
readState,
writeState,
depsHash,
fastPathOk,
nodeVersionOk,
lockPath,
lockIsStale,
acquireLock,
releaseLock,
isCheckoutHookEntry,
stripLegacyHooks,
hasLegacyHooks,
linkCli,
installDeps,
serverIsLive,
liveServers,
livePids,
stopDashboard,
startDashboard,
bootstrap,
};

Some files were not shown because too many files have changed in this diff Show More