Files
AgentHub/ARCHITECTURE.md
T
nntrivi2001 5fe5d51b1b feat: v2.5.5 — AI Router renamed AI Proxy, reload onto new builds, update no longer spins
AI Proxy (was AI Router):
- server/lib/ai-proxies.js, proxy-apply.js, routes/ai-proxies.js mounted at
  /api/ai-proxies and the old /api/ai-routers; `router` still accepted on the
  start routes; profiles move to <CLAUDE_HOME>/agenthub-proxies/
- dashboard_runs.router renamed in place to proxy: an older build on the same
  DB fails on that column, so dev and prod sharing one DB update together
- a proxy's baseUrl stored as its root (a typed /v1 made /v1/v1/messages);
  Test probes Claude /v1/messages and Codex /v1/responses
- Settings → AI Proxy (/settings/ai-router redirects), ProxyPicker

Updates:
- every tab reloads onto the new UI build when its WebSocket reconnects to a
  server of another version (lib/newBuildReload.ts, once per version)
- Update now stops waiting on a plugin chain that failed or pulled nothing
  (the old server never goes down), and reloads as soon as a new version answers

Also:
- a pane whose command exits at once says so (EPANEEXITED) instead of
  "Command failed: tmux set-option … mouse on"
- the pane drag preview draws where the pane actually lands
2026-10-06 22:48:09 +07:00

246 KiB
Raw Blame History

Agent Dashboard - System Design and Technical Reference

Architectural overview and technical reference for the Agent Dashboard system, covering design goals, high-level architecture, data flow, server and client components, database design, WebSocket protocol, hook integration, Claude Code plugins & skills, state management, security considerations, performance characteristics, deployment modes, and technology choices.

Claude Code Claude Code Plugins Model Context Protocol Node.js Python Express ws web-push swagger-ui-express multer adm-zip tar React TypeScript Javascript Vite Tailwind CSS PostCSS Autoprefixer React Router Lucide D3.js Mermaid i18next i18next Language Detector SQLite better--sqlite3 better-sqlite3 WAL WebSocket SSE OpenAPI Swagger VS Code Vitest React Testing Library ESLint Prettier Make


Table of Contents


System Overview

Agent Dashboard is a local-first monitoring platform for Claude Code sessions. It captures agent lifecycle events via Claude Code's native hook system, persists them in SQLite, and presents them through a React dashboard with real-time WebSocket updates.

Cursor sessions (informational): The same ~/.claude transcript paths also pick up Cursor agent sessions — Cursor happens to use that layout locally alongside Claude Code. AgentHub does not distinguish which editor created a session.

C4Context
    title System Context Diagram

    Person(user, "Developer", "Uses Claude Code CLI")
    System(claude, "Claude Code", "AI coding assistant with hook system")
    System(dashboard, "Agent Dashboard", "Monitoring platform")
    SystemDb(sqlite, "SQLite", "Persistent storage")

    Rel(user, claude, "Interacts with")
    Rel(claude, dashboard, "Sends hook events via stdin + HTTP")
    Rel(user, dashboard, "Views in browser")
    Rel(dashboard, sqlite, "Reads/writes")

Design goals:

  • Zero-config operation -- auto-discovers sessions from hook events
  • Never block Claude Code -- hooks fail silently with timeouts
  • Instant feedback -- WebSocket push, no polling
  • Portable -- SQLite, no external services, runs on any OS with Node.js 20+
  • Extensible -- plugin marketplace with 10 plugins (53 skills, 14 agents, 30 slash commands, 3 CLI tools)

High-Level Architecture

graph TB
    subgraph "Claude Code Process"
        CC[Claude Code CLI]
        H0[SessionStart Hook]
        H1[PreToolUse Hook]
        H2[PostToolUse Hook]
        H3[Stop Hook]
        H4[SubagentStop Hook]
        H5[Notification Hook]
        H6[SessionEnd Hook]
        CC --> H0 & H1 & H2 & H3 & H4 & H5 & H6
    end

    subgraph "Plugin Layer"
        direction TB
        PM["Plugin Marketplace<br/>(10 plugins, 53 skills)"]
        PA["agenthub-analytics"]
        PP["agenthub-productivity"]
        PD["agenthub-devtools"]
        PI["agenthub-insights"]
        PC["agenthub-dashboard"]
        PG["agenthub-cost-guard"]
        PS["agenthub-sessions"]
        PW["agenthub-workflows"]
        PQ["agenthub-quality"]
        PF["agenthub-config"]
        PM --> PA & PP & PD & PI & PC & PG & PS & PW & PQ & PF
    end

    subgraph "Hook Layer"
        HH["hook-handler.js<br/>(stdin → HTTP)"]
        H0 & H1 & H2 & H3 & H4 & H5 & H6 -->|stdin JSON| HH
    end

    subgraph "Server Process (port 4820)"
        direction TB
        EX[Express Server]
        HR[Hook Router]
        SR[Session Router]
        AR[Agent Router]
        ER[Event Router]
        STR[Stats Router]
        ANR[Analytics Router]
        WFR[Workflows Router]
        PR[Pricing Router]
        DB[(SQLite<br/>WAL mode)]
        WSS[WebSocket Server]

        EX --> HR & SR & AR & ER & STR & ANR & WFR & PR
        HR -->|transaction| DB
        SR & AR & ER & STR & ANR & WFR & PR --> DB
        HR -->|broadcast| WSS
        SR & AR -->|broadcast| WSS
    end

    subgraph "Client (Browser)"
        direction TB
        VITE[Vite Dev Server<br/>or Static Files]
        APP[React App]
        WS_CLIENT[WebSocket Client]
        EB[Event Bus]
        PAGES[Pages:<br/>Dashboard / Kanban /<br/>Sessions / Activity /<br/>Analytics / Workflows]

        VITE --> APP
        APP --> WS_CLIENT
        WS_CLIENT --> EB
        EB --> PAGES
        PAGES -->|fetch| EX
    end

    HH -->|"POST /api/hooks/event"| HR
    WSS -->|push messages| WS_CLIENT
    PA & PP & PD & PI & PC -->|"curl API"| EX

    style CC fill:#6366f1,stroke:#818cf8,color:#fff
    style DB fill:#003B57,stroke:#005f8a,color:#fff
    style WSS fill:#10b981,stroke:#34d399,color:#fff
    style EB fill:#f59e0b,stroke:#fbbf24,color:#000
    style PM fill:#8b5cf6,stroke:#a78bfa,color:#fff

Data Flow

Event Ingestion Pipeline

sequenceDiagram
    participant CC as Claude Code
    participant HH as hook-handler.js
    participant API as POST /api/hooks/event
    participant TX as SQLite Transaction
    participant WS as WebSocket.broadcast()
    participant UI as React Client

    CC->>HH: stdin: {"session_id":"abc","tool_name":"Bash",...}
    Note over HH: Reads stdin, parses JSON,<br/>wraps with hook_type

    HH->>API: POST {"hook_type":"PreToolUse","data":{...}}
    Note over API: Validates hook_type + data

    API->>TX: BEGIN TRANSACTION
    TX->>TX: ensureSession(session_id)
    Note over TX: Creates session + main agent<br/>if first contact. Also persists<br/>data.transcript_path onto the session row<br/>(SQL-guarded, so subsequent events no-op).<br/>Syncs sessions.name from the transcript title<br/>(custom-title &gt; ai-title &gt; first user prompt).

    TX->>TX: Process by hook_type
    Note over TX: Dispatches by hook_type. Maintains the agent and<br/>session state machines plus the awaiting_input_since flag.<br/>SubagentStop also triggers a JSONL scan that emits per_tool<br/>events under each subagent. See the hook table below for<br/>the full per_event behaviour.

    TX->>TX: insertEvent(...)
    TX->>TX: COMMIT

    API->>WS: broadcast("agent_updated", agent)
    API->>WS: broadcast("new_event", event)

    WS->>UI: {"type":"agent_updated","data":{...}}
    UI->>UI: eventBus.publish(msg)
    UI->>UI: Page re-renders with new data

Client Data Loading Pattern

sequenceDiagram
    participant Page as React Page
    participant API as api.ts
    participant Server as Express
    participant EB as eventBus
    participant WS as WebSocket

    Note over Page: Component mounts
    Page->>API: load() via useEffect
    API->>Server: GET /api/sessions (or agents, events, stats)
    Server-->>API: JSON response
    API-->>Page: setState(data)

    Note over Page: Subscribes to live updates
    Page->>EB: eventBus.subscribe(handler)

    loop Real-time updates
        WS->>EB: eventBus.publish(msg)
        EB->>Page: handler(msg)
        Page->>Page: Reload or optimistic update
    end

    Note over Page: Component unmounts
    Page->>EB: unsubscribe()

Server Architecture

Module Dependency Graph

graph TD
    INDEX[server/index.js<br/>Express app + HTTP server]
    DB[server/db.js<br/>SQLite + prepared statements<br/>better-sqlite3 → node:sqlite fallback]
    WS[server/websocket.js<br/>WS server + broadcast]
    HOOKS[routes/hooks.js<br/>Hook event processing]
    TC[lib/transcript-cache.js<br/>JSONL cache + incremental reads]
    SESSIONS[routes/sessions.js<br/>Session CRUD]
    AGENTS[routes/agents.js<br/>Agent CRUD]
    EVENTS[routes/events.js<br/>Event listing]
    STATS[routes/stats.js<br/>Aggregate queries]
    PRICING[routes/pricing.js<br/>Cost calculation + pricing CRUD]
    SETTINGS[routes/settings.js<br/>System info + data management]
    WORKFLOWS[routes/workflows.js<br/>Workflow visualizations]
    ALERTSR[routes/alerts.js<br/>Alert rules CRUD + feed]
    ALERTS[lib/alerts.js<br/>Rule evaluation engine]
    WEBHOOKSR[routes/webhooks.js<br/>Webhook target CRUD + test]
    WEBHOOKS[lib/webhooks.js<br/>Webhook delivery engine]
    WEBHOOKPROV[lib/webhook-providers.js<br/>14-provider registry + formatters]

    INDEX --> DB
    INDEX --> WS
    INDEX --> HOOKS & SESSIONS & AGENTS & EVENTS & STATS & PRICING & SETTINGS & WORKFLOWS & ALERTSR & WEBHOOKSR

    HOOKS --> DB & WS & TC
    HOOKS --> ALERTS
    ALERTSR --> DB & WS & ALERTS
    ALERTS --> DB & WS
    ALERTS --> WEBHOOKS
    WEBHOOKSR --> DB & WEBHOOKS & WEBHOOKPROV
    WEBHOOKS --> DB & WEBHOOKPROV
    SETTINGS --> DB & TC
    INDEX --> TC
    SESSIONS --> DB & WS
    AGENTS --> DB & WS
    EVENTS --> DB
    STATS --> DB & WS
    PRICING --> DB
    WORKFLOWS --> DB

    style INDEX fill:#6366f1,stroke:#818cf8,color:#fff
    style DB fill:#003B57,stroke:#005f8a,color:#fff
    style WS fill:#10b981,stroke:#34d399,color:#fff

Server Components

