# 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](https://img.shields.io/badge/Claude_Code-orange?style=flat-square&logo=claude&logoColor=white) ![Claude Code Plugins](https://img.shields.io/badge/Claude_Code-Plugins_&_Skills-orange?style=flat-square&logo=anthropic&logoColor=white) ![Model Context Protocol](https://img.shields.io/badge/Model_Context_Protocol-1.0-0f766e?style=flat-square&logo=modelcontextprotocol&logoColor=white) ![Node.js](https://img.shields.io/badge/Node.js-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white) ![Python](https://img.shields.io/badge/Python-%3E%3D3.6-3776AB?style=flat-square&logo=python&logoColor=white) ![Express](https://img.shields.io/badge/Express-4.21-000000?style=flat-square&logo=express&logoColor=white) ![ws](https://img.shields.io/badge/ws-WebSocket_server-010101?style=flat-square&logo=socketdotio&logoColor=white) ![web-push](https://img.shields.io/badge/web--push-VAPID-3b82f6?style=flat-square&logo=javascript&logoColor=white) ![swagger-ui-express](https://img.shields.io/badge/swagger--ui--express-5.0-85EA2D?style=flat-square&logo=swagger&logoColor=white) ![multer](https://img.shields.io/badge/multer-multipart_upload-FF6B6B?style=flat-square&logo=express&logoColor=white) ![adm-zip](https://img.shields.io/badge/adm--zip-archive_extract-FBBF24?style=flat-square&logo=files&logoColor=white) ![tar](https://img.shields.io/badge/tar-tgz_extract-A78BFA?style=flat-square&logo=gnu&logoColor=white) ![React](https://img.shields.io/badge/React-18.3-61DAFB?style=flat-square&logo=react&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178C6?style=flat-square&logo=typescript&logoColor=white) ![Javascript](https://img.shields.io/badge/JavaScript-ES6-F7DF1E?style=flat-square&logo=javascript&logoColor=white) ![Vite](https://img.shields.io/badge/Vite-6.1-646CFF?style=flat-square&logo=vite&logoColor=white) ![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-3.4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white) ![PostCSS](https://img.shields.io/badge/PostCSS-8.5-DD3A0A?style=flat-square&logo=postcss&logoColor=white) ![Autoprefixer](https://img.shields.io/badge/Autoprefixer-10.4-DD3735?style=flat-square&logo=autoprefixer&logoColor=white) ![React Router](https://img.shields.io/badge/React_Router-6.28-CA4245?style=flat-square&logo=reactrouter&logoColor=white) ![Lucide](https://img.shields.io/badge/Lucide_Icons-0.474-F56565?style=flat-square&logo=lucide&logoColor=white) ![D3.js](https://img.shields.io/badge/D3.js-7-F9A03C?style=flat-square&logo=d3&logoColor=white) ![Mermaid](https://img.shields.io/badge/Mermaid-10.2-ff3333?style=flat-square&logo=mermaid&logoColor=white) ![i18next](https://img.shields.io/badge/i18next-22.4-7A42FF?style=flat-square&logo=i18next&logoColor=white) ![i18next Language Detector](https://img.shields.io/badge/i18next_Language_Detector-6.1-7A42FF?style=flat-square&logo=i18next&logoColor=white) ![SQLite](https://img.shields.io/badge/SQLite-3-003B57?style=flat-square&logo=sqlite&logoColor=white) ![better--sqlite3](https://img.shields.io/badge/better--sqlite3-11.7-003B57?style=flat-square&logo=sqlite&logoColor=white) ![better-sqlite3 WAL](https://img.shields.io/badge/better--sqlite3-WAL_mode-003B57?style=flat-square&logo=sqlite&logoColor=white) ![WebSocket](https://img.shields.io/badge/WebSocket-RFC_6455-010101?style=flat-square&logo=socketdotio&logoColor=white) ![SSE](https://img.shields.io/badge/SSE-Server_Sent_Events-FF6600?style=flat-square&logo=googlechrome&logoColor=white) ![OpenAPI](https://img.shields.io/badge/OpenAPI-3.0-000000?style=flat-square&logo=openapiinitiative&logoColor=white) ![Swagger](https://img.shields.io/badge/Swagger-3.0-85EA2D?style=flat-square&logo=swagger&logoColor=white) ![VS Code](https://img.shields.io/badge/VS_Code-Extension-007ACC?style=flat-square&logo=vscodium&logoColor=white) ![Vitest](https://img.shields.io/badge/Vitest-1.0-646CFF?style=flat-square&logo=vitest&logoColor=white) ![React Testing Library](https://img.shields.io/badge/React_Testing_Library-13.0-FF5733?style=flat-square&logo=testinglibrary&logoColor=white) ![ESLint](https://img.shields.io/badge/ESLint-8.44-4B32C3?style=flat-square&logo=eslint&logoColor=white) ![Prettier](https://img.shields.io/badge/Prettier-3.8-F7B93E?style=flat-square&logo=prettier&logoColor=white) ![Make](https://img.shields.io/badge/Make-4.3-000000?style=flat-square&logo=make&logoColor=white) --- ## Table of Contents - [System Overview](#system-overview) - [High-Level Architecture](#high-level-architecture) - [Data Flow](#data-flow) - [Server Architecture](#server-architecture) - [Client Architecture](#client-architecture) - [Internationalization Architecture](#internationalization-architecture) - [Database Design](#database-design) - [WebSocket Protocol](#websocket-protocol) - [Hook Integration](#hook-integration) - [Import Pipeline](#import-pipeline) - [Agent Extension Layer](#agent-extension-layer) - [Plugin Marketplace](#plugin-marketplace) - [State Management](#state-management) - [Browser Notification System](#browser-notification-system) - [Update Notifier Subsystem](#update-notifier-subsystem) - [Sagi Companion Subsystem](#sagi-companion-subsystem) - [Security Considerations](#security-considerations) - [Performance Characteristics](#performance-characteristics) - [Deployment Modes](#deployment-modes) - [Model Name Formatting](#model-name-formatting-client) - [Technology Choices](#technology-choices) - [Build & Run Targets](#build--run-targets) --- ## 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. ```mermaid 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 ```mermaid 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
(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
(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
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
or Static Files] APP[React App] WS_CLIENT[WebSocket Client] EB[Event Bus] PAGES[Pages:
Dashboard / Kanban /
Sessions / Activity /
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 ```mermaid 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,
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
if first contact. Also persists
data.transcript_path onto the session row
(SQL-guarded, so subsequent events no-op).
Syncs sessions.name from the transcript title
(custom-title > ai-title > first user prompt). TX->>TX: Process by hook_type Note over TX: Dispatches by hook_type. Maintains the agent and
session state machines plus the awaiting_input_since flag.
SubagentStop also triggers a JSONL scan that emits per_tool
events under each subagent. See the hook table below for
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 ```mermaid 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 ```mermaid graph TD INDEX[server/index.js
Express app + HTTP server] DB[server/db.js
SQLite + prepared statements
better-sqlite3 → node:sqlite fallback] WS[server/websocket.js
WS server + broadcast] HOOKS[routes/hooks.js
Hook event processing] TC[lib/transcript-cache.js
JSONL cache + incremental reads] SESSIONS[routes/sessions.js
Session CRUD] AGENTS[routes/agents.js
Agent CRUD] EVENTS[routes/events.js
Event listing] STATS[routes/stats.js
Aggregate queries] PRICING[routes/pricing.js
Cost calculation + pricing CRUD] SETTINGS[routes/settings.js
System info + data management] WORKFLOWS[routes/workflows.js
Workflow visualizations] ALERTSR[routes/alerts.js
Alert rules CRUD + feed] ALERTS[lib/alerts.js
Rule evaluation engine] WEBHOOKSR[routes/webhooks.js
Webhook target CRUD + test] WEBHOOKS[lib/webhooks.js
Webhook delivery engine] WEBHOOKPROV[lib/webhook-providers.js
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+) | | `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 ` 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` 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 | | `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 (``, ``/`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. 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`). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately | | `lib/alerts.js` | Rule evaluation engine for the alerting feature. Four rule types: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing | | `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` | | `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes | | `lib/webhooks.js` | Universal webhook delivery engine driven by the provider registry. `buildRequest()` resolves the URL, formats the provider-native payload, and assembles headers (provider auth headers + generic-family custom headers + optional HMAC-SHA256 signature via `X-Webhook-Signature` / `X-Webhook-Timestamp`). `dispatchAlert()` fans a fired alert out to every enabled, in-scope target (optional per-rule scoping via `rule_ids`); each `deliver()` POSTs with an `AbortController` timeout and bounded retry/backoff (retries transport errors / 429 / 5xx, never other 4xx) and records the attempt-chain outcome in `webhook_deliveries` (pruned to the newest 2000 rows). Delivery is detached and fully fail-safe — it never throws into the alert path. Enabled targets are cached like alert rules; tunables (`WEBHOOK_TIMEOUT_MS`, `WEBHOOK_MAX_ATTEMPTS`, `WEBHOOK_RETRY_BASE_MS`) are env-overridable. `sendTest()` awaits a synthetic delivery for the test endpoint | | `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. `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/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 -d cwd -Fn` on macOS or `/proc//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 `) 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 `//subagents/*` and `/subagents//*` 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//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 `-jsonl-*` rows. It also **skips `importSubagents` entirely when subagent transcripts exist** — the main-transcript `Agent`-block rows (`-subagent-N`) and the transcript rows (`-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 `//.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//` 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/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) into SQL predicates + bind params, shared by the data endpoints (`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`) so a client **data-scope** selection narrows every query consistently. No filter → the existing unscoped queries run unchanged | | `server/lib/scoped-stats.js` | Source-scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set | | `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 (`/skills//SKILL.md`), subagents (`/agents/*.md`), slash commands (`/commands/*.md`), output styles (`/output-styles/*.md`), plugins (`/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 (`/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`/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//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 `/.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 `/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 `/cc-config-backups//..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 `/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`.json` and `/.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 `/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 (`/skills//SKILL.md`), agents (`/agents/*.toml`), rules (`/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 | | `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-`. 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 | | `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` 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. | | `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_ + 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/` — 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 `/.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 ` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `_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/.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/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `/.playwright-mcp/proof///`. 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 ```mermaid flowchart LR REQ[Incoming
Request] --> CORS[CORS
Middleware] CORS --> JSON[JSON Body
Parser
1MB limit] JSON --> ROUTER{Route
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
client/dist] HOOKS --> DB[(SQLite)] SESSIONS --> DB AGENTS --> DB EVENTS --> DB STATS --> DB ANALYTICS --> DB PRICING --> DB SETTINGS --> DB WORKFLOWS --> DB HOOKS --> WS[WebSocket
Broadcast] SESSIONS --> WS AGENTS --> WS ``` --- ## Client Architecture ### Component Tree ```mermaid graph TD APP["App.tsx
Router + WebSocket"] LAYOUT["Layout.tsx
Sidebar + Outlet"] SIDEBAR["Sidebar.tsx
Nav (scroll-bounded with overflow
chevrons) + Connection Status"] DASH["Dashboard.tsx
(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
(sessions/agents/subagents/
events today/total events/cost)
3-column grid"] DASH --> AC1["AgentCard[]
with collapsible subagent hierarchy"] DASH --> EV1["Event rows"] DASH --> HEALTH["SystemHealthTab
(health score ring, storage donut,
cache/error/success gauges,
tool bars, subagent effectiveness,
model tokens, compaction stats)"] KANBAN --> COL["Agents view: 4 columns
(working/waiting/
completed/error)
Sessions view: 5 columns
(active/waiting/completed/
error/abandoned)"] COL --> AC2["AgentCard[]"] SESS --> TABLE["Session Table
with filters"] DETAIL --> OVERVIEW["SessionOverview
(stat tiles, top tools,
subagent breakdown,
token flow, event mix)"] DETAIL --> AC3["AgentCard hierarchy
parent → children tree"] DETAIL --> CONV["ConversationView
(MarkdownContent + CodeBlock
+ ToolCallBlock per-tool styling)"] DETAIL --> TL["Event Timeline"] ACTIVITY --> FEED["Streaming Event List
(click row → expand payload;
Session btn → session detail)"] WORKFLOWS_P --> WFC["Own-pipeline panels +
D3 analytics components
(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. - **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 ``. 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 `` and `` for standalone home-screen mode on Safari. ### Client Module Graph ```mermaid graph TD MAIN["main.tsx
React entry"] APP["App.tsx
Router + WS + Notifications"] EB["eventBus.ts
Pub/sub + connection state"] WS["useWebSocket.ts
Auto-reconnect hook"] NOTIF["useNotifications.ts
Browser notification triggers"] API["api.ts
Typed fetch client"] TYPES["types.ts
Interfaces + configs"] FMT["format.ts
Date/time/model-name utilities"] DS["dataScope.ts
Global data-scope store"] MAIN --> APP APP --> WS APP --> EB APP --> NOTIF NOTIF --> EB WS --> TYPES EB --> TYPES subgraph Pages D["Dashboard
(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/
(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 ```mermaid graph LR ROOT["/ (index)"] --> DASH["Dashboard
(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
lanes + runs merged"] SET_R["/settings"] --> SETHUB["SettingsHub
(?tool= tab shell)"] SETHUB --> SETDASH["dashboard tab
Settings"] SETHUB --> SETCC["claude-code tab
CcConfig"] SETHUB --> SETCODEX["codex tab
CodexConfig"] NF_R["/*"] --> NF[NotFound] ALL["All routes"] --> LAYOUT["Layout wrapper
(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_id={sid}` (subagent hierarchy), 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, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `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=` 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. **dashboard** tab (default, no `?tool=`) → `Settings`: `GET /api/settings/info`, `GET /api/pricing`, `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. 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//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. 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: ```mermaid 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` (`expandedEvents`) allowing multiple rows to be open simultaneously. - Keyboard accessible: `Enter` and `Space` on the row trigger expand; the Session button is a standard `` 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. ```mermaid graph TD WF["Workflows.tsx
Page orchestrator"]:::root API_AGG["GET /api/workflows?status=...
Aggregate data (filterable)"] API_DI["GET /api/workflows/session/:id
Session drill-in"] WS_D["WebSocket auto-refresh
(3s debounce)"] WF --> API_AGG WF --> API_DI WS_D --> WF WF --> S1["WorkflowStats
Summary cards"] WF --> S2["LanePipelinePanel
Per-node pass rates
(lanes + archived features)"] WF --> S3["SkillUsagePanel
Skill + plugin call counts"] WF --> S4["WorkflowRunsPanel
Workflow-tool runs
(on-disk journals)"] WF --> S5["OrchestrationDAG
Horizontal DAG —
Sessions → Main → Subagents → Outcomes"] WF --> S6["WorkflowPatterns
Auto-detected sequences"] WF --> S7["ModelDelegationFlow
Model → agent routing"] WF --> S8["ErrorPropagationMap
React horizontal bars +
API/session error support"] WF --> S9["SessionComplexityScatter
D3 bubble chart"] WF --> S10["CompactionImpact
Token compression analysis"] WF --> S11["SessionDrillIn
Searchable session explorer
(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 `
` 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..*`. 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. ```mermaid flowchart LR A["Browser load"] --> B["LanguageDetector
localStorage -> navigator"] B --> C["Resolved language
en | zh | vi | ko (fallback en)"] C --> D["Namespace resources
common/nav/dashboard/sessions/..."] D --> E["React pages/components
useTranslation(ns)"] E --> F["format.ts locale mapping
en-US | zh-CN | vi-VN | ko-KR"] F --> G["Localized labels,
dates, number formatting,
and model name display"] ``` See [docs/I18N.md](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 ```mermaid 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" 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` | events | `session_id` | Session detail event list | | `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` | Keeps `importSubagentFromJsonl`'s per-tool-event `data LIKE` dedup an index seek instead of a full events scan — a large re-import (startup sweep touching a subagent-heavy session) drops from tens of seconds to sub-second | | `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: ```typescript { 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 ```mermaid 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)
Serializes to JSON,
sends to all OPEN clients"] end subgraph "Client Handling" WS["useWebSocket hook
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
(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](#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: ```mermaid 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): ```mermaid flowchart TD START[Claude Code fires hook] --> STDIN[Read stdin to EOF] STDIN --> RESOLVE["resolveAllDashboardPorts()
via server/lib/server-info.js"] RESOLVE --> PARSE{Parse JSON?} PARSE -->|Success| POST["POST to 127.0.0.1:{port}/api/hooks/event
(one per unique dataDir, parallel)"] PARSE -->|Failure| WRAP["Wrap raw input as
#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. - 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`: ```mermaid flowchart TD START[Run install-hooks.js] --> READ{~/.claude/settings.json
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:
SessionStart, PreToolUse, PostToolUse,
Stop, SubagentStop, Notification, SessionEnd"] LOOP --> EXISTS{Our hook
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. Last in that worker — deliberately, because it clones from the network and nothing above it may wait — it installs the plugins the pipeline templates drive (`superpowers`) via `server/lib/skill-plugins.js`, the same module `/agenthub-doctor`'s `Skill plugins` row reports from. The report is derived from the templates; the install consults a fixed allowlist in that file instead, because a template is user-writable. Full behavior: [`docs/PLUGINS.md`](docs/PLUGINS.md). --- ## 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 ```mermaid flowchart TD subgraph Clients UI["Browser: Settings →
Import History panel"] CLI["CLI: npm run import-history"] STARTUP["Server startup
(auto-import)"] end UI -->|POST /api/import/guide
POST /api/import/rescan
POST /api/import/scan-path
POST /api/import/upload| RT["server/routes/import.js"] CLI --> IMP["scripts/import-history.js
importAllSessions()"] STARTUP --> IMP RT -->|archives| AR["server/lib/archive.js
extractZip / extractTar /
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
single transaction| DB[("SQLite:
sessions / agents / events /
token_usage")] IS -.->|progress throttled
~150ms| WS["server/websocket.js
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. ```mermaid 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-*')
stored on req._agenthubUploadDir M->>M: fileFilter: reject unsupported
(tracked in req._agenthubRejected) alt All files rejected API-->>UI: 400 NO_FILES
+ 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,
skipped, errors, rejected_files} end API->>AR: rmTempDir(workDir) API->>M: rmTempDir(req._agenthubUploadDir) ``` ### Idempotence and cost accuracy ```mermaid flowchart LR A[Parse session JSONL] --> B{Session ID
already in DB?} B -->|no| C[Insert session,
main agent, events,
token_usage] B -->|yes| D{Any new fields,
tools, compactions,
turn durations?} D -->|no| E[skipped = true] D -->|yes| F[Backfill: insert
missing events +
enrich metadata] F --> G[backfilled = true] C --> H[replaceTokenUsage] F --> H H --> I[baseline := max of old + new
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 | `/.jsonl` | Session transcript | | Default subagent | `//subagents/agent-*.jsonl` | Paired with parent on discovery | | Alternative subagent | `/subagents//agent-*.jsonl` | Paired with parent on discovery | | Workflow inner-agent (nested) | `//subagents/workflows//agent-*.jsonl` | Summarized by Workflow-run ingest; transcript resolved on read + preserved in the durable snapshot | | Orphan subagent (no parent JSONL in source) | `/subagents//agent-*.jsonl` | `importFromDirectory` probes both candidates; attaches if `sid` exists | | Flat JSONL drop | `/.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. ```json { "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/.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: ``` /// workflows/ scripts/-wf_.js # the workflow script — written at LAUNCH wf_.json # the run journal — written at COMPLETION subagents/ workflows//agent-.jsonl # inner-agent transcript (current builds — NESTED per run) agent-.jsonl # flat layout — older builds / regular sub-agents ``` The run journal (`wf_.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-.jsonl` basename, so the workflow → inner-agent linkage is explicit. Current Claude Code builds write that transcript **nested** under `subagents/workflows//`; 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_.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//`: a streaming `journal.jsonl` (a `started` / `result` event per inner agent) and the growing `agent-.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-.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-` 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-.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-` 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-.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//` 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//` 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 Ingestion (no hooks) A `codex` lane has no hook stream at all — Codex does not implement Claude Code's hook protocol — so `touchLaneFromHook` (the only caller of `stage-detect.detect()`) can never see one, and is deliberately pinned to `"claude"` so a Claude hook cannot touch a codex lane sharing the same directory. `server/lib/codex-ingest.js` is the codex-side source. Codex appends a rollout journal per thread at `$CODEX_HOME/sessions///
/rollout--.jsonl` (`$CODEX_HOME` defaults to `~/.codex`); the first line is a `session_meta` payload carrying the thread's `cwd`, and each tool call is a `custom_tool_call` or `function_call` payload. 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) and matches it to a `codex` lane by deepest path-boundary prefix. 3. Only bytes appended since the last pass are read. On the FIRST sight of a file only its recent 64 KiB tail is read, and nothing at all when that file is already idle — replaying a thread that finished days ago would paint stages for work nobody is doing. `completeLines()` does the framing **on the buffer, never on a decoded string** (a tail offset can land inside a multi-byte UTF-8 sequence, where the decoder's U+FFFD would throw the byte math off): a tail read's leading fragment is dropped, the stored offset advances only past the last newline, and a trailing fragment is re-read whole next pass. A read is capped at 4 MiB, and the two tracking maps are pruned to the current scan once they exceed 4 × the 40-file cap. 4. `eventsFromPayload()` maps each payload onto the hook-shaped `{tool_name, tool_input}` the matcher already understands: shell calls (`exec`/`exec_command`/`shell`/`local_shell`) → `Bash`, an embedded `apply_patch` heredoc → one `Write`/`Edit` per touched file, `update_plan` → `TaskCreate`, `spawn_agent` → `Agent`. Everything else is ignored. Newlines inside a patch heredoc arrive escaped (it rides inside a JS string literal), so they are unescaped before the file list is scanned. 5. The last matching event of the batch goes through the same `recordDetection()` as a hook — same forward-only, declared-wins, amber-never-green rules — and the same pass mirrors the lane's `status` between `idle` and `running` from the journal's mtime, under the identical guard hook ingestion uses (no `run_id`, only from `idle`/`running`). 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. No schema change, no new route, no new WebSocket message type — the existing `lane_update` broadcast carries it. 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. `rolloutFiles()` is the single function to re-point if that changes. A written detection then **also advances the lane's declared `stage`** — `setStage(lane.id, { stage: nodeId, keepDetection: true })`, the only place in AgentHub where an inference moves the blue `current` ring. It is gated on `recordDetection` having written, so it inherits forward-only/declared-wins/TTL unchanged; it passes no `evidence`, so the node renders `passed-no-evidence` (amber) and never `done`; and `keepDetection` is the sole exception to `setStage`'s clearing of the detection columns, so the card still shows which command moved it rather than looking like an agent's own claim. No template's last node carries `detect` rules, so a lane can never be auto-declared `done`. ### Codex evidence reliability (`server/lib/codex-nudge.js`) Auto-advance moves the ring but can never supply evidence; only the session's own `agenthub stage --evidence` call can, so three mechanisms make that more likely without weakening declared-wins/inferred-never-`done`: - **Opening-prompt priming** (`LaneConsolePane.tsx`'s `promptPrefill`): a codex pane's first prompt is pre-filled with a plain-text checklist of `agenthub stage --tool codex` calls, one per node of `Lane.pipeline_nodes` — the same up-front nudge a Claude lane gets from its pre-filled `/agenthub:ship-feature-lane` skill command, in plain text since Codex has no slash commands. - **`AGENTS.md` stage block** (`server/lib/lane-agents.js`): on a `codex` lane, `agenthub lanes agents install` writes a marker-delimited `` section into `/AGENTS.md` instead of copying the two agent templates into `.claude/agents/`, which Codex never reads. Appended to an existing file (user prose preserved), and a reinstall replaces exactly that block. - **Missing-evidence nudge** (`sweepCodexNudges`, an unref'd 60 s interval from `server/index.js`, tunable via `DASHBOARD_CODEX_NUDGE_MS`, `0` disables): when a codex lane's current stage has been held for at least two minutes with `stages[stage].evidence` still null and hasn't already been nudged for that exact stage, it types a one-line `[agenthub] ... run: agenthub stage ... --evidence "..."` reminder into the lane's live tmux pane via `tmux.sendText` (skipped if the pane isn't running an interactive program — `pty-run.js`'s `SHELL_COMMANDS` — since Enter there would execute the reminder as a shell command instead of submitting it as a chat turn). It never writes `stage`, `stages` or `detected_stage` — any resulting evidence is still something the codex session declared itself, through the real `agenthub stage` call. --- ## 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`) ```mermaid graph TD USER["Developer"] --> CLAUDE["Claude Code"] USER --> CODEX["Codex"] CLAUDE --> C_MEM["CLAUDE.md"] CLAUDE --> C_RULES[".claude/rules/*"] CLAUDE --> C_SKILLS["skills/*
agenthub plugin skills"] CLAUDE --> C_PLUGINS["plugins/
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:`, bare `/` alias): - onboarding - feature shipping - live issue debugging - `agents/` provides specialized review workers (shipped as the `agenthub` plugin's agents): - backend reviewer - frontend reviewer - qc-local, senior-gate-reviewer (the ship-feature-lane pipeline's QC and publish gate) - `plugins/` provides distributable plugin marketplace (see [Plugin Marketplace](#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 ```mermaid graph TD subgraph Marketplace[".claude-plugin/marketplace.json"] M["Marketplace Manifest"] end subgraph Plugins["plugins/"] A["agenthub-analytics
4 skills, 1 agent, 1 CLI"] P["agenthub-productivity
4 skills, 1 agent"] D["agenthub-devtools
4 skills, 1 agent, 2 CLIs"] I["agenthub-insights
4 skills, 1 agent"] C["agenthub-dashboard
2 skills"] G["agenthub-cost-guard
5 skills, 1 agent, Stop hook"] S["agenthub-sessions
5 skills, 1 agent"] W["agenthub-workflows
5 skills, 1 agent"] Q["agenthub-quality
5 skills, 1 agent"] F["agenthub-config
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 @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 ```bash # 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`](docs/PLUGINS.md) --- ## State Management ### Client-Side Architecture The client uses a deliberately simple state management approach: ```mermaid graph TD subgraph "Data Sources" REST["REST API
(initial load + refresh)"] WSM["WebSocket Messages
(real-time updates)"] LS["localStorage
(notification prefs)"] end subgraph "Distribution" EB["eventBus
(Set-based pub/sub)"] end subgraph "App-Level Hooks" NOTIF_H["useNotifications
reads prefs, fires
browser notifications"] SAGI_H["useSagiBrain
derives mascot mood +
speech from WS stream"] end subgraph "Page State" US1["useState
Dashboard
(Monitor/Analytics/Health)"] US3["useState
Sessions"] US4["useState
SessionDetail"] US5["useState
ActivityFeed"] US8["useState
Workflows"] US7["useState
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. ```typescript // 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 ```mermaid graph TD subgraph "Server Side" API_P["Push API
(/api/push/*)"] WP["web-push lib
(VAPID)"] DB_P["push_subscriptions
table"] KEYS["vapid-keys.json
(persisted)"] end subgraph "Client Side" SW["Service Worker
(sw.js)"] PUSH["useNotifications hook
(subscribes via SW)"] PREFS["localStorage
(event preferences)"] BROWSER["Browser Push Service
(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` to focus/open the dashboard. | | **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 ```mermaid flowchart TD EVENT["Server Event
(e.g. SessionStart)"] --> PREFS{"User Prefs
Enabled?"} PREFS -->|No| SKIP[Skip] PREFS -->|Yes| SUBS["Fetch all subscriptions
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)
silent: false"] style SHOW fill:#10b981,stroke:#34d399,color:#fff ``` ### 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 during push fan-out | | Session Error | `onSessionError` | Filtered during push fan-out | | Session Complete | `onSessionComplete` | Filtered during push fan-out | | Subagent Spawn | `onSubagentSpawn` | Filtered during push fan-out | ### 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///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 Sidebar 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 ` → `claude plugin update @ -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 ```mermaid graph TD subgraph Server LIB["update-check.js
getUpdatesStatus + applyUpdate
forge releases API"] SCHED["update-scheduler.js
startUpdateScheduler"] ROUTE["routes/updates.js
GET status, POST check, POST apply"] PLUG["lib/plugin-update.js
schedulePluginUpdate"] HELPER["scripts/plugin-update-helper.js
detached: claude plugin update
then plugin-bootstrap --force"] WS["websocket.js
broadcast update_status"] end subgraph Client API["lib/api.ts
api.updates"] BUS["lib/eventBus.ts
subscribe and publish"] MODAL["UpdateNotifier.tsx
dismissedSha in localStorage"] SIDEBAR["Sidebar.tsx
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 ```mermaid 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_`) 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 `/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 --tags && git merge --ff-only ` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch && git merge --ff-only ` for forks; `git fetch ` 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`** | Always-visible "Check for updates" button in the footer. 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 — `" available"`, `"Dev build · N ahead"` (when `dev_build`), `"Up to date"`, or `"Check for updates"` before the first result. | ### Payload Shape ```ts 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_" 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:}` | 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:""}` | 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 /master..."}` | Modal suppressed. | | Healthy, up to date | `{git_repo:true, update_available:false, commits_behind:0, situation:"tracking_canonical"\|...}` | Sidebar neutral, 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. | | 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. | | 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 "}` | 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 ```mermaid graph TD subgraph "Pure Core (framework-free, unit-tested)" BRAIN["brain.ts
reduceSagi reducer +
deriveMood state machine
(injected clock, no side effects)"] INTENTS["intents.ts
local Q&A over cached status;
unmatched → Run handoff"] QUIPS["quips.ts
mood → phrase pools"] PREFS["prefs.ts
localStorage enabled/muted
(cross-tab sync)"] end subgraph "Hook (only eventBus consumer)" HOOK["useSagiBrain.ts
wires brain to real timers
(idle/sleep/stuck), speech-bubble
queue, mute, clear-alerts"] end subgraph "Presentational (pure)" SHELL["Sagi.tsx
shell: open/closed state,
⌘B / Esc, reduced-motion,
navigation"] AVATAR["SagiAvatar.tsx
SVG mascot; data-mood drives CSS;
cursor-tracking pupils"] BUBBLE["SpeechBubble.tsx
bubble"] PANEL["SagiPanel.tsx
status + quick actions + Ask box"] CSS["sagi.css
keyframes + per-mood expressions"] end BUS["lib/eventBus.ts
(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 ```mermaid flowchart LR WSS["Server WebSocket
broadcast"] --> UWS["useWebSocket"] UWS --> PUB["eventBus.publish"] PUB --> SUB["useSagiBrain
(subscriber)"] SUB --> DERIVED["derived state
{ mood, status, bubble }"] DERIVED --> AVATAR["SagiAvatar"] DERIVED --> BUBBLE["SpeechBubble"] DERIVED --> PANEL["SagiPanel"] PANEL -->|"unmatched Ask"| ENSURE["POST /api/lanes/ensure
(adopt 'Sagi' lane, cwd = home)"] ENSURE --> START["POST /api/run
(spawn tmux-backed claude
in Sagi lane, initialPrompt=…)"] START --> RUN["/run?laneId=…
(existing Run page picks up
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 `` 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 `` once, as a sibling of ``. | | `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 `, 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 ```mermaid graph LR subgraph "Without WAL" W1[Writer] -->|blocks| R1[Reader] end subgraph "With WAL" W2[Writer] --- R2[Reader] Note["Concurrent reads
during writes"] end style Note fill:#10b981,stroke:#34d399,color:#fff ``` --- ## Deployment Modes ### Development ```mermaid graph LR subgraph "Terminal" DEV["npm run dev
(concurrently)"] end DEV --> SERVER["node --watch server/index.js
Port 4820
Auto-restart on changes"] DEV --> VITE["vite dev server
Port 5173
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 ```mermaid graph LR BUILD["npm run build
(vite build in client/)"] --> DIST["client/dist/
Static files"] START["npm start"] --> SERVER["node server/index.js
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 Settings page, 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 toggle re-themes the app with one class flip rather than per-component `dark:` variants | | **React Router 6** | Standard routing for React SPAs. Layout routes with `` 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.