Module Responsibility
server/index.js Express app setup, middleware, route mounting, static file serving in production, HTTP server creation. Static middleware sets explicit Cache-Control headers — immutable for /assets/*, no-cache, must-revalidate for index.html / sw.js / manifest.json, a short revalidation window otherwise — so a rebuild always replaces the in-browser bundle without a hard refresh. Runs a periodic maintenance sweep — cadence derived from DASHBOARD_STALE_MINUTES (¼ of the threshold, clamped to 60 s – 5 min, default ~45 min) — that abandons stale sessions with transcript cache eviction and scans active sessions for new compaction entries by reading sessions.transcript_path directly (an O(active sessions) lookup; the previous SELECT DISTINCT json_extract(events.data,'$.transcript_path') scan grew with the events table and is gone). Error detection watchdog runs every 15 seconds: finds active sessions with no recent hook events (>10 s stale), re-reads their transcript files looking for API errors (401 auth, rate limits, quota exhaustion), derives transcript paths from session cwd for imported sessions, and marks sessions/agents as error when API errors are found — catches cases where the CLI doesn't fire a hook after API errors. The same watchdog also performs user-interrupt (Esc) recovery: Esc fires no hook, so a cancelled turn would otherwise leave the main agent stuck working. It detects this two ways — (a) the transcript's [Request interrupted by user] marker, surfaced as result.pendingInterrupt from TranscriptCache (computed from transcript ordering alone, immune to server/transcript clock skew), and (b) an idle-working fallback for an Esc pressed before any output (which writes no marker): when the main agent has been working with current_tool null and neither a hook event nor the transcript mtime has advanced for DASHBOARD_WORKING_IDLE_SECONDS (default 120) — and moves the session to Waiting (agent → waiting, awaiting_input_since stamped) with an Interrupted event. Triggers legacy session import (with active-session detection for recently-modified JSONL files) and compaction backfill on startup, plus a boot liveness reap — immediately for rows from a previous run and again ~5 s later for rows the startup sync just imported (see the routes/hooks.js row) — so sessions that died while the dashboard was down never render as Waiting. Also starts the Remote Data Sources sync poller (startRemoteSourceSync) that pulls each enabled remote source on an interval — DASHBOARD_REMOTE_SYNC_MS (default 60000; 0 disables) — reusing server/lib/remote-sync.js. Graceful shutdown (SIGTERM/SIGINT) tears down in order: drop realtime clients first (closeWebSocket terminates WS clients so their sockets release), then httpServer.close() to stop new connections, then httpServer.closeAllConnections() to drop lingering keep-alive sockets so close() fires promptly, and only then close SQLite — inside the close() callback, after the HTTP server has drained. Closing the DB before drain made in-flight requests throw The database connection is not open; leaving WS/keep-alive sockets open stalled the shutdown until the 5 s force-exit backstop (the "waiting for graceful termination" hang under node --watch). A second signal forces an immediate exit
server/openapi.js OpenAPI 3.0.3 document generator for the backend API (metadata, schemas, endpoint paths), merging supplementary fragments from server/openapi-extra/ in createOpenApiSpec(). Feeds the raw spec endpoint (/api/openapi.json), Swagger UI (/api/docs), ReDoc (/api/redoc, served via server/lib/redoc.js with a self-hosted bundle — never a CDN), and the committed openapi.yaml regenerated by npm run openapi:yaml
server/lib/redoc.js Serves the ReDoc API reference (/api/redoc) as a self-hosted three-panel rendering of the OpenAPI spec, with the ReDoc bundle served locally from /api/redoc/redoc.standalone.js (bundled via the redoc dependency, never a CDN) so the reference works fully offline / air-gapped
server/openapi-extra/ Supplementary OpenAPI path/schema fragments merged into the spec by createOpenApiSpec() — covers cc-config.js, push.js, run.js, and misc.js route groups
server/db.js SQLite connection with WAL mode, schema migration (CREATE TABLE IF NOT EXISTS + ALTER TABLE for column additions), all prepared statements as a reusable stmts object. Tries better-sqlite3 first, falls back to node:sqlite via compat-sqlite.js. Migrations use literal defaults for ALTER TABLE since SQLite does not support expressions like strftime() in column defaults added via ALTER TABLE
server/compat-sqlite.js Compatibility wrapper that gives Node.js built-in node:sqlite (DatabaseSync) the same API as better-sqlite3 — pragma, transaction, prepare. Used as automatic fallback when the native module is unavailable (Node 22+) Maps every better-sqlite3 API the server uses — function (the trim_event_data SQL function db.js registers at boot), stmt.pluck(), the readonly/fileMustExist open options — and db.js exports the chosen class as Database so other SQLite files (agy's conversations) open with the same driver. A plugin install has NO better-sqlite3 build, so this is production there; server/__tests__/compat-sqlite.test.js boots db.js with better-sqlite3 blocked because the rest of the suite never runs on it.
server/websocket.js WebSocket server on /ws path, 30s heartbeat with ping/pong dead connection detection, typed broadcast function. Upgrades run through the same Host-header allowlist and optional DASHBOARD_TOKEN check as the HTTP surface (isWebSocketAuthorized)
server/lib/security.js Network-hardening module (fix for GHSA-gr74-4xfh-6jw9). resolveHost() picks the bind address — 127.0.0.1 by default, widened only by DASHBOARD_HOST (logs a warning for non-loopback binds). hostGuard rejects requests whose Host header isn't in the loopback set or DASHBOARD_ALLOWED_HOSTS (DNS-rebinding defense). corsOptions() allows only loopback origins while letting No-Origin (curl/CLI) requests through. tokenGuard + isWebSocketAuthorized enforce the optional DASHBOARD_TOKEN on /api/* and WebSocket upgrades (accepted as Authorization: Bearer, x-dashboard-token, or ?token=; off by default). Exempt paths and token-matching helpers (tokensMatch, extractToken) live here too
routes/hooks.js Core event processing inside a SQLite transaction. Auto-creates sessions/agents. Handles 8 hook types: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStop, Notification, SessionEnd, plus synthetic Compaction events. Manages the agent state machine plus the awaiting_input_since overlay (stamped on SessionStart for fresh CLIs — startup/resume/clear only, since a compact-source SessionStart fires mid-turn while Claude is working and is deliberately skipped so a working session stays Active — on non-error Stop, and on permission Notifications (which now also set agent status to waiting); cleared on UserPromptSubmit / PreToolUse / PostToolUse / SessionStart-resume / SessionEnd; SubagentStop intentionally does NOT clear it; and stamped by the 15 s watchdog on user-interrupt (Esc) recovery — see the index.js row — since Esc fires no hook). After res.json() returns on SubagentStop, fires a fire-and-forget scanAndImportSubagents (from scripts/import-history.js) that parses every subagents/agent-*.jsonl, pairs tool_use ↔ tool_result blocks by tool_use_id, and emits per-tool PreToolUse + PostToolUse events under each subagent's own agent_id — closes the gap where subagent-internal tool calls would otherwise never reach the events table. The same scan also reparents nested subagents under their true spawner (see the import-history.js row); it returns { created, reparented }, and the follow-up new_event refetch nudge fires when either is non-zero so a pure re-parent (tree shape changed, no new rows) still refreshes the UI. The same scan attributes each subagent's tokens to its own model (resolved from the subagent transcript) and stamps metadata.model on the subagent row (issue #185), so a tiered pipeline (Opus orchestrator + Sonnet/Haiku subagents) is priced per real model rather than entirely at the orchestrator's rate; the parent-model bucket is skipped to avoid colliding with the main-transcript token writer's compaction baseline logic. Session reactivation on resume (including Stop/SubagentStop reactivation for imported completed/abandoned sessions), orphaned-session cleanup uses DASHBOARD_STALE_MINUTES (default 180). Uses a shared TranscriptCache instance (server/lib/transcript-cache.js) for extraction of tokens, API errors, turn durations, thinking blocks, and usage extras — stat-based caching with incremental byte-offset reads avoids re-reading entire JSONL files on every event. Detects compaction via isCompactSummary in JSONL transcripts and creates compaction agents + events (deduplicated by uuid). Token baselines (baseline_* columns) preserve pre-compaction totals so no usage is lost. Cache entries are evicted on SessionEnd. SessionEnd preserves error state — but only when the error is still unrecovered at the transcript tail (isErrorAtTail: latest API error with no successful turn after it); a transient error the CLI retried past finalizes as completed instead of freezing in a stale error. Error recovery: UserPromptSubmit and PreToolUse recover a session from error; additionally the 15 s watchdog now scans error sessions and self-heals one back to active when its transcript has progressed past the last API error (isErrorAtTail false) — closing the gap where a transient API error left an imported or sweep-monitored session (no live recovery hook) pinned in error forever. Session naming: on every event, syncs sessions.name from the transcript title surfaced by TranscriptCache and broadcasts session_updated — an explicit custom-title (/rename, claude -n, picker Ctrl+R) always wins, an ai-title (auto / plan-accept) only fills a placeholder/auto name (Session <id8> or a cwd-folder import name) so a user-chosen name is never clobbered. When neither title exists, the session's first user prompt (surfaced by TranscriptCache as firstUserMessage; tool-result / meta / slash-command plumbing entries skipped) fills the placeholder session name plus the main agent's placeholder name and empty task (issue #201) — a later ai-title can still replace a descriptor-filled name, and the agent fill passes the in-flight current_tool through (the shared updateAgent statement writes that column verbatim) so it is never wiped mid-turn. The guarded updateSessionName no-ops on the unchanged case, so the broadcast path stays quiet; the 15 s error-watchdog runs the same sync for idle sessions that fire no hook after a rename. Dead-session liveness reap: the same watchdog completes any active tool = 'claude' session whose cwd has no running claude CLI process (probe via lib/session-liveness.js) — recovering a SessionEnd lost while the dashboard was down (e.g. Ctrl+C) that previously left the session in Waiting until the 3 h stale sweep; watchdog ticks are gated on the transcript mtime (fallback updated_at when no transcript exists) being older than DASHBOARD_LIVENESS_IDLE_SECONDS (default 60 s); the boot passes — immediately at startup and again ~5 s later (post-import) — skip the gate so a session quit moments before launch clears at once, disabled via DASHBOARD_LIVENESS_PROBE=0 / on Windows / in containers, and a false completion self-heals through hook reactivation. Sessions whose cwd is not POSIX-absolute (household-hook-forwarded from another machine, e.g. a Windows D:\… path a local /proc/lsof scan can never match) are skipped by the reap — so a mixed local + forwarded deployment stays correct without disabling the whole probe. Remote Data Source sessions (sessions.source ≠ local) are excluded from the reap, the error/interrupt watchdog scan, and both stale sweeps (all gated on source = 'local'): their POSIX-absolute cwd lives on another machine, so local process/clock heuristics would wrongly terminate a running remote session — their status is reconciled from the SSH mirror by remote-sync.js instead A hook posted with tool: "codex" (Codex hooks) creates a tool = 'codex' session, reaches only the codex lane at its cwd, skips every Claude-transcript read, and has PermissionRequest → Notification, Interrupt → Stop; one with tool: "antigravity" is mapped by antigravityHookInput (see Antigravity Sessions and Hooks). A Notification is tagged Compaction for tool = 'claude' only. The reap, the error/interrupt watchdog scan and the startup cleanup of stale active sessions touch tool = 'claude' rows only (the periodic 3 h stale sweep takes every tool).
routes/sessions.js Standard CRUD with pagination. GET includes agent count via LEFT JOIN. POST is idempotent on session ID. GET /:id/transcript also surfaces custom-title lines as synthetic session_event (rename) messages — deduped, with ai-title excluded — so TUI-only /rename (which writes no user/assistant turn) is still visible in the conversation viewer. It also surfaces system/local_command lines: newer Claude Code builds write a local slash command's invocation and captured output (<command-name>, <local-command-stdout>/stderr) as system/local_command entries with the TUI markup in a top-level content string (older builds used user messages), so the route re-emits those as user-side text and the client's tuiSegments parser renders the command pill + its output (e.g. /color → a /color pill plus "Session color set to: cyan"). Content-less local_command lines (e.g. /clear) and every other system subtype (turn_duration, stop_hook_summary, …) are dropped as noise
routes/agents.js CRUD with status/session_id filtering (session_ids=a,b,…, ≤ 200, for several sessions in one call). PATCH broadcasts agent_updated. Agent-list responses (GET /api/agents, GET /api/sessions/:id/agents) attach a per-agent cost via pricing.attachAgentCosts — each subagent's OWN cost, computed from its metadata.tokens at current rates (main agents get 0; their cost is the session total), so a subagent card shows only what that subagent spent rather than the session total
routes/events.js Read-only event listing with session_id filter and pagination
routes/stats.js Single aggregate query returning total/active counts + status distributions
routes/metrics.js Prometheus / OpenMetrics text-exposition endpoint (GET /api/metrics) — re-exposes the dashboard's live counters (sessions/agents by status, event + token totals, connected WebSocket clients, configured remote sources, process uptime/RSS, build version) in the v0.0.4 text format for scraping into Prometheus / Grafana. Read-only; reads the same db.js prepared statements the REST API uses, so numbers match the UI. Status series are enumerated so a gauge never drops out at zero. Mounted under /api, so it sits behind the Host-header (DNS-rebinding) guard and the optional DASHBOARD_TOKEN guard — a non-loopback scraper needs DASHBOARD_ALLOWED_HOSTS (+ token if set)
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), 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 formatter, 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
routes/workflows.js Aggregate workflow visualization data (agent orchestration graphs, tool transition flows, collaboration networks, workflow pattern detection, model delegation, error propagation, concurrency timelines, session complexity metrics, compaction impact). Accepts ?status=active|completed query parameter to filter all data by session status and ?sections=a,b to compute only those parts. The GET / bundle itself lives in lib/workflow-stats.js and runs in the read worker (lib/read-pool.js). GET /api/workflows/skill-usage reports per-skill and per-plugin call counts, counting PreToolUse rows only so a call is not double-counted against its PostToolUse twin. Per-session drill-in endpoint with agent tree, tool timeline, and event details
lib/workflow-stats.js The eleven GET /api/workflows aggregations as pure reads over a passed-in connection (computeWorkflowData(conn, status, sections)), so they run in the read worker. stats.topFlow is the head of toolFlow.transitions: tool pairs come from one LEAD() OVER (PARTITION BY session_id ORDER BY id) pass — never a correlated MIN(id) … id > ? per event, which is quadratic in a session's size
lib/read-pool.js Heavy read-only jobs off the event loop: one worker thread with its own query_only connection (same driver order as db.js), identical in-flight calls shared, a job past AGENTHUB_READ_TIMEOUT_MS (60 s) refused EREADTIMEOUT and its worker replaced, inline when AGENTHUB_READ_WORKER=0 or a worker cannot start. Add a job to JOBS to move another aggregate there
lib/transcript-cache.js Stat-based JSONL transcript cache with incremental byte-offset reads. Shared between hooks.js (token extraction on every event) and the periodic compaction scanner (index.js). Extracts tokens, compaction entries, API errors (isApiErrorMessage + raw error responses), turn durations (system subtype turn_duration), thinking block counts, usage extras (service_tier, speed, inference_geo), user-interrupt markers (the transcript [Request interrupted by user] entry / interruptedMessageId field — surfaced as pendingInterrupt, computed from transcript ordering: latest interrupt vs latest real turn activity, both on Claude Code's clock), and the latest session title — custom-title (/rename, claude -n, picker Ctrl+R) and ai-title (auto / plan-accept), append-only so the last value wins, carried through both full and incremental reads — plus the session's first user prompt (firstUserMessage: tool-result, meta/caveat, slash-command plumbing, compact-summary, and interrupt entries skipped; whitespace-collapsed, capped at 500 chars; first value wins across incremental reads), used as a fallback descriptor for placeholder-named sessions/agents. Uses (path, mtime, size) cache key — unchanged files return cached results instantly, grown files only parse new bytes, shrunk files (compaction) trigger full re-read. Each cache entry stores only {mtimeMs, size, bytesRead, result} — the previous shape that duplicated every growable array at both the top level and inside result is gone, halving steady-state memory per entry. Per-entry growable arrays (turnDurations, errors, compaction.entries, usageExtras.*) are bounded to TRANSCRIPT_CACHE_MAX_ARRAY_LEN (default 1000, tail-kept) — older items remain in the events table thanks to hook dedup, so the cap only affects the in-memory view. Trimming runs both during parse (when an array reaches 2 * MAX_ARRAY_LEN, amortized O(N)) and at finalize, so even a fresh full-file parse on a multi-day session cannot accumulate an unbounded transient before returning. Chunked sync byte-stream reader (_streamRange, 4 MiB chunks split on 0x0A bytes — safe across UTF-8 multibyte sequences — with a growable per-line byte buffer capped at 64 MiB) replaces the previous readFileSync("utf8") so transcripts larger than V8's max JS string length (~512 MiB on 64-bit Node 20) parse without aborting Node with FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal. Both full and incremental reads share the same line-level state machine (_initParseState / _consumeLine / _finalizeState). LRU eviction caps at 200 entries. Entries evicted on SessionEnd and abandoned session cleanup
lib/session-liveness.js Process-liveness probe for the watchdog's dead-session reap. probeLiveCwds() enumerates running claude CLI processes (ps -Ao pid=,args=, then lsof -a -p <pids> -d cwd -Fn on macOS or /proc/<pid>/cwd on Linux) and returns the set of their working directories; isClaudeCommand() matches the bare binary and node/bun-launched shims while rejecting lookalikes (claude-mem, Claude.app). Fail-safe by contract: returns available: false (callers must change nothing) on Windows, inside containers (reuses isInsideContainer from scripts/install-hooks.js — host processes are invisible there, so an empty list would lie), on ps/lsof failure, or when DASHBOARD_LIVENESS_PROBE=0
bin/agenthub.js Dependency-free umbrella CLI (agenthub <command>) exposing the full dashboard surface in the terminal: monitoring (health / stats / kanban / tail via short-interval event polling), data browsing (sessions, per-session detail with an indented agent tree + cost + recent events, agents, events), insights (analytics, workflow intelligence, dynamic Workflow-tool runs, per-model cost), alerts + rules + webhook test probes, pricing CRUD, imports (rescan / scan-path), and administration (doctor, info, export, cleanup, reinstall-hooks, clear-data). Linked globally by npm run setup via a fail-soft npm link (link-cli script). Discovers the live server through server/lib/server-info.js (~/.claude/.agenthub.json, PID-liveness-checked) with CLAUDE_DASHBOARD_PORT / DASHBOARD_PORT env overrides and a 4820 fallback; renders a full terminal UI — box-drawn width-fitted tables with right-aligned numeric columns, status icons, inline bar charts (stats / analytics / cost), ├─/└─ agent trees, and a TTY spinner for start — whose ANSI styling degrades to plain text when piped (--no-color / NO_COLOR / FORCE_COLOR / AGENTHUB_COLOR respected); the one destructive command (clear-data) refuses to run without --yes. Server lifecycle: agenthub status shows a ●/○ up/down indicator and agenthub start boots a detached production server (waits for /api/health, logs to data/agenthub-server.log). agenthub repl (aliases shell / i) opens an interactive shell — a readline prompt with tab-completion (commands / subcommands / flags), persisted arrow-key history (data/.agenthub_repl_history), and a live server-status prompt; each entered line runs as a short-lived child agenthub process (via runCommand dispatch), so an offline refusal or a blocking tail cannot take the shell down, and piped input runs each line in order then exits at EOF. When the server is down, read-only commands (sessions / session / agents / events / kanban / stats / pricing list / alerts list / rules / export / doctor) fall back to direct SQLite reads of data/dashboard.db under a ⚠ Offline-mode banner — the connection is opened without SQLite readonly mode so a live WAL stays visible, and is SELECT-only by construction — while server-only commands (tail, analytics, workflows, runs, cost, mutations) refuse with the specific reason
scripts/import-history.js Batch history importer used by (a) server startup auto-import, (b) the /api/import/* routes, (c) the import-history CLI, and (d) live SubagentStop ingestion via the exported scanAndImportSubagents(dbModule, sessionId, transcriptPath). Exposes importAllSessions(dbModule) for the default ~/.claude/projects tree, syncDefaultProjects(dbModule, {mtimeCache}) (the incremental, mtime-fingerprinted re-sweep that backs the continuous background sync — parses only new/changed files and reports [{sessionId, isNew}]), and the generalized importFromDirectory(dbModule, rootDir, {onProgress}) which walks any directory recursively, classifies each .jsonl as session vs subagent (with findSessionSubagents probing both <proj>/<sid>/subagents/* and <proj>/subagents/<sid>/* layouts), and funnels everything through the shared parseSessionFile + importSession pipeline. The durable transcript snapshot (snapshotTranscript) additionally preserves nested Workflow-tool inner-agent transcripts (subagents/workflows/<runId>/agent-*.jsonl) via the separate findSessionWorkflowSubagents probe — mirroring the run subpath so the read route resolves the snapshot identically to the live file, without pulling those nested agents into the flat sub-agent import (no double-count). After each batch imports, importAllSessions / importFromDirectory also call ingestWorkflowsForSession (from server/lib/workflow-ingest.js) per session — outside the SQLite transaction, since the ingest is async — so a Workflow-tool run whose journal never reached a live server (a headless claude -p run, a CI job, or an HPC/cluster node emits no hooks) still links its inner agents to their run_id on a plain agenthub import rescan / agenthub import path, instead of leaving them orphaned (workflow_run_id = NULL) with the run stuck at 1 agent — see "Workflow-Tool Run Ingestion". parseSubagentFile extracts ordered toolEvents (tool_use + tool_result paired by tool_use_id) so importSubagentFromJsonl can emit per-tool PreToolUse + PostToolUse rows under each subagent's own agent_id. The importer dedups against live hook-created subagent rows via findLiveSubagentForJsonl (session + subagent_type + start-time within 30 s) so backfill never produces parallel <sid>-jsonl-* rows. It also skips importSubagents entirely when subagent transcripts exist — the main-transcript Agent-block rows (<sid>-subagent-N) and the transcript rows (<sid>-jsonl-*) would otherwise both be created and only deduped by a fragile type+timing match, doubling every subagent; when transcripts are present the richer -jsonl- rows are authoritative and the -subagent-N fallback runs only when there are none. importSession now also persists transcript_path on the session row (via setSessionTranscriptPath) so the abandon sweep, compaction scanner, and per-agent cost backfill can locate the transcript later, and stamps each subagent's own token buckets into metadata.tokens (used for per-agent cost). backfillSubagentTokenMetadata (a deferred, self-limiting startup pass) fills metadata.tokens on subagents that predate per-agent cost, deriving the transcript path from <projectsDir>/<slug>/<sid>.jsonl when transcript_path is null and covering both subagent-dir layouts — metadata-only, so it never touches session token_usage. classifyJsonl treats any file under a subagents/ ancestor at any depth (including the subagents/workflows/<runId>/ tree) as a subagent, so workflow inner-agent transcripts are never misimported as top-level sessions. Nested-subagent hierarchy is rebuilt by reconcileSubagentParents: every subagent is inserted flat under the main agent (no single hook/JSONL carries the spawner id), then parseSubagentFile also returns spawnedChildren — the child agent ids named on each Task tool result (toolUseResult.agentId) — which are inverted to a child→parent map and used to repoint parent_agent_id (via the setAgentParent statement) so a subagent that spawns its own subagents nests under its true spawner instead of collapsing flat under main; any subagent no other subagent claims stays under main. It resolves both the child and parent to their live-or-jsonl DB id (mirroring importSubagentFromJsonl), so it also corrects the live hook heuristic's guesses once the transcripts land. Idempotent and additive (only rewrites parent_agent_id), it runs in all three group-import paths (importSession ×2, live scanAndImportSubagents); scanAndImportSubagents returns a reparented count alongside created. Re-import is fully incremental: for each existing session a per-event-type high-water mark (MAX(created_at) GROUP BY event_type) is read up-front and only JSONL entries with ts > cutoff[type] are inserted for Stop / PostToolUse / TurnDuration / ToolError — so long-running sessions whose transcripts grow across multiple days continue to receive new events on every re-run instead of being blocked by the old "if zero of type X then dump all" check. sessions.ended_at is rolled forward to the JSONL's last activity when it surpasses the stored value, and metadata.user_messages / assistant_messages / turn_count are refreshed on every pass. parseSessionFile also captures the transcript title (custom-title / ai-title) and importSession prefers it for sessions.name over the cwd-folder fallback, backfilling existing auto/placeholder names on re-import (same precedence as the live hook sync). Other idempotency keys are unchanged: data LIKE '%"tool_use_id":"X"%' skips any tool event already inserted, compaction agents/events dedup by uuid, API errors dedup by summary, and baseline_* columns preserve pre-compaction token totals. Token totals, per-model cost, compactions, subagents, tool events, API errors, and turn durations are identical to live ingestion. Creates APIError, TurnDuration, and ToolError event types during import; subagent tool events carry imported: true, source: "subagent_jsonl" in their data payload so analytics can distinguish backfilled rows when needed
server/routes/import.js Express router for the Import History feature. Three endpoints funnel into the same pipeline: POST /api/import/rescan (default projects dir), POST /api/import/scan-path (arbitrary absolute dir with ~ expansion), POST /api/import/upload (multer multipart accepting .jsonl, .meta.json, .zip, .tar, .tar.gz, .tgz, .gz). GET /api/import/guide returns OS-aware instructions + archive command + default-dir stats. Each request uses a per-request temp dir (req._agenthubUploadDir for multer staging, a separate workDir for extraction) that is reclaimed in finally. Progress is broadcast as import.progress websocket messages throttled at ~150 ms. Limits configurable via AGENTHUB_IMPORT_MAX_BYTES / AGENTHUB_IMPORT_MAX_FILES
server/lib/pricing-sync.js Automatic pricing from LiteLLM's public price list, fetched at most daily: a row (source = 'sync') for every Claude, OpenAI (gpt-…, o<n>…, codex-…) or Gemini (gemini-…) family used in token_usage with no <family>% row of its own AND for every current-generation chat model of those vendors before it is used; its own rows follow a rate change; a row edited in Settings (source = 'user') or deleted (model_pricing_dismissed) is never touched again. Off with AGENTHUB_PRICING_SYNC=0.
server/lib/ai-tools.js The AI Tools catalog: one JSON per AI coding CLI (server/data/ai-tools/, the synced newer-release copy, DASHBOARD_AI_TOOLS_DIR, rising precedence), PATH detection, enable/default state (<data dir>/ai-tools.json). launcher/install/update are honoured only in built-in and release files.
server/lib/shell-path.js Merges the login shell's PATH into the server's ($SHELL -ilc, 5 s) and keeps the shell's own order (shellPath()) for install jobs.
server/lib/ai-tool-models.js GET /api/ai-tools/:id/models: each tool's model list from its own source — Codex's models_cache.json (listed slugs only; identity is never read), Claude's aliases labelled with the newest priced version plus pinned versions from model_pricing, a catalog tool's vendor-priced ids (model.vendor) plus distinct token_usage models of its sessions.
server/lib/ai-tool-config.js A catalog tool's own config files behind GET/PUT /api/ai-tools/:id/config[/:index]: only the ~/-relative paths a trusted catalog file lists in config, existing regular text files ≤ 1 MB, never a credential-looking name (also after a symlink), saved over the opened sha (ESTALE), JSON/JSONC syntax-checked, backed up under <data dir>/ai-tool-config-backups/<id>/ (newest 10), written atomically keeping the mode.
server/lib/ai-tool-jobs.js The one-at-a-time install/update runner behind `POST /api/ai-tools/:id/install
server/lib/ai-tool-versions.js npm-managed check (binary under npm prefix -g, used by update-all to skip an npm updater for a tool installed another way) and GET /api/ai-tools/versions, cached 24 h; dropped on Refresh and after each job. The UI asks for no version: every updater gets Update.
server/lib/ai-tools-sync.js Daily copy of the newest release's AI Tools catalog into <data dir>/runtime/ai-tools-remote/, used only while that release is newer than the build. Off with AGENTHUB_AI_TOOLS_SYNC=0.
server/lib/ai-tool-icons.js Server-side favicon cache (/favicon.ico of the catalog domain, raster by magic bytes, ≤ 256 KB) behind GET /api/ai-tools/:id/icon.
server/lib/data-transfer.js Full-dataset export/import ("backup / restore"). buildExportBundle(db, stmts) serializes every user-owned table (sessions, agents, events, token_usage, workflows, dashboard_runs, alert_rules, model_pricing) into one versioned JSON bundle (format: "agenthub-export"); machine-bound/secret tables (push_subscriptions, webhook_targets/deliveries, alert_events) are excluded. importExportBundle(db, bundle) restores it session-atomically inside one transaction with defer_foreign_keys ON: a session already present (by UUID) is skipped WHOLE (with its agents/events/token_usage/workflows) so re-import and cross-machine merges never duplicate or clobber; events are re-inserted without their non-portable autoincrement id; config rows use INSERT OR IGNORE on their natural key. Backs GET/POST /api/settings/{export,import} and agenthub import-data
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) 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
lib/cc-update-all.js Starts and reports on the "Update all" batch, whose work lives in the detached scripts/cc-update-all-helper.js. Detached for a structural reason: the batch ends with the plugin THIS dashboard runs from, and that step's plugin-bootstrap.js --force SIGTERMs this very server, so an in-process loop would die with the remaining plugins unrun and nothing left to report. Order is marketplaces → every other plugin → AgentHub last → one reload. The worker journals every step transition to cc-update-all.json under the runtime dir — outside the plugin cache the update replaces — so whichever server is alive can serve progress from it; the POST's response is only an acknowledgement. readUpdateAll() DERIVES an abandoned status for a running journal older than STALE_MS (1 h), because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would otherwise stay wedged forever. isRunning() gates a concurrent batch, since two claude plugin update chains over one config dir race
routes/cc-config.js HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (PUT /file, DELETE /file, and a structured PUT /keybindings) that delegate to cc-mutate.js, plus a GET /backups listing for the recovery modal, plus the auth-profile routes (GET/POST/PATCH/DELETE /profiles, POST /profiles/activate) that delegate to cc-profiles.js, plus POST /profiles/usage (a POST because answering it refreshes and rewrites that profile's stored OAuth tokens — see cc-usage.js), plus the install-wide update surface: GET/POST /plugins/update-all (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and POST /reload (types /reload-plugins+/reload-skills into the live lane panes; see cc-reload.js). After every successful PUT/DELETE the route broadcasts cc_config_changed over the WebSocket so any open /cc-config tab refetches without polling. All errors return structured {error: {code, message}} shapes mapped to 400/404/413/500 statuses
lib/cc-profiles.js Named Claude Code auth profiles — swap which Anthropic account (and therefore which quota) the local claude CLI runs as. A profile is exactly two identity artifacts: claudeAiOauth out of <CLAUDE_HOME>/.credentials.json (on macOS: out of the login Keychain item Claude Code-credentials, which is what the CLI reads there — the file is only a fallback, and AGENTHUB_CC_KEYCHAIN=0 pins it), and oauthAccount + userID out of the .claude.json config file(s) (<CLAUDE_HOME>.json and <CLAUDE_HOME>/.claude.json, each key-merged in place and only when it already exists, so projects/numStartups/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; mcpOAuth stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under <CLAUDE_HOME>/agenthub-profiles/ (dir 0700, files 0600), holding live OAuth tokens, so no route ever returns token material — listProfiles() answers identity metadata only. A profile carries two names: name, the filename and the id every route takes, and label, the free-form display name (spaces and diacritics fine, since it never touches a path) that renameProfile() edits in place without moving the file. One account is one profile — saveProfile() refuses EDUPLICATE rather than making a second copy, because duplicates both report themselves active, and deleteProfile() blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only claude auth login can): a new profile is captured by logging in, then snapshotting. activateProfile() re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (EUNSAVED) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at 0600
lib/cc-usage.js How much of an account's quota is spent, for the profile switcher — the number that decides which saved profile is worth switching to. No local source exists (the credentials file has subscriptionType/rateLimitTier and nothing else), so it asks GET https://api.anthropic.com/api/oauth/usage, the same endpoint the CLI's own /usage reads, with that profile's OAuth token. Two rules: a profile's token is sent to Anthropic and nowhere else — what leaves the module is two percentages and two reset times, never token material — and nothing here runs on a poll. Any profile that is not the active one normally holds an expired access token, so answering means refreshing it, and the refresh ROTATES the stored refresh token; the new pair is written back through cc-profiles.writeProfile() because a dropped refresh leaves an account that needs a fresh claude auth login. A still-valid token is used as-is and rewrites nothing; a 401 on a token that looked valid is retried once through the refresh
lib/cc-watcher.js Best-effort fs.watch over ~/.claude/ (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus ~/.claude.json. Coalesces bursts at 500 ms and broadcasts cc_config_changed with { source: "fs", paths: [...] } so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual settings.json edits, dropping a new skill) without a manual refresh. Started from server/index.js after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down
lib/codex-discovery.js Read-only discovery of the Codex CLI's file-backed config surfaces for the Codex Config panel — the Codex counterpart of cc-discovery.js. Surfaces: skills (<root>/skills/<name>/SKILL.md), agents (<root>/agents/*.toml), rules (<root>/rules/*), memory (AGENTS.md at user ~/.codex + project root), config (config.toml at user + project .codex/). No filesystem watcher counterpart to cc-watcher.js exists for Codex. readFileSafe() re-validates path containment (leaf/ancestor symlink-escape guard) independent of the write side's own check. Codex's scope union is "user" | "project" only — it has no "project-local" settings layer and no per-project file-based auto-memory store
lib/codex-mutate.js Create / overwrite / delete for the Codex file-backed artifacts (skills, agents, rules, memory, config) — the Codex counterpart of cc-mutate.js. name is required for skills/agents/rules and rejected (EBADNAME) for memory/config, since each is a single fixed file per scope. Every mutation writes a timestamped backup first (listBackups() powers GET /backups); project scope always resolves against the server's own cwd, never a caller-supplied override
lib/codex-cli.js Shells out to the codex CLI (argv array, never a shell string) for the three surfaces with no on-disk reader: MCP servers (mcp list|add|remove), plugins (plugin list|add|remove), and marketplaces (marketplace list|add|remove). runCodex() parses stdout as JSON when possible; empty stdout on a zero-exit is a normal empty list, not a parse failure. A non-zero exit is returned as a structured failure (surfaced by the route as 422, never a 500) with stdout/stderr preserved
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
lib/pty-run.js PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named agenthub-lane-<lane.id>. Exports startRun() to create/attach a session and return a runId opaque handle; internally uses tmux to manage the pseudoterminal. Spawned claude processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in /api/sessions, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over /ws-pty/:runId (see server/websocket.js) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into dashboard_runs (cwd, model, prompt preview, resume_session_id, lane id, start time) — that row is the ONLY source publicRun has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. mode is NOT NULL in that table and recordRun swallows insert errors by design, which is why this module passes a literal "interactive" rather than null. Create-or-adopt has one refusal: a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a resumeSessionId or an initial prompt cannot be typed over a live agent, so it throws ERUNLIVE (409) rather than reporting a resume that never spawned
lib/pty-attach.js Client-side PTY attachment via WebSocket. Establishes a /ws-pty/:runId connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending TIOCSWINSZ ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, agenthub lanes shell CLI, etc.), all synced live The server side (server/lib/pty-attach.js) runs tmux attach with the LIVE tmux server's own binary (/proc/<tmux.serverPid()>/exe, else tmux on PATH): a tmux upgraded while its server runs leaves a newer client on PATH that the old server refuses with open terminal failed: not a terminal.
routes/run.js HTTP surface for the tmux+PTY Run feature. Same-origin guard on every route — browser requests must come from a localhost-ish Origin (localhost, 127.0.0.1, ::1, 0.0.0.0); missing-Origin (curl/CLI) requests pass. When DASHBOARD_TOKEN is configured it is also required on these routes (same as the rest of /api/*). GET /api/run lists all live runs (computed fresh from tmux state via tmux list-sessions). GET /api/run/tmux reports whether tmux is installed and on PATH (required for the feature to work). GET /api/run/binary probes whether claude is on PATH. GET /api/run/cwds suggests cwds (dashboard + home + recent from sessions table). GET /api/run/history?laneId=... returns persisted run history, optionally scoped to one lane. GET /api/run/files?cwd=&q= powers the Workspace page's @-file autocomplete: scoped fuzzy search inside cwd skipping node_modules, .git, dist, build, .next, .cache, coverage, vendor, etc., capped result count, ranked by basename match. POST /api/run/drop-image saves an image dropped on the terminal (raw body, four image types, random name under <tmpdir>/agenthub-drops-<uid>/) and returns the path the terminal pastes. POST /api/run requires laneId in the body and starts/attaches a tmux-backed run. GET /api/run/:id returns the run handle. DELETE /api/run/:id kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over /ws-pty/:runId as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. tmux must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements)
routes/lanes.js Durable-lane API. POST /api/lanes/worktree, PATCH /api/lanes/:id, destructive actions, and DELETE /api/lanes/:id use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as provisioning, returns 202, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing lane_update payload as idle; a git failure leaves a row that the non-destructive delete route can forget. GET /api/lanes/:id/preflight?action=reset|remove|purge produces counted confirmation facts. Confirmed POST /:id/{reset,remove,purge} actions require a complete expect, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return 500 ERUNTIMEOUT before git), clear run_id, reject changed facts with 409 ESTALE including expected/current diagnostics, and require force for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-LANES_ROOT, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-LANES_ROOT checks. start returns 409 ERUNLIVE rather than overwriting a live run_id and orphaning its child. kind, source_repo, slug, base_branch, slot and ports are not patchable — provisioning writes them through lanesLib.setProvisioningFacts. Worktree provisioning also runs lane-runtime.js:provisionLane (A2) when the repo declares a .agenthub/profile — seed .env, bootstrap, create the database, migrate, seed — before the lane reports idle; reset/remove likewise call resetLaneData/removeLaneData, with reset accepting a body keepDb: true to skip the whole drop-recreate-migrate-reseed block. The runtime routes (GET /:id/runtime, POST /:id/up, POST /:id/down, POST /:id/hook/:name, GET /:id/logs/:svc) are registered before the /:id/:action catch-all so up/down are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. GET /api/lanes/pipeline-stats aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under /:id/git/*, again before the catch-all: GET /:id/git/changes (ranges work/branch/commit, with context), /:id/git/log, /:id/git/file, /:id/git/ls, /:id/git/tree and /:id/git/branches are read-only and unguarded like /:id/git, while POST /:id/git/:action is same-origin guarded and runs under the lane lock — and its per-file actions (stage, unstage, discard) accept only paths git status reports as changed, with discard additionally requiring confirm: true.
lib/ports.js TCP probing for runtime allocation. isListening(port) connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. listenerPids(port) shells to lsof, falls back to ss, and returns [] with a one-time warning when neither exists — a missing tool must never fail a lane operation
lib/lane-slots.js Slot and port allocation — the numbering Shipyard gets free from fixed lane1..lane9 directories and AgentHub, keyed by cwd, must allocate. allocateSlot takes the lowest free of LANE_MAX_SLOTS (default 9) under the per-lane lock, with a partial unique index on lanes.slot as the backstop; allocation is lazy, so a lane that is only watched never consumes one. releaseSlot runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). resolvePorts prefers PORT_BASE_<name> + slot, then steps +100 at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. slotDirs puts run/log state under LANES_ROOT/.state/lane<slot>/ — outside the worktree, because reset's git clean -fd would otherwise sweep live pid files. dbName/dataFacts (A2) derive the same kind of slot-based fact one layer up: database name, DATABASE_URL/TEST_DATABASE_URL, a Redis logical index, and the upload directory — each null when its owning profile declaration (DB_PREFIX, REDIS, UPLOAD_SUBDIR) is absent
lib/lane-profile.js The stack seam. Resolves <repo>/.agenthub/profile/ from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. profile.env is parsed, not sourced — $(…) stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. runHook spawns bash <hook> with an argv array through a wrapper that export -fs harness_spawn/die, exports Shipyard's env contract verbatim (LANE is the slot, plus <NAME>_PORT per declared port, plus A2's DB_NAME/DATABASE_URL/TEST_DATABASE_URL/PG_HOST/PG_PORT/PG_USER/REDIS_URL/REDIS_HOST/REDIS_PORT/UPLOAD_DIR when their profile declaration is present) so its profiles port unchanged, scrubs GIT_* exactly as worktree.js:git() does, redacts any secrets.js password from its output before it reaches the log file or the lane_hook_output broadcast, and streams the rest to both $LOG_DIR/<hook>.log and that broadcast. Hook names come from a fixed allowlist, never from a request
lib/lane-runtime.js Lane stack lifecycle. upLane runs boot then health (never bootstrap — that belongs to provision/reset) and leaves processes running on a failed health check, because their logs are the evidence. Before boot it also repairs .env, ensures the database exists, migrates every boot, and seeds only the boot that created the database (A2). downLane kills each recorded pid tree bottom-up (a parent killed first reparents children to init) and only sweeps port listeners when a pid file existed — an unconditional sweep would kill a server the user started on a lane's stale port. runtimeFacts computes liveness on every read from pid files and probes rather than caching it (plus the database name/Redis index, never a connection string), which is both correct when a process dies unobserved and why adopting a stack after a dashboard restart needs no code at all. provisionLane/resetLaneData/removeLaneData (A2) drive the data-isolation lifecycle at worktree-provision, reset, and remove time — see docs/LANES.md#data-isolation-database-redis-and-env-a2. Writes slot/ports and nothing else on the lane row: status=running means an agent is working, not that a server is listening, and conflating them would corrupt lane liveness
lib/lane-detect.js (A3) Node.js project detection and .agenthub/profile/ scaffolding. detectNode reads package.json layout (root, or backend/+frontend/) and docker-compose.yml (a service matching /postgres/i or /redis/i) — read-only, never executes anything. Any detected name written into GENERATED SHELL TEXT (an npm script name, a compose service name) must pass a strict identifier check first; a profile.env value is already safe regardless, since that file is parsed, never sourced. scaffoldProfile writes the profile, always leaving migrate/seed as an exit 0 TODO stub when a database is found rather than guessing a migration tool. checkProfile is the read-only hard gate: unresolved TODO:, a missing/non-executable hook, or a port already in use all fail it; a missing ~/.agenthub/secrets.env only warns
lib/secrets.js (A2) Reads ~/.agenthub/secrets.env — machine-level database/Redis credentials, deliberately outside any repository. Parsed with lane-profile.js's literal KEY=VALUE reader, never sourced. Falls back to local defaults (with a one-time warning) when the file is absent; refuses to load a file readable by group or world rather than trusting it. Never returned by any route
lib/lane-env.js (A2) seedEnv copies a repo's real .env into a lane on first boot (or --force) and rewrites the declared ENV_REWRITE keys (DATABASE_URL/REDIS_URL/UPLOAD_DIR) in place, byte-identical otherwise. A --force refresh preserves ENV_PRESERVE keys (e.g. JWT_SECRET) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to .env.example with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config
lib/lane-services.js (A2) ensureDatabase/dropDatabase call the profile's db-create/db-drop hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain createdb can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how upLane knows to seed only a freshly-created database. dropDatabase asserts the lane is managed and that the name being dropped is one this lane's own slot actually derives (itself or its _test sibling) before spawning anything
lib/lane-features.js (B) Per-feature state and archive. activateFeature archives the lane's current active feature (if different) and restores the target's saved stage onto the live lanes row — the row stays the one live view every other reader already uses. canonicalizeSlug is a DELIBERATELY separate rule from worktree.js:slugify (drops a leading feat/, keeps [A-Za-z0-9._-], does not lowercase) — the two must never be conflated. clearLane (lib/lanes.js) archives the active feature (if any) before resetting; a lane that never activated one is unaffected
lib/git-diff.js (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — work (HEAD vs the working tree, untracked files included), branch (<base>...HEAD) or commit (one sha), at a caller-chosen context (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: listCommits, readFileAt (the work revision is realpath-confined to the lane; anything else goes through git show), listPaths and listTree (both git ls-files, so .gitignore is honoured without a skip-list). Read-only, argv-only through worktree.js:git(). An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with git diff --no-index, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets truncated
lib/git-ops.js (#34) The git WRITES a lane offers: fetch, pull, push, stage, unstage, discard, stage-hunk/discard-hunk (one hunk, matched by its @@ line against a fresh diff — or one block, a run of +/- lines inside it, the others folded into context by onlyBlock in git-diff.js (#50); discard-hunk needs confirm:true), commit (everything, or only:"staged"), switch, branch-create (never switches) and branch-delete (-d, refusing main/master/development/develop, the current branch and one another worktree holds), publish (remote add origin -- <url> + push -u origin HEAD, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by validateRemoteUrl — https:// without userinfo, ssh://, scp-like user@host:path — and origin is removed again when the push fails), and merge/resolve/merge-commit/merge-abort (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind confirm:true). A caller-supplied path is validated against git status before it reaches an argv, so only a path git already reports as changed is reachable; discard — the one irreversible action — additionally requires confirm: true. No rebase, reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (reset stays behind worktree.js's three-check destroy guard). pull is ALWAYS --ff-only and switch refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; push pushes HEAD, never a branch name from the request. Network actions carry a 60s timeout
lib/proof.js (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's lane_features) and phase, under <lane.cwd>/.playwright-mcp/proof/<slug>/<group>/. Every path is resolved, realpath'd, and containment-checked before touching fs — the entire security surface. ensureProofLink ports Shipyard's ensure_proof_link (converge a stray clone-root proof/ onto the canonical dir); never auto-invoked, exposed only as agenthub lanes proof-link
lib/named-lock.js (D) Cross-lane named locks — the OTHER axis from lib/lane-lock.js's per-lane, in-process serialization, deliberately a separate module. mkdir is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). LOCK_MAX_HOLD (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's agenthub lock acquire owns the polling loop, keeping the server side non-orchestrating like every other lane primitive

API Documentation

Both JSDoc and Swagger/OpenAPI 3.0.3 are used for API documentation. JSDoc comments in route handlers provide inline documentation and type hints, while the OpenAPI spec is generated centrally and rendered three ways for interactive and read-optimized API exploration.

Layer Source Purpose
Inline code docs JSDoc blocks in server/index.js, server/db.js, server/routes/*.js, and server/lib/*.js Explain route behavior, lifecycle logic, and internal contracts close to implementation
Machine-readable API contract server/openapi.js (createOpenApiSpec()) + fragments under server/openapi-extra/ Defines OpenAPI 3.0.3 info, schemas, parameters, and all documented /api/* paths (75 path entries, comprehensive route coverage)
Interactive docs GET /api/openapi.json and GET /api/docs Exposes raw OpenAPI JSON and Swagger UI (try-it-out) for exploration and integration testing
Read-optimized reference GET /api/redoc (served by server/lib/redoc.js) ReDoc three-panel rendering of the same spec; the ReDoc bundle is self-hosted at /api/redoc/redoc.standalone.js (never a CDN) so it works offline / air-gapped
Committed spec snapshot openapi.yaml (repo root) Generated from server/openapi.js via npm run openapi:yaml — mirrors the live spec, never hand-edited

The OpenAPI metadata is grounded in real project data (package.json version/license/repository/bugs), and route coverage is enforced in server/__tests__/api.test.js by asserting expected paths exist in the spec.

Request Processing

flowchart LR
    REQ[Incoming<br/>Request] --> CORS[CORS<br/>Middleware]
    CORS --> JSON[JSON Body<br/>Parser<br/>1MB limit]
    JSON --> ROUTER{Route<br/>Match}
    ROUTER -->|/api/hooks| HOOKS[hooks.js]
    ROUTER -->|/api/sessions| SESSIONS[sessions.js]
    ROUTER -->|/api/agents| AGENTS[agents.js]
    ROUTER -->|/api/events| EVENTS[events.js]
    ROUTER -->|/api/stats| STATS[stats.js]
    ROUTER -->|/api/analytics| ANALYTICS[analytics.js]
    ROUTER -->|/api/remote-sources| REMOTE[remote-sources.js]
    ROUTER -->|/api/pricing| PRICING[pricing.js]
    ROUTER -->|/api/settings| SETTINGS[settings.js]
    ROUTER -->|/api/workflows| WORKFLOWS[workflows.js]
    ROUTER -->|/api/openapi.json| OPENAPI[OpenAPI JSON]
    ROUTER -->|/api/docs| SWAGGER[Swagger UI]
    ROUTER -->|/api/health| HEALTH[Health Check]
    ROUTER -->|"* (prod)"| STATIC[Static Files<br/>client/dist]

    HOOKS --> DB[(SQLite)]
    SESSIONS --> DB
    AGENTS --> DB
    EVENTS --> DB
    STATS --> DB
    ANALYTICS --> DB
    PRICING --> DB
    SETTINGS --> DB
    WORKFLOWS --> DB

    HOOKS --> WS[WebSocket<br/>Broadcast]
    SESSIONS --> WS
    AGENTS --> WS

Client Architecture

Component Tree

graph TD
    APP["App.tsx<br/>Router + WebSocket"]
    LAYOUT["Layout.tsx<br/>Sidebar + Outlet"]
    SIDEBAR["Sidebar.tsx<br/>Nav (scroll-bounded with overflow<br/>chevrons) + Connection Status"]
    DASH["Dashboard.tsx<br/>(Monitor / Analytics / Health tabs)"]
    SESS["Sessions.tsx"]
    DETAIL["SessionDetail.tsx"]
    ACTIVITY["ActivityFeed.tsx"]
    SETTINGS_P["Settings.tsx"]

    WORKFLOWS_P["Workflows.tsx"]
    NOTFOUND["NotFound.tsx"]

    APP --> LAYOUT
    LAYOUT --> SIDEBAR
    LAYOUT --> DASH & SESS & DETAIL & ACTIVITY & WORKFLOWS_P & SETTINGS_P & NOTFOUND

    DASH --> SC1["StatCard x6<br/>(sessions/agents/subagents/<br/>events today/total events/cost)<br/>3-column grid"]
    DASH --> AC1["AgentCard[]<br/>with collapsible subagent hierarchy"]
    DASH --> EV1["Event rows"]
    DASH --> HEALTH["SystemHealthTab<br/>(health score ring, storage donut,<br/>cache/error/success gauges,<br/>tool bars, subagent effectiveness,<br/>model tokens, compaction stats)"]

    KANBAN --> COL["Agents view: 4 columns<br/>(working/waiting/<br/>completed/error)<br/>Sessions view: 5 columns<br/>(active/waiting/completed/<br/>error/abandoned)"]
    COL --> AC2["AgentCard[]"]

    SESS --> TABLE["Session Table<br/>with filters"]
    DETAIL --> OVERVIEW["SessionOverview<br/>(stat tiles, top tools,<br/>subagent breakdown,<br/>token flow, event mix)"]
    DETAIL --> AC3["AgentCard hierarchy<br/>parent → children tree"]
    DETAIL --> CONV["ConversationView<br/>(MarkdownContent + CodeBlock<br/>+ ToolCallBlock per-tool styling)"]
    DETAIL --> TL["Event Timeline"]
    ACTIVITY --> FEED["Streaming Event List<br/>(click row → expand payload;<br/>Session btn → session detail)"]
    WORKFLOWS_P --> WFC["Own-pipeline panels +<br/>D3 analytics components<br/>(workflows/ directory)"]

    style APP fill:#6366f1,stroke:#818cf8,color:#fff
    style LAYOUT fill:#1a1a28,stroke:#2a2a3d,color:#e4e4ed

Splash & loading UX

  • SplashScreen.tsx — rendered by App.tsx as a fixed full-screen overlay alongside the router. Shows once per browser session (sessionStorage gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (splash i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is opaque from the first paint (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors prefers-reduced-motion. CSS-only keyframes, no added dependencies.
  • Colour tokens (client/src/index.css, tailwind.config.js) — one CSS-variable set per mode, plus named-theme override blocks keyed by data-theme on <html> (Darcula, Apple Dark, Apple Light, IntelliJ Light, Canvas — picked in Settings → Appearance, components/settings/AppearanceSettings.tsx, state in hooks/useTheme.ts), consumed as bg-surface-N / text-fg-* / border-border* / text-status-* / text-syntax-*. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is surface-0 #e8e5da and a .card is the brighter surface-3 #f5f3ec (Claude's warm ivory family rather than Radix slate — a cool near-white page reads as glare at any lightness, while dark mode stays slate + blue), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light --accent is #924224 — Claude's terracotta three steps down, since the brand #cc785c is 3.1:1 on ivory — not dark's blue; --accent is a TEXT colour (dark Default #70b8ff), so white text sits on --accent-solid (bg-accent-solid, .btn-primary, Switch, Checkbox — dark Default #0076d1, light = --accent), never on --accent; --fg-muted is held at ≥4.5:1 because it carries timestamps and counts; --shadow-card, not the deliberately quiet --border, carries "raised" in light mode. Syntax colouring is tokenised too (--syn-*), dark keeping its 300-level pastels and light taking the same hues four steps darker. A hardcoded near-black panel, a hover:text-white, or filter: invert(1) on a form glyph is by definition a light-mode bug — token it, or scope it under .dark. Status and accent fills are pre-mixed rather than translucent: tint() in tailwind.config.js compiles bg-status-success/10 and border-status-danger/40 to color-mix(…, rgb(var(--tint-base))), because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to backgroundColor/borderColor — text-*/NN and ring-*/NN must stay translucent, and a translucent surface is what every scrim is made of.
  • Tooltip.tsx (GlobalTooltip) — mounted once by App.tsx beside the router. Every control in the app keeps writing the native title="…"; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in data-tip, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / Escape. No wrapper at the call site, nothing to remember for the next title=, and the native tooltip plus the control's accessible name survive with JS off. data-tip-off opts an element out; Tip.tsx (cursor-following, for expanding a truncated number mid-sentence) draws the same TOOLTIP_SURFACE.
  • Loading skeletons — the shared Skeleton primitive (components/Skeleton.tsx) uses Tailwind animate-pulse. Analytics.tsx now renders a pulsing AnalyticsChartsSkeleton for the whole chart region while data is null (previously it fell back to empty/zero charts).
  • Workflows.tsx Section — the right-aligned section subtitle is clamped to a single line (truncate + max-w + hover title) so a long translation never wraps and unbalances the header; the full text stays in the section's i popover.

Self-hosted assets (no external CDN)

Nothing the dashboard renders is fetched from a third-party CDN at runtime — fonts are served locally, so the UI works fully offline and leaks nothing to external hosts.

  • React app fonts — Inter + JetBrains Mono are imported from @fontsource (latin subset) in client/src/main.tsx. Vite bundles the per-weight WOFF2 into client/dist/assets/ with content hashes at build time; there is no Google Fonts <link>. Importing the latin-* subset entry points keeps the emitted set to one WOFF2 per weight. Net effect: no fonts.googleapis.com or fonts.gstatic.com requests anywhere (verified by git grep).

PWA Architecture

The dashboard ships as a Progressive Web App with its own Web App Manifest and Service Worker (client/public/manifest.json + client/public/sw.js), so the browser treats it as an installable application with its own cache.

Service Worker lifecycle:

  1. Install → skipWaiting() — new SW activates immediately, no waiting for tabs to close.
  2. Activate → old caches deleted (keyed by CACHE_NAME, e.g. dashboard-v2). Bump the version string to force a cache bust.
  3. Fetch → Navigation requests are network-first with offline fallback to cached HTML. Static assets are cache-first with runtime caching on miss.

The fetch handler skips /api/*, /ws, and Vite HMR (__vite) URLs so live data and development tooling are never cached. Only responses with response.type === "basic" (same-origin) are stored. The push notification handlers (push, notificationclick) live alongside the caching logic — their payload icon is the same-origin /favicon.svg (see server/lib/push.js).

Manifest icons: favicon.svg with sizes="any" and type="image/svg+xml" — supported in Chrome 107+, Firefox 110+, Edge 107+. Two icon entries: one with purpose: "any" and one with purpose: "maskable".

iOS meta tags: client/index.html includes <meta name="apple-mobile-web-app-capable" content="yes"> and <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> for standalone home-screen mode on Safari.

Client Module Graph

graph TD
    MAIN["main.tsx<br/>React entry"]
    APP["App.tsx<br/>Router + WS + Notifications"]
    EB["eventBus.ts<br/>Pub/sub + connection state"]
    WS["useWebSocket.ts<br/>Auto-reconnect hook"]
    NOTIF["useNotifications.ts<br/>Browser notification triggers"]
    API["api.ts<br/>Typed fetch client"]
    TYPES["types.ts<br/>Interfaces + configs"]
    FMT["format.ts<br/>Date/time/model-name utilities"]
    DS["dataScope.ts<br/>Global data-scope store"]

    MAIN --> APP
    APP --> WS
    APP --> EB
    APP --> NOTIF
    NOTIF --> EB
    WS --> TYPES
    EB --> TYPES

    subgraph Pages
        D["Dashboard<br/>(Monitor / Analytics / Health)"]
        S[Sessions]
        SD[SessionDetail]
        AF[ActivityFeed]
        WF[Workflows]
        SET[Settings]
        NF[NotFound]
    end

    APP --> D & S & SD & AF & WF
    D & S & SD & AF & WF --> API
    D & S & SD & AF & WF --> EB
    D & S & SD & AF & WF --> FMT
    SET --> API
    SET --> EB
    SET --> FMT
    D & S & AF & WF --> DS
    DS --> API
    API --> TYPES

    subgraph Components
        L[Layout]
        SB[Sidebar]
        AGC[AgentCard]
        STC[StatCard]
        STB[StatusBadge]
        ES[EmptyState]
        TB["Sagi/<br/>(floating mascot companion)"]
    end

    D --> STC & AGC & STB
    S --> STB & ES
    SD --> AGC & STB
    AF --> STB & ES
    APP --> L
    L --> SB
    L --> TB
    EB --> TB

    style TYPES fill:#3178C6,stroke:#5a9fd4,color:#fff
    style EB fill:#f59e0b,stroke:#fbbf24,color:#000
    style API fill:#10b981,stroke:#34d399,color:#fff

Routing

graph LR
    ROOT["/ (index)"] --> DASH["Dashboard<br/>(Monitor / Analytics / Health)"]
    SESS_R["/sessions"] --> SESS[Sessions]
    DETAIL_R["/sessions/:id"] --> DETAIL[SessionDetail]
    ACT_R["/activity"] --> ACT[ActivityFeed]
    WF_R["/workflows"] --> WF[Workflows]
    CC_R["/cc-config"] -->|redirect| SET_R
    LANES_R["/lanes"] -->|redirect| RUN_R["/run"]
    RUN_R --> WORKSPACE["Workspace<br/>lanes + runs merged"]
    SET_R["/settings"] --> SETHUB["SettingsHub<br/>(?tool= tab shell)"]
    SETHUB --> SETDASH["dashboard tab<br/>Settings"]
    SETHUB --> SETCC["claude-code tab<br/>CcConfig"]
    SETHUB --> SETCODEX["codex tab<br/>CodexConfig"]
    NF_R["/*"] --> NF[NotFound]

    ALL["All routes"] --> LAYOUT["Layout wrapper<br/>(Sidebar + Outlet)"]
Route Page Data Sources
/ Dashboard Three tabs (Monitor / Analytics / Health). Monitor: GET /api/stats, GET /api/agents, GET /api/events, GET /api/agents?session_ids={sid,…} (every active session's subagent hierarchy in one call), the pinned Needs you strip (components/NeedsYouStrip.tsx, GET /api/sessions?awaiting=1), dynamic item counts via ResizeObserver. Analytics (merged from the former standalone /analytics page): GET /api/analytics + GET /api/pricing/cost — event heatmap, cost/token/workflow/productivity sub-tabs. Health: GET /api/settings/info + GET /api/workflows (5 s auto-refresh) — composite health score, storage donut, cache/error/success gauges, tool invocation bars, subagent effectiveness, model token distribution, compaction stats
/sessions Sessions GET /api/sessions?status=&q=&limit=PAGE_SIZE&offset=page*PAGE_SIZE — true server-side pagination. The search box passes q to the server (300 ms debounced). Response carries total for the paginator UI. Cost computation runs server-side over the visible page only. Polls /api/run (and listens for run_status) to badge any row whose session is currently being driven from /run with a clickable green ▶ Run pill
/sessions/:id SessionDetail GET /api/sessions/:id (agents + events), GET /api/sessions/:id/stats (overview tiles, top tools, subagent breakdown, token totals — debounced live-refresh on new_event/agent_*/session_updated), GET /api/sessions/:id/transcripts (Conversation tab transcript list), GET /api/sessions/:id/transcript (cursor-paginated message stream, including inline session_event rename markers; optional run_id resolves a workflow inner agent's nested transcript). Probes /api/run (and listens for run_status) to surface a green "Open in Run page" banner when this session is currently being driven by an in-flight Run handle. When the session sits in the Waiting overlay, renders a yellow waiting-for-input banner naming the awaiting_reason (Needs input / Turn done / At prompt / Interrupted) and how long it has waited; every Waiting StatusBadge across the app carries the same reason as a hover tooltip, with an inline nested chip on the wider surfaces (Sessions table, session-detail header) — compact card badges (Dashboard) stay tooltip-only
/activity ActivityFeed GET /api/events?limit=100 — click row to expand inline payload; "Session →" button navigates to /sessions/:id
/workflows Workflows GET /api/workflows?status=active|completed, GET /api/workflows/session/:id + WebSocket auto-refresh (3s debounce)
/cc-config (redirect) Redirects to /settings?tool=claude-code — the config explorer moved under the Settings hub as one of its per-tool panels (see /settings below)
/run Workspace Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls POST /api/run with that lane's id, and receives a runId + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map (only while a pipeline template is installed — none ships), and a real interactive terminal (xterm.js) fed by /ws-pty/:runId binary frames below. TerminalView takes the keyboard on mount (term.focus()), so typing in a lane reaches tmux without clicking the pane first, and it reports the server's exit frame to its owner through onExit — LaneConsolePane runs newRun() there, so a pane that ends (a double Ctrl+C out of claude, exit, a crash) swaps straight back to the run setup form instead of leaving a dead terminal behind a "New run" button. It re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on that exit frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls preventDefault() on Shift+Tab (keeping xterm's own handling): xterm emits ESC[Z for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — shift+tab to cycle (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns false: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (ESC[A/ESC[B), which a claude pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (.xterm-viewport rule in client/src/index.css): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: GET /api/run/{tmux,binary,cwds,files} for tmux availability + claude binary check + @-file autocomplete. Start/resume: POST /api/run (requires laneId; optionally accepts prompt to send immediately); GET /api/run/:id (returns handle); DELETE /api/run/:id (stops). History: GET /api/run/history?laneId=<n> lists only that lane's runs. PTY streaming: /ws-pty/:runId delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, agenthub lanes shell CLI, other tools), all synced live. Lane self-heal: GET /api/lanes/:id auto-corrects run_id/status if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. The console never writes a lane's stage — stage moves only through agenthub stage commands. Live / Offline indicator next to the title
/settings SettingsHub Tabbed shell (?tool= query param picks the active tab, PageTabs) hosting the dashboard's own settings plus one configuration panel per AI tool. An AI Tools button at the tab bar's right edge (PageTabs actions) opens components/settings/AiToolsModal.tsx around AiToolsSettings.tsx; /settings#ai-tools (the Add-lane modal's link) opens it too, and closing drops the hash. dashboard tab (default, no ?tool=) → Settings: an Appearance section first (mode + named theme, localStorage only), GET /api/settings/info, GET /api/pricing/cost + localStorage for notification prefs; hosts the Remote Data Sources panel (components/RemoteSources.tsx) — CRUD + test + sync over /api/remote-sources, live status from remote_source.status WS messages. claude-code tab (?tool=claude-code) → CcConfig: 12-tab Claude Code configuration explorer whose Overview also renders components/settings/ClaudeAgentHubSettings.tsx (there is no AgentHub sub-tab; the overview's path list leaves CLAUDE_HOME to that editor) holding the dashboard's own Claude Code settings — Model Pricing (GET/PUT/DELETE /api/pricing, POST /api/settings/reset-pricing), hook status + Reinstall (GET /api/settings/info, POST /api/settings/reinstall-hooks) and CLAUDE_HOME (GET/PUT /api/settings/claude-home); its Reset Defaults sends {scope: "claude"}. They were on the dashboard tab until #40 part B, and a tool's own settings go in that tool's tab from now on. Both editable home directories render through components/settings/HomeDirSetting.tsx and are saved to AgentHub's settings file (server/lib/settings-env.js: ~/.config/agenthub/settings.env, never the plugin cache's .env; load order real environment > the checkout's .env > that file), with a warning when the real environment or .env owns the variable (env_owned); both PUTs carry sameOriginGuard. Reads via GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (*.md under ~/.claude/projects/<slug>/memory/, grouped by project and searchable in the Memory tab, with clickable MEMORY.md index links that scroll to + highlight the matching fact file) — via PUT /api/cc-config/file + DELETE /api/cc-config/file (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via PUT /api/cc-config/keybindings (same backup-first, atomic-write guarantees). GET /api/cc-config/file?path=… for single-file viewer. GET /api/cc-config/backups for the recovery modal. A Switch profile button left of Backups opens the auth-profile modal (/api/cc-config/profiles*): save the logged-in Anthropic account, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. It cannot log in for you (claude auth login does that) and a switch applies to new sessions only. Subscribes to cc_config_changed WS messages for live refresh on both dashboard mutations and external file edits picked up by cc-watcher. The Settings tab (within CcConfig) leads with a client-side Current configuration summary that resolves the /config options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. codex tab (?tool=codex) → CodexConfig: the Codex CLI counterpart — 8 tabs (overview, skills, agents, rules, memory, config, mcp, plugins; marketplaces are folded into the plugins tab rather than given their own, since the CLI treats them as one small add/remove surface). Codex's scope union is "user" | "project" only — no "project-local", no per-project file-based memory. Reads/mutates via GET/PUT/DELETE /api/codex-config/* (skills/agents/rules/memory/config are file-backed with the same backup-first write path as cc-config; mcp/plugins/marketplaces are CLI-owned add/remove only, no artifact editor). Subscribes to codex_config_changed WS messages. Its Overview also holds (no AgentHub sub-tab) Model Pricing for OpenAI models (components/settings/PricingSettings.tsx, the same table Claude Code's Overview shows filtered to claude-… rows, each with a Show all models switch) CODEX_HOME (GET/PUT /api/settings/codex-home) and the Codex hooks card (components/settings/CodexHooksSettings.tsx, GET/POST/DELETE /api/settings/codex-hooks). Every other installed, enabled catalog tool (no launcher) gets a tab of its own, ?tool=<id> → AiToolConfig: where the CLI lives, its homepage, an Update… link into the AI Tools modal, its session count and cost (GET /api/ai-tools/:id/usage) and its lanes (links to /workspace?lane=<id>), a Default model picker (PUT /api/ai-tools/:id {defaultModel}) for a tool with a model flag, Model Pricing for its model.vendor (Antigravity: Gemini), and the Config files its catalog file lists (GET/PUT /api/ai-tools/:id/config[/:index], server/lib/ai-tool-config.js). Antigravity's tab (?tool=antigravity) adds the Antigravity hooks card (components/settings/AntigravityHooksSettings.tsx, GET/POST/DELETE /api/settings/antigravity-hooks). A tool's tab is hidden while that tool is disabled in AI Tools. Both config panels share a tool-agnostic component set under client/src/components/config/ (EditorModal, ConfirmDeleteModal, FileViewer, ToastNotice, MdItemList, ScopeBadge, configPrimitives) generic over an artifact-type parameter, so the two tools' editors stay visually and behaviorally consistent without duplicating the modal code. Every tab shows the Live / Offline indicator next to the page title
/* NotFound None (static 404 page)

Activity Feed Interaction Model

The Activity Feed (/activity) separates two previously conflated interactions into distinct affordances:

flowchart LR
    ROW["Event row\n(div role=button)"] -->|click / Enter / Space| EXPAND["Toggle inline\nEventDetail panel"]
    ROW --> BTN["Session → button\n(right edge, Link)"]
    BTN -->|click - stopPropagation| NAV["/sessions/:id"]
    EXPAND --> DETAIL["EventDetail.tsx\nparsed payload fields\n+ terminal JSON blocks"]

    style ROW fill:#1a1a28,stroke:#2a2a3d,color:#e4e4ed
    style BTN fill:#6366f1,stroke:#818cf8,color:#fff
    style DETAIL fill:#10b981,stroke:#34d399,color:#fff
    style NAV fill:#f59e0b,stroke:#fbbf24,color:#000
  • Row click (anywhere except the Session button) toggles the EventDetail dropdown for the selected event. Chevron rotates 90° as a visual indicator.
  • Session → button uses e.stopPropagation() to navigate to session details without triggering the expand toggle.
  • Expanded state is tracked in a Set<number> (expandedEvents) allowing multiple rows to be open simultaneously.
  • Keyboard accessible: Enter and Space on the row trigger expand; the Session button is a standard <a> element navigable by Tab.

Workflows Page Architecture

The Workflows page (/workflows) is the most visualization-heavy page, composed of 12 child components in client/src/components/workflows/. Most D3.js rendering is done client-side using data from two API endpoints. The aggregate endpoint accepts an optional ?status=active|completed query parameter to filter all workflow data by session status.

graph TD
    WF["Workflows.tsx<br/>Page orchestrator"]:::root
    API_AGG["GET /api/workflows?status=...<br/>Aggregate data (filterable)"]
    API_DI["GET /api/workflows/session/:id<br/>Session drill-in"]
    WS_D["WebSocket auto-refresh<br/>(3s debounce)"]

    WF --> API_AGG
    WF --> API_DI
    WS_D --> WF

    WF --> S1["WorkflowStats<br/>Summary cards"]
    WF --> S2["LanePipelinePanel<br/>Per-node pass rates<br/>(lanes + archived features)"]
    WF --> S3["SkillUsagePanel<br/>Skill + plugin call counts"]
    WF --> S4["WorkflowRunsPanel<br/>Workflow-tool runs<br/>(on-disk journals)"]
    WF --> S5["OrchestrationDAG<br/>Horizontal DAG —<br/>Sessions → Main → Subagents → Outcomes"]
    WF --> S6["WorkflowPatterns<br/>Auto-detected sequences"]
    WF --> S7["ModelDelegationFlow<br/>Model → agent routing"]
    WF --> S8["ErrorPropagationMap<br/>React horizontal bars +<br/>API/session error support"]
    WF --> S9["SessionComplexityScatter<br/>D3 bubble chart"]
    WF --> S10["CompactionImpact<br/>Token compression analysis"]
    WF --> S11["SessionDrillIn<br/>Searchable session explorer<br/>(3 tabs: tree / timeline / events)"]

    classDef root fill:#6366f1,stroke:#818cf8,color:#fff
Component Visualization D3 Feature
LanePipelinePanel Pass rate per pipeline node across every lane and archived feature Plain React stacked bars from GET /api/lanes/pipeline-stats, scaled against the busiest node. Green/amber/red split evidence-backed passes from bare claims and failures; read-only, the live per-lane view stays on Workspace
SkillUsagePanel Which skills and plugins the sessions actually use Plain React ranked bars from GET /api/workflows/skill-usage; plugin chips sum skill calls and the plugin's own MCP tool calls
WorkflowRunsPanel Workflow-tool runs reconstructed from on-disk journals Per-run phase chips, per-agent table, expandable results
OrchestrationDAG Horizontal DAG of aggregate spawning patterns Custom DAG layout, capped at top 7 subagent types with overflow node
WorkflowPatterns Common orchestration sequences Pattern detection from event data; clicking a row expands the full step chain, a stats grid, a deterministic narrative and a suggestion bucket, all i18n-driven (workflows.patterns.detail.*)
ModelDelegationFlow Model routing through agent hierarchies Hierarchical layout
ErrorPropagationMap Error clustering by hierarchy depth with API/session event errors Pure React horizontal bars, eventErrors support for API and session-level errors
SessionComplexityScatter Duration vs agents vs tokens D3 bubble/scatter chart
CompactionImpact Sessions-by-compaction-count histogram D3 histogram with axis titles, stat tiles and React-managed hover tooltips
SessionDrillIn Per-session agent tree, tool timeline, events Searchable dropdown with pagination, 3 tabs

Cross-filtering: Clicking nodes in the OrchestrationDAG filters data in other sections. JSON export: All workflow data can be exported as JSON from the page header.

Tooltip rendering strategy

Every chart in the Workflows page follows a single, deterministic tooltip pattern designed to avoid the failure modes of naive React tooltips (laggy mousemove re-renders, sticky tooltips after D3 re-renders, clipping by parent overflow:hidden):

  • One DOM-ref tooltip element per chart. Each chart owns a single <div ref={tipRef}> that lives at the bottom of its render tree. D3 mouse handlers mutate that element's content imperatively (textContent, appendChild, inline style), so hovering never triggers a React re-render of the SVG.
  • No mousemove follow. The tooltip is positioned once on mouseenter from the hovered element's getBoundingClientRect(), with viewport clamping (8 px margin) and an automatic flip below → above when there's no room. Position never updates as the cursor moves, which removes per-pixel state churn.
  • Container-level mouseleave fallback. The chart's outer wrapper also calls hideTip() on leave. If a node-level handler is missed because D3 destroyed the element under the cursor on data refresh, the wrapper guarantees dismissal.
  • Re-render safety. Each chart's render effect ends with hideTip() so any stale tooltip from before a websocket-driven refresh is cleared the moment new data arrives.
  • Fade transitions. Tooltips stay in the DOM with opacity: 0 and pointer-events: none, transitioning over 120 ms — show/hide feels smooth instead of flickering, and the element never intercepts pointer events that would prevent mouseleave from firing on the chart.

Structured info popovers

Two classes of explanatory popover sit on top of the chart layer, both i18n-driven:

  • Stat-card popovers (WorkflowStats.tsx). Each of the six headline cards (Avg Agent Depth, Avg Subagents/Session, Agent Success Rate, Most Common Flow, Avg Compactions, Avg Duration) carries an info i icon at the bottom-right of the card. Hovering it opens a fixed-positioned, viewport-clamped popover with three sections: a value+label header, a "How it's calculated" paragraph (workflows.stats.tooltip.calc.*), and a "What this number means" paragraph that renders "{value} {phrase} means {interpretation}". The interpretation comes from a deterministic, value-bucket function (interp*) — pure rule-based mapping with no AI generation, so the same input always yields the same explanation across all three locales.
  • Chart-section popovers (Workflows.tsx → ChartInfoPopover). The i icon next to each section title (1–11) opens a structured "What this shows / How to read it / Why it matters" popover sourced from workflows.chartInfo.<sectionKey>.*. Each of the 11 charts has its own three-paragraph entry, fully translated to en/vi/zh.

Both popover classes use the same fixed-position + viewport-clamp algorithm: anchor right of the icon (or center for chart-section popovers), clamp to a viewport margin, and flip above when there isn't enough room below. They are never clipped by the sidebar, the right edge of the screen, or any ancestor's overflow:hidden.


Internationalization Architecture

The client localization stack is powered by i18next + react-i18next (client/src/i18n/index.ts) and currently supports four languages: English (en), Chinese (zh), Vietnamese (vi), and Korean (ko). Language detection prefers localStorage (i18nextLng) and falls back to the browser locale (navigator) with en as final fallback.

flowchart LR
    A["Browser load"] --> B["LanguageDetector<br/>localStorage -> navigator"]
    B --> C["Resolved language<br/>en | zh | vi | ko (fallback en)"]
    C --> D["Namespace resources<br/>common/nav/dashboard/sessions/..."]
    D --> E["React pages/components<br/>useTranslation(ns)"]
    E --> F["format.ts locale mapping<br/>en-US | zh-CN | vi-VN | ko-KR"]
    F --> G["Localized labels,<br/>dates, number formatting,<br/>and model name display"]

See docs/I18N.md for resource strategy, key naming conventions, localization tests, troubleshooting, and rollout checklists.

Coverage scope. The translation layer extends end-to-end through the Workflows tooltip surfaces — workflows.stats.tooltip.* (calculation copy, deterministic value-bucket interpretations, metric phrases), workflows.chartInfo.* (per-section "What / How to read / Why" entries), and workflows.{orchestration,toolFlow}.tooltip.* (per-graph hover content) — plus the Settings additions: settings.pricing.tooltip.* (pricing rule lookup, % wildcard syntax, manual-update reminder), settings.claudeHome.* (CLAUDE_HOME panel labels), and the full settings.import.* block (now translated to vi/zh, where the panel previously fell back to English).


Database Design

Entity Relationship Diagram

erDiagram
    sessions ||--o{ agents : has
    sessions ||--o{ events : has
    sessions ||--o{ token_usage : tracks
    agents ||--o{ events : generates
    agents ||--o{ agents : spawns

    sessions {
        TEXT id PK "UUID"
        TEXT name "Human-readable label"
        TEXT status "active|completed|error|abandoned"
        TEXT cwd "Working directory"
        TEXT model "Claude model ID"
        TEXT started_at "ISO 8601"
        TEXT ended_at "ISO 8601 or NULL"
        TEXT metadata "JSON blob"
        TEXT awaiting_input_since "ISO 8601 or NULL — set by waiting Notifications"
        TEXT awaiting_reason "notification|stop|session_start|interrupted or NULL"
        TEXT source "Data source — 'local' or a remote source id; NOT NULL DEFAULT 'local'"
    }

    agents {
        TEXT id PK "UUID or session_id-main"
        TEXT session_id FK "References sessions.id"
        TEXT name "Main Agent — {session name} or subagent description"
        TEXT type "main|subagent"
        TEXT subagent_type "Explore|general-purpose|etc"
        TEXT status "working|waiting|completed|error"
        TEXT task "Current task description"
        TEXT current_tool "Active tool name or NULL"
        TEXT started_at "ISO 8601"
        TEXT ended_at "ISO 8601 or NULL"
        TEXT parent_agent_id FK "References agents.id"
        TEXT metadata "JSON blob"
        TEXT awaiting_input_since "ISO 8601 or NULL — main-agent waiting flag"
        TEXT awaiting_reason "notification|stop|session_start|interrupted or NULL"
    }

    events {
        INTEGER id PK "Auto-increment"
        TEXT session_id FK "References sessions.id"
        TEXT agent_id FK "References agents.id"
        TEXT event_type "PreToolUse|PostToolUse|Stop|etc"
        TEXT tool_name "Tool that triggered the event"
        TEXT summary "Human-readable summary"
        TEXT data "Full event JSON"
        TEXT created_at "ISO 8601"
    }

    token_usage {
        TEXT session_id PK "FK to sessions + part of composite PK"
        TEXT model PK "Model identifier + part of composite PK"
        TEXT speed PK "standard or fast (fast mode) — pricing dimension"
        TEXT inference_geo PK "global or us (data residency) — pricing dimension"
        TEXT service_tier PK "standard or batch (Batch API) — pricing dimension"
        INTEGER input_tokens "Current JSONL total"
        INTEGER output_tokens "Current JSONL total"
        INTEGER cache_read_tokens "Current JSONL total"
        INTEGER cache_write_tokens "Total ephemeral cache writes (5m + 1h)"
        INTEGER cache_write_1h_tokens "Subset stored at the 1h tier; 5m = total - 1h"
        INTEGER web_search_requests "server_tool_use web search count"
        INTEGER web_fetch_requests "server_tool_use web fetch count"
        INTEGER code_execution_requests "server_tool_use code execution count"
        INTEGER baseline_input "High-water baseline; effective = live + baseline = max ever seen (one per metric)"
    }

    model_pricing {
        TEXT model_pattern PK "SQL LIKE pattern e.g. claude-opus-4-6%"
        TEXT display_name "Human-readable name"
        REAL input_per_mtok "Cost per million input tokens"
        REAL output_per_mtok "Cost per million output tokens"
        REAL cache_read_per_mtok "Cost per million cache read tokens (cache hits)"
        REAL cache_write_per_mtok "Cost per million 5m cache-write tokens"
        REAL cache_write_1h_per_mtok "Cost per million 1h cache-write tokens"
        REAL fast_input_per_mtok "Fast-mode input rate (0 = none)"
        REAL fast_output_per_mtok "Fast-mode output rate (0 = none)"
        REAL intro_input_per_mtok "Intro input rate, used when usage date <= intro_until (0 = none)"
        REAL intro_output_per_mtok "Intro output rate"
        REAL intro_cache_read_per_mtok "Intro cache-read rate"
        REAL intro_cache_write_per_mtok "Intro 5m cache-write rate"
        REAL intro_cache_write_1h_per_mtok "Intro 1h cache-write rate"
        TEXT intro_until "Promo cutoff YYYY-MM-DD; usage on/before uses intro rates, after uses standard (NULL = no intro)"
        TEXT updated_at "ISO 8601"
    }

    push_subscriptions {
        TEXT endpoint PK "Subscription URL"
        TEXT p256dh "Public key"
        TEXT auth "Auth secret"
        TEXT created_at "ISO 8601"
    }

    remote_sources {
        TEXT id PK "Source id — also stamped onto sessions.source"
        TEXT label "Human-readable name"
        TEXT host "SSH host"
        INTEGER ssh_port "SSH port"
        TEXT identity_file "Optional SSH identity file path or NULL (no secrets stored)"
        TEXT remote_home "Remote home holding ~/.claude/projects, or NULL"
        INTEGER enabled "1|0 — polled by the background sync when 1"
        TEXT status "idle|syncing|ok|error"
        TEXT last_error "Last sync error or NULL"
        TEXT last_sync_at "ISO 8601 or NULL"
        TEXT last_sync_counts "JSON import counters from the last sync or NULL"
        TEXT created_at "ISO 8601"
        TEXT updated_at "ISO 8601"
    }

    alert_rules ||--o{ alert_events : fires

    alert_rules {
        TEXT id PK "UUID"
        TEXT name "User-facing rule name"
        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"
        TEXT created_at "ISO 8601"
        TEXT updated_at "ISO 8601"
    }

    alert_events {
        INTEGER id PK "Auto-increment"
        TEXT rule_id FK "References alert_rules.id, ON DELETE CASCADE"
        TEXT rule_name "Snapshot — survives rule edits"
        TEXT rule_type "Snapshot"
        TEXT session_id "No FK — alert history survives session cleanup"
        TEXT agent_id "Affected agent or NULL"
        TEXT message "Human-readable alert text"
        TEXT details "JSON context blob"
        TEXT triggered_at "ISO 8601"
        TEXT acknowledged_at "ISO 8601 or NULL"
    }

    webhook_targets ||--o{ webhook_deliveries : delivers

    webhook_targets {
        TEXT id PK "UUID"
        TEXT name "User-facing target name"
        TEXT type "provider key — slack|discord|teams|telegram|pagerduty|… (15)"
        TEXT url "Endpoint URL (may be '' when derived) — server-only, masked in responses"
        INTEGER enabled "1|0"
        TEXT secret "Generic-family HMAC-SHA256 signing secret or NULL"
        TEXT headers "Generic-family extra request headers (JSON) or NULL"
        TEXT rule_ids "JSON array scoping to rules, or NULL = all"
        TEXT config "Provider params JSON (routing_key, chat_id, …); secrets redacted in responses"
        TEXT created_at "ISO 8601"
        TEXT updated_at "ISO 8601"
    }

    webhook_deliveries {
        INTEGER id PK "Auto-increment"
        TEXT target_id FK "References webhook_targets.id, ON DELETE CASCADE"
        TEXT target_name "Snapshot"
        TEXT target_type "Snapshot"
        INTEGER alert_id "alert_events.id, NULL for test pings (no FK)"
        TEXT status "success|failed"
        INTEGER status_code "Last HTTP status or NULL"
        INTEGER attempts "Attempt-chain length"
        TEXT error "Failure reason or NULL"
        TEXT created_at "ISO 8601"
    }

Indexes

Index Table Column(s) Purpose
idx_agents_session agents session_id Fast agent lookup by session
idx_agents_status agents status Kanban board column queries
idx_events_session_created events session_id, created_at A session's newest event / time order (watchdog every 15 s, every lane payload)
idx_events_tool events tool_name Per-tool counts (analytics, workflows) and the events filter's tool list without a scan of every event row
idx_events_session events session_id, id A session's events in insert order: every hook's duplicate guard reads the latest one. Not a prefix of idx_events_session_type (that one is not id-ordered); dropping it once made every hook sort its whole session
idx_events_type events event_type Filter events by type
idx_events_created events created_at DESC Activity feed ordering
idx_events_session_type events session_id, event_type Per-session event-type filters
idx_events_agent_type events agent_id, event_type importSubagentFromJsonl reads the agent's existing tool_use_ids once through it (it used to run a data LIKE per tool event)
idx_sessions_status sessions status Status filter on Sessions page and Kanban Sessions view
idx_sessions_started sessions started_at DESC Default sort order
idx_sessions_source sessions source Data-scope (?sources=) filtering by source
idx_alert_events_triggered alert_events triggered_at DESC Alert feed ordering
idx_alert_events_rule alert_events rule_id Cooldown lookup per rule
idx_alert_events_session alert_events session_id Per-session alert history
idx_webhook_deliveries_target webhook_deliveries target_id, created_at DESC Per-target delivery log + last-delivery lookup
idx_webhook_deliveries_created webhook_deliveries created_at DESC Delivery-log pruning (newest 2000)

SQLite Configuration

Pragma Value Rationale
journal_mode WAL Concurrent reads during writes, better performance for read-heavy workload
foreign_keys ON Referential integrity enforcement
busy_timeout 5000 Wait up to 5s for write lock instead of failing immediately

Prepared Statements

All queries use prepared statements (db.prepare()) for:

  • Security -- parameterized queries prevent SQL injection
  • Performance -- compiled once, executed many times
  • Reliability -- syntax errors caught at startup, not runtime

Notable prepared statements include findStaleSessions (used by SessionStart to identify active sessions with no activity for a configurable number of minutes), touchSession (bumps updated_at on every event), and reactivateSession / reactivateAgent (used when a previously completed/abandoned session receives new work or stop events — Stop/SubagentStop reactivate completed/abandoned sessions to handle sessions imported before the server started).


WebSocket Protocol

Connection

  • Path: /ws
  • Protocol: Standard WebSocket (RFC 6455)
  • Heartbeat: Server sends ping every 30 seconds; clients that don't pong are terminated

Message Format

All messages are JSON with this envelope:

{
  type: "session_created" | "session_updated" | "agent_created" | "agent_updated" | "new_event"
      | "alert_triggered" | "alert_updated" | "workflow_upserted" | "remote_source.status";
  data: Session | Agent | DashboardEvent | AlertEvent | WorkflowRun | RemoteSourceStatus;
  timestamp: string; // ISO 8601
}

The remote_source.status message (emitted by the Remote Data Sources sync poller and the /api/remote-sources routes) carries { id, status, error?, last_sync_at? }, where status is one of idle | syncing | ok | error | deleted.

Message Flow

graph TD
    subgraph "Server Events"
        A[Hook event processed]
        B[Session created/updated via API]
        C[Agent created/updated via API]
    end

    subgraph "Broadcast"
        BC["broadcast(type, data)<br/>Serializes to JSON,<br/>sends to all OPEN clients"]
    end

    subgraph "Client Handling"
        WS["useWebSocket hook<br/>Auto-reconnect on close"]
        EB["eventBus.publish(msg)"]
        SUB1["Dashboard subscriber"]
        SUB2["Kanban subscriber"]
        SUB3["Sessions subscriber"]
        SUB4["SessionDetail subscriber"]
        SUB5["ActivityFeed subscriber"]
        SUB6["Workflows subscriber<br/>(3s debounce)"]
    end

    A & B & C --> BC
    BC --> WS
    WS --> EB
    EB --> SUB1 & SUB2 & SUB3 & SUB4 & SUB5 & SUB6
    EB --> SUB7["Sagi companion subscriber"]

    style BC fill:#10b981,stroke:#34d399,color:#fff
    style EB fill:#f59e0b,stroke:#fbbf24,color:#000

The Sagi companion (see Sagi Companion Subsystem) is an additional read-only eventBus subscriber. It consumes the existing message envelope above and introduces no new WebSocket message types and no protocol changes.

Client Reconnection

The useWebSocket hook implements automatic reconnection:

stateDiagram-v2
    [*] --> Connecting: Component mounts
    Connecting --> Connected: onopen
    Connected --> Closed: onclose
    Connected --> Closed: onerror → close
    Closed --> Connecting: setTimeout(2000ms)
    Connected --> [*]: Component unmounts
    Closed --> [*]: Component unmounts

Hook Integration

Hook Handler Design

scripts/hook-handler.js is designed to be a minimal, fail-safe forwarder that POSTs each hook to one ingest target per unique SQLite data directory (different databases still each get hooks; same database never double-ingests):

flowchart TD
    START[Claude Code fires hook] --> STDIN[Read stdin to EOF]
    STDIN --> RESOLVE["resolveAllDashboardPorts()<br/>via server/lib/server-info.js"]
    RESOLVE --> PARSE{Parse JSON?}
    PARSE -->|Success| POST["POST to 127.0.0.1:{port}/api/hooks/event<br/>(one per unique dataDir, parallel)"]
    PARSE -->|Failure| WRAP["Wrap raw input as<br/>#123;raw: ...#125;"]
    WRAP --> POST
    POST --> AGG{Promise.all settled}
    AGG -->|All resolved or rejected| EXIT0[exit = 0]
    AGG -->|Any single timeout 3s| DESTROY[Destroy that request only]
    DESTROY --> AGG

    SAFETY[Safety net: setTimeout 5s] --> EXIT0_SAFETY[exit = 0]

    style EXIT0 fill:#10b981,stroke:#34d399,color:#fff
    style EXIT0_SAFETY fill:#10b981,stroke:#34d399,color:#fff

Key design decisions:

  • Always exits 0 — never blocks Claude Code regardless of any server's state.
  • 3-second HTTP timeout per target + 5-second process-wide safety net.
  • Uses Node.js http module directly — no dependencies.
  • Resolution order is env override → discovery file → default:
    • CLAUDE_DASHBOARD_PORT forces a single target (no fan-out, no discovery).
    • Otherwise server/lib/server-info.js reads ~/.claude/.agenthub.json, prunes dead-PID entries, and returns one live port per unique dataDir (lowest port wins when two instances share ~/.claude/agenthub). The handler POSTs to each returned port in parallel. The other instance learns of a lane the hooks moved through broadcastForeignLaneChanges (server/routes/lanes.js): every DASHBOARD_LANE_SYNC_MS (2 s) it reads PRAGMA data_version, which moves only on another connection's commit, and re-broadcasts each lane whose updated_at changed (and removed for a vanished one) to its own browsers. Sessions and events are not re-broadcast this way — a page on that instance shows them on its next fetch.
    • If neither yields anything, the handler falls back to 4820.
  • Per-target promises never reject — a dead listener can't starve the others, and the handler can wait on Promise.all for clean exit timing.

Hook Installation

scripts/install-hooks.js modifies ~/.claude/settings.json:

flowchart TD
    START[Run install-hooks.js] --> READ{~/.claude/settings.json<br/>exists?}
    READ -->|Yes| PARSE[Parse JSON]
    READ -->|No| EMPTY[Start with empty object]
    PARSE --> CHECK
    EMPTY --> CHECK

    CHECK[Ensure hooks section exists]
    CHECK --> LOOP["For each hook type:<br/>SessionStart, PreToolUse, PostToolUse,<br/>Stop, SubagentStop, Notification, SessionEnd"]

    LOOP --> EXISTS{Our hook<br/>already installed?}
    EXISTS -->|Yes| UPDATE[Update command path]
    EXISTS -->|No| APPEND[Append to array]
    UPDATE --> NEXT
    APPEND --> NEXT

    NEXT{More hook types?}
    NEXT -->|Yes| LOOP
    NEXT -->|No| WRITE[Write settings.json]
    WRITE --> DONE[Print summary]

Preserves existing hooks -- only adds or updates entries containing hook-handler.js.

Plugin installs declare the same hooks instead

When the dashboard is installed as the agenthub Claude Code plugin, the eight hook entries are declared inline in .claude-plugin/plugin.json (each running ${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js), and install-hooks.js is not used at all. claude plugin install itself materializes these into ~/.claude/settings.json with ${CLAUDE_PLUGIN_ROOT} already resolved to the plugin's cache path — confirmed by installing the plugin for real and inspecting the file, not just reading the docs. Both a checkout install and a plugin install writing hooks at once would POST every event twice — events carry no id, so ingest cannot deduplicate them, and every token and cost figure would double. Two guards keep that from happening silently:

  • scripts/plugin-bootstrap.js strips checkout-style hook-handler.js entries out of ~/.claude/settings.json on session start (backing the file up as settings.json.agenthub-bak first) and logs what it removed. Because the plugin's own entries ALSO contain hook-handler.js (just resolved to a cache path instead of a raw filesystem one), a plain substring match cannot tell them apart — isCheckoutHookEntry() only treats an entry as removable when its command does NOT resolve under ~/.claude/plugins/cache/, so the plugin's own legitimate hooks are never touched.
  • install-hooks.js warns when the plugin runtime state exists, and /agenthub-doctor reports any surviving checkout-style duplicates as a FAIL using the same isCheckoutHookEntry() predicate.

scripts/plugin-bootstrap.js also owns the rest of the plugin's runtime: the Node >= 22.5 gate (node:sqlite), an atomic mkdir lock, the dependency install into ~/.claude/agenthub/runtime/, the ~/.local/bin/agenthub launcher, and the detached server spawn with NODE_PATH and DASHBOARD_CLIENT_DIST pointed at that runtime directory. It never writes into the plugin cache, which Claude Code garbage-collects and replaces on every update.


Import Pipeline

The dashboard ships with a first-class history importer that backfills sessions, agents, events, tokens, and costs from Claude Code JSONL transcripts. Live hook ingestion and manual import share the exact same parser (parseSessionFile + importSession in scripts/import-history.js), which is the architectural contract that guarantees imported token and cost values are identical to those captured in real time.

Design goals

  • Accuracy by construction — any code path that creates a session goes through a single importSession entry point. There is no "import math" distinct from "live math."
  • Idempotence — re-importing the same source must never double-count. Session IDs are the dedup key; compaction baseline_* columns preserve pre-compaction token totals so re-ingesting a compacted transcript never shrinks historical cost.
  • Source flexibility — users bring history from the default location, any folder, or a drag-dropped archive. A single generalized walker feeds the parser regardless of the source.
  • Safety — archive extraction enforces path containment and an extraction size cap (zip/tar/gzip-bomb defense), and every request has its own staging directory reclaimed on both success and error paths.

Component overview

flowchart TD
    subgraph Clients
      UI["Browser: Settings →<br/>Import History panel"]
      CLI["CLI: npm run import-history"]
      STARTUP["Server startup<br/>(auto-import)"]
    end

    UI -->|POST /api/import/guide<br/>POST /api/import/rescan<br/>POST /api/import/scan-path<br/>POST /api/import/upload| RT["server/routes/import.js"]
    CLI --> IMP["scripts/import-history.js<br/>importAllSessions()"]
    STARTUP --> IMP

    RT -->|archives| AR["server/lib/archive.js<br/>extractZip / extractTar /<br/>extractGzSingle"]
    RT -->|directory walk| FD["importFromDirectory()"]
    AR -.->|temp workDir| FD
    IMP --> FD

    FD -->|per-session| PS["parseSessionFile()"]
    FD -->|per-subagent| PSA["parseSubagentFile()"]
    PS --> IS["importSession()"]
    PSA --> IS

    IS -->|prepared stmts<br/>single transaction| DB[("SQLite:<br/>sessions / agents / events /<br/>token_usage")]
    IS -.->|progress throttled<br/>~150ms| WS["server/websocket.js<br/>broadcast('import.progress')"]
    WS -.-> UI

    style UI fill:#a855f7,stroke:#c084fc,color:#fff
    style RT fill:#1a1a28,stroke:#2a2a3d,color:#e4e4ed
    style AR fill:#1a1a28,stroke:#2a2a3d,color:#e4e4ed
    style FD fill:#1a1a28,stroke:#2a2a3d,color:#e4e4ed
    style IMP fill:#1a1a28,stroke:#2a2a3d,color:#e4e4ed
    style IS fill:#f59e0b,stroke:#fbbf24,color:#000
    style DB fill:#10b981,stroke:#34d399,color:#fff

Continuous background sync. The startup auto-import (autoImportLegacySessions) is a one-time backfill, marker-gated by .legacy-import.done — so a project folder that appears after first launch, and whose sessions never flow through hooks (e.g. a checkout run with host-only hooks disabled), would otherwise stay invisible until a manual rescan. To close that gap, startSessionSync (server/index.js) keeps ~/.claude/projects in sync through three triggers that share one mtimeCache and a single coalesced sweep, the exported syncDefaultProjects(dbModule, { mtimeCache }):

  1. Immediate — one sweep at startup, so a project the marker-gated backfill missed surfaces right away rather than after the first interval.
  2. Watcher — a debounced fs.watch on the projects tree fires a sweep the instant a new session file or project folder appears (near-real-time, no poll wait). Events for files already in mtimeCache — active transcripts being appended to — are ignored, so a busy session never thrashes the importer. Recursive watching is used only on macOS/Windows (native, stable); on Linux, where Node's userland recursive watcher trips on the high-churn projects tree (same hazard lib/cc-watcher.js avoids), the root plus each immediate child folder are watched non-recursively instead.
  3. Poll — a periodic safety-net sweep (watchers can miss events / not fire on network filesystems), tunable via DASHBOARD_SESSION_SYNC_MS (default 30 s); 0 disables the poll but leaves the watcher running.

Each sweep re-parses only files whose mtime is new or has advanced (the common "nothing changed" tick is just a handful of stat calls), funnels them through the same parseSessionFile + importSession pipeline as every other path, and broadcasts session_created for newly imported sessions / session_updated for grown ones — the same events hooks emit, so the UI refreshes live. All timers and watchers are unref'd and best-effort; nothing here blocks shutdown or can take down the server.

Upload request sequence

The upload path is the most complex of the three — it must accept multipart data, extract archives safely, stage them on disk, then invoke the shared importer. The sequence below captures the complete request/response path including the failure modes explicitly guarded against.

sequenceDiagram
    autonumber
    participant UI as Settings UI
    participant API as /api/import/upload
    participant M as multer (disk)
    participant AR as archive.js
    participant IMP as importFromDirectory
    participant DB as SQLite
    participant WS as WebSocket /ws

    UI->>API: POST multipart files[]
    API->>M: route through uploadMiddleware
    M->>M: mkTempDir('agenthub-upload-*')<br/>stored on req._agenthubUploadDir
    M->>M: fileFilter: reject unsupported<br/>(tracked in req._agenthubRejected)
    alt All files rejected
      API-->>UI: 400 NO_FILES<br/>+ rejected_files[]
    else Files accepted
      API->>AR: mkTempDir('agenthub-import-work-*')
      loop per uploaded file
        API->>AR: extractInto(srcPath, workDir, name)
        AR->>AR: safeJoin: reject absolute / ..
        AR->>AR: enforce MAX_EXTRACT_BYTES
        alt Extraction cap exceeded
          AR-->>API: throw ExtractionLimitError
          API-->>UI: 413 EXTRACTION_LIMIT_EXCEEDED
          API-->>WS: import.progress{phase:error}
          Note over API: break and cleanup
        else OK
          AR-->>API: {extracted, skipped}
        end
        API->>WS: import.progress{phase:extract}
      end
      API->>IMP: importFromDirectory(dbModule, workDir)
      IMP->>IMP: collectJsonlFiles (recursive)
      IMP->>IMP: parseSessionFile per JSONL
      IMP->>IMP: findSessionSubagents (2 layouts)
      IMP->>DB: importSession in one transaction
      IMP-->>WS: import.progress{phase:parse,complete}
      API-->>UI: 200 {imported, backfilled,<br/>skipped, errors, rejected_files}
    end
    API->>AR: rmTempDir(workDir)
    API->>M: rmTempDir(req._agenthubUploadDir)

Idempotence and cost accuracy

flowchart LR
    A[Parse session JSONL] --> B{Session ID<br/>already in DB?}
    B -->|no| C[Insert session,<br/>main agent, events,<br/>token_usage]
    B -->|yes| D{Any new fields,<br/>tools, compactions,<br/>turn durations?}
    D -->|no| E[skipped = true]
    D -->|yes| F[Backfill: insert<br/>missing events +<br/>enrich metadata]
    F --> G[backfilled = true]

    C --> H[replaceTokenUsage]
    F --> H
    H --> I[baseline := max of old + new<br/>effective = max of old_effective, new_live]
    I --> K[Overwrite live with new totals]

    style I fill:#10b981,stroke:#34d399,color:#fff
    style E fill:#1a1a28,stroke:#2a2a3d,color:#e4e4ed
    style G fill:#f59e0b,stroke:#fbbf24,color:#000

The baseline_* columns keep the effective total (live + baseline) a monotonic high-water mark: the cost endpoint sums input_tokens + baseline_input (and the matching output, cache_read, cache_write pairs), and replaceTokenUsage sets baseline := max(old_live + old_baseline − new_live, 0) so effective = max(old_effective, new_live). This never decreases (a compaction that shrinks the transcript keeps its pre-compaction usage) and — crucially — never inflates past the largest value ever seen. The earlier formula added the current value into baseline on any decrease, which two writers of different scope (the live hook writer stores main-only tokens; importSession stores main+subagents) could ratchet upward on every downward fluctuation — one 26-day session accumulated a baseline ~11× its real usage before this was fixed.

Supported source layouts

Layout Example Handling
Default Claude Code <proj>/<sid>.jsonl Session transcript
Default subagent <proj>/<sid>/subagents/agent-*.jsonl Paired with parent on discovery
Alternative subagent <proj>/subagents/<sid>/agent-*.jsonl Paired with parent on discovery
Workflow inner-agent (nested) <proj>/<sid>/subagents/workflows/<runId>/agent-*.jsonl Summarized by Workflow-run ingest; transcript resolved on read + preserved in the durable snapshot
Orphan subagent (no parent JSONL in source) <proj>/subagents/<sid>/agent-*.jsonl importFromDirectory probes both candidates; attaches if sid exists
Flat JSONL drop <root>/<sid>.jsonl Recognized as a loose session
Archives (.zip, .tar, .tar.gz, .tgz) any of the above nested inside Extracted into a per-request temp dir, then walked by the same importer
Single-file gzip any.jsonl.gz Gunzipped in streaming mode with size cap

Safety model

Threat Mitigation
Path traversal via archive entries archive.safeJoin resolves under the extraction root; any .. or absolute path returns null
Zip / tar / gzip bombs MAX_EXTRACT_BYTES (default 4 GB) enforced by running byte counter; aborts with ExtractionLimitError
Per-file upload size abuse multer limits.fileSize = MAX_UPLOAD_BYTES (default 1 GB)
Too many files per request multer limits.files = MAX_UPLOAD_FILES (default 2000)
Unsupported file types fileFilter drops them early and reports them in rejected_files[]
Concurrent upload temp-dir collisions Per-request temp dir on req._agenthubUploadDir; created in multer destination, cleaned in finally
Arbitrary absolute path on scan-path Validated: must be absolute (after ~ expansion), exist, and be a directory
Relative / traversal paths on scan-path Rejected with INVALID_INPUT

Environment variables

Variable Default Purpose
AGENTHUB_IMPORT_MAX_BYTES 1 GB Maximum size per uploaded file
AGENTHUB_IMPORT_MAX_FILES 2000 Maximum files per upload request
AGENTHUB_IMPORT_MAX_EXTRACT_BYTES 4 GB Ceiling on total uncompressed bytes from any single archive

WebSocket progress events

Every import emits import.progress messages on /ws. Messages are throttled to at most one every ~150 ms to avoid flooding the channel on multi-thousand-session imports; the terminal complete and error frames are never throttled.

{
  "type": "import.progress",
  "timestamp": "2026-04-18T15:48:34.123Z",
  "data": {
    "importId": "upload-1729264114000",
    "phase": "parse",
    "source": "upload",
    "processed": 184,
    "total": 512,
    "current": "/tmp/agenthub-import-work-xyz/project/<uuid>.jsonl",
    "counters": { "imported": 120, "backfilled": 40, "skipped": 20, "errors": 4 }
  }
}

Phases: start → scan → extract (upload only) → parse → complete, with error / extract_error replacing complete on failure.


Workflow-Tool Run Ingestion

"Dynamic workflows" — the fleets of sub-agents spawned by the Claude Code Workflow tool (and self-paced /loop runs) — are invisible to hooks. Inner agent() calls emit no PreToolUse/SubagentStop events, so hook-based ingestion can never see the fleet. Instead, everything is persisted on disk under the launching session's transcript folder:

<projects>/<enc-cwd>/<sessionId>/
  workflows/
    scripts/<name>-wf_<runId>.js          # the workflow script — written at LAUNCH
    wf_<runId>.json                       # the run journal — written at COMPLETION
  subagents/
    workflows/<runId>/agent-<agentId>.jsonl  # inner-agent transcript (current builds — NESTED per run)
    agent-<agentId>.jsonl                    # flat layout — older builds / regular sub-agents

The run journal (wf_<runId>.json) is the first-class record: identity (runId, taskId, workflowName), lifecycle (status, startTime, durationMs, defaultModel), aggregates (agentCount, totalTokens, totalToolCalls), phases[], and workflowProgress[] — one entry per inner agent with agentId, agentType, model, state, label, phaseTitle, tokens, tool calls, duration, and previews. Critically, workflowProgress[].agentId is the exact agent-<agentId>.jsonl basename, so the workflow → inner-agent linkage is explicit. Current Claude Code builds write that transcript nested under subagents/workflows/<runId>/; older builds (and all regular sub-agents) write it flat under subagents/. Both layouts are resolved on read — see "Reading full agent text in the UI" below.

The terminal-journal constraint

wf_<runId>.json is written only when the workflow finishes. While a run is in flight, its live state lives in a per-run dir subagents/workflows/<runId>/: a streaming journal.jsonl (a started / result event per inner agent) and the growing agent-<id>.jsonl transcripts. The ingester handles both phases:

  • Completed runs are ingested in full from the terminal journal: a workflows row (keyed by run_id) plus linked inner-agent rows, with phases and per-agent labels.
  • Running runs (no terminal journal yet) are ingested live by ingestLiveWorkflow: it reads journal.jsonl for each agent's started/done state + result, and parses the live agent-<id>.jsonl transcripts (via parseSubagentFile) for real-time per-agent tokens, tool calls, duration, and model. It synthesizes a progress[] so the UI shows live activity (phase/label aren't known until the terminal journal lands). The run is status: running and replaced by the terminal journal on completion (idempotent upsert by run_id; launch time preserved). A launch script with no run dir yet falls back to a minimal running row.

Ingestion module and triggers

server/lib/workflow-ingest.js (ingestWorkflowsForSession) reuses the import pipeline's parseSubagentFile and importSubagentFromJsonl, so inner agents become agent rows under the same ${sessionId}-jsonl-<agentId> id scheme the subagent importer already uses — ingestion therefore converges with any prior subagent import (no duplicate rows). Each inner agent is stamped with agents.workflow_run_id + agents.workflow_phase. The per-agent table in the UI is read from the journal's progress[] JSON.

Cost folding. Inner agents are sidechain contexts whose token usage is NOT in the parent transcript (the same reason combineSessionTokens adds subagent tokens). So the fleet's real token split — parsed from each agent-<id>.jsonl — is written into the session's token_usage under a namespaced service_tier = 'workflow' bucket. That bucket is isolated from the main-transcript writer's rows (which use the real tier), so the two never collide or clobber, while calculateCost still sums them per model. The write is a full recompute each ingest → replaceTokenUsage's replace semantics make it idempotent (no double-count across re-ingests).

Ingestion runs from five fail-safe, off-the-response-path triggers:

  1. Live — routes/hooks.js, on Stop / SubagentStop / SessionEnd (the lifecycle hooks that bracket a workflow finishing).
  2. Real-time poll — startWorkflowPoll (server/index.js) scans active sessions every ~12 s, skipping any whose workflow artifacts are unchanged. The newest-mtime fingerprint (workflowsMaxMtime) includes the live journal.jsonl + agent-*.jsonl of any in-flight run (bounded to runs without a terminal journal), so the poll re-ingests as a running workflow's tokens/tools/agents grow — the UI updates live without waiting for a hook or completion. Tunable via DASHBOARD_WORKFLOW_POLL_MS (0 disables).
  3. Periodic — the server/index.js maintenance sweep, scanning active sessions' workflows/ directories (flips running → completed when a journal lands without a subsequent hook).
  4. Backfill — a one-time pass in autoImportLegacySessions ingests historical on-disk workflows for every recorded session.
  5. Offline CLI import — the batch importer (importAllSessions / importFromDirectory in scripts/import-history.js, backing agenthub import rescan / agenthub import path and the /api/import/* routes) calls ingestWorkflowsForSession per imported session, after the batch's transcripts land and outside the SQLite transaction (the ingest is async). Triggers 1–3 all run inside the server; this is the only path that links a fleet whose journal never reached a live server — a headless claude -p run, a CI job, or an HPC/cluster compute node emits no hooks — so an offline rescan links the inner agents instead of leaving them orphaned (workflow_run_id = NULL, run stuck at 1 agent). Idempotent: converges on the same ${sessionId}-jsonl-<agentId> rows, cheap for sessions with no workflow artifacts (early return).

Each ingest that changes anything broadcasts workflow_upserted and a session_updated (so the cost views refetch) over WebSocket. Runs surface via GET /api/workflows/runs (list) and GET /api/workflows/runs/:runId (detail with linked agents + events), and are attached to the launching session via the workflows[] field on GET /api/sessions/:id. The UI shows them in a "Workflow Runs" panel on the Workflows page and a subsection on Session Detail.

Reading full agent text in the UI

The run journal only carries truncated promptPreview / resultPreview strings (Claude Code truncates them with a trailing …), so the panel alone can never show an inner agent's complete prompt or result. The full text lives in the per-agent agent-<agentId>.jsonl transcript, which the dashboard surfaces on demand:

  • Dual-layout resolution. server/lib/claude-home.js exposes resolveAgentTranscriptInDir(subagentsDir, agentId, runId?), used by all three sub-agent path resolvers (getSubagentTranscriptPath, findSubagentTranscriptPath, getSnapshotSubagentTranscriptPath). It checks the flat path first (so regular sub-agents resolve exactly as before), then the nested workflows/<runId>/ path. When runId is known the run dir is read directly; when it is unknown the nested tree is scanned and a match is returned only if exactly one run contains that agentId — an ambiguous id across runs resolves to null rather than guessing.
  • run_id query param. GET /api/sessions/:id/transcript accepts an optional run_id=wf_<…> alongside agent_id, threaded into the resolver chain so a workflow inner agent's nested transcript is found deterministically. The endpoint shape is unchanged; the param is additive.
  • Lazy fetch on expand. The Workflow Runs panel (client/src/components/workflows/WorkflowRunsPanel.tsx) fetches the transcript the first time a result row is expanded (deduped per ${run_id}::${agentId}), derives prompt + result via extractPromptResult, and renders the full text — falling back to the journal teaser while loading, on error, or for schema-mode agents whose final turn is a tool call rather than text. Nothing is eagerly ingested into the DB. "Full text" means the complete message body up to the endpoint's per-message 10,240-char cap (limit ≤ 200 messages).
  • Durable snapshot. The snapshot writer (snapshotTranscript in scripts/import-history.js) preserves nested workflow transcripts via the dedicated findSessionWorkflowSubagents discovery — mirroring the live subagents/workflows/<runId>/ subpath into the snapshot dir — so the full text still resolves after Claude Code prunes the live files under its cleanupPeriodDays retention. This is kept separate from findSessionSubagents (flat sub-agents) so the regular sub-agent import path is unchanged and nested inner-agents are never double-counted.

Schema

A workflows table (run_id PK, session_id FK ON DELETE CASCADE, status as an open string, phases/progress as JSON blobs, source = journal | live) plus two additive agents columns (workflow_run_id, workflow_phase). No existing table, response shape, or WebSocket message type changes.


Codex Lane Status, Sessions and Hooks

A Claude hook reaches only a claude lane and a Codex hook only a codex lane: touchLaneFromHook resolves the lane by (cwd, tool), so two lanes sharing a directory never see each other's hooks, and it runs stage-detect.detect() for claude lanes only. Codex lanes carry no pipeline: nothing detects, advances or nudges their stage, and the Workspace hides the stage chip, time-on-stage, pipeline/feature pickers and PipelineMap for them. Codex's hooks are opt-in (see Codex hooks below); without them a codex lane's only signal is its rollout journal.

server/lib/codex-ingest.js only mirrors a codex lane's status. Codex appends a rollout journal per thread at $CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<threadId>.jsonl ($CODEX_HOME defaults to ~/.codex); the first line is a session_meta payload carrying the thread's cwd. One pass:

  1. rolloutFiles() walks sessions/ (depth ≤ 4), newest mtime first, capped at 40 files.
  2. rolloutCwd() reads the thread's cwd from the file's first line (memoized — it is written once, at thread creation; the cache is pruned to the current scan once it exceeds 4 × the 40-file cap) and matches it to a codex lane by deepest path-boundary prefix.
  3. The lane is running while ANY of its journals has an mtime under two minutes old (one verdict per lane — judging file by file flapped a lane with an old journal beside the live one), idle once they are older or more than a second in the future (a clock stepped back would otherwise read as live; the second is for a nanosecond mtime against Date.now()), under the identical guard hook ingestion uses (no run_id, only from idle/running). A lane whose bound session got a hook event in the last two minutes is skipped: its hooks are current. Nothing else is written: not stage, not notes, not slot/ports.

startCodexPoll(broadcastLane) runs it on an unref'd 15 s interval from server/index.js, tunable via DASHBOARD_CODEX_POLL_MS (0 disables). Every step is fail-safe: an absent ~/.codex, a malformed journal, a lane deleted mid-pass or a busy SQLite is swallowed locally, and a pass never throws into the event loop. The existing lane_update broadcast carries the change.

Codex 0.149 also mirrors threads into version-suffixed SQLite files (state_5.sqlite, thread_history_1.sqlite), but every thread row still points at a live rollout file, so reading the JSONL avoids coupling to a schema the next Codex release renames. allRolloutFiles() is the single function to re-point if that changes.

Codex sessions and token usage come from the same journals, in server/lib/codex-usage.js (startCodexUsagePoll(broadcast), same DASHBOARD_CODEX_POLL_MS beat, a first pass over every journal in the background at startup). Each file's read offset and parse state live in the codex_rollouts table, so an unchanged file is never re-read and a grown one is read from where it stopped. token_count events are summed per turn_context model — input_tokens includes cached_input_tokens (stored as input − cached plus cache_read) and output_tokens includes reasoning. A fork copies its parent's whole history into its own file, token events included, so every total vector found in the parent's journal is skipped in the child; a sub-agent thread (session_meta.session_id ≠ id) is summed into its root session. The result is one sessions row per root session with tool = 'codex', no transcript_path (the Claude transcript parsers and sweeps skip it), status active while any of its journals was written in the last two minutes, and exact per-model totals written with stmts.setTokenUsage. A row whose tool is not codex is never touched, and a Codex session whose hooks are current (hooksCurrent: its newest event at or after its newest journal write minus two minutes) keeps the status, ended_at and updated_at its hooks gave it (the poll still writes its usage, model and name); once the hooks stop — removed, or untrusted in Codex — the journal decides again, in settleActive too. The Workspace's external-session list (attach, claude --resume) offers tool = 'claude' rows only.

Codex hooks (server/lib/codex-hooks.js, Settings → Codex → Overview → Codex hooks, GET/POST/DELETE /api/settings/codex-hooks) are installed only on a click. Install merges one group per event — SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, Stop, Interrupt, SessionEnd, timeout: 3 — into $CODEX_HOME/hooks.json, keeping every other entry (a file that is not a JSON object is refused, EBADJSON); a forwarder path holding anything not AgentHub's (other content, an unreadable file, a directory — scriptIsOurs) is never overwritten (EFOREIGN, 400) or removed, hooks.json keeps its mode, and a symlinked hooks.json is emptied through the link instead of unlinked; remove takes out only AgentHub's own hooks — recognised by their exact '<dir>/agenthub-hook.sh' <Event> command, so a user's hook added to the same group or with a similar name stays — and the routes carry sameOriginGuard. A symlinked CODEX_HOME is matched both as given and resolved. Codex runs a hook only after the user trusts its exact definition — it asks at startup ("Hooks need review → Trust all and continue"), or via /hooks — and records that as [hooks.state."<hooks.json>:<event>:<group>:<hook>"] trusted_hash in config.toml, which AgentHub reads to report and never writes; it never passes --dangerously-bypass-hook-trust. Because trust is keyed on the definition, the command is a stable path, '<CODEX_HOME>/agenthub-hook.sh' <Event>: that script (AgentHub's own) runs this build's scripts/hook-handler.js <Event> codex with the server's node and CLAUDE_HOME, and every server start rewrites its content when stale or no longer executable — only when the file carries AgentHub's marker line (refreshScript), so an AgentHub update never costs the user's trust. The handler posts tool: "codex", which POST /api/hooks/event reads through an allowlist. For a Codex hook the route:

  • creates the session as tool = 'codex' (named Codex <8> until the poll names it) and stores no transcript_path; the Claude transcript block (tokens, titles, errors, compactions), the sub-agent JSONL scan and the workflow ingest are skipped — the poll owns a Codex session's tokens;
  • turns PermissionRequest into the Notification Claude sends for a permission prompt (Codex needs your permission: <description|command>), so the awaiting flag, the lane's needs_action, alerts and the browser notification follow unchanged, and Interrupt (a declined permission, Esc) into Stop;
  • puts a sub-agent thread's hooks (its rollout, which must be a rollout-*.jsonl under $CODEX_HOME/sessions, names another root session) on the root session, dropping its SessionStart/SessionEnd/Stop/Interrupt/UserPromptSubmit; its PreToolUse/PostToolUse are marked _codexChild and record work only — they neither reactivate the session, clear its waiting flag or set the main agent working, nor clear the lane's needs_action or move its status;
  • never tags a Notification as Compaction (that is for tool = 'claude'), so a permission request quoting tar --compress stays a permission prompt.

A hook-fed Codex session whose process dies without SessionEnd stays active until the stale sweep: the liveness reap probes claude processes only.

Antigravity Sessions and Hooks

antigravity lanes run Antigravity's agy CLI (1.2.x). Nothing about its files is documented; what AgentHub reads was verified against a real run and the binary's own proto descriptors.

Sessions and tokens (server/lib/antigravity-usage.js, startAntigravityUsagePoll, every DASHBOARD_ANTIGRAVITY_POLL_MS, default 15000, 0 disables). agy writes one SQLite file per conversation, ~/.gemini/antigravity-cli/conversations/<conversation id>.db. Each is opened read-only whenever its mtime (or its non-empty -wal's) changes; antigravity_conversations holds the mtime read, so a session the user deleted stays deleted until agy writes to it again. A read-only open still lets SQLite leave an empty -wal and a -shm beside a cleanly closed file, which is why an empty -wal is not activity. A file that is not agy's (SQLITE_NOTADB, SQLITE_CORRUPT, no such table) is recorded with session_id '' and tried again only once it changes or at the next server start, which deletes those rows; any other failure (a busy file, AgentHub's own error) is retried on the next pass. From each file:

  • the file name — the session id, the same conversationId the hooks report;
  • trajectory_metadata_blob — protobuf; its first file:// URI is the session's cwd;
  • gen_metadata — one protobuf row per model call. Field 1 holds the model name (field 19) and ModelUsageStats (field 4): input tokens (2, cache reads NOT included), output (3, thinking included), cache write (4), cache read (5). They are summed per model and written whole with setTokenUsage.

The name (first prompt line, 60 characters) and start come from brain/<id>/.system_generated/logs/transcript_full.jsonl. The session is tool = 'antigravity', has no transcript_path, and is active while its last activity — the file (a non-empty -wal included), the transcript, or its newest hook event — is recent: ten minutes for a session with any hook event (mid-turn a long tool call writes nothing until it ends, and after a Stop agy may be waiting for the user — it fires no exit hook, so an open agy and a closed one look the same), two minutes for one without. An error a failed run's Stop gave it is kept. agy fires no hook when it exits, nor when a run ends on an API error or on a permission that headless mode refused. So a session settling to completed also completes its agents, clears its waiting flags and sets idle its lane that the hooks left running, when no dashboard pane (run_id) owns the lane's status. Only the CLI's directory is read, never the Antigravity IDE's (~/.gemini/antigravity/). Gemini rates come from pricing-sync.js (gemini-… families, from LiteLLM's Vertex AI or Gemini API entries).

Antigravity hooks (server/lib/antigravity-hooks.js, Settings → Antigravity → Antigravity hooks, GET/POST/DELETE /api/settings/antigravity-hooks, sameOriginGuard on the writes) are installed only on a click.

  • Where: ~/.gemini/config/hooks.json maps hook names to their events. It is global, and the Antigravity IDE loads it too. AgentHub owns the name agenthub and leaves every other name alone. A file that is not a JSON object is refused (EBADJSON).
  • Trust: agy asks for none.
  • Events: only three, and all of them observational: PreInvocation, PostToolUse (a {matcher: ".*", hooks} group) and Stop, each with timeout: 5.
  • Never PreToolUse: its answer is a permission decision. A {} answer makes agy DENY the tool, allow would override the user's permission rules, and ask would force a prompt.
  • No permission event: agy has none, so an Antigravity lane never gets needs_action.
  • The command: '<gemini>/config/agenthub-hook.sh' <Event>. The script runs this build's scripts/hook-handler.js <Event> antigravity, then prints {}, and it is refreshed at every server start when it is ours and stale. A file at that path that is not ours is never overwritten or removed (EFOREIGN, 400), and a rewritten hooks.json keeps its mode — the same holds for Codex's forwarder and hooks.json.

In POST /api/hooks/event (tool: "antigravity", allowlisted), a hook is ignored unless:

  • its conversationId is a UUID;
  • its transcriptPath lies under the CLI's own directory, as configured or as resolved through a symlink (agyCliDirs). The IDE's hooks fail this check.

What each event becomes:

  • session_id is the conversationId;
  • cwd is workspacePaths[0];
  • a PreInvocation becomes UserPromptSubmit when it is a run's first (invocationNum: 0), the session has no event yet, its last event is a Stop, the session is not active (a turn that ended with no Stop, one the poll settled), or its last event is more than two minutes old; any other is dropped (inside a turn a model call follows a hook within seconds);
  • PostToolUse carries toolCall.name/args as tool_name/tool_input, plus stepIdx, so two identical calls stay two events;
  • Stop stays Stop, with stop_reason: "error" when it carries a non-empty error or a terminationReason matching /error|fail/i, and is dropped while fullyIdle is false (background work still running). Every other event is dropped.

The session is created as tool = 'antigravity' (named Antigravity <8> until the poll names it), the lane is resolved by (cwd, 'antigravity'), and no stage 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:

  • Claude Code-native extensions (CLAUDE.md, .claude/rules, .claude/skills)
  • Codex-native extensions (AGENTS.md, .codex/rules, .codex/agents, .codex/skills)
  • Plugin marketplace (plugins/, .claude-plugin/marketplace.json) — 10 plugins with 53 skills, 14 agents, 30 slash commands, 3 CLI tools
  • Codex-native extensions (AGENTS.md, .codex/rules, .codex/agents, .codex/skills)
graph TD
    USER["Developer"] --> CLAUDE["Claude Code"]
    USER --> CODEX["Codex"]

    CLAUDE --> C_MEM["CLAUDE.md"]
    CLAUDE --> C_RULES[".claude/rules/*"]
    CLAUDE --> C_SKILLS["skills/*<br/>agenthub plugin skills"]
    CLAUDE --> C_PLUGINS["plugins/<br/>10 plugins, 53 skills"]

    CODEX --> X_MEM["AGENTS.md"]
    CODEX --> X_RULES[".codex/rules/*.rules"]
    CODEX --> X_AGENTS[".codex/agents/*.toml"]
    CODEX --> X_SKILLS[".codex/skills/*"]

    style C_PLUGINS fill:#8b5cf6,stroke:#a78bfa,color:#fff

Claude Code extension scope

  • CLAUDE.md defines always-on project working agreements.
  • .claude/rules/ adds path-scoped guidance by file area.
  • skills/ provides reusable workflows, shipped as the agenthub plugin's own skills (/agenthub:<name>, bare /<name> alias):
    • onboarding
    • live issue debugging
    • project-doc updates
    • file headers
  • plugins/ provides distributable plugin marketplace (see Plugin Marketplace):
    • agenthub-analytics (session reports, cost analysis, usage trends, productivity scoring)
    • agenthub-productivity (standups, weekly reports, sprint summaries, workflow optimization)
    • agenthub-devtools (session debugging, hook diagnostics, data export, health checks)
    • agenthub-insights (pattern detection, anomaly alerting, optimization, session comparison)
    • agenthub-dashboard (status checks, quick stats, endpoint probing)
    • agenthub-cost-guard (budget guardrails, week/month-end spend forecasting, cost-threshold alerts, model-routing savings)
    • agenthub-sessions (session forensics: search, timeline, transcript replay, per-cwd rollup, cleanup)
    • agenthub-workflows (multi-agent orchestration & fleet intelligence: DAG map, delegation audit, concurrency, error propagation, fleet runs)
    • agenthub-quality (reliability & SLOs: error scan, API-error report, hook-failure audit, SLO check, regression alert)
    • agenthub-config (Claude Code config & memory governance: config audit, memory review, skill/MCP/hook inventory)

Codex extension scope

  • AGENTS.md provides project-wide default behavior.
  • .codex/rules/default.rules controls external execution decisions.
  • .codex/agents/ provides custom subagent templates.
  • .codex/skills/ provides reusable task workflows.

Plugin Marketplace

The repository includes an official Claude Code plugin marketplace with ten production-ready plugins. These extend Claude Code itself (not just the dashboard) with skills, agents, slash commands, hooks, CLI tools, and MCP integration — all deeply grounded in the actual dashboard data model. The five original plugins (agenthub-analytics, agenthub-productivity, agenthub-devtools, agenthub-insights, agenthub-dashboard) were each deepened with more agents/skills and a commands/ dir of slash commands, and five new plugins were added: agenthub-cost-guard (budget guardrails — set budgets, forecast week/month-end spend, cost-threshold alerts, model-routing savings, with a fail-safe Stop hook), agenthub-sessions (session forensics — search, timeline, transcript replay, per-cwd rollup, cleanup), agenthub-workflows (multi-agent orchestration & fleet intelligence — DAG map, delegation audit, concurrency, error propagation, fleet runs, built on the 11-dataset workflow intelligence API), agenthub-quality (reliability & SLOs — error scan, API-error report, hook-failure audit, SLO check, regression alert), and agenthub-config (Claude Code config & memory governance — config audit, memory review, skill/MCP/hook inventory via the Config Explorer API).

Marketplace Architecture

graph TD
    subgraph Marketplace[".claude-plugin/marketplace.json"]
        M["Marketplace Manifest"]
    end

    subgraph Plugins["plugins/"]
        A["agenthub-analytics<br/>4 skills, 1 agent, 1 CLI"]
        P["agenthub-productivity<br/>4 skills, 1 agent"]
        D["agenthub-devtools<br/>4 skills, 1 agent, 2 CLIs"]
        I["agenthub-insights<br/>4 skills, 1 agent"]
        C["agenthub-dashboard<br/>2 skills"]
        G["agenthub-cost-guard<br/>5 skills, 1 agent, Stop hook"]
        S["agenthub-sessions<br/>5 skills, 1 agent"]
        W["agenthub-workflows<br/>5 skills, 1 agent"]
        Q["agenthub-quality<br/>5 skills, 1 agent"]
        F["agenthub-config<br/>5 skills, 1 agent"]
    end

    subgraph API["Dashboard API (port 4820)"]
        STATS["/api/stats"]
        ANALYTICS["/api/analytics"]
        PRICING["/api/pricing/cost"]
        WORKFLOWS["/api/workflows/session/:id"]
        SESSIONS["/api/sessions"]
    end

    M --> A & P & D & I & C & G & S & W & Q & F
    A & P & I --> ANALYTICS & PRICING & WORKFLOWS
    D --> STATS & SESSIONS
    C --> STATS & ANALYTICS
    G --> PRICING & ANALYTICS
    S --> SESSIONS
    W --> WORKFLOWS
    Q --> STATS & SESSIONS
    F --> STATS

    style M fill:#6366f1,stroke:#818cf8,color:#fff
    style A fill:#10b981,stroke:#34d399,color:#fff
    style P fill:#f59e0b,stroke:#fbbf24,color:#000
    style D fill:#ef4444,stroke:#f87171,color:#fff
    style I fill:#8b5cf6,stroke:#a78bfa,color:#fff
    style C fill:#06b6d4,stroke:#22d3ee,color:#000
    style G fill:#ec4899,stroke:#f472b6,color:#fff
    style S fill:#14b8a6,stroke:#2dd4bf,color:#000
    style W fill:#a855f7,stroke:#c084fc,color:#fff
    style Q fill:#f43f5e,stroke:#fb7185,color:#fff
    style F fill:#3b82f6,stroke:#60a5fa,color:#fff

Plugin Structure

Each plugin follows the official Claude Code plugin specification:

plugins/agenthub-{name}/
├── .claude-plugin/
│   └── plugin.json              # Manifest: name, version, description, author
├── skills/
│   └── {skill-name}/
│       └── SKILL.md             # Skill definition with $ARGUMENTS placeholder
├── agents/
│   └── {agent-name}.md          # Agent: model, tools, instructions
├── hooks/
│   └── hooks.json               # Event hooks (fail-safe, non-blocking)
├── bin/
│   └── {cli-tool}               # Executable scripts (added to PATH)
└── settings.json                # Plugin settings (optional)

Skills are namespaced: /agenthub-analytics:session-report, /agenthub-productivity:daily-standup, etc.

Plugin Catalog

Plugin Skills Agent CLI Tools Hooks
agenthub-analytics session-report, cost-breakdown, usage-trends, productivity-score analytics-advisor agenthub-stats Stop, SubagentStop
agenthub-cost-guard budget-set, spend-forecast, cost-alert, model-savings, daily-budget-check budget-sentinel — Stop
agenthub-productivity daily-standup, weekly-report, sprint-summary, workflow-optimizer productivity-coach — SessionStart, SessionEnd
agenthub-devtools session-debug, hook-diagnostics, data-export, health-check issue-triager agenthub-doctor, agenthub-export —
agenthub-insights pattern-detect, anomaly-alert, optimization-suggest, session-compare insights-advisor — —
agenthub-sessions session-search, session-timeline, transcript-replay, cwd-rollup, session-cleanup session-investigator — —
agenthub-workflows dag-map, delegation-audit, concurrency-report, error-propagation, fleet-runs orchestration-analyst — —
agenthub-quality error-scan, api-error-report, hook-failure-audit, slo-check, regression-alert reliability-engineer — —
agenthub-config config-audit, memory-review, skill-inventory, mcp-audit, hook-inventory config-auditor — —
agenthub-dashboard dashboard-status, quick-stats — — —

Totals: 10 plugins, 53 skills, 14 agents, 30 slash commands, 3 CLI tools, 3 hook configurations. Each plugin is installable via claude plugin install <name>@agenthub, and a server test (server/__tests__/plugins-marketplace.test.js) validates the marketplace↔directory bijection plus every plugin.json, agent, skill, and command.

Data Model Grounding

Every skill and agent references the actual dashboard API response shapes:

Data Source Key Fields Used by Plugins
Token tracking input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens + 4 baseline_* columns (preserve pre-compaction data)
Cost engine (tokens / 1M) × rate_per_mtok for each type; longest model_pattern match wins; pre-seeded Opus/Sonnet/Haiku rates
Session metadata thinking_blocks, turn_count, total_turn_duration_ms, usage_extras ({ service_tiers[], speeds[], inference_geos[] })
Event types PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification, Compaction, APIError, TurnDuration, ToolError, Interrupted
Workflow intelligence 11 datasets per session: stats, orchestration (DAG), toolFlow (transitions), effectiveness, patterns, modelDelegation, errorPropagation (by depth), concurrency (lanes), complexity (score), compaction (impact), cooccurrence (agent pairs)
Agent hierarchy Recursive CTE with parent_agent_id, subagent_type, depth tracking

Key Derived Metrics

Plugins compute these from raw API data:

  • Cache efficiency: cache_read / (cache_read + input) — trending up = improving prompt reuse
  • Compaction pressure: sum(baseline_*) / sum(effective_tokens) — high = frequent context overflow
  • Tool success rate: PostToolUse count / PreToolUse count — should be ~1.0; gap = tool failures
  • Turn velocity: turn_count / (total_turn_duration_ms / 1000) — turns per second
  • Cost per completed session: total_cost / completed_sessions

Installation

# Marketplace install
claude plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
claude plugin install agenthub-analytics@agenthub

# Local development testing
claude --plugin-dir plugins/agenthub-analytics

Full documentation: docs/plugins.md


State Management

Client-Side Architecture

The client uses a deliberately simple state management approach:

graph TD
    subgraph "Data Sources"
        REST["REST API<br/>(initial load + refresh)"]
        WSM["WebSocket Messages<br/>(real-time updates)"]
        LS["localStorage<br/>(notification prefs)"]
    end

    subgraph "Distribution"
        EB["eventBus<br/>(Set-based pub/sub)"]
    end

    subgraph "App-Level Hooks"
        NOTIF_H["useNotifications<br/>reads prefs, fires<br/>browser notifications"]
        SAGI_H["useSagiBrain<br/>derives mascot mood +<br/>speech from WS stream"]
    end

    subgraph "Page State"
        US1["useState<br/>Dashboard<br/>(Monitor/Analytics/Health)"]
        US3["useState<br/>Sessions"]
        US4["useState<br/>SessionDetail"]
        US5["useState<br/>ActivityFeed"]
        US8["useState<br/>Workflows"]
        US7["useState<br/>Settings"]
    end

    REST --> US1 & US3 & US4 & US5 & US8 & US7
    WSM --> EB
    EB --> US1 & US3 & US4 & US5 & US8 & US7
    EB --> NOTIF_H
    EB --> SAGI_H
    LS --> NOTIF_H
    LS --> SAGI_H
    LS --> US7

Why no Redux / Zustand / Context:

  • Each page owns its data and lifecycle
  • No cross-page state sharing needed (notification prefs use localStorage as the shared store; the one small global store is lib/dataScope.ts, which holds the app-wide data scope — the set of sources whose data is shown — and appends ?sources= to the data queries)
  • WebSocket events trigger reload or append, not complex state merging
  • Simpler mental model, fewer abstraction layers, easier to debug

Event Bus

The eventBus is a Set-based pub/sub with subscribe() returning an unsubscribe function. It also tracks WebSocket connection state, exposing connected (boolean getter), setConnected(value), and onConnection(handler) so any component can subscribe to connection status changes.

// Subscribe to messages in useEffect, unsubscribe on cleanup
useEffect(() => {
  return eventBus.subscribe((msg) => {
    if (msg.type === "agent_updated") load();
  });
}, [load]);

// Read connection state reactively (e.g. with useSyncExternalStore)
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);

This pattern ensures:

  • No memory leaks (cleanup on unmount)
  • No stale closures (subscribe with latest callback ref)
  • Only active pages receive messages
  • Connection state is available to any component without prop drilling

Browser Notification System

The dashboard implements a robust notification system using the Web Push API (VAPID) and Service Workers, allowing for reliable delivery even when the browser is backgrounded or closed.

Notification Architecture

graph TD
    subgraph "Server Side"
        API_P["Push API<br/>(/api/push/*)"]
        WP["web-push lib<br/>(VAPID)"]
        DB_P["push_subscriptions<br/>table"]
        KEYS["vapid-keys.json<br/>(persisted)"]
    end

    subgraph "Client Side"
        SW["Service Worker<br/>(sw.js)"]
        PUSH["useNotifications hook<br/>(subscribes via SW)"]
        PREFS["localStorage<br/>(event preferences)"]
        BROWSER["Browser Push Service<br/>(FCM/Mozilla/Safari)"]
    end

    API_P --> WP
    WP -->|signed push| BROWSER
    BROWSER --> SW
    SW -->|showNotification| USER["Developer"]
    PUSH -->|subscribe| SW
    PUSH -->|POST /subscribe| API_P
    API_P --> DB_P
    WP -->|read keys| KEYS

    style SW fill:#f59e0b,stroke:#fbbf24,color:#000
    style BROWSER fill:#10b981,stroke:#34d399,color:#fff

Key Components

Component Responsibility
VAPID Pipeline Uses the web-push library on the server. VAPID keys are auto-generated on first run and persisted to data/vapid-keys.json to ensure subscription continuity.
Service Worker Located at client/public/sw.js. It runs independently of the dashboard tab, listening for push events from the browser's push service. It handles notificationclick by navigating (client.navigate, falling back to clients.openWindow) an existing dashboard tab to the notification's data.url before focusing it — so a click lands on the lane that raised it, not just whichever tab happened to be open.
macOS Audio Support Notifications are explicitly sent with silent: false and sound: "default". This overrides macOS behavior that would otherwise suppress audio for web notifications.
Subscription Management The dashboard registers the service worker and requests a PushSubscription. This subscription (endpoint and keys) is stored in the push_subscriptions table, indexed by endpoint.
Event Routing When a WebSocket event (e.g., session_created) is broadcast, the server also triggers sendPushToAll(), which iterates through active subscriptions and sends signed VAPID payloads.

Notification Flow

flowchart TD
    EVENT["Server Event<br/>(e.g. SessionStart)"] --> PREFS{"User Prefs<br/>Enabled?"}
    PREFS -->|No| SKIP[Skip]
    PREFS -->|Yes| SUBS["Fetch all subscriptions<br/>from DB"]
    SUBS --> LOOP["For each subscription:"]
    LOOP --> SEND["webpush.sendNotification()"]
    SEND --> BROWSER["Browser Push Service"]
    BROWSER --> SW["Service Worker"]
    SW --> SHOW["showNotification(title, body)<br/>silent: false"]

    style SHOW fill:#10b981,stroke:#34d399,color:#fff

Delivery fallback

POST /api/push/send answering 200 means the request was accepted, not that anyone was notified: it reports {ok, native, pushed, failed}, and a browser that never subscribed (no service worker, a non-secure origin such as http://<lan-ip>:4820, a push service the server cannot reach) gets native:false, pushed:0. notify() in useNotifications.ts therefore treats "delivered" as native || pushed > 0 and otherwise shows the notification locally through the service worker / Notification — a fetch that merely resolved is not proof. The fallback is skipped when the push did land, so nothing is shown twice.

Note the browser-side floor this cannot lift: the Notification API needs a secure context, so open the dashboard on http://localhost (secure by definition) or behind HTTPS — on a bare LAN IP no notification of any kind is possible.

Click-through to the lane

Every notify() call in useNotifications.ts resolves a per-event url — /workspace?lane=<id> — by matching the event's session_id against a lane whose session_id currently equals it (a lightweight lane cache the hook keeps synced off lane_update broadcasts, seeded once via GET /api/lanes). POST /api/push/send's optional url field carries that path through the VAPID payload as data.url; sw.js's notificationclick handler reads it back to navigate. The Workspace page consumes ?lane=<id> in a one-shot effect (selects the lane, then strips the param via replace: true so it doesn't override a later manual lane switch). No lane match (event predates lane tracking, or the lane was removed) — url stays undefined and the click just focuses the dashboard, the prior behavior.

Preference Storage

Notification preferences remain in localStorage (agent-monitor-notifications) for UI-side filtering, while the actual push delivery is managed by the server-side subscription store.

Preference UI Key Logic
Master Toggle enabled Controls whether the subscription is active
New Session onNewSession Filtered client-side in useNotifications
Session Error onSessionError Filtered client-side in useNotifications
Awaiting Input onAwaitingInput Notification events — Claude is blocked on the user. Default ON
Turn Complete onTurnComplete Stop events — Claude finished a turn. Default ON
Session Complete onSessionComplete SessionEnd events. Filtered client-side
Subagent Spawn onSubagentSpawn Filtered client-side in useNotifications

Service Worker Caching

The dashboard's Service Worker (client/public/sw.js) serves dual purposes: push notification delivery (described above) and runtime caching. The fetch handler is split by URL shape so a rebuild can never leave a user stuck on a stale UI:

  • Cache-first for /assets/* — Vite emits content-hashed filenames, so a given URL is immutable for the lifetime of that build. Cached aggressively for fast warm starts; old entries simply never get re-requested when a new build ships different hashes.
  • Network-first with cache fallback for everything else (navigations, the SW itself, manifest.json, icons, root /). The user always gets the freshest UI while online; the cache is only consulted when the network fails.
  • Bypass for /api/*, /ws, and Vite HMR (__vite) — these are never cached.

Cache versioning is controlled by the CACHE_NAME constant (dashboard-v2). On activate, any caches whose key doesn't match are deleted, so bumping the version string forces a clean refresh. skipWaiting() + clients.claim() ensure the new SW takes over immediately.

client/src/main.tsx snapshots navigator.serviceWorker.controller before registration and listens for controllerchange: when a new SW activates on an already-controlled page, it reloads exactly once so the page picks up the new asset URLs without a hard refresh. The first install (no previous controller) does not reload.

These behaviors are reinforced by explicit Cache-Control headers from the production Express static middleware in server/index.js: immutable, max-age=31536000 for /assets/*; no-cache, must-revalidate for index.html, sw.js, and manifest.json; a short revalidation window for other static files. The SPA fallback sendFile sends the same no-cache header.


Update Notifier Subsystem

The Update Notifier tells the user when a newer release of the dashboard has been published, and — on an ordinary git checkout that can be fast-forwarded — can apply it on one click.

The version signal is the forge's own release list, read over REST at /api/v1/repos/<owner>/<repo>/releases/latest (no hardcoded host, no credentials for a public repo). The owner and repo come from one of two sources, chosen by install kind: a checkout derives them from its origin remote; a plugin-cache install derives them from the marketplace manifest on disk (pluginMarketplaceUrl), because it has no remote to read — and often no .git at all, since a marketplace entry with "source": "url" (this project's own) makes Claude Code unpack an archive rather than clone. The published tag is compared against this install's package.json version. When no release can be read — a non-HTTP remote, an offline machine, a repo with no releases — detection falls back to the original commit count against the canonical default branch and puts the reason in release_check, so "up to date" never means "we couldn't look".

Two states the release model makes visible that a commit count cannot:

  • dev_build — the install is strictly ahead of the newest release and missing nothing from it. It is a build off the branch, not a shipped version, and the connection details modal says so instead of "Up to date".
  • plugin_managed — the install runs from ~/.claude/plugins/cache/. Claude Code owns that directory and replaces it wholesale, so applyUpdate will not rewrite it: it refuses with reason: "plugin_managed", decided from the path before any status lookup so the refusal holds offline too. A self-pull would leave a cache directory whose name (a commit sha) no longer matched its contents. This branch is evaluated before the .git probe, and must stay there. An archive-sourced cache directory is not a clone, so a .git check reached first answers "not a git clone; check manually" and skips the release layer entirely — which is exactly what 1.5.1 shipped: update_available pinned false however many releases were published, plugin_marketplace/plugin_name never set, so /apply's plugin branch could never fire and its 409 claimed nothing newer was published. It reads as "up to date", so it stays invisible while the install happens to be current. The button still works here, by a different route: POST /api/updates/apply catches that refusal and hands the job to the detached chain in server/lib/plugin-update.js — claude plugin marketplace update <mkt> → claude plugin update <plugin>@<mkt> -y → plugin-bootstrap.js --force from the new cache directory, which stops this server and starts the new one. It answers 200 {applied:true, mode:"plugin"}. The one thing no background process can do is /reload-plugins: that is a REPL action scoped to the session that runs it, so an already-open Claude Code session still needs it for new skills/commands/hooks. The dashboard does not, because plugin-bootstrap.js derives PLUGIN_ROOT from its own __dirname rather than from ${CLAUDE_PLUGIN_ROOT}.

A shallow clone (--depth) is the third special case, and the plugin cache is one: history is grafted, so every commit-distance number is a graft artefact rather than a distance. All commits_*_release fields are null there and dev_build is never asserted; the version string is the only signal used.

Module Layout

graph TD
    subgraph Server
        LIB["update-check.js<br/>getUpdatesStatus + applyUpdate<br/>forge releases API"]
        SCHED["update-scheduler.js<br/>startUpdateScheduler"]
        ROUTE["routes/updates.js<br/>GET status, POST check, POST apply"]
        PLUG["lib/plugin-update.js<br/>schedulePluginUpdate"]
        HELPER["scripts/plugin-update-helper.js<br/>detached: claude plugin update<br/>then plugin-bootstrap --force"]
        WS["websocket.js<br/>broadcast update_status"]
    end

    subgraph Client
        API["lib/api.ts<br/>api.updates"]
        BUS["lib/eventBus.ts<br/>subscribe and publish"]
        MODAL["UpdateNotifier.tsx<br/>dismissedSha in localStorage"]
        SIDEBAR["Sidebar.tsx<br/>Check-for-updates button"]
    end

    SCHED -->|tick every 5 min| LIB
    ROUTE -->|on request| LIB
    ROUTE -->|apply, plugin install| PLUG
    PLUG -->|spawn detached| HELPER
    HELPER -->|verdict file| LIB
    SCHED -->|fingerprint changed| WS
    ROUTE -->|on POST check| WS
    WS -->|update_status frame| API
    API -->|mirror to bus| BUS
    WS --> BUS
    BUS --> MODAL
    BUS --> SIDEBAR
    SIDEBAR -->|click| API
    MODAL -->|click| API

    style WS fill:#6366f1,stroke:#818cf8,color:#fff
    style LIB fill:#10b981,stroke:#34d399,color:#fff

Detection Pipeline

sequenceDiagram
    autonumber
    participant Sched as Scheduler
    participant Lib as update-check lib
    participant Git as git
    participant WS as WebSocket broadcast
    participant Client as Modal and Sidebar

    Sched->>Lib: tick
    Lib->>Lib: check .git exists
    alt not a git repo
        Lib-->>Sched: soft payload, git_repo false
    else git repo
        Lib->>Git: git remote, pick upstream then origin
        alt no remotes configured
            Lib-->>Sched: soft payload, no remotes message
        else
            Lib->>Git: git fetch canonical remote, 120s timeout
            alt fetch fails
                Lib-->>Sched: soft payload with fetch_error
            else
                Lib->>Git: rev-parse HEAD and canonical ref
                Lib->>Git: rev-list --count HEAD..ref
                Lib->>Git: read current branch and its tracked upstream
                Lib-->>Sched: full payload with situation and manual_command
            end
        end
    end
    Sched->>Sched: compute fingerprint
    alt fingerprint changed
        Sched->>WS: broadcast update_status
        WS->>Client: WS frame
        Client->>Client: syncFromPayload, render modal or badge
    else unchanged
        Sched->>Sched: skip broadcast
    end

Component Responsibilities

Component Responsibility
server/lib/update-check.js getUpdatesStatus(root?, { skipFetch?, latestRelease? }) + applyUpdate(root?). Runs every git call via execFile (no shell, 10s–120s timeouts) and reads the newest release over fetch with an AbortSignal.timeout — fetchLatestRelease NEVER throws, resolving to {ok:false, reason} (no_remote, remote_not_http, no_releases, unreachable, bad_payload, http_<status>) so a status poll degrades instead of erroring. releaseApiUrl derives the endpoint from a repository URL (https, scp-style and ssh:// forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. fetchLatestRelease finds that URL via the origin remote; fetchReleaseFromUrl takes one directly, which is how the git-free plugin path works: pluginMarketplaceUrl(mkt, plugin) reads known_marketplaces.json → the marketplace's .claude-plugin/marketplace.json → the entry's source.url, returning null (never a guessed host) for a github owner/repo shorthand and never throwing. Order matters: detectPluginInstall runs FIRST in getUpdatesStatus, ahead of the .git probe — a plugin cache unpacked from an archive has no .git, and probing first is the 1.5.1 bug that made update_available permanently false. compareVersions orders numeric triples and strips a leading v, since this repo carries both v1.5.0 and 1.4.6. isShallowClone gates every commit-distance calculation; detectPluginInstall reads the marketplace and plugin name out of the cache path. Branch- and fork-aware as before: resolves <remote>/master//main//HEAD, reads the current branch and its tracked upstream, and shapes manual_command per situation — the claude plugin ... commands for plugin_managed; git fetch <remote> --tags && git merge --ff-only <tag> for release_available; git pull --ff-only when the branch tracks the canonical ref; git fetch <remote> && git merge --ff-only <ref> for forks; git fetch <remote> only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws.
server/update-scheduler.js Ticks the lib every DASHBOARD_UPDATE_CHECK_INTERVAL_MS (default 300 000, floor 60 000). First tick is scheduled 8s after server start with .unref() so it doesn't block shutdown. Broadcasts only when the fingerprint {update_available, remote_sha, commits_behind, fetch_error, manual_command} changes — manual_command is included so situation transitions (e.g. user switches branches, or adds an upstream remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. DASHBOARD_UPDATE_CHECK=0|false|off disables the scheduler entirely.
server/routes/updates.js Three endpoints: GET /status (read-only check), POST /check (check + broadcast), POST /apply. /apply has two modes, chosen by how this copy was installed, never by the request body: checkout — fast-forward onto the release tag, npm run setup, npm run build in production, then scheduleRestart(); plugin — applyUpdate's plugin_managed refusal is caught and schedulePluginUpdate() runs instead, answering 200 {applied:true, mode:"plugin"}. The plugin branch fires only when there is genuinely something to pull (update_available plus a marketplace and plugin name parsed from the cache path) — a plugin_managed refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 UPDATE_NOT_APPLICABLE with reason "up_to_date", "not_fast_forwardable", or "plugin_managed". Both modes write the response BEFORE anything stops the process. Mounted under /api/*, so all three ride the default loopback bind as their trust boundary and are gated by DASHBOARD_TOKEN when one is set.
server/lib/plugin-update.js + scripts/plugin-update-helper.js The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, not the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as self-restart.js, one step further. The helper re-validates the marketplace and plugin names against /^[A-Za-z0-9][\w.-]*$/ (they reach an argv across a process boundary), resolves claude on PATH or at ~/.local/bin/claude (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain scripts/plugin-bootstrap.js, and records every outcome — including an unexpected throw — to plugin-update.json under ~/.claude/agenthub/runtime/, outside the cache so it survives the swap. Steps: validate, resolve_claude, marketplace_update, plugin_update, already_current (ok:true; nothing was pulled and the old server was left running), bootstrap, done. readLastPluginUpdate() surfaces it as last_plugin_update on the status payload — without it a failed chain is indistinguishable from a slow one.
UpdateNotifier.tsx Modal. Hydrates from api.updates.status() on mount and mirrors the payload back into the local eventBus so the Sidebar can listen without a second git fetch. Subscribes to update_status WS frames for ongoing sync. Keeps the dismissal key in localStorage (agent-monitor-update-dismissed-sha) and in React state — the release tag when one was read, else the upstream SHA, so a newer release re-opens the prompt while a dismissed one stays dismissed; a window event dashboard:reset-update-dismissal from the Sidebar clears both. ESC / backdrop click dismisses.
Sidebar.tsx The update state and its Check for updates button live in the connection details modal the sidebar owns (opened from the status bar; the sidebar has no footer); the status bar shows a green dot beside the version while a release is out. Subscribes to update_status (no own fetch). On click: clears dismissed SHA in localStorage, dispatches dashboard:reset-update-dismissal, then calls api.updates.check(). Visual state: emerald badge when update_available, amber when fetch_error, neutral otherwise. Label has four resting states, not two — "<tag> available", "Dev build · N ahead" (when dev_build), "Up to date", or "Check for updates" before the first result.

Payload Shape

interface UpdateStatusPayload {
  git_repo: boolean;
  update_available: boolean;
  repo_root?: string;
  remote_ref?: string | null;          // "upstream/master" | "origin/main" | ...
  canonical_remote?: string | null;    // "upstream" preferred, else "origin"
  current_branch?: string | null;      // null on detached HEAD
  tracking_upstream?: string | null;   // e.g. "origin/feature/foo", null if no upstream
  tracks_canonical?: boolean;          // true when branch upstream === remote_ref
  situation?:                          // categorical hint for the UI
    | "plugin_managed"                 // ~/.claude/plugins/cache — /apply delegates
    | "release_available"              // fast-forward onto the release tag
    | "tracking_canonical"
    | "fork_or_diverged_tracking"
    | "feature_branch"
    | "detached_head";
  situation_note?: string | null;      // human-readable explanation when not tracking_canonical
  local_sha?: string | null;
  remote_sha?: string | null;
  commits_behind?: number;
  manual_command?: string | null;      // shaped for the user's situation
  message?: string | null;
  fetch_error?: string;                // set when git fetch fails

  // ── Release layer ──
  install_kind?: "plugin" | "checkout";
  plugin_marketplace?: string | null;  // "sg" — parsed from the cache path
  plugin_name?: string | null;         // "agenthub" — same source
  last_plugin_update?: {               // verdict of the previous chain, if any
    ok: boolean;
    step: string;                      // "validate" | "resolve_claude" | ...
    error?: string;
    note?: string;
    plugin_root?: string;
    finished_at?: string;
  } | null;
  local_version?: string | null;       // this install's package.json version
  shallow_clone?: boolean;             // true => every commits_* below is null
  release_check?: string;              // "ok" | "no_remote" | "remote_not_http"
                                       //      | "no_releases" | "unreachable"
                                       //      | "bad_payload" | "http_<status>"
  latest_release?: string | null;      // tag, e.g. "v1.5.0"
  latest_release_name?: string | null;
  latest_release_url?: string | null;
  latest_release_published_at?: string | null;
  latest_release_notes?: string | null; // truncated server-side to 4000 chars
  release_sha?: string | null;         // commit the tag resolves to locally
  commits_behind_release?: number | null;
  commits_ahead_of_release?: number | null;
  dev_build?: boolean;                 // ahead of the newest release, missing nothing
}

The same shape is used by GET /status, POST /check, and the update_status WS message. POST /apply returns it plus applied: boolean; in plugin mode it also carries mode: "plugin" and runtime_dir, and on a 409 a reason of "up_to_date", "not_fast_forwardable", or "plugin_managed". update_available and dev_build are never both true.

Failure Mode Matrix

Condition Returned payload User-visible effect
Not a git clone (and not a plugin cache) {git_repo:false, update_available:false, message:"Install directory is not a git clone..."} Modal suppressed (update_available false). Sidebar stays neutral.
Plugin cache with no .git (archive-sourced marketplace entry) {git_repo:false, install_kind:"plugin", release_check:"ok", latest_release:"vX.Y.Z", update_available:<version compare>} Checked normally: the repo URL comes from the marketplace manifest and package.json is the only local signal. The notGit note is suppressed and Update now is offered.
No remotes configured {git_repo:true, update_available:false, message:"No git remotes configured..."} Same as above.
git fetch failed (offline, auth) {git_repo:true, update_available:false, canonical_remote, fetch_error:"<stderr>"} Sidebar button goes amber; modal stays suppressed until a successful check.
Canonical default branch unresolvable {git_repo:true, update_available:false, canonical_remote, message:"Could not resolve <remote>/master..."} Modal suppressed.
Healthy, up to date {git_repo:true, update_available:false, commits_behind:0, situation:"tracking_canonical"|...} Connection modal neutral, no status-bar dot, update modal suppressed.
Release lookup failed (offline, non-HTTP remote, no releases) {release_check:"unreachable"|"remote_not_http"|"no_releases"|..., latest_release:null} Detection falls back to the commit count; the modal states the reason so "up to date" is not read as "release checked".
Ahead of the newest release {update_available:false, dev_build:true, commits_ahead_of_release:N} Sidebar reads Dev build · N ahead; modal suppressed (a dev build is not an available update).
Shallow clone (incl. the plugin cache) {shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false} No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides update_available.
Newer release, ordinary checkout {update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."} Modal opens titled with the release version, links its notes, and shows Update now.
Newer release, plugin-cache install {update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."} Modal opens WITH Update now; POST /apply answers 200 {applied:true, mode:"plugin"} and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs /reload-plugins for new skills/commands. It then polls status and reloads once the old server went down and answered again, or as soon as a different local_version answers; a verdict of this chain (finished_at after the click) that is ok:false or already_current ends the wait and shows it, since the old server then never goes down. Independently, every tab reloads itself onto the new UI build when its WebSocket reconnects to a server whose /api/health version is not the bundle's (client/src/lib/newBuildReload.ts, once per version).
Plugin chain ran but nothing was published {last_plugin_update:{ok:true, step:"already_current"}} The old server keeps running, untouched; no restart, no error banner — a tab waiting on Update now stops waiting and says nothing new was pulled.
Plugin chain mid-handover {last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}} Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave last_plugin_update: null for an update that succeeded. Overwritten by done when the helper survives.
Plugin chain failed (no claude on PATH, npm ci broke, bootstrap died) {last_plugin_update:{ok:false, step:"resolve_claude"|"plugin_update"|"bootstrap", error:"..."}} Danger banner in the modal naming the failed step. A bootstrap failure means the old server was already stopped, so the verdict says to run /agenthub-update to recover — and it is read back by whichever server comes up next, because it lives outside the cache.
Plugin /apply with nothing newer published 409 {reason:"plugin_managed", release_check:"ok"} No chain is spawned. Message names the newest release and points at /agenthub-update to force a refresh.
Plugin /apply after a failed release check 409 {reason:"plugin_managed", release_check:"unreachable"|"no_local_version"|...} No chain is spawned, and the message says the check did not succeed rather than claiming nothing newer exists — an unknown reported as a negative is how a broken check looks identical to an up-to-date install.
Healthy, behind, on canonical branch {update_available:true, situation:"tracking_canonical", manual_command:"...git pull --ff-only..."} Modal opens with git pull flow + restart hint.
Healthy, behind, fork (origin = fork, upstream = canonical) {update_available:true, situation:"fork_or_diverged_tracking", manual_command:"...git fetch upstream && git merge --ff-only upstream/master..."} Modal opens with merge flow + restart hint + situation_note explaining the divergence.
Healthy, behind, on a feature branch {update_available:true, situation:"feature_branch", manual_command:"...git fetch <remote>"} Modal opens; situation_note explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed.

Why Self-Apply Stays Narrow

POST /api/updates/apply is the one path in this dashboard that mutates its own working tree and restarts the process. It is deliberately narrow: in checkout mode it acts only on release_available, tracking_canonical, and fork_or_diverged_tracking, always with --ff-only, and refuses outright on a feature branch or a detached HEAD. A plugin_managed install is not a git operation at all — it is delegated to claude plugin update, whose own tooling owns that directory. Everywhere else the dashboard prints a command and lets the user run it, because the constraints below are real and an automatic path cannot answer them:

  • Supervisor ambiguity. npm run dev (concurrently), npm start, pm2, systemd, and launchd each need different restart logic; an in-process helper could only encode one of them and would silently mis-restart the rest.
  • Silent failures. npm install / npm run build / port-release timing issues surface as a dead server with no user-facing feedback once the original process has exited.
  • No rollback. A partial pull + install leaves a broken checkout with no atomic recovery — the working tree is mutated mid-flight.
  • Branch coverage. A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed git fetch and no button.
  • Ownership. A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls claude plugin update and then re-runs plugin-bootstrap.js from the directory that tool produced. The residual limit is only /reload-plugins, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved.

What --ff-only buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, npm run setup/build failures reject the request before any restart is scheduled, and the restart itself goes through self-restart.js + the detached scripts/restart-helper.js rather than an in-process guess at the supervisor.


Sagi Companion Subsystem

Sagi is a client-only floating mascot companion that reacts to live session activity. It is purely additive UI: there are no new server routes, no new WebSocket message types, and no database schema changes. Sagi reuses the existing real-time event stream (the same eventBus every page already consumes) and the existing Run lane/run APIs for its "ask a real question" path. The entire subsystem lives under client/src/components/Sagi/.

The only server-side accommodation is that POST /api/lanes/ensure now defaults a missing cwd to the user's home directory, so the mascot can adopt a stable "Sagi" lane (titled, fixed cwd) without forcing the client to know any absolute path. The behavior is gated by the ensure route; the underlying createLane still rejects empty cwds.

The design follows a strict pure-core / hook / presentational split: a framework-free brain (a WSMessage reducer plus a mood state machine with an injected clock and zero side effects) is fully unit-tested in isolation, a single React hook is the only consumer of the global eventBus and the only owner of timers and side effects, and the SVG/markup components are pure presentational views driven by props.

Module Layout

graph TD
    subgraph "Pure Core (framework-free, unit-tested)"
        BRAIN["brain.ts<br/>reduceSagi reducer +<br/>deriveMood state machine<br/>(injected clock, no side effects)"]
        INTENTS["intents.ts<br/>local Q&A over cached status;<br/>unmatched → Run handoff"]
        QUIPS["quips.ts<br/>mood → phrase pools"]
        PREFS["prefs.ts<br/>localStorage enabled/muted<br/>(cross-tab sync)"]
    end

    subgraph "Hook (only eventBus consumer)"
        HOOK["useSagiBrain.ts<br/>wires brain to real timers<br/>(idle/sleep/stuck), speech-bubble<br/>queue, mute, clear-alerts"]
    end

    subgraph "Presentational (pure)"
        SHELL["Sagi.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
        AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
        BUBBLE["SpeechBubble.tsx<br/>bubble"]
        PANEL["SagiPanel.tsx<br/>status + quick actions + Ask box"]
        CSS["sagi.css<br/>keyframes + per-mood expressions"]
    end

    BUS["lib/eventBus.ts<br/>(existing WS stream)"]

    BUS --> HOOK
    HOOK --> BRAIN
    HOOK --> QUIPS
    HOOK --> PREFS
    SHELL --> HOOK
    SHELL --> INTENTS
    SHELL --> AVATAR & BUBBLE & PANEL
    AVATAR --> CSS

    style BRAIN fill:#10b981,stroke:#34d399,color:#fff
    style BUS fill:#f59e0b,stroke:#fbbf24,color:#000
    style HOOK fill:#6366f1,stroke:#818cf8,color:#fff

Data Flow

flowchart LR
    WSS["Server WebSocket<br/>broadcast"] --> UWS["useWebSocket"]
    UWS --> PUB["eventBus.publish"]
    PUB --> SUB["useSagiBrain<br/>(subscriber)"]
    SUB --> DERIVED["derived state<br/>{ mood, status, bubble }"]
    DERIVED --> AVATAR["SagiAvatar"]
    DERIVED --> BUBBLE["SpeechBubble"]
    DERIVED --> PANEL["SagiPanel"]
    PANEL -->|"unmatched Ask"| ENSURE["POST /api/lanes/ensure<br/>(adopt 'Sagi' lane, cwd = home)"]
    ENSURE --> START["POST /api/run<br/>(spawn tmux-backed claude<br/>in Sagi lane, initialPrompt=…)"]
    START --> RUN["/run?laneId=…<br/>(existing Run page picks up<br/>the live run)"]

    style PUB fill:#f59e0b,stroke:#fbbf24,color:#000
    style ENSURE fill:#6366f1,stroke:#818cf8,color:#fff
    style START fill:#6366f1,stroke:#818cf8,color:#fff
    style RUN fill:#10b981,stroke:#34d399,color:#fff

The mood state machine in deriveMood resolves to a single expression using a fixed priority order: disconnected > worried > stuck > happy > thinking > watching > sleeping > idle. The resolved mood is written to a data-mood attribute on the SVG mascot, and sagi.css maps each mood to its keyframe animation and expression.

Component Responsibilities

Component Responsibility
brain.ts Pure, framework-free core. Exposes a WSMessage reducer (reduceSagi) and a mood state machine (deriveMood) with the priority order disconnected > worried > stuck > happy > thinking > watching > sleeping > idle. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation.
useSagiBrain.ts The only consumer of the global eventBus. Wires the pure brain to real timers (idle / sleep / stuck), the speech-bubble queue, mute, and clear-alerts. Produces the derived { mood, status, bubble } the presentational components render.
SagiAvatar.tsx Pure presentational SVG mascot. The data-mood attribute drives CSS; pupils track the cursor. Rendered at 90×90 px (size default, kept in sync with SAGI_SIZE in useSagiPosition.ts, which is the draggable button's real footprint) through a viewBox cropped to the artwork's measured bounding box instead of the exporter's padded 0 0 1280 1280 canvas. Path data is never rescaled, so MAX_PUPIL_SHIFT stays in the original 1280-unit space. The transform-origin values in sagi.css do not: an origin on an inner SVG element resolves against transform-box: view-box, whose 0,0 sits at the viewBox min (253.5 106), so those are mascot coordinates minus that offset. The root .sagi-avatar origin is a percentage on purpose — on the outermost <svg> a length would be measured against the 68 px CSS box, and the resulting ~9× overshoot turns every mood animation into a whole-avatar lurch.
SpeechBubble.tsx Pure presentational speech bubble.
SagiPanel.tsx Pure presentational panel: status readout + quick actions + the Ask box.
Sagi.tsx Shell component. Mounted once in client/src/components/Layout.tsx as a sibling of UpdateNotifier. Owns open/closed state, the ⌘B / Esc shortcuts, reduced-motion detection, and navigation.
intents.ts Pure local Q&A over the cached status snapshot. Queries that don't match a local intent become a handoff: Sagi.tsx calls POST /api/lanes/ensure (adopting the "Sagi" lane whose cwd defaults to $HOME), then POST /api/run to spawn a fresh tmux-backed claude session in that lane with the prompt as its initialPrompt, then navigates to /run?laneId=…&autostart=1 so the live run is observable.
quips.ts Pure mood → phrase pools.
prefs.ts localStorage-backed enabled / muted preferences with cross-tab sync.
sagi.css Keyframes and per-mood expressions; selected via the data-mood attribute.

Touchpoints Outside the Folder

Sagi's only contact with the rest of the app is four light, additive touchpoints — nothing in the server, database, or WebSocket protocol changes:

File Touchpoint
client/src/components/Layout.tsx Mounts <Sagi /> once, as a sibling of <UpdateNotifier />.
client/src/pages/Settings.tsx On/off toggle wired to sagiPrefs (localStorage).
client/src/pages/Run.tsx Consumes ?laneId= to attach the page to the lane whose run Sagi just spawned.
server/routes/lanes.js (POST /api/lanes/ensure) Accepts a missing cwd and resolves it to os.homedir() so the mascot can adopt a stable "Sagi" lane.
client/src/i18n/locales/{en,vi}/settings.json sagi.* strings for the Settings toggle (zh / ko fall back to the hardcoded English defaults).

Security Considerations

Area Approach
SQL injection All queries use prepared statements with parameterized values
Request size Express JSON body parser limited to 1MB
Input validation Required fields checked before database operations; CHECK constraints on status enums
Hook safety Hook handler always exits 0; 5s max lifetime; uses 127.0.0.1 not external hosts
CORS Loopback-only (corsOptions() in server/lib/security.js): cross-origin pages can't read responses; No-Origin requests (curl/CLI) still pass. The legacy Access-Control-Allow-Origin: * via bare cors() is gone
Network posture Server binds 127.0.0.1 (loopback) by default — not network-reachable out of the box (fix for GHSA-gr74-4xfh-6jw9; it previously bound 0.0.0.0 with no auth). Widen the bind via DASHBOARD_HOST (e.g. 0.0.0.0), which logs a startup warning. A Host-header allowlist on HTTP + WebSocket upgrades blocks DNS-rebinding; extra LAN names go in DASHBOARD_ALLOWED_HOSTS (comma-separated)
Auth (optional) Off by default — the loopback bind is the trust boundary. Set DASHBOARD_TOKEN to gate every /api/* request and the WebSocket, presented as Authorization: Bearer <token>, an x-dashboard-token header, or ?token=. Exempt even when set: /api/health, /api/openapi.json, /api/docs, and /api/hooks (local Claude Code hook ingestion)
No secrets No API keys, tokens, or credentials stored or transmitted
Dependency surface Minimal: 5 runtime server deps, 5 runtime client deps (includes d3 for Workflows visualizations)

Performance Characteristics

Metric Value Notes
Server startup < 200ms SQLite opens instantly; schema migration is idempotent
Hook latency < 5ms (cache hit), < 50ms (miss) TranscriptCache: stat-check only on cache hit; incremental byte-offset read on file growth; full read only on first contact or compaction
Client bundle 200 KB JS, 17 KB CSS Gzipped: ~63 KB JS, ~4 KB CSS
WebSocket latency < 5ms Local loopback, JSON serialization only
SQLite write throughput ~50,000 inserts/sec WAL mode on SSD; far exceeds hook event rate
Max events before slowdown ~1M rows SQLite handles this easily; pagination prevents full-table scans
Memory usage ~30 MB server, ~15 MB client SQLite in-process, no ORM overhead. TranscriptCache adds ~1 KB per active session (LRU-capped at 200 entries)

SQLite WAL Mode Benefits

graph LR
    subgraph "Without WAL"
        W1[Writer] -->|blocks| R1[Reader]
    end

    subgraph "With WAL"
        W2[Writer] --- R2[Reader]
        Note["Concurrent reads<br/>during writes"]
    end

    style Note fill:#10b981,stroke:#34d399,color:#fff

Deployment Modes

Development

graph LR
    subgraph "Terminal"
        DEV["npm run dev<br/>(concurrently)"]
    end

    DEV --> SERVER["node --watch server/index.js<br/>Port 4820<br/>Auto-restart on changes"]
    DEV --> VITE["vite dev server<br/>Port 5173<br/>HMR, proxies /api + /ws to 4820"]
    BROWSER["Browser"] --> VITE
    VITE -->|proxy| SERVER

    style VITE fill:#646CFF,stroke:#818cf8,color:#fff
    style SERVER fill:#339933,stroke:#5cb85c,color:#fff

Production

graph LR
    BUILD["npm run build<br/>(vite build in client/)"] --> DIST["client/dist/<br/>Static files"]
    START["npm start"] --> SERVER["node server/index.js<br/>Port 4820"]
    SERVER -->|serves| DIST
    BROWSER["Browser"] --> SERVER

    style SERVER fill:#339933,stroke:#5cb85c,color:#fff
    style DIST fill:#646CFF,stroke:#818cf8,color:#fff
Aspect Development Production
Processes 2 (Express + Vite) 1 (Express)
Client Vite HMR on :5173 Static files from client/dist
API proxy Vite proxies /api + /ws to :4820 Same origin, no proxy needed
File watching node --watch + Vite HMR None
Source maps Inline External files

Model Name Formatting (Client)

The client/src/lib/format.ts module exports a formatModelName() utility that converts raw model identifiers stored in the database into human-readable display names throughout the UI (everywhere except the Model Pricing table in Settings → Claude Code → Overview, which shows raw patterns for pricing rule configuration).

Transformation rules:

Raw identifier Formatted display
claude-opus-4-7-20260101 Claude Opus 4.7
claude-sonnet-4-5-20250514 Claude Sonnet 4.5
claude-haiku-3-5-latest Claude Haiku 3.5
claude-opus-4-7[1m] Claude Opus 4.7 (1M)
gpt-4o-mini GPT-4o Mini
gemini-1-5-pro Gemini 1.5 Pro
anthropic/claude-opus-4-7 Claude Opus 4.7

The function handles:

  • Provider prefix stripping (anthropic/, openai/)
  • Date suffix removal (-YYYYMMDD)
  • -latest suffix removal
  • Context-window tag extraction ([1m] → (1M))
  • Brand capitalization (Claude, GPT, Gemini)
  • Version number dot-joining (hyphen-separated digits → dotted)
  • Title-casing for word segments

Components that consume this: SessionDetail, Analytics (donut chart + breakdown), Dashboard (model stats), MessageList, AgentCard, SessionDrillIn, and EventDetail.

Technology Choices

Technology Why This Over Alternatives
SQLite (via better-sqlite3 or built-in node:sqlite) Zero-config, embedded, no server process. WAL mode gives concurrent reads. Synchronous API is simpler than async alternatives for this use case. Falls back to Node.js built-in node:sqlite when better-sqlite3 cannot be compiled
Express Battle-tested, minimal, well-understood. Overkill would be Fastify for this scale; underkill would be raw http module
ws Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages
React 18 Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA
Vite Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly
Tailwind CSS Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (darkMode: "class") so the Dark/Light/System switch re-themes the app with one class flip rather than per-component dark: variants, and a named theme (Settings → Appearance) is one data-theme attribute selecting an override block of the same variables
React Router 6 Standard routing for React SPAs. Layout routes with <Outlet> give clean shell composition
Lucide React Tree-shakeable icon library. Only imports what's used (~20 icons)
TypeScript Strict Catches null/undefined bugs at compile time. noUncheckedIndexedAccess prevents array bounds issues

Build & Run Targets

A root Makefile mirrors every npm script for developers who prefer make. Run make help for the full list.

make setup          Install all dependencies (root + client)
make dev            Start server + client in watch mode
make build          Build the React client for production
make start          Start the production server
make test           Run all tests (server + client)
make format         Format all files with Prettier

See Makefile for the complete set of 17 targets covering setup, dev, testing, formatting, and data management.