Merge branch 'perf/local-opt': faster local install and updates, lighter polling and ingest

This commit is contained in:
2026-10-16 10:27:26 +07:00
79 changed files with 5395 additions and 1796 deletions
+13 -1
View File
@@ -72,9 +72,21 @@
# DASHBOARD_SESSION_SYNC_MS=30000
# Hourly database upkeep: a non-blocking WAL checkpoint (the WAL then shrinks
# to 64 MB) and PRAGMA optimize. 0 turns it off. Never VACUUMs.
# to 64 MB), PRAGMA optimize, the opt-in retention below and a bounded
# incremental_vacuum. 0 turns it off. Never a full VACUUM (that is Settings →
# Data → Compact, on a click).
# DASHBOARD_DB_MAINTAIN_MS=3600000
# Automatic retention (OFF unless set): finished sessions that started more
# than N days ago (1–3650) are purged by the hourly upkeep. Usually set from
# Settings → Data → Session cleanup, which writes it to the settings file.
# AGENTHUB_RETENTION_DAYS=90
# Update check interval (ms, default 3600000 = 1 h, minimum 60000). A tick
# with no browser connected is skipped; the next browser to connect gets one.
# DASHBOARD_UPDATE_CHECK=0 turns the scheduled check off.
# DASHBOARD_UPDATE_CHECK_INTERVAL_MS=3600000
# Poll interval (ms) for Antigravity CLI sessions and tokens, read from agy's
# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000).
# DASHBOARD_ANTIGRAVITY_POLL_MS=15000
+1
View File
@@ -11,3 +11,4 @@ client/package-lock.json
*.md
*.yml
*.yaml
server/vendor/
+19 -7
View File
@@ -310,7 +310,7 @@ graph TD
|---------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `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/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` (vendored under `server/vendor/redoc/` — the `redoc` npm package is not a dependency — never a CDN) so the reference works fully offline / air-gapped |
| `server/openapi-extra/` | Supplementary OpenAPI path/schema fragments merged into the spec by `createOpenApiSpec()` — covers `cc-config.js`, `push.js`, `run.js`, and `misc.js` route groups |
| `server/db.js` | SQLite connection with WAL mode, schema migration (CREATE TABLE IF NOT EXISTS + ALTER TABLE for column additions), all prepared statements as a reusable `stmts` object. Tries `better-sqlite3` first, falls back to `node:sqlite` via `compat-sqlite.js`. Migrations use literal defaults for ALTER TABLE since SQLite does not support expressions like `strftime()` in column defaults added via ALTER TABLE |
| `server/compat-sqlite.js` | Compatibility wrapper that gives Node.js built-in `node:sqlite` (`DatabaseSync`) the same API as `better-sqlite3` — pragma, transaction, prepare. Used as automatic fallback when the native module is unavailable (Node 22+) Maps every better-sqlite3 API the server uses — `function` (the `trim_event_data` SQL function `db.js` registers at boot), `stmt.pluck()`, the `readonly`/`fileMustExist` open options — and `db.js` exports the chosen class as `Database` so other SQLite files (agy's conversations) open with the same driver. A plugin install has NO better-sqlite3 build, so this is production there; `server/__tests__/compat-sqlite.test.js` boots `db.js` with better-sqlite3 blocked because the rest of the suite never runs on it. |
@@ -1066,7 +1066,7 @@ flowchart TD
- Uses Node.js `http` module directly — no dependencies.
- Resolution order is **env override → discovery file → default**:
- `CLAUDE_DASHBOARD_PORT` forces a single target (no fan-out, no discovery).
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when two instances share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel. The other instance learns of a lane the hooks moved through `broadcastForeignLaneChanges` (`server/routes/lanes.js`): every `DASHBOARD_LANE_SYNC_MS` (2 s) it reads `PRAGMA data_version`, which moves only on another connection's commit, and re-broadcasts each lane whose `updated_at` changed (and `removed` for a vanished one) to its own browsers. Sessions and events are not re-broadcast this way — a page on that instance shows them on its next fetch.
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when two instances share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel. The other instance learns of a lane the hooks moved through `broadcastForeignLaneChanges` (`server/routes/lanes.js`): every `DASHBOARD_LANE_SYNC_MS` (2 s) — while `peersSharingDataDir()` finds another live dashboard on its data dir, which it checks every 30 s — it reads `PRAGMA data_version`, which moves only on another connection's commit, and re-broadcasts each lane whose `updated_at` changed (and `removed` for a vanished one) to its own browsers. Sessions and events are not re-broadcast this way — a page on that instance shows them on its next fetch.
- If neither yields anything, the handler falls back to `4820`.
- Per-target promises never reject — a dead listener can't starve the others, and the handler can wait on `Promise.all` for clean exit timing.
@@ -1223,7 +1223,12 @@ 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
refreshes live. Two kinds of transcript are not re-parsed: one whose session
hooks are feeding **live** (status `active`, newest event a hook's, a hook
within `DASHBOARD_SYNC_LIVE_HOOK_MS`, default 5 min) is left until it goes
quiet or ends — then caught up once — and one whose session a hook created
(no `imported` flag, which `importSession` skips anyway) only has its durable
snapshot refreshed, never a parse. All timers and watchers are `unref`'d and best-effort; nothing
here blocks shutdown or can take down the server.
### Upload request sequence
@@ -1579,7 +1584,11 @@ re-point if that changes.
`DASHBOARD_CODEX_POLL_MS` beat, a first pass over every journal in the
background at startup). Each file's read offset and parse state live in the
`codex_rollouts` table, so an unchanged file is never re-read and a grown one is
read from where it stopped. `token_count` events are summed per `turn_context`
read from where it stopped (a pass loads every row's size/mtime in one query).
The first pass and one every five minutes walk every journal; the poll's other
passes stat only the two newest day folders plus the journals the table saw
written in the last hour (or not yet read to the end), so a thread resumed
after an hour's quiet in an older folder is picked up by the next full pass. `token_count` events are summed per `turn_context`
model — `input_tokens` includes `cached_input_tokens` (stored as input − cached
plus cache_read) and `output_tokens` includes reasoning. A **fork** copies its
parent's whole history into its own file, token events included, so every total
@@ -1654,7 +1663,9 @@ the binary's own proto descriptors.
**Sessions and tokens** (`server/lib/antigravity-usage.js`,
`startAntigravityUsagePoll`, every `DASHBOARD_ANTIGRAVITY_POLL_MS`, default
15000, `0` disables). agy writes one SQLite file per conversation,
15000, `0` disables; after a pass that read nothing, wrote nothing and left no
fresh session to settle it slows to once a minute until one finds work —
`server/lib/backoff-poll.js`). agy writes one SQLite file per conversation,
`~/.gemini/antigravity-cli/conversations/<conversation id>.db`. Each is opened
read-only whenever its mtime (or its non-empty `-wal`'s) changes;
`antigravity_conversations` holds the mtime read, so a session the user deleted
@@ -1743,7 +1754,8 @@ is ever inferred.
## Cursor Sessions
`server/lib/cursor-usage.js` (`startCursorUsagePoll`, every
`DASHBOARD_CURSOR_POLL_MS`, default 15000, `0` disables) reads the agent
`DASHBOARD_CURSOR_POLL_MS`, default 15000, `0` disables; idle passes slow it to
once a minute like the Antigravity poll) reads the agent
transcripts Cursor writes — the CLI (`cursor-agent`) and the editor's agent both —
at `~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl`
(`AGENTHUB_CURSOR_DIR` overrides `~/.cursor`). Each becomes a `sessions` row with
@@ -2225,7 +2237,7 @@ sequenceDiagram
| Component | Responsibility |
| --- | --- |
| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_<status>`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from a repository URL (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `fetchLatestRelease` finds that URL via the `origin` remote; `fetchReleaseFromUrl` takes one directly, which is how the **git-free plugin path** works: `pluginMarketplaceUrl(mkt, plugin)` reads `known_marketplaces.json` → the marketplace's `.claude-plugin/marketplace.json` → the entry's `source.url`, returning null (never a guessed host) for a `github` `owner/repo` shorthand and never throwing. **Order matters:** `detectPluginInstall` runs FIRST in `getUpdatesStatus`, ahead of the `.git` probe — a plugin cache unpacked from an archive has no `.git`, and probing first is the 1.5.1 bug that made `update_available` permanently false. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `<remote>/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. |
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 300 000, floor 60 000). First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. |
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 3 600 000 = 1 h, floor 60 000). A tick with no `/ws` browser connected (`getConnectionCount`) is skipped, and the first browser to connect afterwards (`onConnection` in `server/websocket.js`) gets one check 3 s later if the last one is older than the interval; the manual `POST /api/updates/check` is unaffected. 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 (both through the bootstrap's `npmCommand` with `NODE_ENV` dropped — `npmInvocation` in `server/lib/update-check.js` — since the server's `NODE_ENV=production` makes npm omit the client's devDependency build toolchain, and a bare `npm.cmd` is EINVAL under `execFile` on Windows), 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. |
+407 -82
View File
@@ -1,6 +1,6 @@
# Installation
A step-by-step guide to get AgentHub — the local control surface for Claude Code agents — up and running on your machine, with optional sections for importing history and running in a container.
The one install-and-setup guide for AgentHub — the local control surface for Claude Code agents: the plugin install, a checkout install, configuration, the database, importing history, and troubleshooting. (`SETUP.md` is only a pointer here now.)
## Fastest path — install it as a Claude Code plugin
@@ -17,17 +17,20 @@ on PATH — no clone, no `npm run setup`, no
result and `/agenthub-open` to build the UI and get the URL.
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
the server stores data through `node:sqlite`). Full behavior, including the
uninstall cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
the server stores data through `node:sqlite`), plus `git` and `tmux` from
[Step 0](#step-0--system-packages). Full behavior, including the uninstall
cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md). If
the dashboard never comes up, see
[Plugin bootstrap never comes up](#plugin-bootstrap-never-comes-up).
Follow the steps below instead when you want a checkout — to develop the
dashboard, run it from source, or stay on Node 20.
dashboard or run it from source.
## Requirements
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 20+ (22+ recommended) | Required for server and client |
| Node.js | **22.5+** | Required for server and client (`node:sqlite`). Node 20 is end-of-life and unsupported |
| npm | 9+ | Comes with Node.js |
| Claude Code | 2.x+ | Required for hook integration |
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
@@ -76,7 +79,7 @@ Check it:
```bash
tmux -V # e.g. tmux 3.4
git --version
node -v # v20.x / v22.x / v24.x
node -v # v22.5+ / v24.x
```
Once the server is up you can ask it directly — this is the same probe the
@@ -113,8 +116,8 @@ brew install node git tmux # Node >= 22.5 for the plugin install (nvm work
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub`
```
- **Node:** a plugin install needs **>= 22.5** (it stores data through
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
- **Node:** **>= 22.5** (the plugin stores data through `node:sqlite`; a
checkout needs the same). The `agenthub` launcher
pins the node that installed it and falls back to `node` on `PATH` when a
`brew upgrade` or `nvm` switch deletes that binary.
- **Starts at login once you turn it on (LaunchAgent).** Off by default; with
@@ -172,24 +175,20 @@ cd AgentHub
## Step 2 — Install dependencies
```bash
npm run setup
npm run setup # or: make setup
```
This installs all server and client dependencies, plus the VS Code extension, and links the `agenthub` CLI.
This runs `npm install` (whose `postinstall` hook installs the client's
dependencies in `client/`), installs `client/` once more explicitly, and links
the `agenthub` CLI (`npm link`; if that has no permission it prints how to link
it by hand). A plain `npm install` is enough to build and run the dashboard —
only the CLI link is missing.
A plain root install already covers server **and** client — a `postinstall` hook installs the client dependencies automatically, so this alone is enough to build and run the dashboard:
With `--ignore-scripts` the `postinstall` hook is skipped; install the client
yourself with `npm --prefix client install`.
```bash
npm install
```
`npm run setup` additionally installs the VS Code extension and links the `agenthub` CLI. (If you install with `--ignore-scripts`, the `postinstall` hook is skipped — run `cd client && npm install` manually in that case.)
Or via Makefile:
```bash
make setup
```
`better-sqlite3` gyp/compile warnings here are harmless — see
[Troubleshooting](#better-sqlite3-errors-during-npm-install--npm-run-setup).
---
@@ -234,7 +233,7 @@ After starting a Claude Code session, you should see:
- **Dashboard** — stats updating in real-time
- **Settings page** — data export and cleanup tools on the Dashboard tab; Claude Code's model pricing rules, hook configuration status and CLAUDE_HOME on **Settings → Claude Code → Overview**
If nothing appears after 30 seconds, see [SETUP.md](./SETUP.md#troubleshooting).
If nothing appears after 30 seconds, see [Troubleshooting](#no-sessions-appearing-after-starting-claude-code).
### PWA install (optional)
@@ -246,57 +245,110 @@ The dashboard is a Progressive Web App. After opening it in a supported browser
---
## Step 5 — (Optional) Import existing Claude Code history
## Step 5 — (Optional) Importing existing Claude Code history
The server **automatically imports** every session under
`~/.claude/projects/` on startup, so if you've used Claude Code on this
machine before, your historical sessions, agents, events, token counts,
and cost totals should already be visible in the Sessions list.
The dashboard automatically imports sessions from `~/.claude/projects/` on
**every startup**, so if Claude Code has been used on this machine, you'll
see history immediately after the first launch. If you need to bring in
history from another machine, from a backup, or just force a rescan, use
**Settings → Import History** in the UI — it's a guided, drag-and-drop
experience with live progress.
To bring in history from another machine, a backup, or a `.tar.gz`
archive a teammate sent you, use **Settings → Import History** in the
UI. It supports three modes:
### Pick the right mode
```mermaid
flowchart LR
UI["Settings →<br/>Import History"] --> M1["Rescan default folder<br/>~/.claude/projects"]
UI --> M2["Scan a folder<br/>any absolute path"]
UI --> M3["Upload files<br/>.jsonl / .zip / .tar.gz / .gz"]
M1 --> P["Same parser as live<br/>hook ingestion"]
M2 --> P
M3 --> P
P --> DB[("SQLite<br/>sessions + tokens + cost")]
flowchart TD
Q["Where is the history?"] --> Q1{Is it on this machine<br/>under ~/.claude/projects?}
Q1 -->|yes, and I just want<br/>to re-scan| M1["Mode: Rescan default folder<br/>one click"]
Q1 -->|yes, but in another folder<br/>on this machine| M2["Mode: Scan a folder<br/>paste the absolute path"]
Q1 -->|no — it's on another machine<br/>or in an archive file| M3["Mode: Upload files<br/>drag-drop JSONL or archive"]
style UI fill:#a855f7,stroke:#c084fc,color:#fff
style P fill:#f59e0b,stroke:#fbbf24,color:#000
style DB fill:#10b981,stroke:#34d399,color:#fff
M3 --> PREP["Archive source:<br/>tar -czf claude-history.tar.gz<br/>-C ~/.claude projects"]
style M1 fill:#10b981,stroke:#34d399,color:#fff
style M2 fill:#f59e0b,stroke:#fbbf24,color:#000
style M3 fill:#a855f7,stroke:#c084fc,color:#fff
```
Re-imports are idempotent: sessions are deduplicated by UUID and
compaction baselines preserve pre-compaction token totals, so running
the importer twice never double-counts tokens or cost.
### Step-by-step: moving history from one machine to another
Verify it worked by opening the **Analytics** page and checking that
per-model token totals and estimated cost look correct. Full walkthrough
with per-OS archive commands in
[SETUP.md → Importing existing Claude Code history](./SETUP.md#importing-existing-claude-code-history).
### Optional: tune import limits
**On the source machine**, bundle the projects folder:
If you regularly import very large archives, these environment variables
can be raised (the defaults are generous for typical use):
```bash
# macOS / Linux
tar -czf claude-history.tar.gz -C ~/.claude projects
| 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 uncompressed bytes per archive (zip-bomb guard) |
# Windows (PowerShell, via built-in tar)
tar -czf claude-history.tar.gz -C "$env:USERPROFILE\.claude" projects
```
Set them before `npm run dev` or `npm start`:
Transfer the resulting `claude-history.tar.gz` to the destination machine
however you like — AirDrop, `scp`, USB, cloud storage.
**On the destination machine**, in the dashboard:
1. Open **Settings → Import History**.
2. Pick **Upload files** (the third tab).
3. Drag the archive onto the drop zone.
4. Click **Upload & Import** and watch the progress.
5. When the green result card appears, open **Analytics → Cost** to confirm
per-model token totals and estimated cost.
### Supported inputs
Any of the following can be dropped onto the upload zone or found inside a
folder given to **Scan a folder**:
- `.jsonl` — session transcripts
- `.meta.json` — subagent metadata sidecars
- `.zip` — extracted with path-traversal protection
- `.tar`, `.tar.gz`, `.tgz` — extracted via the `tar` package
- `.gz` — single gzipped JSONL (streaming-decompressed)
### Accuracy guarantees
- **Idempotent** — re-importing never double-counts. Sessions are
deduplicated by UUID.
- **Cost-preserving** — the `token_usage` table uses `baseline_*` columns
to preserve pre-compaction token totals, so re-ingesting a compacted
transcript never erases historical cost.
- **Same parser as live** — `parseSessionFile` + `importSession` is the
single source of truth for both hook-driven ingestion and manual
import, so imported numbers match captured numbers exactly.
### Safety
Archive extraction is hardened against path traversal and archive bombs.
The defaults are generous for real-world transcripts but tight enough to
stop obvious attacks. If you regularly import very large archives, raise them
before `npm run dev` or `npm start` (also listed under
[Environment variables](#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 uncompressed bytes per archive (zip-bomb guard) |
```bash
AGENTHUB_IMPORT_MAX_EXTRACT_BYTES=17179869184 npm start # allow 16 GB extraction
```
### CLI alternative
For scripts and automation, the same logic runs from the terminal:
```bash
# Import (or re-import) everything under ~/.claude/projects
npm run import-history
# Dry run — show what would be imported without writing
node scripts/import-history.js --dry-run
# Scope to a single project dir
node scripts/import-history.js --project my-project
```
---
## Production mode
@@ -328,44 +380,260 @@ and, for plugin installs, [`docs/PLUGINS.md` → Auto-start on boot](docs/PLUGIN
---
## Optional: Agent extension packs
## How it works
This repository includes extension packs for both Claude Code and Codex.
AgentHub integrates with Claude Code through its native hook system. When Claude Code performs any action (session start, tool use, turn completion, subagent finish, session exit), it fires a hook that calls a small Node.js script bundled with this project. That script forwards the event over HTTP to the dashboard server, which stores it in SQLite and broadcasts it to the browser over WebSocket.
- Claude Code loads project extensions from:
```
Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
↓
Browser ← WebSocket broadcast ← Express server ← SQLite
```
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
---
## Configuration
### Hook auto-installation
When the dashboard is running directly on the host, the server writes the following to `~/.claude/settings.json` every time it starts:
```json
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionStart" }] }],
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PreToolUse" }] }],
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PostToolUse" }] }],
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Stop" }] }],
"SubagentStop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SubagentStop" }] }],
"Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Notification" }] }],
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionEnd" }] }]
}
}
```
> [!NOTE]
> Note: `SessionStart` and `SessionEnd` hooks do not support the `matcher` field — they fire unconditionally on every session start and exit.
Existing hooks in that file are preserved. The dashboard only adds or updates entries that contain `hook-handler.js`.
To re-run hook installation manually:
```bash
npm run install-hooks
```
> [!TIP]
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
> [!NOTE]
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
### Environment variables
| Variable | Default | Description |
|---|---|---|
| `DASHBOARD_PORT` | `4820` | Port the Express server listens on |
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
| `NODE_ENV` | `development` | Set to `production` to serve built client |
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `agenthub` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
| `AGENTHUB_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
| `DASHBOARD_DATA_DIR` | `~/.claude/agenthub` | AgentHub's data dir; a plugin install keeps its runtime (deps, UI build, logs) in `runtime/` under it |
Example with a custom port:
```bash
DASHBOARD_PORT=9000 npm run dev
```
> [!NOTE]
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agenthub.json` so the Claude Code hook handler discovers it without any env var.
>
> Multiple dashboards can run side by side — for example two `npm run dev` checkouts, or `npm run dev` alongside `npm start`. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
>
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
>
> If you bypass the picker (e.g. `npm run dev:raw`, container builds, or anything else that calls `node server/index.js` directly), make sure your client is built / proxied against the port the server actually bound.
### Agent extension setup (Claude Code + Codex)
This repository ships extension files for both agent ecosystems:
- Claude Code:
- `CLAUDE.md`
- `.claude/rules/`
- `skills/` (shipped as the `agenthub` plugin's skills)
- Codex project packs live under `.codex/`:
- `.claude/rules/*`
- `skills/*` (the `agenthub` plugin's own skills)
- Codex:
- `AGENTS.md`
- `.codex/rules/`
- `.codex/agents/`
- `.codex/skills/`
- `.codex/config.toml`
- `.codex/rules/default.rules`
- `.codex/agents/*`
- `.codex/skills/*`
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
### PWA configuration (optional)
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
- `name` / `short_name` — displayed on the home screen / dock
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
- `background_color` — splash screen background
- `start_url` — entry point when launched from home screen
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
---
## Database
The SQLite database is created automatically on first run (`data/dashboard.db` in a checkout, unless `DASHBOARD_DB_PATH` says otherwise). The directory is created if it does not exist. The database uses WAL mode for concurrent reads and foreign keys for referential integrity.
### Clear all data
To remove all sessions, agents, events, and token usage (useful after running seed data or for a clean start):
```bash
npm run clear-data
```
### Data management via Settings page
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
and **Hook Configuration** are not on its Dashboard tab: they sit under the
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
hooks in their own tabs.
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
- **Hook Configuration** — check which hooks are installed and reinstall them
- **Data Export** — download all sessions, agents, events, and pricing as a JSON file
- **Session Cleanup** — abandon stale active sessions after N hours, purge old completed sessions after N days
- **Clear All Data** — remove all sessions, agents, events, and token usage
- **Data Management** and **About** sections render with loading placeholders while server info is being fetched, so the page is always fully navigable
### Seed demo data
To populate the dashboard with sample sessions, agents, and events for UI exploration:
```bash
npm run seed
```
---
## Scripts reference
| Script | Command | Description |
|---|---|---|
| `setup` | `npm run setup` | Install all dependencies (server + client) |
| `dev` | `npm run dev` | Start server + client in development mode |
| `start` | `npm start` | Start server in production mode |
| `build` | `npm run build` | Build the React client to `client/dist/` |
| `install-hooks` | `npm run install-hooks` | Write Claude Code hooks to `~/.claude/settings.json` |
| `clear-data` | `npm run clear-data` | Delete all data from the database |
| `seed` | `npm run seed` | Insert demo sessions/agents/events |
| `import-history` | `npm run import-history` | Import legacy sessions from `~/.claude/` (also runs on startup) |
| `claude` | Claude CLI | Uses `CLAUDE.md`, `.claude/rules`, and `.claude/skills` automatically |
| `test` | `npm test` | Run all server and client tests |
| `test:server` | `npm run test:server` | Run server integration tests only |
| `test:client` | `npm run test:client` | Run client unit tests only |
| `format` | `npm run format` | Format all files with Prettier |
| `format:check` | `npm run format:check` | Check formatting without writing |
---
## Makefile targets
All npm scripts are mirrored as `make` targets for convenience. Run `make help` to list them:
```bash
make help
```
Commonly used targets:
| Make target | Equivalent npm command | Description |
|---|---|---|
| `make setup` | `npm run setup` | Install all dependencies (root + client) |
| `make dev` | `npm run dev` | Start server + client in watch mode |
| `make build` | `npm run build` | Build the React client for production |
| `make start` | `npm start` | Start the production server |
| `make prod` | `npm run build && npm start` | Build then start in one step |
| `make test` | `npm test` | Run all tests (server + client) |
| `make test-server` | `npm run test:server` | Run server tests only |
| `make test-client` | `npm run test:client` | Run client tests only |
| `make format` | `npm run format` | Format all files with Prettier |
| `make format-check` | `npm run format:check` | Check formatting without writing |
| `make seed` | `npm run seed` | Load demo data |
| `make clear-data` | `npm run clear-data` | Delete all data rows |
---
## Troubleshooting
### `npm run setup` shows `better-sqlite3` errors
### Plugin bootstrap never comes up
This is expected and **non-fatal**. `better-sqlite3` is a native C++ module listed as an optional dependency. If prebuilt binaries are not available for your Node version or platform, npm will print gyp/compilation errors but still complete successfully.
The plugin's `SessionStart` hook only checks a fast path and hands the real
work (npm install, UI build, server start) to a detached background worker, so
a failure there does not show in the session. When the worker's last run
failed, the next session start prints a one-time **AgentHub:** message naming
the log to read. Everything is logged under `~/.claude/agenthub/runtime/`:
At runtime the server uses this fallback chain:
| File | What it holds |
|---|---|
| `bootstrap.log` | Each bootstrap step and why it stopped (Node too old, install failed, superseded build, …) |
| `npm-install.log` | npm's output for the runtime dependency install |
| `client-build.log` | The dashboard UI build (the API still works when only this fails) |
| `server.log` | The server's own output, when it was started detached |
| `last-failure.json` | The last failure the session-start message reports (removed once a bootstrap succeeds) |
1. **`better-sqlite3`** — used when prebuilt binaries are available (Node 20/22/24 on Windows x64, macOS arm64/x64, Linux x64/arm64)
2. **`node:sqlite`** — Node.js built-in SQLite module, used automatically on Node 22+ when `better-sqlite3` is unavailable
Then run `/agenthub-doctor` from Claude Code — it checks Node, the runtime
dependencies, the server and the service unit, and names the fix for each
failing row. `/agenthub-update` re-runs the bootstrap in the foreground with its
output visible. The most common causes: Node older than 22.5 (`node -v` in the
shell Claude Code was started from), no network or a proxy blocking the npm
registry (`npm-install.log`), and a port held by another program (`server.log`,
see [Port 4820 already in use](#port-4820-already-in-use)).
If you see an error box at startup saying *"SQLite backend not available"*, either:
### `better-sqlite3` errors during `npm install` / `npm run setup`
- **Upgrade to Node.js 22+** (recommended — zero native dependencies needed), or
- **Install build tools** so `better-sqlite3` can compile from source:
- **Windows:** `npm install -g windows-build-tools` or install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
- **macOS:** `xcode-select --install`
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu) or equivalent
Only a **checkout** install sees these — the plugin installs with
`--ignore-scripts` and never builds `better-sqlite3`. They are **harmless**:
`better-sqlite3` is an optional dependency, and when it cannot be built npm
skips it and the server uses Node's built-in `node:sqlite` (Node 22.5+). You do
**not** need Python, Visual Studio Build Tools or a C++ compiler.
Then run: `npm rebuild better-sqlite3`
To build it anyway (it is used when present), install a toolchain and run
`npm rebuild better-sqlite3`:
- **Windows:** [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
- **macOS:** `xcode-select --install`
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu) or equivalent
### "SQLite backend not available" error on startup
Neither `better-sqlite3` nor `node:sqlite` could be loaded — almost always a
Node older than 22.5. Upgrade Node.
### Database is locked / busy errors
The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock errors:
- Ensure only one dashboard server instance is running
- Check for zombie `node server/index.js` processes: `ps aux | grep server/index`
- Delete `data/dashboard.db-wal` and `data/dashboard.db-shm` if the server was killed uncleanly, then restart
### `npm run dev` fails immediately
@@ -375,7 +643,7 @@ Ensure both server and client dependencies are installed:
npm run setup
```
If the error mentions a missing module like `express` or `react`, dependencies may be incomplete. Delete `node_modules` in both root and `client/`, then re-run setup:
If the error mentions a missing module like `express` or `react`, dependencies may be incomplete. Delete `node_modules` in both the root and `client/`, then re-run setup:
```bash
rm -rf node_modules client/node_modules
@@ -409,9 +677,66 @@ that case.
### No sessions appearing after starting Claude Code
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
**Check 1 — Is the server running?**
---
```bash
curl http://localhost:4820/api/health
# Expected: {"status":"ok","timestamp":"..."}
```
**Check 2 — Are hooks installed?**
With the plugin, its hooks come with it — run `/agenthub-doctor` instead. For a
checkout, open `~/.claude/settings.json` and confirm it contains a `hooks`
section with entries referencing `hook-handler.js`. If not, run:
```bash
npm run install-hooks
```
**Check 3 — Did you start a new Claude Code session after the server started?**
Hooks only apply to sessions started after installation. Restart Claude Code.
**Check 4 — Is Node.js in PATH when Claude Code runs hooks?**
On some systems, the shell environment when Claude Code fires hooks may not include the full PATH. Test with:
```bash
node --version
```
If Node.js is not found, use the full path to `node` in the hook command. Edit `scripts/install-hooks.js`, replace `node` with the absolute path (e.g. `/usr/local/bin/node`), and re-run `npm run install-hooks`.
### Dashboard shows "Disconnected" in the sidebar
The WebSocket connection to the server failed. Ensure the server is running:
```bash
npm run dev
```
The client will automatically reconnect every 2 seconds once the server is available.
### Port 4820 already in use
When another program holds the port, the server stops with
`Port 4820 is already in use by another program…` (a running AgentHub there is
detected and simply reused). Pick another port:
```bash
DASHBOARD_PORT=4821 npm start # or: DASHBOARD_PORT=4821 npm run dev
```
The Vite dev proxy and the Claude Code hooks follow the port on their own
(`client/vite.config.ts` reads `DASHBOARD_PORT`; the server publishes the port it bound).
And make sure Claude Code posts hooks to the new port:
```bash
CLAUDE_DASHBOARD_PORT=4821 claude
# or edit scripts/hook-handler.js and change the default port
```
## Ports
+12 -420
View File
@@ -1,422 +1,14 @@
# Setup Guide
A comprehensive guide to setting up and configuring the Agent Dashboard, including how it integrates with Claude Code, environment variables, container deployment, and troubleshooting common issues.
## How it works
Agent Dashboard integrates with Claude Code through its native hook system. When Claude Code performs any action (session start, tool use, turn completion, subagent finish, session exit), it fires a hook that calls a small Node.js script bundled with this project. That script forwards the event over HTTP to the dashboard server, which stores it in SQLite and broadcasts it to the browser over WebSocket.
```
Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
↓
Browser ← WebSocket broadcast ← Express server ← SQLite
```
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
---
## Configuration
### Hook auto-installation
When the dashboard is running directly on the host, the server writes the following to `~/.claude/settings.json` every time it starts:
```json
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionStart" }] }],
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PreToolUse" }] }],
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PostToolUse" }] }],
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Stop" }] }],
"SubagentStop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SubagentStop" }] }],
"Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Notification" }] }],
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionEnd" }] }]
}
}
```
> [!NOTE]
> Note: `SessionStart` and `SessionEnd` hooks do not support the `matcher` field — they fire unconditionally on every session start and exit.
Existing hooks in that file are preserved. The dashboard only adds or updates entries that contain `hook-handler.js`.
To re-run hook installation manually:
```bash
npm run install-hooks
```
> [!TIP]
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
> [!NOTE]
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
### Environment variables
| Variable | Default | Description |
|---|---|---|
| `DASHBOARD_PORT` | `4820` | Port the Express server listens on |
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
| `NODE_ENV` | `development` | Set to `production` to serve built client |
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `agenthub` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
| `AGENTHUB_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
Example with a custom port:
```bash
DASHBOARD_PORT=9000 npm run dev
```
> [!NOTE]
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agenthub.json` so the Claude Code hook handler discovers it without any env var.
>
> Multiple dashboards can run side by side — for example two `npm run dev` checkouts, or `npm run dev` alongside `npm start`. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
>
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
>
> If you bypass the picker (e.g. `npm run dev:raw`, container builds, or anything else that calls `node server/index.js` directly), make sure your client is built / proxied against the port the server actually bound.
### Agent extension setup (Claude Code + Codex)
This repository ships extension files for both agent ecosystems:
- Claude Code:
- `CLAUDE.md`
- `.claude/rules/*`
- `skills/*` (the `agenthub` plugin's own skills)
- `agents/*` (the `agenthub` plugin's own agents)
- Codex:
- `AGENTS.md`
- `.codex/config.toml`
- `.codex/rules/default.rules`
- `.codex/agents/*`
- `.codex/skills/*`
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
### PWA configuration (optional)
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
- `name` / `short_name` — displayed on the home screen / dock
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
- `background_color` — splash screen background
- `start_url` — entry point when launched from home screen
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
---
## Database
The SQLite database is created automatically at `data/dashboard.db` on first run. The directory is created if it does not exist. The database uses WAL mode for concurrent reads and foreign keys for referential integrity.
### Clear all data
To remove all sessions, agents, events, and token usage (useful after running seed data or for a clean start):
```bash
npm run clear-data
```
### Data management via Settings page
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
and **Hook Configuration** are not on its Dashboard tab: they sit under the
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
hooks in their own tabs.
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
- **Hook Configuration** — check which hooks are installed and reinstall them
- **Data Export** — download all sessions, agents, events, and pricing as a JSON file
- **Session Cleanup** — abandon stale active sessions after N hours, purge old completed sessions after N days
- **Clear All Data** — remove all sessions, agents, events, and token usage
- **Data Management** and **About** sections render with loading placeholders while server info is being fetched, so the page is always fully navigable
### Seed demo data
To populate the dashboard with sample sessions, agents, and events for UI exploration:
```bash
npm run seed
```
---
## Importing existing Claude Code history
The dashboard automatically imports sessions from `~/.claude/projects/` on
**every startup**, so if Claude Code has been used on this machine, you'll
see history immediately after the first launch. If you need to bring in
history from another machine, from a backup, or just force a rescan, use
**Settings → Import History** in the UI — it's a guided, drag-and-drop
experience with live progress.
### Pick the right mode
```mermaid
flowchart TD
Q["Where is the history?"] --> Q1{Is it on this machine<br/>under ~/.claude/projects?}
Q1 -->|yes, and I just want<br/>to re-scan| M1["Mode: Rescan default folder<br/>one click"]
Q1 -->|yes, but in another folder<br/>on this machine| M2["Mode: Scan a folder<br/>paste the absolute path"]
Q1 -->|no — it's on another machine<br/>or in an archive file| M3["Mode: Upload files<br/>drag-drop JSONL or archive"]
M3 --> PREP["Archive source:<br/>tar -czf claude-history.tar.gz<br/>-C ~/.claude projects"]
style M1 fill:#10b981,stroke:#34d399,color:#fff
style M2 fill:#f59e0b,stroke:#fbbf24,color:#000
style M3 fill:#a855f7,stroke:#c084fc,color:#fff
```
### Step-by-step: moving history from one machine to another
**On the source machine**, bundle the projects folder:
```bash
# macOS / Linux
tar -czf claude-history.tar.gz -C ~/.claude projects
# Windows (PowerShell, via built-in tar)
tar -czf claude-history.tar.gz -C "$env:USERPROFILE\.claude" projects
```
Transfer the resulting `claude-history.tar.gz` to the destination machine
however you like — AirDrop, `scp`, USB, cloud storage.
**On the destination machine**, in the dashboard:
1. Open **Settings → Import History**.
2. Pick **Upload files** (the third tab).
3. Drag the archive onto the drop zone.
4. Click **Upload & Import** and watch the progress.
5. When the green result card appears, open **Analytics → Cost** to confirm
per-model token totals and estimated cost.
### Supported inputs
Any of the following can be dropped onto the upload zone or found inside a
folder given to **Scan a folder**:
- `.jsonl` — session transcripts
- `.meta.json` — subagent metadata sidecars
- `.zip` — extracted with path-traversal protection
- `.tar`, `.tar.gz`, `.tgz` — extracted via the `tar` package
- `.gz` — single gzipped JSONL (streaming-decompressed)
### Accuracy guarantees
- **Idempotent** — re-importing never double-counts. Sessions are
deduplicated by UUID.
- **Cost-preserving** — the `token_usage` table uses `baseline_*` columns
to preserve pre-compaction token totals, so re-ingesting a compacted
transcript never erases historical cost.
- **Same parser as live** — `parseSessionFile` + `importSession` is the
single source of truth for both hook-driven ingestion and manual
import, so imported numbers match captured numbers exactly.
### Safety
Archive extraction is hardened against path traversal and archive bombs.
The defaults are generous for real-world transcripts but tight enough to
stop obvious attacks; see the env vars table above for
`AGENTHUB_IMPORT_MAX_BYTES`, `AGENTHUB_IMPORT_MAX_FILES`, and
`AGENTHUB_IMPORT_MAX_EXTRACT_BYTES`.
### CLI alternative
For scripts and automation, the same logic runs from the terminal:
```bash
# Import (or re-import) everything under ~/.claude/projects
npm run import-history
# Dry run — show what would be imported without writing
node scripts/import-history.js --dry-run
# Scope to a single project dir
node scripts/import-history.js --project my-project
```
---
## Scripts reference
| Script | Command | Description |
|---|---|---|
| `setup` | `npm run setup` | Install all dependencies (server + client) |
| `dev` | `npm run dev` | Start server + client in development mode |
| `start` | `npm start` | Start server in production mode |
| `build` | `npm run build` | Build the React client to `client/dist/` |
| `install-hooks` | `npm run install-hooks` | Write Claude Code hooks to `~/.claude/settings.json` |
| `clear-data` | `npm run clear-data` | Delete all data from the database |
| `seed` | `npm run seed` | Insert demo sessions/agents/events |
| `import-history` | `npm run import-history` | Import legacy sessions from `~/.claude/` (also runs on startup) |
| `claude` | Claude CLI | Uses `CLAUDE.md`, `.claude/rules`, and `.claude/skills` automatically |
| `test` | `npm test` | Run all server and client tests |
| `test:server` | `npm run test:server` | Run server integration tests only |
| `test:client` | `npm run test:client` | Run client unit tests only |
| `format` | `npm run format` | Format all files with Prettier |
| `format:check` | `npm run format:check` | Check formatting without writing |
---
## Makefile targets
All npm scripts are mirrored as `make` targets for convenience. Run `make help` to list them:
```bash
make help
```
Commonly used targets:
| Make target | Equivalent npm command | Description |
|---|---|---|
| `make setup` | `npm run setup` | Install all dependencies (root + client) |
| `make dev` | `npm run dev` | Start server + client in watch mode |
| `make build` | `npm run build` | Build the React client for production |
| `make start` | `npm start` | Start the production server |
| `make prod` | `npm run build && npm start` | Build then start in one step |
| `make test` | `npm test` | Run all tests (server + client) |
| `make test-server` | `npm run test:server` | Run server tests only |
| `make test-client` | `npm run test:client` | Run client tests only |
| `make format` | `npm run format` | Format all files with Prettier |
| `make format-check` | `npm run format:check` | Check formatting without writing |
| `make seed` | `npm run seed` | Load demo data |
| `make clear-data` | `npm run clear-data` | Delete all data rows |
---
## Troubleshooting
### `better-sqlite3` errors during `npm install` / `npm run setup`
These warnings are **harmless**. `better-sqlite3` is an optional dependency — if it cannot compile, npm skips it and the server falls back to Node.js built-in `node:sqlite` (available on Node 22+).
You do **not** need Python, Visual Studio Build Tools, or any C++ compiler to run this project on Node 22+.
If you are on Node 20 or 21 and `better-sqlite3` prebuilds are not available for your platform (there is no `node:sqlite` fallback below Node 22), you have two options:
1. **Upgrade to Node.js 22+** — the built-in `node:sqlite` fallback requires no native compilation at all
2. **Install build tools** and run `npm rebuild better-sqlite3`:
- **Windows:** install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
- **macOS:** `xcode-select --install`
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu)
### "SQLite backend not available" error on startup
This means neither `better-sqlite3` nor `node:sqlite` could be loaded. The most common cause is running Node.js < 22 without `better-sqlite3` prebuilds. Upgrade to Node.js 22+ to resolve this.
### Database is locked / busy errors
The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock errors:
- Ensure only one dashboard server instance is running
- Check for zombie `node server/index.js` processes: `ps aux | grep server/index`
- Delete `data/dashboard.db-wal` and `data/dashboard.db-shm` if the server was killed uncleanly, then restart
---
### The run console won't start a run / the terminal never attaches
Runs are tmux-backed: the server executes the `tmux` binary and has no fallback.
```bash
tmux -V # missing → sudo apt install tmux
curl -s http://localhost:4820/api/run/tmux # {"available":true}
curl -s "http://localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
```
`{"available":false}` while your own shell finds tmux means the **server's**
`PATH` differs from yours — typically a systemd `--user` service, which never
reads your shell profile. Install tmux system-wide (`/usr/bin/tmux`) and
re-run `agenthub service uninstall && agenthub service install`. The same
applies to `claude` / `codex`: a run launches them by name.
Install instructions per OS: [INSTALL.md → Step 0](./INSTALL.md#step-0--system-packages).
---
### No sessions appearing after starting Claude Code
**Check 1 — Is the server running?**
```bash
curl http://localhost:4820/api/health
# Expected: {"status":"ok","timestamp":"..."}
```
**Check 2 — Are hooks installed?**
Open `~/.claude/settings.json` and confirm it contains a `hooks` section with entries referencing `hook-handler.js`. If not, run:
```bash
npm run install-hooks
```
**Check 3 — Did you start a new Claude Code session after the server started?**
Hooks only apply to sessions started after installation. Restart Claude Code.
**Check 4 — Is Node.js in PATH when Claude Code runs hooks?**
On some systems, the shell environment when Claude Code fires hooks may not include the full PATH. Test with:
```bash
node --version
```
If Node.js is not found, use the full path to `node` in the hook command. Edit `scripts/install-hooks.js`, replace `node` with the absolute path (e.g. `/usr/local/bin/node`), and re-run `npm run install-hooks`.
---
### Dashboard shows "Disconnected" in the sidebar
The WebSocket connection to the server failed. Ensure the server is running:
```bash
npm run dev
```
The client will automatically reconnect every 2 seconds once the server is available.
---
### Events Today shows 0 despite recent activity
This was a known timezone bug (fixed in current version). If you are still seeing this, ensure you are running the latest code and restart the server.
---
### Port 4820 already in use
```bash
DASHBOARD_PORT=4821 npm run dev
```
Then update the Vite proxy in `client/vite.config.ts`:
```ts
proxy: {
"/api": "http://localhost:4821",
"/ws": { target: "ws://localhost:4821", ws: true }
}
```
And make sure Claude Code posts hooks to the new port:
```bash
CLAUDE_DASHBOARD_PORT=4821 claude
# or edit scripts/hook-handler.js and change the default port
```
This guide has moved into **[INSTALL.md](./INSTALL.md)** — the one install and
setup guide (it and this file had grown into near-duplicates). Everything that
was here is there:
| Was in SETUP.md | Now in INSTALL.md |
|---|---|
| How it works | [How it works](./INSTALL.md#how-it-works) |
| Hook auto-installation, environment variables, agent extensions, PWA | [Configuration](./INSTALL.md#configuration) |
| Database (clear, Settings page, seed) | [Database](./INSTALL.md#database) |
| Importing existing Claude Code history | [Step 5](./INSTALL.md#step-5--optional-importing-existing-claude-code-history) |
| Scripts reference, Makefile targets | [Scripts reference](./INSTALL.md#scripts-reference) |
| Troubleshooting (incl. "Port 4820 already in use") | [Troubleshooting](./INSTALL.md#troubleshooting) |
+14 -3
View File
@@ -76,7 +76,7 @@
*
* ----------------------------------------------------------------------------- */
import { useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { lazy, Suspense, useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { Menu } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -85,9 +85,15 @@ import { UpdateNotifier } from "./UpdateNotifier";
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
import { SagiMount } from "./Sagi/SagiMount";
import { StatusBar } from "./StatusBar";
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
import { useOnboarding } from "../lib/userPrefs";
import { OnboardingTour } from "./onboarding/OnboardingTour";
// Loaded only for an account that has not finished onboarding: the wizard
// carries the appearance, pet and notification settings screens.
const OnboardingWizard = lazy(() =>
import("./onboarding/OnboardingWizard").then((m) => ({ default: m.OnboardingWizard }))
);
const WIDTH_KEY = "agenthub.sidebar.width";
/** The default width, and the narrowest the edge drags to. */
export const SIDEBAR_MIN = 240;
@@ -136,6 +142,7 @@ export function Layout({ wsConnected }: LayoutProps) {
const { t } = useTranslation("nav");
const [collapsed, setCollapsed] = useState(loadCollapsed);
const [connectionOpen, setConnectionOpen] = useState(false);
const onboarding = useOnboarding();
const [updateAvailable, setUpdateAvailable] = useState(false);
const drawer = useMedia(DRAWER_QUERY);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -177,7 +184,11 @@ export function Layout({ wsConnected }: LayoutProps) {
<UpdateNotifier />
<InterruptedSessionsPrompt />
<SagiMount />
<OnboardingWizard />
{onboarding.loaded && !onboarding.onboardedAt && (
<Suspense fallback={null}>
<OnboardingWizard />
</Suspense>
)}
<OnboardingTour />
{drawer && drawerOpen && (
<div
+1 -17
View File
@@ -81,6 +81,7 @@ import {
} from "react";
import { useNavigate } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
import { useTranslation } from "react-i18next";
import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite";
@@ -130,23 +131,6 @@ interface Anchor {
dragging: boolean;
}
/** The OS "reduce motion" preference, followed live. */
export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
);
useEffect(() => {
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
if (!mq) return;
const onChange = () => setReduced(mq.matches);
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, []);
return reduced;
}
/**
* Fixed-position wrapper that places its content next to the avatar and clamps
* it inside the viewport. It measures itself (and re-measures on content/size
+56 -40
View File
@@ -9,8 +9,12 @@
* Workspace page, the open workspace's total (`?lane=` on the same call: the
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
* the URL's `session`, or the focused pane's session from the page's run poll,
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
* whenever the URL names another workspace or session. Each figure carries its
* `LIVE_RUNS_EVENT`), all re-read at once whenever the URL names another
* workspace or session, then at most every 30 s while events or session
* updates arrive (spend only moves with work) and every 2 min otherwise, while
* the tab is visible. The session counts re-read only when a session is
* created or its status / waiting / archived state changes (every 60 s
* otherwise). Each figure carries its
* token total, and hovering it lists tokens and cost per model. At the right
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
@@ -31,9 +35,15 @@ import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { sessionChangeMatcher } from "../lib/sessionChange";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
/** Spend moves only with work: at most this often while events arrive… */
const COST_POLL_MS = 30_000;
/** …and this often regardless (date rollover, budget period, a missed frame). */
const COST_FALLBACK_MS = 120_000;
/** The counts' safety poll; session frames that change a count drive the rest. */
const COUNTS_FALLBACK_MS = 60_000;
const QUOTA_POLL_MS = 5 * 60_000;
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
claude: "Claude",
@@ -384,51 +394,57 @@ export function StatusBar({
.catch(() => undefined);
}, []);
useEffect(loadCounts, [loadCounts]);
useLiveRefresh(loadCounts, {
intervalMs: 15_000,
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
});
// Only a frame that moves a session between the counted states (a new
// session, a status / waiting / archived change) re-reads: a session row is
// re-broadcast on almost every hook.
const [countsChanged] = useState(() => sessionChangeMatcher());
useLiveRefresh(loadCounts, { intervalMs: COUNTS_FALLBACK_MS, match: countsChanged });
// Bumped on every lane/session switch: an answer for the previous one is dropped.
const costGen = useRef(0);
const loadCosts = useCallback(() => {
const gen = costGen.current;
const live = () => gen === costGen.current;
api.pricing
.totalCost({ date: localDate() })
.then((r) => live() && setToday(r))
.catch(() => undefined);
api.budgets
.status()
.then(({ budgets }) => {
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
if (live()) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
.then((r) => live() && setLaneCost(r))
.catch(() => undefined);
}
if (sessionId) {
api.pricing
.sessionCost(sessionId)
.then((r) => live() && setSessionCost(r))
.catch(() => undefined);
}
}, [lane, sessionId]);
useEffect(() => {
let live = true;
costGen.current++;
// Another workspace or session: never show the previous one's figure.
setLaneCost(null);
setSessionCost(null);
const tick = () => {
if (document.visibilityState !== "visible") return;
api.pricing
.totalCost({ date: localDate() })
.then((r) => live && setToday(r))
.catch(() => undefined);
api.budgets
.status()
.then(({ budgets }) => {
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
if (live) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
.then((r) => live && setLaneCost(r))
.catch(() => undefined);
}
if (sessionId) {
api.pricing
.sessionCost(sessionId)
.then((r) => live && setSessionCost(r))
.catch(() => undefined);
}
};
tick();
const id = setInterval(tick, COST_POLL_MS);
document.addEventListener("visibilitychange", tick);
if (document.visibilityState === "visible") loadCosts();
return () => {
live = false;
clearInterval(id);
document.removeEventListener("visibilitychange", tick);
costGen.current++;
};
}, [lane, sessionId]);
}, [loadCosts]);
useLiveRefresh(loadCosts, {
intervalMs: COST_FALLBACK_MS,
throttleMs: COST_POLL_MS,
match: (msg) =>
msg.type === "new_event" || msg.type === "session_updated" || msg.type === "session_created",
});
return (
<footer
@@ -14,6 +14,10 @@
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
* whether its session has started (`onNotStarted`: no row yet — the CLI may
* be asking in the terminal, e.g. to trust the folder, which this then says).
* A caller that knows the session has ended passes `ended`: nothing more is
* being written, so the catch-up poll drops from 3 s to 60 s and the
* sub-agent list stops being rescanned — a `new_event` frame, a reconnect or
* the view being shown again still fetch the tail at once.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -79,6 +83,9 @@ import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/typ
// hook event. A short visibility-gated poll closes that gap and also rescues
// the conversation from missed/late WebSocket frames.
const POLL_INTERVAL_MS = 3000;
// An ended session writes nothing more: a slow safety poll only, in case it was
// resumed somewhere whose frames this view missed.
const ENDED_POLL_INTERVAL_MS = 60000;
// Rescan the transcripts list periodically so new subagents that spawn
// mid-session appear in the dropdown without a page reload.
const TRANSCRIPTS_REFRESH_MS = 15000;
@@ -110,6 +117,8 @@ interface ConversationViewProps {
onContext?: (tokens: number, model: string | null) => void;
/** A live pane's session has no row yet: the CLI has not started it. */
onNotStarted?: (notStarted: boolean) => void;
/** The session has ended (and no pane holds it): poll slowly, if at all. */
ended?: boolean;
}
function ConversationViewPane({
@@ -124,6 +133,7 @@ function ConversationViewPane({
stopped = 0,
onContext,
onNotStarted,
ended = false,
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
@@ -188,14 +198,18 @@ function ConversationViewPane({
}
}
loadTranscripts();
const interval = window.setInterval(() => {
if (isShown(onScreen)) loadTranscripts();
}, TRANSCRIPTS_REFRESH_MS);
// An ended session spawns no new sub-agents: the load above (re-run when
// it ends, for the last ones) is enough.
const interval = ended
? undefined
: window.setInterval(() => {
if (isShown(onScreen)) loadTranscripts();
}, TRANSCRIPTS_REFRESH_MS);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [sessionId]);
}, [sessionId, ended]);
// Sync external initialTranscriptId to internal state
useEffect(() => {
@@ -355,9 +369,12 @@ function ConversationViewPane({
let interval: number | null = null;
function start() {
if (interval !== null) return;
interval = window.setInterval(() => {
if (isShown(onScreen)) fetchNewMessages();
}, POLL_INTERVAL_MS);
interval = window.setInterval(
() => {
if (isShown(onScreen)) fetchNewMessages();
},
ended ? ENDED_POLL_INTERVAL_MS : POLL_INTERVAL_MS
);
}
function stop() {
if (interval !== null) {
@@ -382,7 +399,7 @@ function ConversationViewPane({
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [fetchNewMessages, onScreen]);
}, [fetchNewMessages, onScreen, ended]);
// Manual refresh - surfaces a control in the toolbar so users can force
// a sync without reloading the page.
@@ -132,7 +132,11 @@ export function SessionReplay({
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} fill />
<ConversationView
sessionId={sessionId}
fill
ended={!livePane && !!session && session.status !== "active"}
/>
</div>
{livePane ? (
<div
+11 -4
View File
@@ -59,7 +59,13 @@ import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
import { isSubmitEnter } from "../../lib/keys";
import { ConversationView } from "../conversation/ConversationView";
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
import {
ModelMenu,
PromptEditor,
promptForPane,
promptWithImages,
type PromptImage,
} from "./RunSetup";
import { ChatAddMenu } from "./ChatAddMenu";
import { addAttachments, dragAttachments } from "../../lib/promptAttach";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
@@ -491,7 +497,7 @@ export const PaneChat = memo(function PaneChat({
const [notStarted, setNotStarted] = useState(false);
// A control is a command typed into the pane, like the user would.
const command = async (line: string) => {
const command = async (line: string, files: string[] = []) => {
if (
notStarted &&
!(await confirmDialog({
@@ -501,14 +507,15 @@ export const PaneChat = memo(function PaneChat({
}))
)
return false;
const ok = submitToRun(runId, line);
const ok = submitToRun(runId, line, files);
if (!ok) toast.error(t("chat.notSent"));
return ok;
};
const send = async () => {
if (!text || uploading) return;
if (!(await command(text))) return;
const out = promptForPane(prompt, images);
if (!(await command(out.text, out.files))) return;
setFollow((n) => n + 1);
setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
+3 -3
View File
@@ -73,7 +73,7 @@ import { RunDot, type RunState } from "./ConsoleTabs";
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
import { promptWithImages } from "./RunSetup";
import { promptForPane } from "./RunSetup";
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
@@ -246,8 +246,8 @@ const ITEM =
* the CLI's input box, without Enter. */
export function showTerminal(runId: string, sessionId: string) {
const draft = readChatDraft(sessionId);
const text = promptWithImages(draft.prompt, draft.images);
if (text && pasteToRun(runId, text)) writeChatDraft(sessionId, "", []);
const { text, files } = promptForPane(draft.prompt, draft.images);
if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []);
setSessionChat(sessionId, false);
}
+17
View File
@@ -208,6 +208,23 @@ export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
}
const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i;
/** The prompt as pasted into a live pane: the text with every non-image file
* in it (as `promptWithImages`), and the images' paths apart — each goes in
* a paste of its own, the one shape `claude` turns into `[Image #n]` on every
* platform (a Windows path inside a paste of text stayed a plain path). */
export function promptForPane(
text: string,
images: PromptImage[]
): { text: string; files: string[] } {
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
return {
text: promptWithImages(text, images.filter((i) => !isImage(i))),
files: images.filter(isImage).map((i) => i.path),
};
}
/** A plain-text paste longer than this is attached as `pasted-text.txt`. */
export const PASTE_AS_FILE_CHARS = 5000;
+6 -2
View File
@@ -666,8 +666,12 @@ export const TerminalView = memo(function TerminalView({
() =>
registerPane(
runId,
(text, submit) => {
termRef.current?.paste(text);
(text, submit, files = []) => {
const term = termRef.current;
// The text, then each image's path in a paste of its own — the one
// shape `claude` turns into `[Image #n]` on every platform.
if (text) term?.paste(files.length ? `${text} ` : text);
files.forEach((f, i) => term?.paste(i < files.length - 1 ? `${f} ` : f));
// Enter a beat after the paste, so a TUI that times its paste
// detection never reads it as a newline inside the paste. A submit
// comes from the pane's Chat view, which keeps the keyboard.
@@ -267,7 +267,7 @@ describe("PaneChat — model, effort and permission mode", () => {
renderChat();
fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ }));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet");
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet", []);
});
it("shows the mode the pane shows, and asks the server to step it", async () => {
@@ -430,7 +430,7 @@ describe("PaneChat — the unsent prompt", () => {
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
expect(screen.getByText("a.ts")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts", []);
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
});
@@ -462,7 +462,8 @@ describe("PaneChat — the unsent prompt", () => {
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith(
"agenthub-lane-1",
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts'
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts',
[]
);
});
@@ -490,7 +491,7 @@ describe("PaneChat — the unsent prompt", () => {
expect(box).toHaveValue("hello");
confirmDialog.mockResolvedValueOnce(true);
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello", []));
});
it("sends without asking once the session has started", async () => {
@@ -499,7 +500,7 @@ describe("PaneChat — the unsent prompt", () => {
renderChat();
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
fireEvent.click(screen.getByTestId("pane-chat-send"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi"));
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi", []));
expect(confirmDialog).not.toHaveBeenCalled();
});
});
@@ -24,6 +24,7 @@ import {
ModelPicker,
PASTE_AS_FILE_CHARS,
PromptEditor,
promptForPane,
promptWithImages,
type PromptImage,
} from "../RunSetup";
@@ -586,3 +587,23 @@ describe("ModelMenu", () => {
expect(onChange).toHaveBeenCalledWith("claude-sonnet-5-5");
});
});
describe("promptForPane — images apart, every other file in the text", () => {
it("keeps @-mentions and other files in the text, and takes images out", () => {
// A Windows path inside a paste of text stayed a plain path in claude; a
// paste that is only the path becomes [Image #n].
const images: PromptImage[] = [
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
{ path: "/data/drops/0123456789abcdef.JPG", name: "b.jpg" },
];
expect(promptForPane(" look ", images)).toEqual({
text: 'look /data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG"',
files: [
"C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
"/data/drops/0123456789abcdef.JPG",
],
});
});
});
@@ -28,7 +28,7 @@ import { SettingsRow, SettingsSwitchRow } from "./SettingsRow";
import { SagiAvatar } from "../Sagi/SagiAvatar";
import { PetSprite } from "../Sagi/PetSprite";
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs";
import { usePrefersReducedMotion } from "../Sagi/Sagi";
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { usePets, useWornPet } from "../Sagi/usePets";
@@ -0,0 +1,180 @@
/**
* @file RetentionSettings.tsx
* @description Settings → Data → Session cleanup, under the manual purge: the
* OPT-IN automatic cleanup (`AGENTHUB_RETENTION_DAYS`, 0 = off, the default)
* that the hourly maintenance applies, the database's size and free space, and
* **Compact now** — a full VACUUM behind a confirm, polled through
* `GET /api/settings/retention` while it runs, then toasted. Backed by
* `server/lib/db-retention.js`; a value the environment set is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, Minimize2, RefreshCw } from "lucide-react";
import { api } from "../../lib/api";
import type { RetentionState } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { formatBytes, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
export function RetentionSettings() {
const { t } = useTranslation("settings");
const [state, setState] = useState<RetentionState | null>(null);
const [days, setDays] = useState("0");
const [saving, setSaving] = useState(false);
useEffect(() => {
api.settings.retention
.get()
.then((s) => {
setState(s);
setDays(String(s.days));
})
.catch(() => undefined);
}, []);
// A Compact takes a while on a large database: poll until it ends.
const compacting = !!state?.compacting;
useEffect(() => {
if (!compacting) return;
const id = window.setInterval(() => {
api.settings.retention
.get()
.then((s) => {
setState(s); // the draft keeps its value
if (s.compacting || !s.last_compact) return;
if (s.last_compact.ok) {
toast.success(
t("retention.compactDone", {
before: formatBytes(s.last_compact.size_before ?? 0),
after: formatBytes(s.last_compact.size_after ?? 0),
})
);
} else {
toast.error(t("retention.compactFailed", { error: s.last_compact.error }));
}
})
.catch(() => undefined);
}, 2000);
return () => window.clearInterval(id);
}, [compacting, t]);
const max = state?.max_days ?? 3650;
const n = Number(days);
const valid = days.trim() !== "" && Number.isInteger(n) && n >= 0 && n <= max;
const changed = state !== null && valid && n !== state.days;
const owned = !!state?.env_owned.days;
async function save() {
setSaving(true);
try {
const s = await api.settings.retention.save(n);
setState(s);
setDays(String(s.days));
toast.success(t("retention.saved"));
} catch (err) {
toast.error(err);
} finally {
setSaving(false);
}
}
async function compact() {
const ok = await confirmDialog({
title: t("retention.compactConfirmTitle"),
message: t("retention.compactConfirmBody"),
confirmLabel: t("retention.compact"),
});
if (!ok) return;
try {
setState(await api.settings.retention.compact());
} catch (err) {
toast.error(err);
}
}
const purged = state?.last_run?.retention?.purged_sessions;
return (
<div className="border-t border-border pt-4 space-y-3">
<div>
<h4 className="text-xs font-medium text-fg-secondary">{t("retention.title")}</h4>
<p className="text-xs text-fg-muted">{t("retention.description")}</p>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="bg-surface-2 rounded-lg px-4 py-3">
<label htmlFor="retention-days" className="text-xs text-fg-secondary block mb-2">
{t("retention.days")}
</label>
<div className="flex items-center gap-2">
<input
id="retention-days"
type="number"
min="0"
max={max}
step="1"
inputMode="numeric"
value={days}
disabled={!state || owned}
onChange={(e) => setDays(e.target.value)}
aria-invalid={!valid || undefined}
aria-describedby={!valid ? "retention-days-error" : undefined}
className="input w-20 text-sm text-right font-mono"
/>
<span className="text-xs text-fg-muted">{t("common:days")}</span>
</div>
{!valid && (
<p id="retention-days-error" className="mt-1 text-[11px] text-status-danger">
{t("retention.invalid", { max })}
</p>
)}
</div>
<button
type="button"
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving || !changed || owned}
onClick={() => void save()}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
</div>
<p className="text-xs text-fg-secondary">
{state && state.days > 0 ? t("retention.on", { days: state.days }) : t("retention.off")}
{state?.last_run && purged !== undefined && (
<> {t("retention.lastRun", { time: formatDateTime(state.last_run.at), n: purged })}</>
)}
</p>
{owned && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("privacy.envOwned")}
</p>
)}
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => void compact()}
disabled={!state || compacting}
className="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border"
>
{compacting ? (
<RefreshCw className="w-3 h-3 animate-spin inline mr-1" />
) : (
<Minimize2 className="w-3 h-3 inline mr-1" />
)}
{compacting ? t("retention.compacting") : t("retention.compact")}
</button>
{state?.space && (
<span className="text-xs text-fg-muted">
{t("retention.space", {
size: formatBytes(state.db_size),
free: formatBytes(state.space.free_bytes),
mode: state.space.auto_vacuum,
})}
</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,27 @@
/**
* @file usePrefersReducedMotion.ts
* @description The OS "reduce motion" preference, followed live. Its own
* module so a settings screen can read it without importing the pet overlay
* (`components/Sagi/Sagi.tsx`), whose chat and pane UI would otherwise be
* pulled into every page's first bundle.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
/** The OS "reduce motion" preference, followed live. */
export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
);
useEffect(() => {
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
if (!mq) return;
const onChange = () => setReduced(mq.matches);
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, []);
return reduced;
}
+17
View File
@@ -1216,6 +1216,23 @@
"ignoreHint": "One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.",
"envOwned": "A value here is set by the environment the dashboard started in, and cannot be changed from this page."
},
"retention": {
"title": "Automatic cleanup",
"description": "Off by default. When on, the hourly maintenance deletes finished sessions (completed, error, abandoned) that started more than this many days ago — with their events, agents and token usage — a little at a time.",
"days": "Delete sessions older than",
"off": "Automatic cleanup is off.",
"on": "Sessions older than {{days}} days are deleted every hour.",
"invalid": "A whole number from 0 (off) to {{max}}",
"saved": "Automatic cleanup saved",
"lastRun": "Last run {{time}}: {{n}} session(s) deleted",
"space": "Database {{size}} · {{free}} free inside it · auto-vacuum: {{mode}}",
"compact": "Compact now",
"compacting": "Compacting…",
"compactConfirmTitle": "Compact the database?",
"compactConfirmBody": "This rewrites the whole database file to give free space back to the disk and turns on incremental auto-vacuum. On a large database it takes a while, and new hook events wait until it finishes.",
"compactDone": "Database compacted: {{before}} → {{after}}",
"compactFailed": "Compact failed: {{error}}"
},
"backup": {
"title": "Daily backup",
"description": "A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.",
+17
View File
@@ -1216,6 +1216,23 @@
"ignoreHint": "Mỗi dòng một đường dẫn tuyệt đối. Hook từ các thư mục này, và mọi thứ bên trong, bị bỏ hoàn toàn; việc nhập lịch sử và bộ đọc Codex cũng bỏ qua phiên của chúng. AGENTHUB_IGNORE_CWD đặt cùng danh sách này.",
"envOwned": "Một giá trị ở đây do môi trường khởi động dashboard đặt, và không thể đổi từ trang này."
},
"retention": {
"title": "Tự động dọn dẹp",
"description": "Mặc định tắt. Khi bật, bảo trì hằng giờ xóa các phiên đã kết thúc (hoàn tất, lỗi, bỏ dở) bắt đầu cách đây hơn số ngày này — cùng sự kiện, agent và token của chúng — từng ít một.",
"days": "Xóa phiên cũ hơn",
"off": "Tự động dọn dẹp đang tắt.",
"on": "Phiên cũ hơn {{days}} ngày được xóa mỗi giờ.",
"invalid": "Số nguyên từ 0 (tắt) đến {{max}}",
"saved": "Đã lưu tự động dọn dẹp",
"lastRun": "Lần chạy gần nhất {{time}}: đã xóa {{n}} phiên",
"space": "Cơ sở dữ liệu {{size}} · {{free}} trống bên trong · auto-vacuum: {{mode}}",
"compact": "Nén ngay",
"compacting": "Đang nén…",
"compactConfirmTitle": "Nén cơ sở dữ liệu?",
"compactConfirmBody": "Thao tác này ghi lại toàn bộ tệp cơ sở dữ liệu để trả dung lượng trống cho ổ đĩa và bật auto-vacuum tăng dần. Với cơ sở dữ liệu lớn sẽ mất một lúc, và sự kiện hook mới phải chờ đến khi xong.",
"compactDone": "Đã nén cơ sở dữ liệu: {{before}} → {{after}}",
"compactFailed": "Nén thất bại: {{error}}"
},
"backup": {
"title": "Backup hằng ngày",
"description": "Mỗi ngày một bản sao đầy đủ của cơ sở dữ liệu vào thư mục bạn chọn — tốt nhất là trên một ổ đĩa khác, để hỏng một ổ không mất luôn bản backup.",
@@ -0,0 +1,34 @@
/**
* @file sessionChange.test.ts
* @description Unit tests for `sessionChangeMatcher`: fires on a new session
* and on a change of the keyed columns, never on a re-broadcast that moved none.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { sessionChangeMatcher } from "../sessionChange";
import type { WSMessage } from "../types";
const msg = (type: string, data: unknown) => ({ type, data, timestamp: "" }) as WSMessage;
describe("sessionChangeMatcher", () => {
it("fires on a new session and on a state change, not on a same-state re-broadcast", () => {
const m = sessionChangeMatcher();
expect(m(msg("session_created", { id: "a", status: "active" }))).toBe(true);
expect(m(msg("session_updated", { id: "a", status: "active", name: "x" }))).toBe(false);
expect(
m(msg("session_updated", { id: "a", status: "active", awaiting_input_since: "t" }))
).toBe(true);
expect(m(msg("session_updated", { id: "a", status: "completed" }))).toBe(true);
expect(m(msg("session_updated", { id: "a", status: "completed", archived_at: "t" }))).toBe(
true
);
});
it("takes an unseen or unkeyable session as a change, and ignores other types", () => {
const m = sessionChangeMatcher();
expect(m(msg("session_updated", { id: "b", status: "active" }))).toBe(true);
expect(m(msg("session_updated", null))).toBe(true);
expect(m(msg("new_event", { session_id: "b" }))).toBe(false);
});
});
+47
View File
@@ -1649,6 +1649,23 @@ export const api = {
/** POST /api/settings/backup/run — starts one; poll `get` while `running` */
run: () => request<BackupState & { ok: boolean }>("/settings/backup/run", { method: "POST" }),
},
/** Opt-in automatic retention and Compact (`server/lib/db-retention.js`). */
retention: {
/** GET /api/settings/retention */
get: () => request<RetentionState>("/settings/retention"),
/** PUT /api/settings/retention — `days` 0 turns it off */
save: (days: number) =>
request<RetentionState & { ok: boolean }>("/settings/retention", {
method: "PUT",
body: JSON.stringify({ days }),
}),
/** POST /api/settings/compact — a full VACUUM; poll `get` while `compacting` */
compact: () =>
request<RetentionState & { ok: boolean }>("/settings/compact", {
method: "POST",
body: JSON.stringify({ confirm: true }),
}),
},
/** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is
* stored: `rotate` returns the new token this once, `get` never does. */
pushToken: {
@@ -5461,6 +5478,36 @@ export interface BackupState {
env_owned: { dir: boolean; hour: boolean; keep: boolean };
}
/** GET /api/settings/retention. `days` 0 = automatic retention off;
* `last_run`/`last_compact` are this server process's own. */
export interface RetentionState {
days: number;
max_days: number;
env_owned: { days: boolean };
last_run: {
at: string;
retention?: { purged_sessions: number; purged_events: number; error?: string };
reclaim?: { reclaimed_pages: number; error?: string };
} | null;
space: {
auto_vacuum: "none" | "full" | "incremental";
page_size: number;
page_count: number;
freelist_pages: number;
free_bytes: number;
} | null;
db_size: number;
compacting: boolean;
last_compact: {
at: string;
ok: boolean;
size_before?: number;
size_after?: number;
ms?: number;
error?: string;
} | null;
}
/** GET /api/settings/push-token — whether push ingest is on; never the token. */
export interface PushTokenStatus {
enabled: boolean;
+9 -5
View File
@@ -14,6 +14,9 @@
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
* input box off the screen and empties it; the chat's draft is pasted back
* without Enter), so the half-typed text is never left behind on the other side.
* Attached images' paths go after the text, each in a paste of its own: `claude`
* turns a paste that is only an image's path into `[Image #n]`, but left a
* Windows path (`C:\…\drops\…png`) inside a paste of text as a plain path.
* The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
* arrows and Enter, or type into its "Type something" row).
@@ -24,7 +27,8 @@
export type PaneScreen = { rows: string[]; cols: number };
interface Pane {
paste: (text: string, submit?: boolean) => void;
/** `files`: image paths pasted after `text`, one paste each. */
paste: (text: string, submit?: boolean, files?: string[]) => void;
visible: () => boolean;
key?: (data: string) => void;
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
@@ -62,19 +66,19 @@ function ofLane(runId: string, laneId: number): boolean {
/** Pastes `text` into the pane of `runId` when it is mounted and on screen;
* false otherwise (not mounted yet, or hidden). */
export function pasteToRun(runId: string, text: string): boolean {
export function pasteToRun(runId: string, text: string, files: string[] = []): boolean {
const p = panes.get(runId);
if (!p?.visible()) return false;
p.paste(text);
p.paste(text, false, files);
return true;
}
/** Sends `text` as a message to the pane of `runId`: a paste, then Enter.
* False when the pane is not mounted or not attached. */
export function submitToRun(runId: string, text: string): boolean {
export function submitToRun(runId: string, text: string, files: string[] = []): boolean {
const p = panes.get(runId);
if (!p?.visible()) return false;
p.paste(text, true);
p.paste(text, true, files);
return true;
}
+32
View File
@@ -0,0 +1,32 @@
/**
* @file sessionChange.ts
* @description `session_created` / `session_updated` matcher for live-refresh
* callers that care about only a few session columns. A session row is
* re-broadcast on nearly every hook (last activity, name, tokens…), so
* matching the bare message types made every such frame refetch; this one
* fires on a new session, or when the chosen key of a session actually
* changed. Each call keeps its own memory of the keys it has seen.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { Session, WSMessage } from "./types";
/** The columns that move a session between the active / waiting / archived counts. */
export const sessionStateKeyOf = (s: Partial<Session>): string =>
`${s.status}:${s.awaiting_input_since ? 1 : 0}:${s.archived_at ? 1 : 0}`;
export function sessionChangeMatcher(
keyOf: (s: Partial<Session>) => string = sessionStateKeyOf
): (msg: WSMessage) => boolean {
const seen = new Map<string, string>();
return (msg) => {
if (msg.type !== "session_created" && msg.type !== "session_updated") return false;
const s = msg.data as Partial<Session> | undefined;
// A frame we cannot key is taken as a change: refetching is the safe side.
if (!s || typeof s.id !== "string") return true;
const key = keyOf(s);
if (msg.type === "session_updated" && seen.get(s.id) === key) return false;
seen.set(s.id, key);
return true;
};
}
+5 -2
View File
@@ -2790,9 +2790,12 @@ function RecentActivityPanel({ sessionsById }: { sessionsById: Map<string, Sessi
useEffect(() => {
load();
}, [load]);
useLiveRefresh(load, { intervalMs: 10000 });
// New events arrive over the WebSocket (prepended below); a reload is only
// for what the socket cannot carry: a remote source's import, a gap while it
// was down (reconnect) or the tab hidden (shown again), and a slow safety poll.
useLiveRefresh(load, { intervalMs: 60000, match: isRemoteDataRefreshMessage });
// A new event is prepended at once (no reload); the poll reconciles.
// A new event is prepended at once (no reload); the reloads reconcile.
useEffect(
() =>
eventBus.subscribe((msg: WSMessage) => {
+5 -1
View File
@@ -1129,7 +1129,11 @@ export function SessionDetail() {
{visitedTabs.has("conversation") && (
<div hidden={activeTab !== "conversation"}>
<ConversationView sessionId={session.id} initialTranscriptId={pendingTranscriptId} />
<ConversationView
sessionId={session.id}
initialTranscriptId={pendingTranscriptId}
ended={session.status !== "active"}
/>
</div>
)}
+4 -1
View File
@@ -1,6 +1,6 @@
/**
* @file Settings.tsx
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the daily database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the daily database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm; under it the opt-in automatic cleanup and Compact, `components/settings/RetentionSettings.tsx`) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -103,6 +103,7 @@ import { RemoteSources } from "../components/RemoteSources";
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
import { PrivacySettings } from "../components/settings/PrivacySettings";
import { BackupSettings } from "../components/settings/BackupSettings";
import { RetentionSettings } from "../components/settings/RetentionSettings";
import { AutostartSettings } from "../components/settings/AutostartSettings";
import { usePermissions } from "../components/AuthGate";
import { Skeleton } from "../components/Skeleton";
@@ -1054,6 +1055,8 @@ export function Settings({
</button>
{actionBanner(["cleanup"])}
<RetentionSettings />
</section>
{/* ─── THIS BROWSER'S PREFERENCES ─── */}
+25 -24
View File
@@ -85,6 +85,9 @@ import { endRuns } from "../lib/stopSession";
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
import { confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
import { sessionChangeMatcher } from "../lib/sessionChange";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useWorkLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
import {
@@ -1044,9 +1047,10 @@ export function Workspace() {
.catch(() => undefined);
}, []);
// Goes through `keepIfSame`: this fires every 5 s below, and a
// fresh-but-identical payload would otherwise re-render the lane strip, the
// lane card and the whole console area on every tick for nothing.
// Goes through `keepIfSame`: this re-reads on run-moving messages and a
// fallback poll below, and a fresh-but-identical payload would otherwise
// re-render the lane strip, the lane card and the whole console area for
// nothing.
const refreshList = useCallback(() => {
api.run
.list()
@@ -1061,33 +1065,30 @@ export function Workspace() {
window.dispatchEvent(new CustomEvent(LIVE_RUNS_EVENT, { detail: activeRuns.items }));
}, [activeRuns]);
// Background poll so the run list and history reflect external changes
// (server-boot reconciliation, sibling tabs, direct DB edits) even when
// no WS event fires. Lighter than typical WS gaps; aggressive enough that
// status flips appear within seconds without needing a manual refresh.
// The run list follows the WebSocket instead of a blind 5 s poll: a pane
// starting, ending or learning its session in slot 0 moves the lane's run
// columns (`lane_update`); an agent starting in a free tab (slots 1–9)
// shows as a new session, one ending as a session status change, and a pane
// dying under its agent as `runs_interrupted`. This page's own starts, stops
// and a shown terminal's exit call `refreshList` directly
// (`onRunsChanged`). What none of those cover — a shell tab opened or closed
// from another browser, server-boot reconciliation — is caught by the
// 30 s fallback poll, on reconnect, and when the tab is shown or focused.
const [laneRunChanged] = useState(() => laneChangeMatcher(runKeyOf));
const [sessionRunChanged] = useState(() => sessionChangeMatcher((s) => `${s.status}`));
useEffect(() => {
refreshList();
const tick = setInterval(() => {
// Hidden tab: skip; the visibility handler below re-reads on show.
if (document.visibilityState === "visible") refreshList();
}, 5000);
return () => clearInterval(tick);
}, [refreshList]);
// Refresh whenever the tab regains focus / visibility - typical when the
// user comes back from running `claude` in a terminal and wants to see the
// current state of every run without waiting for the next poll.
useLiveRefresh(refreshList, {
intervalMs: 30_000,
throttleMs: 1500,
match: (msg) =>
msg.type === "runs_interrupted" || laneRunChanged(msg) || sessionRunChanged(msg),
});
useEffect(() => {
const onFocus = () => refreshList();
const onVis = () => {
if (document.visibilityState === "visible") refreshList();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVis);
return () => {
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVis);
};
return () => window.removeEventListener("focus", onFocus);
}, [refreshList]);
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
@@ -77,6 +77,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
env_owned: { dir: false, hour: false, keep: false },
})),
},
retention: {
get: vi.fn(async () => ({
days: 0,
max_days: 3650,
env_owned: { days: false },
last_run: null,
space: null,
db_size: 0,
compacting: false,
last_compact: null,
})),
},
},
},
};
@@ -9357,6 +9357,108 @@ exit 0
</svg>
Run Cleanup
</button>
<div
class="border-t border-border pt-4 space-y-3"
>
<div>
<h4
class="text-xs font-medium text-fg-secondary"
>
Automatic cleanup
</h4>
<p
class="text-xs text-fg-muted"
>
Off by default. When on, the hourly maintenance deletes finished sessions (completed, error, abandoned) that started more than this many days ago — with their events, agents and token usage — a little at a time.
</p>
</div>
<div
class="flex flex-wrap items-end gap-3"
>
<div
class="bg-surface-2 rounded-lg px-4 py-3"
>
<label
class="text-xs text-fg-secondary block mb-2"
for="retention-days"
>
Delete sessions older than
</label>
<div
class="flex items-center gap-2"
>
<input
class="input w-20 text-sm text-right font-mono"
id="retention-days"
inputmode="numeric"
max="3650"
min="0"
step="1"
type="number"
value="0"
/>
<span
class="text-xs text-fg-muted"
>
days
</span>
</div>
</div>
<button
class="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled=""
type="button"
>
Save
</button>
</div>
<p
class="text-xs text-fg-secondary"
>
Automatic cleanup is off.
</p>
<div
class="flex flex-wrap items-center gap-3"
>
<button
class="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border"
type="button"
>
<svg
class="lucide lucide-minimize2 w-3 h-3 inline mr-1"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="4 14 10 14 10 20"
/>
<polyline
points="20 10 14 10 14 4"
/>
<line
x1="14"
x2="21"
y1="10"
y2="3"
/>
<line
x1="3"
x2="10"
y1="21"
y2="14"
/>
</svg>
Compact now
</button>
</div>
</div>
</section>
<section
class="scroll-mt-24"
@@ -362,6 +362,18 @@ vi.mock("../../lib/api", async (importOriginal) => {
env_owned: { dir: false, hour: false, keep: false },
}),
},
retention: {
get: r({
days: 0,
max_days: 3650,
env_owned: { days: false },
last_run: null,
space: null,
db_size: 0,
compacting: false,
last_compact: null,
}),
},
exportData: () => "/api/settings/export",
cleanup: r({
ok: true,
+13
View File
@@ -1006,6 +1006,9 @@ cannot collide on `index.lock`.
the file resolves by removing it. `merge-commit` is `409 ECONFLICTS` while
anything is unmerged, and it and `merge-abort` are `409 ENOMERGE` with no
merge in progress. `merge-abort` needs `confirm: true` (`400 ECONFIRM`).
- Any action on a host with no `git` binary (or none on the dashboard's PATH)
is `503 EGITMISSING`: a missing dependency, not a bad request; the message
carries the install command (like `ETMUXMISSING` for tmux).
- `push` pushes `HEAD`, never a branch name from the request, so a workspace can
only push the branch it is on.
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
@@ -3057,6 +3060,16 @@ POST /api/settings/backup/run 202 — started (or already running); poll GE
Settings → Data → **Daily backup** (`server/lib/db-backup.js`). Once a day, at `hour` (0–23, the server's local time, default 2), the database is copied to `dir` as `agenthub-YYYYMMDD-HHMMSS.db`: `VACUUM INTO` on a read-only connection in a worker thread, so the copy is a consistent snapshot (writes from another dashboard on the same database never restart it), compacted, and the event loop keeps serving while it runs (≈30 s for a 1.25 GB database). It is written as `<name>.partial` and renamed when complete. Beyond `keep` (1–365, default 7) the oldest are deleted — only regular files matching that exact name; nothing else in the folder, and no symlink, is ever touched. A day missed while the server was down runs at the next start; a failed run is retried the next day or by **Back up now**, never every minute. The three values persist to the settings file as `AGENTHUB_BACKUP_DIR` / `AGENTHUB_BACKUP_HOUR` / `AGENTHUB_BACKUP_KEEP`; one the environment set is `env_owned` (`409 EENVOWNED`). Writes are same-origin, and admin-only in server mode. `400 INVALID_PATH` for a relative, missing, non-directory or unwritable folder, `400 INVALID_INPUT` for an hour or count out of range, `400 ENOBACKUPDIR` for a run with no folder. The scheduler beat is `DASHBOARD_BACKUP_TICK_MS` (default 60000, `0` = off).
Restoring is manual, on purpose: `agenthub stop`, delete `dashboard.db-wal` and `dashboard.db-shm`, copy a backup over `dashboard.db` (its path is on Settings → System), `agenthub start`.
### Retention and Compact
```
GET /api/settings/retention { days, max_days, env_owned: { days }, last_run, space: { auto_vacuum, page_size, page_count, freelist_pages, free_bytes }, db_size, compacting, last_compact }
PUT /api/settings/retention { days } → the same state; 0 turns it off (the default)
POST /api/settings/compact { confirm: true } → 202 — started (or already running); poll GET while `compacting`
```
Settings → Data → **Session cleanup** (`server/lib/db-retention.js`). Automatic retention is OFF unless `days` (1–3650, `AGENTHUB_RETENTION_DAYS` in the settings file) is set; nothing is deleted by default. When set, the hourly maintenance (`DASHBOARD_DB_MAINTAIN_MS`) purges finished (completed/error/abandoned) sessions that started more than `days` ago — the same rule and the same deletion (`purgeSessionIds`: events, agents, token usage, the session) as `POST /api/settings/cleanup` with `purge_days` — 25 sessions per transaction with a yield between, at most 5000 per run. A new database is created with `auto_vacuum = INCREMENTAL`, an existing one up to 32 MB is converted by a VACUUM at startup; the same hourly run then returns free pages to the OS (`incremental_vacuum`, in 1024-page slices, at most 65536 pages per run) once more than 1024 are free. A larger, older database is converted only by **Compact**: a full VACUUM in a worker thread followed by a TRUNCATE checkpoint — it holds the write lock while it runs (tens of seconds on a GB-size file), so hooks wait meanwhile; it runs only on a click. In server mode both writes are an admin's (`/settings` is in `ADMIN_WRITE_PREFIXES`).
---
## WebSocket API
+1 -1
View File
@@ -915,7 +915,7 @@ worker's own writable connection. SQLite's automatic checkpoint runs inside whic
per commit under load. A failed checkpoint switches the automatic one back on. A second
process on the same file (another dashboard, the CLI) keeps its own setting.
**Upkeep.** `maintainDatabase()` (exported by `server/db.js`) runs hourly from `server/index.js` (`DASHBOARD_DB_MAINTAIN_MS`, `0` = off): a `wal_checkpoint(PASSIVE)` — it never waits on a reader, such as the other dashboard — after which the next write shrinks the WAL to `journal_size_limit`, and `PRAGMA optimize`. Nothing runs `VACUUM`: it rewrites the whole file under an exclusive lock, so space freed by a cleanup is reused for new rows instead of returned to the disk.
**Upkeep.** `maintainDatabase()` (exported by `server/db.js`) runs hourly from `server/index.js` (`DASHBOARD_DB_MAINTAIN_MS`, `0` = off): a `wal_checkpoint(PASSIVE)` — it never waits on a reader, such as the other dashboard — after which the next write shrinks the WAL to `journal_size_limit`, and `PRAGMA optimize`. Then `runScheduledMaintenance` (`server/lib/db-retention.js`) applies the OPT-IN retention (`AGENTHUB_RETENTION_DAYS`, off by default: finished sessions older than N days, purged 25 per transaction like the manual cleanup) and, when the database is in `auto_vacuum = INCREMENTAL` and more than 1024 pages are free, a bounded `incremental_vacuum` (1024-page slices, ≤ 65536 pages per run) that returns them to the disk. A new database is created INCREMENTAL; an existing one up to 32 MB is converted by one `VACUUM` at startup (`convertAutoVacuum`), a larger one only by Settings → Data → **Compact** (`POST /api/settings/compact`: a full `VACUUM` in a worker thread, write lock held while it runs). No automatic path runs a full `VACUUM` on a large file. See docs/API.md § Retention and Compact.
**Growth.** `events.data` is the hook payload TRIMMED by `trim_event_data(?)` (`server/lib/event-payload.js`, a SQL function registered on the connection in `server/db.js`, used by every event `INSERT` and by the duplicate guard): Edit/MultiEdit/NotebookEdit/Write drop `tool_response.originalFile`/`oldString`/`newString` (Write also `content`), Read drops `tool_response.file.content`/`base64`, and any string inside `tool_input`/`tool_response` over 8 KB keeps its first 6 KB and last 2 KB; `structuredPatch` and every field outside those two are kept, and `_trimmed` lists what was dropped. The full text stays in the session transcript and AgentHub's snapshot of it under `<data dir>/transcripts/`, which the Conversation tab reads. Rows stored before this are trimmed once in the background (`sweepEventPayloads`, 200 rows per transaction, `user_version` 4 after marker 3); measured on a 1.2 GB install it took `events.data` from 913 MB to ~400 MB. Freed pages are reused, not returned to the disk (no `VACUUM`). No ROW is pruned automatically. The only deletions are the user's: Settings → clear data / cleanup, a workspace's purge, `scripts/clear-data.js`. Clear data runs in ONE transaction (foreign keys off around it, so workflows are deleted explicitly), cleanup deletes in 500-id chunks inside one transaction. `GET /api/settings/export` streams `events` in keyset pages of 500 instead of loading the table.
+2 -2
View File
@@ -256,9 +256,9 @@ DASHBOARD_DB_PATH=/var/lib/agenthub/dashboard.db
DASHBOARD_WORKFLOW_POLL_MS=12000 # Workflow-tool run journals
DASHBOARD_CODEX_POLL_MS=15000 # Codex rollout journals → codex lane running/idle + Codex sessions/tokens
CODEX_HOME= # where Codex keeps sessions/ (default ~/.codex)
DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity sessions/tokens
DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity sessions/tokens (60 s while idle)
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens)
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens; 60 s while idle)
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
+3 -1
View File
@@ -491,7 +491,8 @@ graph TB
> entry whose PID is no longer alive** and POSTs the hook payload to one
> port per **unique SQLite data directory** (lowest port wins when two
> instances share `~/.claude/agenthub`). The other instance re-broadcasts
> the workspaces those hooks moved within `DASHBOARD_LANE_SYNC_MS` (2 s), so its
> the workspaces those hooks moved within `DASHBOARD_LANE_SYNC_MS` (2 s; a server
> alone on its data dir only looks for such a peer every 30 s), so its
> browsers see a workspace's status without a refresh.
> 3. If neither yields a target, the handler falls back to `4820`.
>
@@ -589,6 +590,7 @@ Claude Code waits only for the handler process: Node start-up, reading stdin, se
- **Fire-and-forget POST** — no wait for the dashboard's reply.
- **Incremental transcript reads** — `TranscriptCache` reads only the bytes appended since the last hook.
- **Only new transcript entries are written** — a per-transcript mark (`transcriptMarks` in `server/routes/hooks.js`, set once the hook's transaction commits) skips the token, compaction, API-error and turn-duration writes when the transcript did not change, and walks only the entries after the last one handled when it grew.
- **One POST per database, not per server** — servers sharing a database get each event once (`resolveHookIngestPorts`).
- **Heavy reads off the event loop** — aggregates run in a worker (`server/lib/read-pool.js`), so a slow page never delays ingest.
+1 -1
View File
@@ -115,7 +115,7 @@ Internal build — all rights reserved.
| Need | Version | Required for |
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
| **Node.js** | >= 22.5 (`node:sqlite`; Node 20 is end-of-life) | Everything. Both suites are verified green on **24** and **26** |
| **npm** | 9+ | Ships with Node |
| **git** | any | Cloning, and every workspace operation (worktrees, branch/status reads) |
| **tmux** | 3.x | The browser run console, `agenthub workspace shell`, typing `/reload-plugins` into a live pane |
+1 -1
View File
@@ -81,7 +81,7 @@ Internal build — all rights reserved.
| Cần | Phiên bản | Dùng cho |
|---|---|---|
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
| **Node.js** | >= 22.5 (`node:sqlite`; Node 20 đã hết hạn hỗ trợ) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
| **npm** | 9+ | Đi kèm Node |
| **git** | bất kỳ | Clone repo, và mọi thao tác workspace (worktree, đọc branch/status) |
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub workspace shell`, gõ `/reload-plugins` vào pane đang sống |
+21 -10
View File
@@ -61,13 +61,13 @@ claude --plugin-dir plugins/agenthub-analytics
## Prerequisites
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
- Node **>= 22.5** when installing via the `agenthub` plugin. A plugin install has no
native `better-sqlite3`, so the server stores data through `node:sqlite`, which
landed in 22.5. The bootstrap refuses with one line on anything older instead
of letting the server crash. (A checkout install still works on Node >= 20.)
- Node **>= 22.5**. A plugin install has no native `better-sqlite3`, so the
server stores data through `node:sqlite`, which landed in 22.5. The bootstrap
refuses on anything older instead of letting the server crash, and says so in
the session (below). A checkout needs the same (`engines` in `package.json`).
- The Agent Monitor dashboard reachable at `http://localhost:4820` — the `agenthub`
plugin starts it for you; a checkout starts it with `npm start` (see
[SETUP.md](../SETUP.md))
[INSTALL.md](../INSTALL.md))
Skills and commands are invoked as `/agenthub-<plugin>:<name>`. Agents are dispatched automatically by Claude Code (or named explicitly).
@@ -124,12 +124,12 @@ waits on an install:
1. **Fast path** — recorded state matches this plugin build and a server is live → exit. "Live" means a discovery entry whose pid answers AND whose `startedAt` is after this boot (60 s margin): an entry left over from before a reboot names a pid the OS may have handed to an unrelated process, and without systemd (macOS) nothing else clears it.
2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)).
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites). Also checked by the foreground pass itself (it costs nothing), which then spawns no worker and tells the session at once.
4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir, run as the npm bundled with the node running the bootstrap (`npmCommand`: its `npm-cli.js` beside `process.execPath`, no shell, that node's directory first on PATH; `npm` on PATH only when that node ships none) — the update chain runs with the dashboard server's environment, whose PATH need not reach `npm.cmd` on Windows. The UI build below and the doctor's npm row use the same npm (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
5. **Install** — only when the dependencies changed (`depsHash` covers what npm installs — the dependency lists and the lockfile's package tree — never the release's own `version`, so a plain version bump reinstalls nothing): `npm install --omit=dev --ignore-scripts` into the runtime dir, output in `runtime/npm-install.log`, run as the npm bundled with the node running the bootstrap (`npmCommand`: its `npm-cli.js` beside `process.execPath`, no shell, that node's directory first on PATH; `npm` on PATH only when that node ships none) — the update chain runs with the dashboard server's environment, whose PATH need not reach `npm.cmd` on Windows. The UI build below and the doctor's npm row use the same npm (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
6. **Legacy hook cleanup** — see above.
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev --ignore-scripts`, falling back to `npm install` with the same flags, then `tsc -b` and `vite build` run by the bootstrap's own `node` — the client's `build` script without `npm run`), landing in `runtime/client-dist` — the same directory the server serves from. Nothing in it looks `node` up on PATH: on Windows an install script or a `.bin/*.cmd` shim runs through cmd.exe, and the server's PATH may have no `node` (esbuild's postinstall failed there with "'node' is not recognized" and the update kept the old UI); esbuild, the only dependency with an install script, finds its binary without it. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev --ignore-scripts`, falling back to `npm install` with the same flags — skipped while `client/package.json` and its lockfile are unchanged, because the installed tree in `runtime/client-src/node_modules` is kept with a `.agenthub-deps` stamp; a failed build drops the stamp so the next one installs from clean — then `vite build` run by the bootstrap's own `node`: the vite half of the client's `build` script without `npm run`. The `tsc -b` half is the release's job, not every user's: it emits nothing the bundle needs and cost ~25 s and ~900 MB per update), landing in `runtime/client-dist` — the same directory the server serves from. Nothing in it looks `node` up on PATH: on Windows an install script or a `.bin/*.cmd` shim runs through cmd.exe, and the server's PATH may have no `node` (esbuild's postinstall failed there with "'node' is not recognized" and the update kept the old UI); esbuild, the only dependency with an install script, finds its binary without it. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists (`clientBuild` in `state.json`, recorded only after a build that did not fail, so the next session retries a failed one); a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
9. **Server** — when the user has turned **Start AgentHub automatically** on
(Settings → System; OFF by default), install/refresh the
`agenthub.service` unit on Linux — on macOS with a GUI login, the
@@ -149,10 +149,21 @@ waits on an install:
The first run takes a few minutes — most of it is the UI build, which is what
makes client-only routes (`http://localhost:4820/run`, and every other page)
work the moment `claude` starts, with no manual `/agenthub-open` step. Progress
goes to `~/.claude/agenthub/runtime/bootstrap.log`; the UI build's own
output goes to `client-build.log` next to it; the server's own output goes to
goes to `~/.claude/agenthub/runtime/bootstrap.log`; npm's own output goes to
`npm-install.log` and the UI build's to `client-build.log` next to it; the server's own output goes to
`server.log`.
**A failure is reported once, in the session.** The worker's output is
discarded, so a Node that is too old, a failed npm install or a failed UI build
is recorded in `runtime/last-failure.json` (kind, message, log file). The NEXT
foreground pass — its stdout is what Claude Code reads — prints it as a
SessionStart `{"systemMessage": "AgentHub: … — details in <log>. Run
/agenthub-doctor …"}` and marks it reported, so the same failure is not repeated
every session; a different failure is reported again, and a bootstrap that
succeeds removes the file. The steady state prints nothing, and the report is a
single small read (plus one write, only when there is something to show) — the
fast path still spawns nothing.
### A retired build never acts
`claude plugin update` unpacks the new version into a **new** cache directory
+6 -7
View File
@@ -9,8 +9,7 @@ Comprehensive documentation for the Agent Dashboard project.
- [Architecture Overview](../ARCHITECTURE.md) - System design and technical reference
- [I18N Architecture](./I18N.md) - Internationalization architecture and usage guide
- [CLI Reference](./CLI.md) - The `agenthub` terminal CLI: every command, discovery, safety model
- [Setup Guide](../SETUP.md) - Installation and configuration
- [Installation](../INSTALL.md) - Detailed installation instructions
- [Installation & setup](../INSTALL.md) - Installation, configuration and troubleshooting
---
@@ -20,7 +19,7 @@ Comprehensive documentation for the Agent Dashboard project.
```mermaid
graph TB
Start[Start Here] --> Setup[SETUP.md<br/>Installation & Config]
Start[Start Here] --> Setup[INSTALL.md<br/>Installation & Config]
Start --> Architecture[ARCHITECTURE.md<br/>System Design]
Setup --> Client[Client README<br/>React UI docs]
@@ -62,7 +61,7 @@ graph TB
```mermaid
graph LR
A[New to Project] --> B[Read SETUP.md]
A[New to Project] --> B[Read INSTALL.md]
B --> C[Install Dependencies]
C --> D[Run npm run dev]
D --> E[Open localhost:5173]
@@ -75,7 +74,7 @@ graph LR
1. Install the system packages — `node` (20+), `git`, and `tmux` (the run
console has no fallback without it): [INSTALL.md → Step 0](../INSTALL.md#step-0--system-packages)
2. Read [SETUP.md](../SETUP.md)
2. Read the rest of [INSTALL.md](../INSTALL.md)
3. Run `npm run setup`
4. Run `npm run dev`
5. Open browser to `http://localhost:5173`
@@ -287,7 +286,7 @@ graph LR
**Documentation:**
- [Hook System Guide](./HOOKS.md)
- [Hook Processing](../server/README.md#hook-processing)
- [Installation](../SETUP.md#install-hooks)
- [Installation](../INSTALL.md#hook-auto-installation)
---
@@ -441,4 +440,4 @@ This documentation covers:
- ✅ **Internationalization** - Language resources, switching flow, locale formatting, rollout checklist
- ✅ **Development guides** - Setup, testing, contributing
**Start with:** [SETUP.md](../SETUP.md) for installation, then explore specific areas based on your role.
**Start with:** [INSTALL.md](../INSTALL.md) for installation, then explore specific areas based on your role.
+173 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.8.5
version: 2.8.7
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -13328,6 +13328,178 @@ paths:
description: 'ENOBACKUPDIR: no backup folder is set'
'403':
description: 'EBADORIGIN: another site asked'
/api/settings/retention:
get:
tags:
- Settings
summary: Automatic retention and database space
operationId: getRetention
responses:
'200':
description: Automatic retention setting, database space and the last Compact
content:
application/json:
schema:
type: object
required:
- days
- max_days
- env_owned
- compacting
properties:
days:
type: integer
minimum: 0
maximum: 3650
description: Finished sessions older than this many days are purged by the hourly maintenance; 0 = off (the default)
max_days:
type: integer
env_owned:
type: object
properties:
days:
type: boolean
last_run:
type: object
nullable: true
description: 'This process''s last hourly maintenance: {at, retention?: {purged_sessions, purged_events, purged_agents, error?}, reclaim: {reclaimed_pages, error?}, ms}'
space:
type: object
nullable: true
properties:
auto_vacuum:
type: string
enum:
- none
- full
- incremental
page_size:
type: integer
page_count:
type: integer
freelist_pages:
type: integer
free_bytes:
type: integer
db_size:
type: integer
description: Database file size in bytes
compacting:
type: boolean
last_compact:
type: object
nullable: true
description: 'This process''s last Compact: {at, ok, size_before?, size_after?, ms?, error?}'
put:
tags:
- Settings
summary: Set the automatic retention
description: Persisted to the settings file as AGENTHUB_RETENTION_DAYS. 0 turns it off. Same-origin only; in server mode an admin's.
operationId: setRetention
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- days
properties:
days:
type: integer
minimum: 0
maximum: 3650
responses:
'200':
description: The new state
content:
application/json:
schema:
type: object
required:
- days
- max_days
- env_owned
- compacting
properties:
days:
type: integer
minimum: 0
maximum: 3650
description: Finished sessions older than this many days are purged by the hourly maintenance; 0 = off (the default)
max_days:
type: integer
env_owned:
type: object
properties:
days:
type: boolean
last_run:
type: object
nullable: true
description: 'This process''s last hourly maintenance: {at, retention?: {purged_sessions, purged_events, purged_agents, error?}, reclaim: {reclaimed_pages, error?}, ms}'
space:
type: object
nullable: true
properties:
auto_vacuum:
type: string
enum:
- none
- full
- incremental
page_size:
type: integer
page_count:
type: integer
freelist_pages:
type: integer
free_bytes:
type: integer
db_size:
type: integer
description: Database file size in bytes
compacting:
type: boolean
last_compact:
type: object
nullable: true
description: 'This process''s last Compact: {at, ok, size_before?, size_after?, ms?, error?}'
'400':
description: 'INVALID_INPUT: days is not a whole number from 0 to 3650'
'403':
description: 'EBADORIGIN: another site asked'
'409':
description: 'EENVOWNED: the environment set AGENTHUB_RETENTION_DAYS'
'500':
description: 'ESAVE: the settings file could not be written'
/api/settings/compact:
post:
tags:
- Settings
summary: Compact the database now (full VACUUM)
description: Rewrites the database in a worker thread, switching it to incremental auto_vacuum, then truncates the WAL. Hook writes wait while it runs. Answers at once; poll GET /api/settings/retention while `compacting`. Same-origin only; in server mode an admin's.
operationId: compactDatabase
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- confirm
properties:
confirm:
type: boolean
enum:
- true
responses:
'202':
description: Started (or already running); the retention state
'400':
description: 'ECONFIRM: confirm: true is missing'
'403':
description: 'EBADORIGIN: another site asked'
/api/settings/codex-hooks:
get:
tags:
+19 -981
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -76,7 +76,6 @@
"cors": "^2.8.5",
"express": "^4.21.2",
"multer": "^2.0.0",
"redoc": "^2.5.3",
"swagger-ui-express": "^5.0.1",
"systray2": "^2.1.4",
"tar": "^7.4.3",
@@ -93,6 +92,6 @@
"prettier": "^3.8.1"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.5.0"
}
}
+60
View File
@@ -1937,6 +1937,47 @@ async function ingestWorkflowsForBatch(dbModule, sessions) {
}
}
// Session sync: a session counts as fed LIVE by hooks while it is active, its
// newest event came from a hook (not this importer), and a hook touched it
// within this window. Such a transcript is left to the hooks until it goes
// quiet. DASHBOARD_SYNC_LIVE_HOOK_MS=0 turns the deferral off.
const LIVE_HOOK_MS = (() => {
const n = Number(process.env.DASHBOARD_SYNC_LIVE_HOOK_MS ?? 5 * 60_000);
return Number.isFinite(n) && n >= 0 ? n : 5 * 60_000;
})();
// mtimeCache value for a deferred file: present (so the fs watcher ignores its
// appends) yet never equal to a real mtime (so every sweep re-checks it).
const DEFERRED_MTIME = -1;
const IMPORTED_DATA_RE = /"imported"\s*:\s*true/;
const latestEventStmts = new WeakMap();
function rowIsImported(row) {
try {
return !!(row.metadata && JSON.parse(row.metadata).imported);
} catch {
return false;
}
}
function isHookLive(dbModule, row) {
if (!(LIVE_HOOK_MS > 0) || row.status !== "active") return false;
const touched = Date.parse(row.updated_at);
if (!Number.isFinite(touched) || Date.now() - touched > LIVE_HOOK_MS) return false;
try {
let stmt = latestEventStmts.get(dbModule.db);
if (!stmt) {
stmt = dbModule.db.prepare(
"SELECT data FROM events WHERE session_id = ? ORDER BY id DESC LIMIT 1"
);
latestEventStmts.set(dbModule.db, stmt);
}
const ev = stmt.get(row.id);
return !!ev && !IMPORTED_DATA_RE.test(ev.data || "");
} catch {
return false; // when in doubt, sync as before
}
}
/**
* Incremental, change-driven sync of the default `~/.claude/projects` directory.
*
@@ -2024,6 +2065,25 @@ async function syncDefaultProjects(dbModule, options = {}) {
continue;
}
}
// A session hooks are feeding RIGHT NOW: its transcript keeps growing
// between hooks, so `mtime > updated_at` on nearly every sweep, and a
// full re-parse + snapshot copy here bought nothing the hooks do not
// already record. Leave it until it goes quiet (or ends): the sentinel
// keeps the watcher ignoring its appends, and never equals a real
// mtime, so the first sweep after the hooks stop catches it up.
if (existingRow && isHookLive(dbModule, existingRow)) {
mtimeCache.set(sourcePath, DEFERRED_MTIME);
continue;
}
// A session a hook created (no `imported` flag): importSession() skips
// it outright, so parsing the whole transcript + subagents was pure
// waste — only the durable snapshot copy still matters.
if (existingRow && !rowIsImported(existingRow)) {
mtimeCache.set(sourcePath, mtime);
if (require("../server/lib/run-as").sameOwner(sourcePath, existingRow.cwd))
await snapshotTranscript(sourcePath, sessionId);
continue;
}
const existed = !!existingRow;
const session = await parseSessionForImport(projPath, sourcePath);
// Record the mtime even when the file yields no session, so we don't
+171 -25
View File
@@ -138,14 +138,38 @@ function serverIsLive() {
return livePids().length > 0;
}
/**
* What npm installs from a manifest: its dependency lists, or the lockfile's
* package tree — never the release's own `version`, which both files carry.
* Hashing the whole files reinstalled every dependency on each update (minutes
* on a slow link) when the bump changed nothing npm would fetch.
*/
function installedPart(file, text) {
const json = JSON.parse(text);
if (file === "package.json") {
const { dependencies, optionalDependencies, overrides } = json;
return JSON.stringify({ dependencies, optionalDependencies, overrides });
}
const { "": own, ...packages } = json.packages || {};
const { version: _version, ...rest } = own || {};
return JSON.stringify({ own: rest, packages });
}
/** Hash of the dependency manifest — changes mean the runtime tree must be reinstalled. */
function depsHash(root = PLUGIN_ROOT) {
const h = crypto.createHash("sha256");
for (const f of ["package.json", "package-lock.json"]) {
let text;
try {
h.update(fs.readFileSync(path.join(root, f)));
text = fs.readFileSync(path.join(root, f), "utf8");
} catch {
h.update(f); // absent counts as its own state
continue;
}
try {
h.update(installedPart(f, text));
} catch {
h.update(text); // not JSON npm could read either: the bytes decide
}
}
return h.digest("hex").slice(0, 16);
@@ -196,6 +220,81 @@ function nodeVersionOk(version = process.versions.node) {
return true;
}
/* ------------------------------------------------------- last failure report */
/**
* The worker runs detached with its output discarded, so a failed install, a
* Node that is too old or a failed UI build used to live only in a log nobody
* opens — and every session silently retried. The last failure is kept in one
* small file; the NEXT foreground pass (which Claude Code reads) prints it once
* as a SessionStart `systemMessage`, and a bootstrap that succeeds removes it.
*/
function failurePath(rt = runtimeDir()) {
return path.join(rt, "last-failure.json");
}
function readFailure(rt = runtimeDir()) {
try {
return JSON.parse(fs.readFileSync(failurePath(rt), "utf8"));
} catch {
return null;
}
}
/** Record a failure; the same kind again keeps its `reported` flag (once per failure). */
function recordFailure(rt, kind, message, logFile) {
const prev = readFailure(rt);
const same = prev && prev.kind === kind && prev.message === message;
const rec = {
kind,
message,
log: logFile,
at: new Date().toISOString(),
reported: Boolean(same && prev.reported),
};
try {
fs.mkdirSync(rt, { recursive: true });
fs.writeFileSync(failurePath(rt), JSON.stringify(rec, null, 2) + "\n", "utf8");
} catch {
/* best effort */
}
}
function clearFailure(rt = runtimeDir()) {
try {
fs.rmSync(failurePath(rt), { force: true });
} catch {
/* best effort */
}
}
/**
* The SessionStart output for an unreported failure, or null. Marks it
* reported, so a failure is shown once — not on every session until fixed.
*/
function takeFailureReport(rt = runtimeDir()) {
const rec = readFailure(rt);
if (!rec || rec.reported) return null;
try {
fs.writeFileSync(failurePath(rt), JSON.stringify({ ...rec, reported: true }, null, 2) + "\n");
} catch {
return null; // could not mark it: stay quiet rather than repeat forever
}
return {
systemMessage:
`AgentHub: ${rec.message}` +
(rec.log ? ` — details in ${rec.log}` : "") +
". Run /agenthub-doctor for a diagnosis.",
};
}
function nodeTooOldMessage(version = process.versions.node) {
return (
`Node ${version} is too old — AgentHub needs Node >= ${MIN_NODE.join(".")} ` +
`(the server stores data through node:sqlite). Upgrade Node, then start a new session`
);
}
/* -------------------------------------------------------------------- lock */
function lockPath(rt = runtimeDir()) {
@@ -422,13 +521,22 @@ function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) {
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
}
const npm = npmCommand(["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"]);
const res = spawnSync(npm.file, npm.args, {
cwd: rt,
stdio: "inherit",
env: npm.env,
shell: npm.shell,
});
return res.status === 0;
// npm's output goes to a log: the worker's own stdout is discarded, so
// inheriting it threw away the one explanation of a failed install.
const logPath = path.join(rt, "npm-install.log");
require("./log-rotate").rotateIfLarge(logPath);
const logFd = fs.openSync(logPath, "a");
try {
const res = spawnSync(npm.file, npm.args, {
cwd: rt,
stdio: ["ignore", logFd, logFd],
env: npm.env,
shell: npm.shell,
});
return res.status === 0;
} finally {
fs.closeSync(logFd);
}
}
/* ------------------------------------------------------------------ server */
@@ -671,11 +779,8 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
}
if (!nodeVersionOk()) {
log(
rt,
`Node ${process.versions.node} is too old — AgentHub needs Node >= ${MIN_NODE.join(".")} ` +
`(the server stores data through node:sqlite). Upgrade Node, then start a new session.`
);
log(rt, nodeTooOldMessage());
recordFailure(rt, "node-too-old", nodeTooOldMessage(), path.join(rt, "bootstrap.log"));
return "node-too-old";
}
@@ -695,7 +800,16 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
if (force || state.depsHash !== wantDeps || !fs.existsSync(path.join(rt, "node_modules"))) {
log(rt, "installing runtime dependencies (first run takes a few minutes)");
if (!installDeps(rt)) {
log(rt, "npm install failed — see the output above; run /agenthub-doctor after fixing it");
log(
rt,
`npm install failed — see ${path.join(rt, "npm-install.log")}; run /agenthub-doctor after fixing it`
);
recordFailure(
rt,
"install-failed",
"installing the dashboard's runtime dependencies failed",
path.join(rt, "npm-install.log")
);
return "install-failed";
}
}
@@ -713,17 +827,24 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
// Built eagerly (not lazily behind /agenthub-open) so the dashboard, including
// client-only routes like /run, works the instant `claude` is started —
// same trigger as the dependency install above: missing, or this plugin
// version has not built one yet.
// when missing, or when this plugin version has not built one yet
// (`clientBuild` is recorded only after a build that did not fail, so the
// next session retries a failed one instead of keeping the old UI for good).
const { buildClient } = require("./plugin-open");
const uiResult = buildClient({
rt,
root: PLUGIN_ROOT,
force: force || state.depsHash !== wantDeps,
force: force || state.clientBuild !== pluginVersion(),
logPath: path.join(rt, "client-build.log"),
});
if (uiResult === "failed") {
log(rt, "dashboard UI build failed — the API still works; run /agenthub-open to retry");
recordFailure(
rt,
"ui-build-failed",
"the dashboard UI build failed (the API still works; /agenthub-open retries it)",
path.join(rt, "client-build.log")
);
} else if (uiResult === "built") {
log(rt, "built the dashboard UI bundle");
}
@@ -829,6 +950,7 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
{
pluginVersion: pluginVersion(),
depsHash: wantDeps,
clientBuild: uiResult === "failed" ? state.clientBuild : pluginVersion(),
pluginRoot: PLUGIN_ROOT,
claudeHome: getClaudeHome(),
updatedAt: new Date().toISOString(),
@@ -836,6 +958,8 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
rt
);
if (uiResult !== "failed") clearFailure(rt);
// After the state is durable, so a prune that goes wrong cannot also cost
// us the record of a bootstrap that went right.
pruneRetiredPluginRoots(rt);
@@ -868,13 +992,26 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
* milliseconds in the steady state, and otherwise hands off to a detached
* worker so the session never waits on an install.
*/
function main() {
if (notTheHost() || fastPathOk()) return;
const child = spawn(process.execPath, [__filename, "--worker"], {
detached: true,
stdio: "ignore",
});
child.unref();
function main(rt = runtimeDir()) {
if (notTheHost()) return null;
// Checked here too, not only in the worker: it costs nothing, the worker
// could only log it, and this pass can tell the user right now. A retired
// build stays silent — its worker would do nothing either.
if (!nodeVersionOk()) {
if (supersededBy()) return null;
recordFailure(rt, "node-too-old", nodeTooOldMessage(), null);
return takeFailureReport(rt);
}
if (!fastPathOk(rt)) {
const child = spawn(process.execPath, [__filename, "--worker"], {
detached: true,
stdio: "ignore",
});
child.unref();
}
// A failure the previous worker recorded (it ran after that session's
// foreground pass had already returned). Pure fs; writes only to mark it shown.
return takeFailureReport(rt);
}
// Exported before the entry block below, not after it: `bootstrap()` requires
@@ -897,6 +1034,12 @@ module.exports = {
depsHash,
fastPathOk,
nodeVersionOk,
failurePath,
readFailure,
recordFailure,
clearFailure,
takeFailureReport,
main,
lockPath,
lockIsStale,
acquireLock,
@@ -934,7 +1077,10 @@ if (require.main === module) {
}
} else {
try {
main();
const report = main();
// SessionStart reads a JSON object on stdout; `systemMessage` is shown
// to the user. Nothing at all is printed in the steady state.
if (report) process.stdout.write(JSON.stringify(report) + "\n");
} catch {
// A bootstrap failure must never break a Claude Code session.
}
+46 -12
View File
@@ -68,7 +68,19 @@ function buildClient({
const stdio = logFd === null ? "inherit" : ["ignore", logFd, logFd];
try {
const src = path.join(rt, "client-src");
fs.rmSync(src, { recursive: true, force: true });
// The installed tree is kept across updates while the client's manifests
// are unchanged (`stamp`, written after a successful install): a release
// rarely touches them, and the clean `npm ci` below fetched all of the
// ~200 MB toolchain again on every update.
const stamp = path.join(src, "node_modules", ".agenthub-deps");
const want = clientDepsHash(root);
const reuse = readText(stamp) === want;
if (reuse) {
for (const e of fs.readdirSync(src))
if (e !== "node_modules") fs.rmSync(path.join(src, e), { recursive: true, force: true });
} else {
fs.rmSync(src, { recursive: true, force: true });
}
fs.cpSync(path.join(root, "client"), src, {
recursive: true,
filter: (p) => !/[\\/](node_modules|dist)$/.test(p),
@@ -101,19 +113,25 @@ function buildClient({
// esbuild is the only dependency with an install script, and it finds its
// platform binary without it.
const npmFlags = ["--no-audit", "--no-fund", "--include=dev", "--ignore-scripts"];
if (
!exec(NPM, ["ci", ...npmFlags], src, stdio) &&
!exec(NPM, ["install", ...npmFlags], src, stdio)
) {
return "failed";
if (!reuse) {
if (
!exec(NPM, ["ci", ...npmFlags], src, stdio) &&
!exec(NPM, ["install", ...npmFlags], src, stdio)
) {
return "failed";
}
fs.mkdirSync(path.dirname(stamp), { recursive: true });
fs.writeFileSync(stamp, want);
}
// = client/package.json's "build" script (`tsc -b && vite build`); a test
// keeps the two in step.
// client/package.json's "build" script is `tsc -b && vite build`; only the
// vite half runs here. The type check emits nothing the bundle needs, and on
// a user's machine it cost ~25 s and ~900 MB of memory per update — and a
// type error in a release then kept the old UI everywhere (v2.7.0). The
// release itself still runs `tsc -b`. A test keeps the two in step.
const bin = (...p) => path.join(src, "node_modules", ...p);
if (
!exec(NODE, [bin("typescript", "bin", "tsc"), "-b"], src, stdio) ||
!exec(NODE, [bin("vite", "bin", "vite.js"), "build"], src, stdio)
) {
if (!exec(NODE, [bin("vite", "bin", "vite.js"), "build"], src, stdio)) {
// Maybe a damaged kept tree: the next build starts from a clean install.
fs.rmSync(stamp, { force: true });
return "failed";
}
@@ -125,6 +143,22 @@ function buildClient({
}
}
function readText(file) {
try {
return fs.readFileSync(file, "utf8");
} catch {
return null;
}
}
/** The client's manifests, which decide whether its installed tree still fits. */
function clientDepsHash(root) {
const h = require("crypto").createHash("sha256");
for (const f of ["package.json", "package-lock.json"])
h.update(readText(path.join(root, "client", f)) ?? `no ${f}`);
return h.digest("hex").slice(0, 16);
}
function dashboardUrl() {
try {
return `http://localhost:${resolveDashboardPort()}`;
+3 -2
View File
@@ -439,7 +439,7 @@ All endpoints return JSON unless noted. Error responses use:
| `GET` | `/api/openapi.json` | Raw OpenAPI 3.0.3 spec |
| `GET` | `/api/docs` | Interactive **Swagger UI** (try-it-out request execution) |
| `GET` | `/api/redoc` | **ReDoc** reference — clean, read-optimized three-panel rendering of the same spec |
| `GET` | `/api/redoc/redoc.standalone.js` | Self-hosted ReDoc bundle (via the `redoc` dependency, never a CDN — works offline) |
| `GET` | `/api/redoc/redoc.standalone.js` | Self-hosted ReDoc bundle (vendored in `server/vendor/redoc/`, never a CDN — works offline) |
The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`), merged with supplementary fragments under `server/openapi-extra/`, and is the source of truth for request/response contracts. It now documents every backend route (75 path entries). Both Swagger UI and ReDoc (`server/lib/redoc.js`) render the same spec; the ReDoc bundle is served locally so the reference works offline / air-gapped. A committed `openapi.yaml` at the repo root mirrors the live spec — regenerate it after API changes with `npm run openapi:yaml` (never hand-edit it).
@@ -1600,10 +1600,11 @@ DASHBOARD_DB_PATH=./data/dashboard.db # SQLite database path
DASHBOARD_AI_TOOLS_DIR= # Extra AI Tools catalog JSON files; an id here replaces the built-in one
AGENTHUB_SHELL_PATH=1 # 0 = never run the login shell to read its PATH (startup + Refresh)
AGENTHUB_AI_TOOLS_SYNC=1 # 0 = never copy the newest release's AI Tools catalog (server/lib/ai-tools-sync.js, daily)
DASHBOARD_LANE_SYNC_MS=2000 # re-broadcast lanes another dashboard on the same DB changed (PRAGMA data_version); 0 = off
DASHBOARD_LANE_SYNC_MS=2000 # re-broadcast lanes another dashboard on the same DB changed (PRAGMA data_version), only while one runs (checked every 30 s); 0 = off
# Background services
DASHBOARD_SESSION_SYNC_MS=30000 # Continuous project-sync poll interval (ms); 0 disables the poll (watcher stays)
DASHBOARD_SYNC_LIVE_HOOK_MS=300000 # project sync leaves a transcript alone while hooks fed its session within this window; 0 = never defer
DASHBOARD_LIVENESS_PROBE=1 # 0 disables the dead-session liveness reap (use when hooks arrive from another machine)
DASHBOARD_LIVENESS_IDLE_SECONDS=60 # Idle gate before the liveness reap may complete a process-less session
DASHBOARD_REPORTS_MS=60000 # Scheduled-reports beat (ms); 0 disables the scheduler
+7
View File
@@ -213,6 +213,13 @@ describe("OpenAPI / Swagger", () => {
const res = await fetch("/api/redoc/redoc.standalone.js");
assert.equal(res.status, 200);
assert.match(res.headers["content-type"], /javascript/);
// The vendored file (server/vendor/redoc/), licence banner intact — the
// `redoc` npm package is no longer a dependency.
assert.match(
res.body,
/For license information please see redoc\.standalone\.js\.LICENSE\.txt/
);
assert.doesNotThrow(() => require("fs").statSync(require("../lib/redoc").redocBundlePath()));
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* @file Tests server/lib/backoff-poll.js — the poll that runs at its base pace
* while passes find work (or fail) and slows to its idle pace after a pass
* that resolved `false`, returning to the base pace once work shows up again.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { startBackoffPoll, idleDefault } = require("../lib/backoff-poll");
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
describe("startBackoffPoll", () => {
it("slows down after idle passes and speeds up again on work", async () => {
const answers = [true, false, false, true, "x"];
const at = [];
const t0 = Date.now();
const poll = startBackoffPoll(
() => {
at.push(Date.now() - t0);
const a = answers.shift();
if (a === "x") throw new Error("boom");
return a;
},
{ baseMs: 10, idleMs: 80 }
);
await wait(260);
poll.stop();
assert.ok(at.length >= 5, `ran ${at.length} times`);
const gaps = at.slice(1).map((t, i) => t - at[i]);
assert.ok(gaps[0] < 60, "work → base pace");
assert.ok(gaps[1] >= 75, "idle → idle pace");
assert.ok(gaps[2] >= 75, "still idle");
assert.ok(gaps[3] < 60, "work again → base pace");
});
it("idles at a minute by default, never faster than the base pace", () => {
assert.equal(idleDefault(15_000), 60_000);
assert.equal(idleDefault(120_000), 120_000);
});
});
+78 -1
View File
@@ -179,7 +179,9 @@ describe("ingestCodexUsage", () => {
});
it("settles an active session to completed once its journals go quiet", async () => {
const now = Date.now();
// A whole second: utimes rounds through a float, and a millisecond `now`
// came back from stat 1 ms early about half the time.
const now = Math.floor(Date.now() / 1000) * 1000;
writeRollout(
"s1",
[meta("s1"), ctx("gpt-a"), tc([1, 0, 1], [1, 0, 1])],
@@ -536,3 +538,78 @@ describe("service tier", () => {
]);
});
});
describe("ingestCodexUsage — recent pass", () => {
const NOW = Date.parse("2026-09-29T03:00:00Z");
function writeAt(day, id, lines, mtime) {
const dir = path.join(process.env.CODEX_HOME, "sessions", ...day.split("/"));
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `rollout-${day.replace(/\//g, "-")}T01-00-00-${id}.jsonl`);
fs.writeFileSync(file, jsonl(lines));
fs.utimesSync(file, mtime, mtime);
return file;
}
it("stats only the newest day folders and the journals still growing", async () => {
const quiet = writeAt(
"2026/09/01",
"old1",
[meta("old1"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])],
new Date(NOW - 20 * 86_400_000)
);
const live = writeAt(
"2026/09/02",
"old2",
[meta("old2"), ctx("gpt-a"), tc([10, 0, 1], [10, 0, 1])],
new Date(NOW - 60_000)
);
writeAt("2026/09/27", "a", [meta("a"), ctx("gpt-a")], new Date(NOW - 86_400_000));
writeAt("2026/09/28", "b", [meta("b"), ctx("gpt-a")], new Date(NOW - 86_400_000));
await ingestCodexUsage({ now: NOW }); // the full first pass
// Both old journals grow; only the one written within the hour is looked at.
fs.appendFileSync(quiet, jsonl([tc([30, 0, 3], [20, 0, 2])]));
fs.appendFileSync(live, jsonl([tc([30, 0, 3], [20, 0, 2])]));
fs.utimesSync(quiet, new Date(NOW - 1000), new Date(NOW - 1000));
fs.utimesSync(live, new Date(NOW - 1000), new Date(NOW - 1000));
// A new journal in today's folder is found too.
writeAt(
"2026/09/29",
"new1",
[meta("new1"), ctx("gpt-a"), tc([5, 0, 1], [5, 0, 1])],
new Date(NOW - 1000)
);
await ingestCodexUsage({ now: NOW, recent: true });
assert.deepEqual(usage("old2"), [{ model: "gpt-a", i: 30, r: 0, o: 3 }]);
assert.deepEqual(usage("old1"), [{ model: "gpt-a", i: 10, r: 0, o: 1 }], "left to a full pass");
assert.deepEqual(usage("new1"), [{ model: "gpt-a", i: 5, r: 0, o: 1 }]);
await ingestCodexUsage({ now: NOW }); // the next full pass catches it up
assert.deepEqual(usage("old1"), [{ model: "gpt-a", i: 30, r: 0, o: 3 }]);
});
it("finds a fork's parent in an older folder on a recent pass", async () => {
const parent = [
meta("p9"),
ctx("gpt-a"),
tc([100, 0, 10], [100, 0, 10]),
tc([300, 0, 30], [200, 0, 20]),
];
writeAt("2026/08/01", "p9", parent, new Date(NOW - 50 * 86_400_000));
writeAt("2026/09/28", "x", [meta("x")], new Date(NOW - 86_400_000));
await ingestCodexUsage({ now: NOW });
writeAt(
"2026/09/29",
"f9",
[
meta("f9", { forked_from_id: "p9" }),
meta("p9"),
...parent.slice(1),
tc([350, 0, 35], [50, 0, 5]),
],
new Date(NOW - 1000)
);
await ingestCodexUsage({ now: NOW, recent: true });
assert.deepEqual(usage("f9"), [{ model: "gpt-a", i: 50, r: 0, o: 5 }]);
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* @file Tests server/lib/db-retention.js and its Settings routes — a new
* database is INCREMENTAL auto_vacuum, retention is off by default and purges
* nothing then, when on it removes only finished sessions older than the
* cutoff (with their events, agents and token rows) in bounded batches, the
* free-page reclaim shrinks the freelist, GET/PUT /api/settings/retention
* validate and persist the setting, and POST /api/settings/compact needs
* confirm and runs a VACUUM in a worker. The database and settings file are
* temp paths.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-db-retention-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
delete process.env.AGENTHUB_RETENTION_DAYS;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const retention = require("../lib/db-retention");
let server;
let BASE;
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
delete process.env.AGENTHUB_RETENTION_DAYS;
fs.rmSync(ROOT, { recursive: true, force: true });
});
async function call(method, urlPath, body) {
const res = await fetch(BASE + urlPath, {
method,
headers: body ? { "content-type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json() };
}
const daysAgo = (n) => new Date(Date.now() - n * 86400 * 1000).toISOString();
function seed(id, status, startedAt, events = 3) {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, started_at, updated_at) VALUES (?, ?, ?, '/tmp', ?, ?)"
).run(id, id, status, startedAt, startedAt);
const ins = db.prepare(
"INSERT INTO events (session_id, event_type, summary, data, created_at) VALUES (?, 'PostToolUse', 's', ?, ?)"
);
for (let i = 0; i < events; i++)
ins.run(id, JSON.stringify({ pad: "x".repeat(2000) }), startedAt);
}
const exists = (id) => !!db.prepare("SELECT 1 FROM sessions WHERE id = ?").get(id);
const eventCount = (id) =>
db.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = ?").get(id).n;
describe("auto_vacuum", () => {
it("a new database is INCREMENTAL", () => {
assert.equal(db.pragma("auto_vacuum", { simple: true }), 2);
assert.equal(retention.spaceInfo(db).auto_vacuum, "incremental");
});
});
describe("retentionConfig", () => {
it("is off by default and for anything out of range", () => {
assert.equal(retention.retentionConfig({}).days, 0);
for (const v of ["", "0", "-3", "1.5", "abc", "99999"]) {
assert.equal(retention.retentionConfig({ AGENTHUB_RETENTION_DAYS: v }).days, 0, v);
}
assert.equal(retention.retentionConfig({ AGENTHUB_RETENTION_DAYS: "30" }).days, 30);
});
});
describe("scheduled maintenance", () => {
it("purges nothing while retention is off", async () => {
seed("ret-off-old", "completed", daysAgo(400));
const out = await retention.runScheduledMaintenance({ db });
assert.equal(out.retention, undefined);
assert.ok(exists("ret-off-old"));
db.prepare("DELETE FROM sessions WHERE id = 'ret-off-old'").run();
});
it("purges only finished sessions older than the cutoff, with their rows", async () => {
seed("ret-old-done", "completed", daysAgo(100));
seed("ret-old-err", "error", daysAgo(100));
seed("ret-old-active", "active", daysAgo(100));
seed("ret-new-done", "completed", daysAgo(5));
db.prepare(
"INSERT INTO token_usage (session_id, model, input_tokens) VALUES ('ret-old-done', 'm', 1)"
).run();
process.env.AGENTHUB_RETENTION_DAYS = "30";
try {
const out = await retention.runScheduledMaintenance({ db });
assert.equal(out.retention.purged_sessions, 2);
assert.equal(out.retention.purged_events, 6);
} finally {
delete process.env.AGENTHUB_RETENTION_DAYS;
}
assert.ok(!exists("ret-old-done"));
assert.ok(!exists("ret-old-err"));
assert.equal(eventCount("ret-old-done"), 0);
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM token_usage WHERE session_id = 'ret-old-done'").get().n,
0
);
assert.ok(exists("ret-old-active"), "an active session is never purged");
assert.ok(exists("ret-new-done"), "a recent session is kept");
assert.equal(eventCount("ret-new-done"), 3);
});
it("works in bounded batches and stops at the per-run cap", async () => {
for (let i = 0; i < 7; i++) seed(`ret-batch-${i}`, "abandoned", daysAgo(50), 1);
const first = await retention.applyRetention({ db, days: 10, batch: 2, maxSessions: 5 });
assert.equal(first.purged_sessions, 5);
const second = await retention.applyRetention({ db, days: 10, batch: 2, maxSessions: 5 });
assert.equal(second.purged_sessions, 2);
const third = await retention.applyRetention({ db, days: 10 });
assert.equal(third.purged_sessions, 0);
});
it("returns free pages to the OS", async () => {
for (let i = 0; i < 40; i++) seed(`ret-big-${i}`, "completed", daysAgo(60), 20);
await retention.applyRetention({ db, days: 30 });
const before = db.pragma("freelist_count", { simple: true });
assert.ok(before > 50, `expected free pages, got ${before}`);
const out = await retention.reclaimFreePages({ db, minPages: 10, slice: 16 });
assert.ok(out.reclaimed_pages > 0);
assert.ok(db.pragma("freelist_count", { simple: true }) < before);
});
});
describe("routes", () => {
it("GET reports retention off by default", async () => {
const r = await call("GET", "/api/settings/retention");
assert.equal(r.status, 200);
assert.equal(r.body.days, 0);
assert.equal(r.body.compacting, false);
assert.equal(r.body.space.auto_vacuum, "incremental");
});
it("PUT validates and persists, 0 turns it off", async () => {
for (const bad of [-1, 1.5, "7", 99999, null]) {
const r = await call("PUT", "/api/settings/retention", { days: bad });
assert.equal(r.status, 400, String(bad));
}
let r = await call("PUT", "/api/settings/retention", { days: 90 });
assert.equal(r.status, 200);
assert.equal(r.body.days, 90);
assert.match(fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8"), /RETENTION_DAYS=90/);
r = await call("PUT", "/api/settings/retention", { days: 0 });
assert.equal(r.body.days, 0);
});
it("POST /compact needs confirm and then vacuums", async () => {
let r = await call("POST", "/api/settings/compact", {});
assert.equal(r.status, 400);
r = await call("POST", "/api/settings/compact", { confirm: true });
assert.equal(r.status, 202);
for (let i = 0; i < 200 && retention.isCompacting(); i++) {
await new Promise((res) => setTimeout(res, 50));
}
const last = retention.lastCompactResult();
assert.equal(last?.ok, true, last?.error);
assert.equal(db.pragma("auto_vacuum", { simple: true }), 2);
});
});
+95 -5
View File
@@ -91,6 +91,69 @@ describe("node version gate", () => {
});
});
describe("last failure report (SessionStart systemMessage)", () => {
beforeEach(resetRuntime);
it("reports a recorded failure once, naming the log and /agenthub-doctor", () => {
boot.recordFailure(RT, "install-failed", "npm broke", path.join(RT, "npm-install.log"));
const first = boot.takeFailureReport(RT);
assert.match(first.systemMessage, /npm broke/);
assert.match(first.systemMessage, /npm-install\.log/);
assert.match(first.systemMessage, /\/agenthub-doctor/);
assert.equal(boot.takeFailureReport(RT), null);
});
it("does not re-report the same failure recorded again by a later retry", () => {
boot.recordFailure(RT, "install-failed", "npm broke", "x.log");
boot.takeFailureReport(RT);
boot.recordFailure(RT, "install-failed", "npm broke", "x.log");
assert.equal(boot.takeFailureReport(RT), null);
});
it("reports a different failure, and nothing once cleared", () => {
boot.recordFailure(RT, "install-failed", "npm broke", "x.log");
boot.takeFailureReport(RT);
boot.recordFailure(RT, "ui-build-failed", "vite broke", "y.log");
assert.match(boot.takeFailureReport(RT).systemMessage, /vite broke/);
boot.recordFailure(RT, "ui-build-failed", "vite broke", "y.log");
boot.clearFailure(RT);
assert.equal(boot.readFailure(RT), null);
assert.equal(boot.takeFailureReport(RT), null);
});
it("prints nothing with no failure on record", () => {
assert.equal(boot.takeFailureReport(RT), null);
});
});
describe("depsHash — only what npm installs", () => {
function root(pkg, lock) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-deps-"));
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkg));
fs.writeFileSync(path.join(dir, "package-lock.json"), JSON.stringify(lock));
return dir;
}
const pkg = (version, deps) => ({ name: "agenthub", version, dependencies: deps });
const lock = (version, deps) => ({
name: "agenthub",
version,
packages: { "": { name: "agenthub", version, dependencies: deps }, "node_modules/a": {} },
});
it("ignores a release's version bump", () => {
// Every update reinstalled all runtime dependencies when only the version moved.
const a = root(pkg("1.0.0", { a: "^1" }), lock("1.0.0", { a: "^1" }));
const b = root(pkg("1.0.1", { a: "^1" }), lock("1.0.1", { a: "^1" }));
assert.equal(boot.depsHash(a), boot.depsHash(b));
});
it("changes when a dependency does", () => {
const a = root(pkg("1.0.0", { a: "^1" }), lock("1.0.0", { a: "^1" }));
const b = root(pkg("1.0.0", { a: "^2" }), lock("1.0.0", { a: "^2" }));
assert.notEqual(boot.depsHash(a), boot.depsHash(b));
});
});
describe("fast path", () => {
beforeEach(resetRuntime);
@@ -527,7 +590,6 @@ describe("buildClient dependency install", () => {
assert.deepEqual(calls, [
"ci --no-audit --no-fund --include=dev --ignore-scripts",
"install --no-audit --no-fund --include=dev --ignore-scripts",
"node tsc -b",
"node vite.js build",
]);
});
@@ -567,8 +629,9 @@ describe("buildClient dependency install", () => {
it("builds with the running node, no lifecycle script and no PATH lookup (Windows)", () => {
// On Windows a script or a .bin shim runs through cmd.exe and needs `node`
// on PATH; the server's PATH lacked it, so esbuild's postinstall failed and
// every update kept the previous UI. The build here must equal the
// client's own "build" script, run without npm.
// every update kept the previous UI. The build here is the vite half of
// the client's own "build" script, run without npm; the type check is the
// release's job, not every user's.
assert.equal(
JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "client", "package.json")))
.scripts.build,
@@ -577,8 +640,35 @@ describe("buildClient dependency install", () => {
resetRuntime();
const calls = [];
buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) });
assert.deepEqual(calls.slice(-2), ["node tsc -b", "node vite.js build"]);
assert.ok(calls.every((c) => !c.startsWith("run")));
assert.deepEqual(calls.slice(-1), ["node vite.js build"]);
assert.ok(calls.every((c) => !c.startsWith("run") && !c.includes("tsc")));
});
it("keeps the installed tree while the client's manifests are unchanged", () => {
resetRuntime();
const root = fakeRoot();
const first = [];
buildClient({ rt: RT, root, exec: fakeExec(first) });
assert.ok(first[0].startsWith("ci"));
const again = [];
assert.equal(buildClient({ rt: RT, root, force: true, exec: fakeExec(again) }), "built");
assert.deepEqual(again, ["node vite.js build"]);
fs.writeFileSync(path.join(root, "client", "package.json"), '{"dependencies":{"x":"1"}}');
const changed = [];
buildClient({ rt: RT, root, force: true, exec: fakeExec(changed) });
assert.ok(changed[0].startsWith("ci"), "a changed manifest installs from clean");
});
it("installs from clean again after a build on the kept tree failed", () => {
resetRuntime();
const root = fakeRoot();
buildClient({ rt: RT, root, exec: fakeExec([]) });
const failing = (cmd, args, cwd) => (cmd === "node" ? false : fakeExec([])(cmd, args, cwd));
assert.equal(buildClient({ rt: RT, root, force: true, exec: failing }), "failed");
const next = [];
buildClient({ rt: RT, root, force: true, exec: fakeExec(next) });
assert.ok(next[0].startsWith("ci"));
});
});
+2
View File
@@ -50,6 +50,8 @@ function trackedFiles() {
// Generated from server/openapi-extra/* (which IS linted): the drift test
// below covers it, so linting both would only double-report one fix.
.filter((f) => f !== "openapi.yaml" && !f.endsWith("package-lock.json"))
// Third-party files copied unchanged (server/vendor/redoc): their links are upstream's.
.filter((f) => !f.includes("/vendor/"))
);
}
+82
View File
@@ -150,3 +150,85 @@ describe("syncDefaultProjects", () => {
);
});
});
describe("syncDefaultProjects — sessions hooks are feeding", () => {
const SNAP_DIR = path.join(process.env.DASHBOARD_DATA_DIR, "transcripts");
const more = (sessionId, cwd, ts) => ({
type: "assistant",
cwd,
sessionId,
timestamp: ts,
message: {
model: "claude-opus-4-8",
content: [{ type: "text", text: "later" }],
usage: { input_tokens: 3, output_tokens: 2 },
},
});
const bumpMtime = (file) => {
const future = Date.now() / 1000 + 120;
fs.utimesSync(file, future, future);
};
const ageSession = (id) =>
dbModule.db
.prepare("UPDATE sessions SET updated_at = ? WHERE id = ?")
.run(new Date(Date.now() - 10 * 60_000).toISOString(), id);
it("defers a live hook-fed imported session, then catches it up once quiet", async () => {
const ID = "33333333-cccc-4ccc-8ccc-333333333333";
const cache = new Map();
const file = writeSession("-live", ID, fixtureLines(ID, "/live"));
await syncDefaultProjects(dbModule, { mtimeCache: cache });
assert.ok(dbModule.stmts.getSession.get(ID), "imported first");
// A hook arrives: its event is the newest, and it touches the session.
dbModule.stmts.insertEvent.run(ID, `${ID}-main`, "PreToolUse", "Bash", "hook", "{}");
dbModule.stmts.touchSession.run(ID);
writeSession(
"-live",
ID,
fixtureLines(ID, "/live", [more(ID, "/live", "2026-04-18T12:09:00.000Z")])
);
bumpMtime(file);
const live = await syncDefaultProjects(dbModule, { mtimeCache: cache });
assert.equal(
live.changed.find((c) => c.sessionId === ID),
undefined,
"deferred while live"
);
assert.equal(cache.get(file), -1, "kept in the cache so the watcher ignores its appends");
ageSession(ID); // the hooks went quiet
const quiet = await syncDefaultProjects(dbModule, { mtimeCache: cache });
assert.ok(
quiet.changed.find((c) => c.sessionId === ID && !c.isNew),
"caught up once quiet"
);
assert.notEqual(cache.get(file), -1);
});
it("never re-parses a hook-created session, only refreshes its snapshot once quiet", async () => {
const ID = "44444444-dddd-4ddd-8ddd-444444444444";
dbModule.stmts.insertSession.run(ID, "hooked", "active", "/hooked", null, null);
dbModule.stmts.insertEvent.run(ID, null, "SessionStart", null, "hook", "{}");
const file = writeSession("-hooked", ID, fixtureLines(ID, "/hooked"));
bumpMtime(file);
const snap = path.join(SNAP_DIR, `${ID}.jsonl`);
const cache = new Map();
await syncDefaultProjects(dbModule, { mtimeCache: cache });
assert.equal(fs.existsSync(snap), false, "no snapshot copy while hooks are live");
ageSession(ID);
const before = dbModule.db.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = ?");
const n0 = before.get(ID).n;
const r = await syncDefaultProjects(dbModule, { mtimeCache: cache });
assert.equal(
r.changed.find((c) => c.sessionId === ID),
undefined,
"nothing imported"
);
assert.equal(before.get(ID).n, n0, "no events written for a hook-created session");
assert.equal(fs.existsSync(snap), true, "snapshot taken once quiet");
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* @file Tests for the hook writer's per-transcript "already handled" marks
* (`transcriptMarks` in server/routes/hooks.js): a hook whose transcript is
* unchanged skips the token / compaction / API-error / turn-duration writes,
* a grown transcript is walked only from the last entry handled, and the rows
* left behind are exactly the ones a full re-walk would have written.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-marks-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "home");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const hooksRouter = require("../routes/hooks");
let server;
let BASE;
function post(body) {
return new Promise((resolve, reject) => {
const url = new URL("/api/hooks/event", BASE);
const payload = JSON.stringify(body);
const r = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
},
(res) => {
res.resume();
res.on("end", () => resolve(res.statusCode));
}
);
r.on("error", reject);
r.write(payload);
r.end();
});
}
const line = (o) => JSON.stringify(o) + "\n";
const assistant = (id, inTok) => ({
type: "assistant",
timestamp: "2026-09-18T01:00:00.000Z",
message: {
id,
model: "claude-sonnet-5",
content: [{ type: "text", text: "ok" }],
usage: { input_tokens: inTok, output_tokens: 1 },
},
});
const turn = (ms, ts) => ({
type: "system",
subtype: "turn_duration",
durationMs: ms,
timestamp: ts,
});
const apiError = (text, ts) => ({
type: "assistant",
isApiErrorMessage: true,
error: "rate_limit",
timestamp: ts,
message: { content: [{ type: "text", text }] },
});
const compact = (uuid, ts) => ({ type: "user", isCompactSummary: true, uuid, timestamp: ts });
function rows(sid) {
const ev = db
.prepare(
"SELECT event_type, summary, created_at FROM events WHERE session_id = ? AND event_type IN ('TurnDuration','APIError','Compaction') ORDER BY event_type, created_at, summary"
)
.all(sid)
.map((r) =>
r.event_type === "APIError"
? `${r.event_type}|${r.summary}`
: `${r.event_type}|${r.created_at}|${r.summary}`
);
const tokens = db
.prepare("SELECT model, input_tokens, baseline_input FROM token_usage WHERE session_id = ?")
.all(sid);
return { ev, tokens };
}
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("transcript marks", () => {
it("skips an unchanged transcript and walks only the new tail of a grown one", async () => {
const sid = "abababab-1111-4111-8111-abababababab";
const cwd = path.join(TMP, "work");
fs.mkdirSync(cwd, { recursive: true });
const tpath = path.join(TMP, `${sid}.jsonl`);
fs.writeFileSync(
tpath,
line(assistant("m1", 5)) +
line(turn(1000, "2026-09-18T01:00:01.000Z")) +
line(apiError("slow down", "2026-09-18T01:00:02.000Z")) +
line(compact("c-1", "2026-09-18T01:00:03.000Z")) +
line(assistant("m2", 7)) +
line(turn(2000, "2026-09-18T01:00:04.000Z"))
);
const data = { session_id: sid, cwd, transcript_path: tpath };
assert.equal(await post({ hook_type: "PostToolUse", data }), 200);
const first = rows(sid);
assert.equal(first.ev.filter((e) => e.startsWith("TurnDuration")).length, 2);
assert.equal(first.ev.filter((e) => e.startsWith("APIError")).length, 1);
assert.equal(first.ev.filter((e) => e.startsWith("Compaction")).length, 1);
assert.ok(hooksRouter.transcriptMarks.size >= 1, "a mark is kept after commit");
// Proof of the skip: a row removed behind the writer's back is NOT put
// back by a hook whose transcript did not change (before, every hook
// re-walked the whole history and re-inserted it).
db.prepare(
"DELETE FROM events WHERE session_id = ? AND event_type = 'TurnDuration' AND created_at = '2026-09-18T01:00:01.000Z'"
).run(sid);
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "x" } }), 200);
assert.equal(rows(sid).ev.filter((e) => e.startsWith("TurnDuration")).length, 1);
db.prepare("DELETE FROM events WHERE session_id = ? AND event_type = 'TurnDuration'").run(sid);
hooksRouter.transcriptMarks.clear();
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "y" } }), 200);
assert.deepEqual(rows(sid), first, "a cleared mark re-walks the whole history, as before");
// Grow it: one turn, one new error, a repeat of the old error, a compaction.
fs.appendFileSync(
tpath,
line(assistant("m3", 11)) +
line(turn(3000, "2026-09-18T01:00:05.000Z")) +
line(apiError("slow down", "2026-09-18T01:00:06.000Z")) +
line(apiError("overloaded", "2026-09-18T01:00:07.000Z")) +
line(compact("c-2", "2026-09-18T01:00:08.000Z"))
);
assert.equal(await post({ hook_type: "Stop", data }), 200);
const grown = rows(sid);
// The same transcript walked from scratch must produce the same rows.
hooksRouter.transcriptMarks.clear();
assert.equal(await post({ hook_type: "Stop", data: { ...data, stop_hook_active: true } }), 200);
assert.deepEqual(rows(sid), grown, "the delta wrote exactly what a full walk writes");
assert.equal(grown.ev.filter((e) => e.startsWith("TurnDuration")).length, 3);
assert.equal(grown.ev.filter((e) => e.startsWith("APIError")).length, 2, "repeat deduped");
assert.equal(grown.ev.filter((e) => e.startsWith("Compaction")).length, 2);
assert.equal(grown.tokens.length, 1);
assert.equal(grown.tokens[0].input_tokens + grown.tokens[0].baseline_input, 23);
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* @file Tests server/update-scheduler.js — the default interval is an hour
* (the env override and its 60 s floor still apply), a scheduled tick with no
* browser connected runs no check, and a browser connecting after that gets
* one catch-up check (only when the last check is stale). The check, the
* connection count and the connection event are injected; nothing touches git
* or the network.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, afterEach } = require("node:test");
const assert = require("node:assert/strict");
delete process.env.DASHBOARD_UPDATE_CHECK;
const { startUpdateScheduler, intervalMs } = require("../update-scheduler");
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
let handle = null;
afterEach(() => {
handle?.stop();
handle = null;
delete process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS;
});
function harness(clients) {
const h = { checks: 0, clients, listeners: [] };
handle = startUpdateScheduler({
broadcast: () => {},
getConnectionCount: () => h.clients,
onConnection: (fn) => {
h.listeners.push(fn);
return () => {};
},
getStatus: async () => {
h.checks++;
return { update_available: false };
},
initialDelayMs: 10,
});
h.connect = () => h.listeners.forEach((fn) => fn());
return h;
}
describe("intervalMs", () => {
it("defaults to one hour and keeps the override with its floor", () => {
delete process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS;
assert.equal(intervalMs(), 60 * 60 * 1000);
process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS = "120000";
assert.equal(intervalMs(), 120000);
process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS = "1000";
assert.equal(intervalMs(), 60 * 60 * 1000);
});
});
describe("startUpdateScheduler", () => {
it("checks on the first tick when a browser is connected", async () => {
const h = harness(1);
await wait(40);
assert.equal(h.checks, 1);
});
it("skips the tick with no browser, then checks once when one connects", async () => {
const h = harness(0);
await wait(40);
assert.equal(h.checks, 0);
h.clients = 1;
h.connect();
h.connect(); // a second tab in the same window: still one check
await wait(3_300);
assert.equal(h.checks, 1);
h.connect(); // fresh check: no catch-up
await wait(3_300);
assert.equal(h.checks, 1);
});
});
+20
View File
@@ -465,3 +465,23 @@ describe("listBranches", () => {
assert.deepEqual(branches, []);
});
});
describe("git missing from PATH", () => {
it("answers EGITMISSING with the fix, not a bare ENOENT, when the folder exists", async () => {
const saved = process.env.PATH;
process.env.PATH = path.join(ROOT, "no-such-bin");
try {
await assert.rejects(
() => wt.git(ROOT, ["status"]),
(err) => err.code === "EGITMISSING" && /install/i.test(err.message)
);
// A missing FOLDER is the other ENOENT, and stays one.
await assert.rejects(
() => wt.git(path.join(ROOT, "gone"), ["status"]),
(err) => err.code === "ENOENT"
);
} finally {
process.env.PATH = saved;
}
});
});
+35 -1
View File
@@ -141,6 +141,14 @@ migrateLegacyDatabase(DB_PATH);
const db = new Database(DB_PATH);
// Freed pages go back to the OS only with auto_vacuum. On a NEW database this
// takes effect at once — it must come before `journal_mode = WAL`, which
// writes the header; an existing one is converted further down, by a VACUUM,
// only while it is small (`convertAutoVacuum`), else by Settings → Data →
// Compact. `maintainDatabase` then runs a bounded `incremental_vacuum`.
if (db.pragma("auto_vacuum", { simple: true }) === 0) {
db.pragma("auto_vacuum = INCREMENTAL");
}
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
db.pragma("busy_timeout = 5000");
@@ -1866,6 +1874,28 @@ for (const col of ["display_name", "email", "avatar_at"]) {
}
}
// An existing database made before auto_vacuum: the setting is stored only by
// a VACUUM, which rewrites the whole file under an exclusive lock. Done here,
// at startup and under the migration lock, only for a file up to
// AUTO_VACUUM_CONVERT_MAX_BYTES (well under a second); a bigger one is
// converted by the manual Compact (server/lib/db-retention.js). Best effort: a
// second process holding a read open makes it fail, and the next start retries.
const AUTO_VACUUM_CONVERT_MAX_BYTES = 32 * 1024 * 1024;
function convertAutoVacuum() {
try {
if (db.pragma("auto_vacuum", { simple: true }) !== 0) return false;
const bytes =
db.pragma("page_count", { simple: true }) * db.pragma("page_size", { simple: true });
if (bytes > AUTO_VACUUM_CONVERT_MAX_BYTES) return false;
db.pragma("auto_vacuum = INCREMENTAL");
db.exec("VACUUM");
return db.pragma("auto_vacuum", { simple: true }) === 2;
} catch {
return false;
}
}
convertAutoVacuum();
migrationLock.exec("COMMIT");
migrationLock.close();
@@ -2535,7 +2565,9 @@ const stmts = {
* on a reader — the other dashboard's), after which the next write truncates
* the WAL to `journal_size_limit`; and `PRAGMA optimize`, which refreshes the
* planner's statistics only where they went stale. Never VACUUM: it rewrites
* the whole file under an exclusive lock.
* the whole file under an exclusive lock. The opt-in retention and the
* bounded `incremental_vacuum` run after this, from
* `server/lib/db-retention.js`'s `runScheduledMaintenance`.
*/
function maintainDatabase() {
const out = {};
@@ -2561,4 +2593,6 @@ module.exports = {
applyIntroPricing,
topUpDefaultPricing,
maintainDatabase,
convertAutoVacuum,
AUTO_VACUUM_CONVERT_MAX_BYTES,
};
+59 -13
View File
@@ -33,6 +33,20 @@ if (process.env.GIT_EDITOR === "true") delete process.env.GIT_EDITOR;
// server/lib/settings-env.js. Only when this
// file IS the process: the test suite requires it dozens of times, and a
// developer's real settings file pins DASHBOARD_DATA_DIR at their live database.
/** What to tell the user when the dashboard cannot bind its port. The health
* probe above already turned away a port held by a running dashboard, so a
* port in use here belongs to some other program. */
function listenFailure(err, port) {
if (err?.code === "EADDRINUSE")
return (
`Port ${port} is already in use by another program, so AgentHub cannot start there. ` +
`Stop that program, or run AgentHub on another port with DASHBOARD_PORT (for example DASHBOARD_PORT=4821).`
);
if (err?.code === "EACCES")
return `Not allowed to listen on port ${port}. Pick a port above 1024 with DASHBOARD_PORT.`;
return `AgentHub could not start its server: ${err?.stack || err}`;
}
if (require.main === module) {
const settingsEnv = require("./lib/settings-env");
settingsEnv.loadEnvFiles({
@@ -337,8 +351,12 @@ function startServer(app, port) {
const host = resolveHost();
const boundLoopback = isLoopbackHostname(host);
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
// A port another program holds fails here, not as an uncaught 'error'
// event: the caller says what happened instead of a bare stack trace.
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
// Publish the live port so the Claude Code hook handler can find this
// server even when it bound a non-default port (the desktop app falls
// back off 4820 when that port is already taken).
@@ -482,11 +500,17 @@ function startBackgroundServices() {
.catch(() => {});
// Hourly DB upkeep: a non-blocking checkpoint (the WAL then shrinks to
// journal_size_limit) and PRAGMA optimize. Off with DASHBOARD_DB_MAINTAIN_MS=0.
// journal_size_limit), PRAGMA optimize, the opt-in retention
// (AGENTHUB_RETENTION_DAYS, off by default) and a bounded incremental_vacuum
// (server/lib/db-retention.js). Off with DASHBOARD_DB_MAINTAIN_MS=0.
{
const every = Number(process.env.DASHBOARD_DB_MAINTAIN_MS ?? 60 * 60 * 1000);
if (every > 0) {
const t = setInterval(() => require("./db").maintainDatabase(), every);
const t = setInterval(() => {
require("./lib/db-retention")
.runScheduledMaintenance()
.catch(() => {});
}, every);
if (t.unref) t.unref();
}
}
@@ -807,18 +831,35 @@ function startBackgroundServices() {
}
// Another dashboard on the same database (hooks reach only one of them)
// moving a lane: re-broadcast it here. One pragma per beat when nothing
// changed. Off with DASHBOARD_LANE_SYNC_MS=0.
// changed — and only while such a dashboard runs at all: alone on its data
// dir, this server only looks for one every LANE_PEER_CHECK_MS (the first
// call primes what it has seen, so a peer that starts later is compared
// against it). Off with DASHBOARD_LANE_SYNC_MS=0.
const LANE_SYNC_MS = Number(process.env.DASHBOARD_LANE_SYNC_MS ?? 2_000);
if (Number.isFinite(LANE_SYNC_MS) && LANE_SYNC_MS > 0) {
const { broadcastForeignLaneChanges } = require("./routes/lanes");
const laneSyncTimer = setInterval(() => {
const LANE_PEER_CHECK_MS = Math.max(LANE_SYNC_MS, 30_000);
let peersSeenAt = 0;
let hasPeers = false;
const syncLanes = () => {
try {
broadcastForeignLaneChanges();
} catch (err) {
console.warn("lane sync failed:", err.message);
}
}, LANE_SYNC_MS);
if (laneSyncTimer.unref) laneSyncTimer.unref();
};
syncLanes();
const laneSyncTick = () => {
if (Date.now() - peersSeenAt >= LANE_PEER_CHECK_MS) {
hasPeers = peersSharingDataDir().length > 0;
peersSeenAt = Date.now();
}
if (hasPeers) syncLanes();
const t = setTimeout(laneSyncTick, hasPeers ? LANE_SYNC_MS : LANE_PEER_CHECK_MS);
if (t.unref) t.unref();
};
const first = setTimeout(laneSyncTick, LANE_SYNC_MS);
if (first.unref) first.unref();
}
// Continuous discovery of sessions under ~/.claude/projects. The one-time
// legacy backfill above runs only once (marker-gated), so a project added
@@ -1210,12 +1251,17 @@ if (require.main === module) {
return;
}
const app = createApp();
startServer(app, PORT).then((server) => {
httpServer = server;
startBackgroundServices();
// The status-area icon (server/lib/tray.js) — production servers only.
require("./lib/tray").startTrayWatch();
});
startServer(app, PORT)
.then((server) => {
httpServer = server;
startBackgroundServices();
// The status-area icon (server/lib/tray.js) — production servers only.
require("./lib/tray").startTrayWatch();
})
.catch((err) => {
console.error(listenFailure(err, PORT));
process.exit(1);
});
});
// Graceful shutdown — close connections and DB cleanly
+18 -10
View File
@@ -425,6 +425,9 @@ function writeSession(f, conv, now) {
}
let running = null;
// Fresh sessions the last pass left for a later one to settle (the poll keeps
// its base pace while there are any).
let lastPending = 0;
/**
* One pass over every conversation file: a changed one is re-read, an
@@ -440,12 +443,16 @@ function ingestAntigravityUsage({
running = (async () => {
let read = 0;
let written = 0;
let pending = 0; // still-fresh sessions a later pass may have to settle
for (const f of conversationFiles()) {
try {
const seen = getSeen.get(f.path);
if (seen && seen.mtime_ms === f.mtime) {
const a = activity(f.id, f.mtime);
if (fresh(a, now)) continue;
if (fresh(a, now)) {
pending += 1;
continue;
}
const s = stmts.getSession.get(f.id);
if (s && s.tool === "antigravity" && s.status === "active") {
const at = new Date(a.last).toISOString();
@@ -486,6 +493,7 @@ function ingestAntigravityUsage({
}
await new Promise((r) => setImmediate(r));
}
lastPending = pending;
return { files: read, sessions: written };
})().finally(() => {
running = null;
@@ -504,16 +512,16 @@ function startAntigravityUsagePoll(broadcast, broadcastLane) {
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
// Files an older build could not read get another try.
db.prepare("DELETE FROM antigravity_conversations WHERE session_id = ''").run();
const tick = () => {
ingestAntigravityUsage({ broadcast, broadcastLane })
.then((r) => {
// Slowed to once a minute after a pass that read nothing, wrote nothing and
// left no fresh session to settle; back to POLL_MS once one finds work.
require("./backoff-poll").startBackoffPoll(
() =>
ingestAntigravityUsage({ broadcast, broadcastLane }).then((r) => {
if (r.sessions > 0 && pricingSync.syncEnabled()) void pricingSync.syncPricing(db);
})
.catch(() => {});
};
tick();
const timer = setInterval(tick, POLL_MS);
if (timer.unref) timer.unref();
return r.files + r.sessions + lastPending > 0;
}),
{ baseMs: POLL_MS }
);
}
module.exports = {
+43
View File
@@ -0,0 +1,43 @@
/**
* @file A background poll that slows down while it finds nothing to do: the
* next pass runs `baseMs` after a pass that did work (or failed), `idleMs`
* after one whose `tick` resolved `false` (nothing changed, nothing pending).
* One pass at a time — the next is scheduled only once the last settled — and
* every timer is `unref`'d. Used by the Cursor and Antigravity usage polls.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** Default slow interval: 60 s, never faster than the base one. */
const idleDefault = (baseMs) => Math.max(baseMs, 60_000);
/**
* Run `tick` now, then again and again. `tick` resolves `false` for an idle
* pass; anything else (or a throw) keeps the base pace.
* @returns {{stop: () => void}}
*/
function startBackoffPoll(tick, { baseMs, idleMs = idleDefault(baseMs) }) {
let timer = null;
let stopped = false;
const schedule = (ms) => {
if (stopped) return;
timer = setTimeout(run, ms);
if (timer.unref) timer.unref();
};
function run() {
Promise.resolve()
.then(tick)
.then(
(busy) => schedule(busy === false ? idleMs : baseMs),
() => schedule(baseMs)
);
}
run();
return {
stop() {
stopped = true;
clearTimeout(timer);
},
};
}
module.exports = { startBackoffPoll, idleDefault };
+97 -4
View File
@@ -235,7 +235,9 @@ function threadNames(home = getCodexHome()) {
const names = new Map();
let text = "";
try {
text = require("./run-as").readFileSafe(path.join(home, "session_index.jsonl"), "utf8", { max: 16 << 20 });
text = require("./run-as").readFileSafe(path.join(home, "session_index.jsonl"), "utf8", {
max: 16 << 20,
});
} catch {
return names;
}
@@ -251,6 +253,17 @@ function threadNames(home = getCodexHome()) {
}
const getRollout = db.prepare("SELECT * FROM codex_rollouts WHERE path = ?");
// What a pass needs to skip an unchanged journal, for every journal at once:
// one query per pass instead of one (with its whole parse state) per file.
const rolloutStamps = db.prepare(
`SELECT path, size, mtime_ms, instr(state, '"more":true') > 0 AS more FROM codex_rollouts`
);
// Journals that may still be growing outside the newest day folders: written
// within RECENT_ROW_MS, or not yet read to the end.
const liveRolloutPaths = db.prepare(
`SELECT path FROM codex_rollouts WHERE mtime_ms > ? OR instr(state, '"more":true') > 0`
);
const threadPath = db.prepare("SELECT path FROM codex_rollouts WHERE thread_id = ? LIMIT 1");
const upsertRollout = db.prepare(
`INSERT INTO codex_rollouts (path, session_id, thread_id, size, mtime_ms, offset, state, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
@@ -400,6 +413,59 @@ function writeSession(sessionId, names, now, origin = LOCAL) {
const running = new Map(); // home ("" = CODEX_HOME) → the pass in flight
// A RECENT pass (the poll's, between full ones) stats only the newest
// RECENT_DAY_DIRS day folders plus the journals the table says may still grow
// (RECENT_ROW_MS) — not every rollout Codex ever wrote. A journal in an older
// folder that wakes up again (a resumed thread quiet for over RECENT_ROW_MS)
// is picked up by the next full pass.
const RECENT_DAY_DIRS = 2;
const RECENT_ROW_MS = 60 * 60_000;
function numericDirs(dir) {
try {
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && /^\d+$/.test(e.name))
.map((e) => e.name)
.sort((a, b) => Number(b) - Number(a));
} catch {
return [];
}
}
function recentRolloutFiles(home, now) {
const root = path.join(home || getCodexHome(), "sessions");
const days = [];
outer: for (const y of numericDirs(root))
for (const m of numericDirs(path.join(root, y)))
for (const d of numericDirs(path.join(root, y, m))) {
days.push(path.join(root, y, m, d));
if (days.length >= RECENT_DAY_DIRS) break outer;
}
const paths = new Set();
for (const dir of days) {
try {
for (const e of fs.readdirSync(dir, { withFileTypes: true }))
if (e.isFile() && /^rollout-.*\.jsonl$/.test(e.name)) paths.add(path.join(dir, e.name));
} catch {
/* vanished mid-scan */
}
}
const prefix = root + path.sep;
for (const { path: p } of liveRolloutPaths.all(now - RECENT_ROW_MS))
if (p.startsWith(prefix)) paths.add(p);
const out = [];
for (const p of paths) {
try {
const stat = fs.statSync(p);
if (stat.isFile()) out.push({ path: p, mtime: stat.mtimeMs, size: stat.size });
} catch {
/* gone */
}
}
return out;
}
/**
* One pass over every journal. Yields to the event loop between files; a
* second call for the same home while one runs joins it. `home`, `source` and
@@ -413,21 +479,42 @@ function ingestCodexUsage({
home,
source = LOCAL.source,
activeMs = LOCAL.activeMs,
recent = false,
} = {}) {
const key = home || "";
if (running.has(key)) return running.get(key);
const pass = (async () => {
const files = allRolloutFiles(home);
const files = recent ? recentRolloutFiles(home, now) : allRolloutFiles(home);
const byThread = new Map();
for (const f of files) {
const m = FILE_NAME.exec(path.basename(f.path));
if (m) byThread.set(m[1], f.path);
}
// A recent pass lists only part of the tree: a fork's parent may live in
// an older folder, known to the table (or, failing that, to a full walk).
let walkedAll = !recent;
const parentPath = (id) => {
if (byThread.has(id)) return byThread.get(id);
if (walkedAll) return null;
const known = threadPath.get(id)?.path;
const root = path.join(home || getCodexHome(), "sessions") + path.sep;
if (known && known.startsWith(root) && fs.existsSync(known)) return known;
walkedAll = true;
for (const f of allRolloutFiles(home)) {
const m = FILE_NAME.exec(path.basename(f.path));
if (m && !byThread.has(m[1])) byThread.set(m[1], f.path);
}
return byThread.get(id) || null;
};
const stamps = new Map();
for (const r of rolloutStamps.all()) stamps.set(r.path, r);
const touched = new Set();
let read = 0;
for (const f of files) {
try {
const mtime = Math.floor(f.mtime);
const seen = stamps.get(f.path);
if (seen && seen.size === f.size && seen.mtime_ms === mtime && !seen.more) continue;
const row = getRollout.get(f.path);
let offset = row ? row.offset : 0;
let state = row ? JSON.parse(row.state) : {};
@@ -439,7 +526,8 @@ function ingestCodexUsage({
let skip = null;
const parent = offset === 0 ? firstLineMeta(f.path).forked_from_id : state.forkedFrom;
if (parent && !state.pastFork) {
if (byThread.has(parent)) skip = await parentTotals(byThread.get(parent));
const pp = parentPath(parent);
if (pp) skip = await parentTotals(pp);
else if (offset === 0)
console.warn(`[codex-usage] fork parent ${parent} not found; ${f.path} may overcount`);
}
@@ -508,10 +596,15 @@ function startCodexUsagePoll(broadcast) {
.memberHomes()
.map((h) => path.join(h, ".codex")),
];
// Every FULL_EVERY-th pass (and the first) walks every journal; the others
// only the recent ones (recentRolloutFiles).
const FULL_EVERY = Math.max(1, Math.round((5 * 60_000) / POLL_MS));
let passNo = 0;
const ingestAll = async () => {
const total = { sessions: 0 };
const recent = passNo++ % FULL_EVERY !== 0;
for (const home of homes()) {
const r = await ingestCodexUsage({ broadcast, home });
const r = await ingestCodexUsage({ broadcast, home, recent });
total.sessions += r?.sessions || 0;
}
return total;
+20 -16
View File
@@ -182,14 +182,7 @@ function writeSession(f, now) {
const endedAt = active ? null : lastWrite;
const name = firstPrompt(f.path) || existing?.name || `Cursor ${f.id.slice(0, 8)}`;
if (existing) {
updateSession.run(
name,
status,
endedAt,
lastWrite,
cwd,
f.id
);
updateSession.run(name, status, endedAt, lastWrite, cwd, f.id);
return "session_updated";
}
insertSession.run(
@@ -206,6 +199,9 @@ function writeSession(f, now) {
}
let running = null;
// Fresh sessions the last pass left for a later one to settle (the poll keeps
// its base pace while there are any).
let lastPending = 0;
/**
* One pass over every transcript: a changed one is re-read, an unchanged one
@@ -217,11 +213,15 @@ function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) {
running = (async () => {
let read = 0;
let written = 0;
let pending = 0; // still-fresh sessions a later pass may have to settle
for (const f of transcriptFiles()) {
try {
const seen = getSeen.get(f.path);
if (seen && seen.mtime_ms === f.mtime) {
if (fresh(f.mtime, now)) continue;
if (fresh(f.mtime, now)) {
pending += 1;
continue;
}
const s = stmts.getSession.get(f.id);
if (s && s.tool === "cursor" && s.status === "active") {
settle.run(new Date(f.mtime).toISOString(), s.id);
@@ -242,6 +242,7 @@ function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) {
}
await new Promise((r) => setImmediate(r));
}
lastPending = pending;
return { files: read, sessions: written };
})().finally(() => {
running = null;
@@ -249,18 +250,21 @@ function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) {
return running;
}
/** First pass at once (in the background), then every DASHBOARD_CURSOR_POLL_MS (default 15 s; 0 disables). */
/**
* First pass at once (in the background), then every DASHBOARD_CURSOR_POLL_MS
* (default 15 s; 0 disables) — slowed to once a minute after a pass that read
* nothing, wrote nothing and left no fresh session to settle, and back to the
* base pace as soon as one finds work.
*/
function startCursorUsagePoll(broadcast) {
const POLL_MS = process.env.DASHBOARD_CURSOR_POLL_MS
? Number(process.env.DASHBOARD_CURSOR_POLL_MS)
: 15_000;
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
const tick = () => {
ingestCursorUsage({ broadcast }).catch(() => {});
};
tick();
const timer = setInterval(tick, POLL_MS);
if (timer.unref) timer.unref();
require("./backoff-poll").startBackoffPoll(
() => ingestCursorUsage({ broadcast }).then((r) => r.files + r.sessions + lastPending > 0),
{ baseMs: POLL_MS }
);
}
module.exports = {
+337
View File
@@ -0,0 +1,337 @@
/**
* @file Database size upkeep (Settings → Data → Session cleanup): the shared
* session purge, the OPT-IN automatic retention, the bounded reclaim of free
* pages, and the manual Compact.
*
* - `purgeSessionIds` is THE deletion of whole sessions — events, agents,
* token usage (daily and hourly) and the session row — used by both the
* manual `POST /api/settings/cleanup` and the automatic retention, so the two
* can never leave different leftovers.
* - Retention is OFF unless `AGENTHUB_RETENTION_DAYS` (settings file, 1–3650)
* is set; it never deletes data by default. When set, the hourly
* `runScheduledMaintenance` purges finished (completed/error/abandoned)
* sessions that STARTED more than that many days ago — the manual purge's
* rule — in transactions of `RETENTION_BATCH` sessions, yielding to the event
* loop between them, and at most `RETENTION_MAX_PER_RUN` per run, so a first
* run on a long history never blocks hooks for long. Each batch deletes by
* `idx_events_session`, never a scan of the events table.
* - With `auto_vacuum = INCREMENTAL` (server/db.js sets it on a new database
* and converts a small existing one at startup), the same run returns free
* pages to the OS once the freelist passes `RECLAIM_MIN_PAGES`, in slices of
* `RECLAIM_SLICE_PAGES`, at most `RECLAIM_MAX_PAGES` per run.
* - `runCompact` (`POST /api/settings/compact`) is the one full VACUUM: in a
* worker thread on its own connection, setting `auto_vacuum = INCREMENTAL`
* first so a large existing database is converted, then a TRUNCATE
* checkpoint so the file actually shrinks. It holds the write lock for its
* whole run (tens of seconds on a GB-size file), so hooks wait or are retried
* meanwhile — it only ever runs on a click. This file is also its worker's
* entry point.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads");
const KEYS = { days: "AGENTHUB_RETENTION_DAYS" };
const MAX_DAYS = 3650;
const RETENTION_BATCH = 25;
const RETENTION_MAX_PER_RUN = 5000;
const RECLAIM_MIN_PAGES = 1024;
const RECLAIM_SLICE_PAGES = 1024;
const RECLAIM_MAX_PAGES = 65536;
const PURGE_CHUNK = 500;
const PURGEABLE = "status IN ('completed','error','abandoned')";
const coded = (message, code) => Object.assign(new Error(message), { code });
const yieldLoop = () => new Promise((r) => setImmediate(r));
/** `{ days }` — 0 means off. Anything outside 1–MAX_DAYS reads as off. */
function retentionConfig(env = process.env) {
const raw = env[KEYS.days];
const n = Number(raw);
const days = raw !== undefined && raw !== "" && Number.isInteger(n) && n >= 1 && n <= MAX_DAYS;
return { days: days ? n : 0 };
}
/**
* Delete these sessions and everything hung off them, in chunks (a single
* IN (...) of every id would pass SQLite's bound-variable limit). Runs in the
* CALLER's transaction when there is one. Returns the rows removed.
*/
function purgeSessionIds(db, ids) {
const { forgetHourly } = require("./hourly-usage");
const out = { purged_sessions: 0, purged_events: 0, purged_agents: 0 };
for (let i = 0; i < ids.length; i += PURGE_CHUNK) {
const chunk = ids.slice(i, i + PURGE_CHUNK);
const placeholders = chunk.map(() => "?").join(",");
// Cascading deletes handle agents/events, but token_usage FK might not cascade on all setups
out.purged_events += db
.prepare(`DELETE FROM events WHERE session_id IN (${placeholders})`)
.run(...chunk).changes;
out.purged_agents += db
.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`)
.run(...chunk).changes;
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...chunk);
db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run(
...chunk
);
forgetHourly(chunk);
out.purged_sessions += db
.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`)
.run(...chunk).changes;
}
return out;
}
/** What `purgeSessionIds` would delete, without deleting it (the dry run). */
function countSessionIds(db, ids) {
const out = { purged_sessions: ids.length, purged_events: 0, purged_agents: 0 };
for (let i = 0; i < ids.length; i += PURGE_CHUNK) {
const chunk = ids.slice(i, i + PURGE_CHUNK);
const placeholders = chunk.map(() => "?").join(",");
out.purged_events += db
.prepare(`SELECT COUNT(*) AS n FROM events WHERE session_id IN (${placeholders})`)
.get(...chunk).n;
out.purged_agents += db
.prepare(`SELECT COUNT(*) AS n FROM agents WHERE session_id IN (${placeholders})`)
.get(...chunk).n;
}
return out;
}
/** Finished sessions that started before `cutoff` (ISO), at most `limit`. */
function purgeableBefore(db, cutoff, limit = -1) {
return db
.prepare(`SELECT id, started_at FROM sessions WHERE ${PURGEABLE} AND started_at < ? LIMIT ?`)
.all(cutoff, limit);
}
/**
* Purge finished sessions older than `days`, a batch per transaction with a
* yield between batches. Resolves to the totals; never throws.
*/
async function applyRetention({
db = require("../db").db,
days,
now = Date.now(),
batch = RETENTION_BATCH,
maxSessions = RETENTION_MAX_PER_RUN,
} = {}) {
const out = { purged_sessions: 0, purged_events: 0, purged_agents: 0 };
if (!Number.isInteger(days) || days < 1) return out;
const cutoff = new Date(now - days * 86400 * 1000).toISOString();
try {
while (out.purged_sessions < maxSessions) {
const ids = purgeableBefore(
db,
cutoff,
Math.min(batch, maxSessions - out.purged_sessions)
).map((r) => r.id);
if (ids.length === 0) break;
const r = db.transaction(() => purgeSessionIds(db, ids))();
out.purged_sessions += r.purged_sessions;
out.purged_events += r.purged_events;
out.purged_agents += r.purged_agents;
if (r.purged_sessions === 0) break; // nothing removable: never spin
await yieldLoop();
}
} catch (err) {
out.error = err.message;
}
return out;
}
/** One `incremental_vacuum(n)`, stepped to the end on either driver. */
function incrementalVacuum(db, pages) {
const sql = `PRAGMA incremental_vacuum(${Math.max(1, Math.floor(pages))})`;
try {
db.prepare(sql).all(); // node:sqlite: one row per freed page
} catch (err) {
if (!/does not return data/i.test(err.message)) throw err;
db.pragma(`incremental_vacuum(${Math.max(1, Math.floor(pages))})`); // better-sqlite3
}
}
/**
* Hand free pages back to the OS, a slice at a time. A no-op unless the
* database is in INCREMENTAL auto_vacuum and its freelist is over `minPages`.
*/
async function reclaimFreePages({
db = require("../db").db,
minPages = RECLAIM_MIN_PAGES,
slice = RECLAIM_SLICE_PAGES,
maxPages = RECLAIM_MAX_PAGES,
} = {}) {
const out = { reclaimed_pages: 0 };
try {
if (db.pragma("auto_vacuum", { simple: true }) !== 2) return out;
let free = db.pragma("freelist_count", { simple: true });
if (free < minPages) return out;
while (free > 0 && out.reclaimed_pages < maxPages) {
incrementalVacuum(db, Math.min(slice, free, maxPages - out.reclaimed_pages));
const after = db.pragma("freelist_count", { simple: true });
if (after >= free) break;
out.reclaimed_pages += free - after;
free = after;
await yieldLoop();
}
} catch (err) {
out.error = err.message;
}
return out;
}
let lastRun = null;
let maintaining = null;
/**
* The hourly upkeep: `maintainDatabase` (checkpoint + optimize), then the
* opt-in retention, then the bounded free-page reclaim. A call while one runs
* joins it.
*/
function runScheduledMaintenance(opts = {}) {
if (maintaining) return maintaining;
maintaining = maintain(opts).finally(() => {
maintaining = null;
});
return maintaining;
}
async function maintain({ db } = {}) {
const dbm = require("../db");
const conn = db || dbm.db;
const started = new Date();
const out = { at: started.toISOString() };
try {
if (!db) dbm.maintainDatabase();
} catch {
/* maintainDatabase reports its own errors in its result */
}
const { days } = retentionConfig();
if (days) {
out.retention = await applyRetention({ db: conn, days });
if (out.retention.purged_sessions > 0) {
console.log(
`[retention] purged ${out.retention.purged_sessions} session(s) older than ${days} days`
);
}
}
out.reclaim = await reclaimFreePages({ db: conn });
out.ms = Date.now() - started;
lastRun = out;
return out;
}
/** Page counts for the Settings card. */
function spaceInfo(db = require("../db").db) {
const pageSize = db.pragma("page_size", { simple: true });
const pageCount = db.pragma("page_count", { simple: true });
const free = db.pragma("freelist_count", { simple: true });
const mode = db.pragma("auto_vacuum", { simple: true });
return {
auto_vacuum: mode === 2 ? "incremental" : mode === 1 ? "full" : "none",
page_size: pageSize,
page_count: pageCount,
freelist_pages: free,
free_bytes: free * pageSize,
};
}
let compacting = null;
let lastCompact = null;
function vacuumInWorker(dbPath) {
return new Promise((resolve, reject) => {
const w = new Worker(__filename, { workerData: { agenthubCompactWorker: true, dbPath } });
w.once("message", (m) => (m.error ? reject(coded(m.error.message, m.error.code)) : resolve(m)));
w.once("error", reject);
w.once(
"exit",
(code) => code !== 0 && reject(coded(`compact worker exited (${code})`, "ECOMPACT"))
);
});
}
/** Start (or join) the manual Compact. The outcome is `lastCompactResult()`. */
function runCompact() {
if (compacting) return compacting;
const fs = require("node:fs");
const { DB_PATH } = require("../db");
const size = () => {
try {
return fs.statSync(DB_PATH).size;
} catch {
return 0;
}
};
compacting = (async () => {
const started = Date.now();
const before = size();
try {
await vacuumInWorker(DB_PATH);
lastCompact = {
at: new Date(started).toISOString(),
ok: true,
size_before: before,
size_after: size(),
ms: Date.now() - started,
};
return lastCompact;
} catch (err) {
lastCompact = { at: new Date(started).toISOString(), ok: false, error: err.message };
throw err;
}
})().finally(() => {
compacting = null;
});
return compacting;
}
const isCompacting = () => compacting !== null;
const lastCompactResult = () => lastCompact;
const lastMaintenance = () => lastRun;
if (!isMainThread && workerData?.agenthubCompactWorker) {
// Same driver order as server/db.js: better-sqlite3, else node:sqlite.
let conn;
try {
try {
const Database = require("better-sqlite3");
conn = new Database(workerData.dbPath);
} catch {
const Database = require("../compat-sqlite");
conn = new Database(workerData.dbPath);
}
conn.pragma("busy_timeout = 30000");
conn.pragma("auto_vacuum = INCREMENTAL"); // stored by the VACUUM below
conn.exec("VACUUM");
try {
conn.pragma("wal_checkpoint(TRUNCATE)");
} catch {
/* a reader holds the WAL: the periodic checkpoint finishes it */
}
parentPort.postMessage({ ok: true });
} catch (err) {
parentPort.postMessage({ error: { message: err.message, code: err.code || "ECOMPACT" } });
} finally {
conn?.close();
}
}
module.exports = {
KEYS,
MAX_DAYS,
retentionConfig,
purgeSessionIds,
countSessionIds,
purgeableBefore,
applyRetention,
reclaimFreePages,
runScheduledMaintenance,
spaceInfo,
runCompact,
isCompacting,
lastCompactResult,
lastMaintenance,
};
+16 -8
View File
@@ -2,21 +2,29 @@
* @file Self-hosted ReDoc API reference. ReDoc renders the OpenAPI spec as a
* clean, three-panel reference document — a read-optimized complement to
* Swagger UI's interactive "try it out" console (both are served from the same
* `/api/openapi.json` spec). The ReDoc bundle ships with the `redoc`
* dependency and is served straight from `node_modules` rather than a CDN, so
* the docs render fully offline / air-gapped, consistent with the project's
* no-external-assets policy.
* `/api/openapi.json` spec). The ReDoc bundle is served from the repo itself
* rather than a CDN, so the docs render fully offline / air-gapped, consistent
* with the project's no-external-assets policy.
*
* The bundle is VENDORED (`server/vendor/redoc/`): `redoc.standalone.js` from
* the `redoc` npm package 2.5.4, byte-for-byte, with its MIT `LICENSE` and the
* bundled libraries' `redoc.standalone.js.LICENSE.txt`. The `redoc` package
* itself is not a dependency — the server only ever serves this one browser
* file, and the package pulled ~25 MB of Node-side dependencies into every
* runtime install. To update: `npm pack redoc@<version>`, copy
* `package/bundles/redoc.standalone.js` (+ `.LICENSE.txt`) over these files and
* bump the version named here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("path");
/**
* Absolute path to the prebuilt ReDoc standalone bundle inside the installed
* `redoc` package. Resolved through Node's module resolution so it works
* regardless of hoisting / install layout. Throws if `redoc` is not installed.
* Absolute path to the vendored ReDoc standalone bundle.
* @returns {string}
*/
function redocBundlePath() {
return require.resolve("redoc/bundles/redoc.standalone.js");
return path.join(__dirname, "..", "vendor", "redoc", "redoc.standalone.js");
}
/**
+13
View File
@@ -115,6 +115,19 @@ async function git(
});
return { stdout: result.stdout, stderr: result.stderr };
} catch (err) {
// ENOENT from execFile is ambiguous: a missing binary OR a missing cwd.
// With the folder present it is git itself that is absent — say so, with
// the fix, rather than `git status failed` (like ETMUXMISSING for tmux).
if (err.code === "ENOENT" && cmd === "git" && fs.existsSync(cwd)) {
const error = new Error(
"git is not installed (or not on the dashboard's PATH), and every git action needs it. " +
"Install it (sudo apt install git / brew install git / https://git-scm.com/download/win), " +
"then restart the dashboard."
);
error.code = "EGITMISSING";
error.git = { args, code: "EGITMISSING", stderr: "", stdout: "" };
throw error;
}
const error = new Error(`git ${args[0]} failed`);
error.code = err.code;
error.git = { args, code: err.code, stderr: err.stderr, stdout: err.stdout };
+114
View File
@@ -11,6 +11,9 @@
* - GET /api/settings/backup (Settings)
* - PUT /api/settings/backup (Settings)
* - POST /api/settings/backup/run (Settings)
* - GET /api/settings/retention (Settings)
* - PUT /api/settings/retention (Settings)
* - POST /api/settings/compact (Settings)
* - GET /api/workflows/runs (Workflows)
* - GET /api/workflows/runs/{runId} (Workflows)
* - GET /api/remote-sources (Remote Sources)
@@ -1225,6 +1228,117 @@ const paths = {
},
},
"/api/settings/retention": (() => {
const state = {
description: "Automatic retention setting, database space and the last Compact",
content: {
"application/json": {
schema: {
type: "object",
required: ["days", "max_days", "env_owned", "compacting"],
properties: {
days: {
type: "integer",
minimum: 0,
maximum: 3650,
description:
"Finished sessions older than this many days are purged by the hourly maintenance; 0 = off (the default)",
},
max_days: { type: "integer" },
env_owned: { type: "object", properties: { days: { type: "boolean" } } },
last_run: {
type: "object",
nullable: true,
description:
"This process's last hourly maintenance: {at, retention?: {purged_sessions, purged_events, purged_agents, error?}, reclaim: {reclaimed_pages, error?}, ms}",
},
space: {
type: "object",
nullable: true,
properties: {
auto_vacuum: { type: "string", enum: ["none", "full", "incremental"] },
page_size: { type: "integer" },
page_count: { type: "integer" },
freelist_pages: { type: "integer" },
free_bytes: { type: "integer" },
},
},
db_size: { type: "integer", description: "Database file size in bytes" },
compacting: { type: "boolean" },
last_compact: {
type: "object",
nullable: true,
description:
"This process's last Compact: {at, ok, size_before?, size_after?, ms?, error?}",
},
},
},
},
},
};
return {
get: {
tags: ["Settings"],
summary: "Automatic retention and database space",
operationId: "getRetention",
responses: { 200: state },
},
put: {
tags: ["Settings"],
summary: "Set the automatic retention",
description:
"Persisted to the settings file as AGENTHUB_RETENTION_DAYS. 0 turns it off. Same-origin only; in server mode an admin's.",
operationId: "setRetention",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["days"],
properties: { days: { type: "integer", minimum: 0, maximum: 3650 } },
},
},
},
},
responses: {
200: { ...state, description: "The new state" },
400: { description: "INVALID_INPUT: days is not a whole number from 0 to 3650" },
403: { description: "EBADORIGIN: another site asked" },
409: { description: "EENVOWNED: the environment set AGENTHUB_RETENTION_DAYS" },
500: { description: "ESAVE: the settings file could not be written" },
},
},
};
})(),
"/api/settings/compact": {
post: {
tags: ["Settings"],
summary: "Compact the database now (full VACUUM)",
description:
"Rewrites the database in a worker thread, switching it to incremental auto_vacuum, then truncates the WAL. Hook writes wait while it runs. Answers at once; poll GET /api/settings/retention while `compacting`. Same-origin only; in server mode an admin's.",
operationId: "compactDatabase",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["confirm"],
properties: { confirm: { type: "boolean", enum: [true] } },
},
},
},
},
responses: {
202: { description: "Started (or already running); the retention state" },
400: { description: "ECONFIRM: confirm: true is missing" },
403: { description: "EBADORIGIN: another site asked" },
},
},
},
"/api/settings/codex-hooks": (() => {
const status = {
description: "Where AgentHub's Codex hooks stand",
+85 -8
View File
@@ -294,6 +294,46 @@ const turnDurationStampsStmt = db.prepare(
"SELECT created_at FROM events WHERE session_id = ? AND event_type = 'TurnDuration'"
);
// What this process already handled of each session's transcript parse.
// `transcriptCache.extract` returns the SAME result object while the file is
// unchanged, and a grown file's result is the old arrays plus the new tail
// (errors/turnDurations keep their element objects, compaction entries their
// uuid). So an identical result skips the token, compaction, API-error and
// turn-duration writes outright — each is idempotent against the DB, so they
// would have written nothing new — and a grown one walks only the entries
// after the last one handled. Anything that does not line up (a full re-read,
// the session re-created, the head trimmed off a capped array) falls back to
// the whole array, exactly as before. Marks are applied only once the hook's
// transaction committed; bounded, oldest dropped first.
const TRANSCRIPT_MARKS_MAX = 500;
const transcriptMarks = new Map();
let pendingMarks = null;
function transcriptMarkFor(sessionId, transcriptPath, startedAt) {
const m = transcriptMarks.get(`${sessionId}\0${transcriptPath}`);
return m && m.startedAt === startedAt ? m : null;
}
function queueTranscriptMark(sessionId, transcriptPath, mark) {
if (pendingMarks) pendingMarks.push([`${sessionId}\0${transcriptPath}`, mark]);
}
function applyTranscriptMarks(marks) {
for (const [key, mark] of marks) {
transcriptMarks.delete(key);
transcriptMarks.set(key, mark);
if (transcriptMarks.size > TRANSCRIPT_MARKS_MAX)
transcriptMarks.delete(transcriptMarks.keys().next().value);
}
}
/** The entries of `arr` after `last` (found by `same`), or all of `arr`. */
function tailAfter(arr, last, same) {
if (!arr || last == null) return arr;
for (let i = arr.length - 1; i >= 0; i--) if (same(arr[i], last)) return arr.slice(i + 1);
return arr;
}
/** The session's `<id>-main` agent, created and broadcast. */
function createMainAgent(session) {
const mainAgentId = `${session.id}-main`;
@@ -944,6 +984,11 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
const result = transcriptCache.extract(transcriptPath);
if (result) {
const { tokensByModel, compaction, latestModel } = result;
// Already handled (see transcriptMarks): the same result skips the
// idempotent writes below, a grown one handles only its new entries.
const mark = transcriptMarkFor(sessionId, transcriptPath, session.started_at);
const unchanged = !!mark && mark.result === result;
const lastOf = (arr) => (arr && arr.length ? arr[arr.length - 1] : null);
// Keep session.model in sync with the user's *current* model — the
// transcript's most recent assistant entry is the source of truth, since
@@ -972,8 +1017,13 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
// Register compaction agents and events.
// Each isCompactSummary entry in the JSONL = one compaction that occurred.
// Deduplicate by uuid so we only create once per compaction.
if (compaction) {
for (const entry of compaction.entries) {
if (compaction && !unchanged) {
const fresh = tailAfter(
compaction.entries,
mark?.compactionUuid,
(e, uuid) => e.uuid === uuid
);
for (const entry of fresh) {
const compactId = `${sessionId}-compact-${entry.uuid}`;
if (stmts.getAgent.get(compactId)) continue;
@@ -1024,7 +1074,7 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
}
}
if (tokensByModel) {
if (tokensByModel && !unchanged) {
// Each bucket carries its pricing dimensions (model/speed/geo/tier) plus
// the 1h cache-write split and server-tool request counts.
for (const tokens of Object.values(tokensByModel)) {
@@ -1059,12 +1109,15 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
}
// Register API errors from transcript (quota limits, rate limits, overloaded, etc.)
if (result.errors) {
const freshErrors = unchanged
? null
: tailAfter(result.errors, mark?.lastError, (a, b) => a === b);
if (freshErrors && freshErrors.length) {
let newErrorRecorded = false;
// Deduplicate against the errors already recorded, read ONCE per hook:
// `result.errors` is the transcript's whole history, not just new ones.
const recorded = new Set(apiErrorSummariesStmt.all(sessionId).map((r) => r.summary));
for (const apiErr of result.errors) {
for (const apiErr of freshErrors) {
const errSummary = `${apiErr.type}: ${apiErr.message}`;
if (recorded.has(errSummary)) continue;
recorded.add(errSummary);
@@ -1136,13 +1189,16 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
}
// Register turn duration events from transcript
if (result.turnDurations) {
const freshTurns = unchanged
? null
: tailAfter(result.turnDurations, mark?.lastTurn, (a, b) => a === b);
if (freshTurns && freshTurns.length) {
// `turnDurations` is every turn in the transcript (up to 1000), on
// every hook: dedupe against ONE read of the recorded timestamps (a
// covering-index scan) instead of a query per turn — 23 ms per hook on
// a 763-turn session before.
const recorded = new Set(turnDurationStampsStmt.all(sessionId).map((r) => r.created_at));
for (const td of result.turnDurations) {
for (const td of freshTurns) {
const tdTs = td.timestamp || new Date().toISOString();
if (recorded.has(tdTs)) continue;
recorded.add(tdTs);
@@ -1171,6 +1227,14 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
}
}
queueTranscriptMark(sessionId, transcriptPath, {
result,
startedAt: session.started_at,
compactionUuid: lastOf(compaction?.entries)?.uuid ?? null,
lastError: lastOf(result.errors),
lastTurn: lastOf(result.turnDurations),
});
// Update session metadata with enriched data (thinking blocks, usage extras)
if (result.usageExtras || result.thinkingBlockCount > 0) {
const session = stmts.getSession.get(sessionId);
@@ -1250,7 +1314,19 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
* inside it described a row a later throw could still roll back.
*/
function processEvent(hookType, data, tool) {
return holdFrames(() => processEventTx(hookType, data, tool));
const outer = pendingMarks;
pendingMarks = [];
let marks = null;
try {
const out = holdFrames(() => processEventTx(hookType, data, tool));
marks = pendingMarks;
return out;
} finally {
pendingMarks = outer;
// Only what COMMITTED is marked handled: a rolled-back hook's entries are
// walked again by the next one.
if (marks && !db.inTransaction) applyTranscriptMarks(marks);
}
}
/** Run `fn` with frames held, then send them if it returned (nested: the
@@ -2176,6 +2252,7 @@ const watchdogTimer = setInterval(watchdogCheck, WATCHDOG_INTERVAL_MS);
if (watchdogTimer.unref) watchdogTimer.unref();
router.transcriptCache = transcriptCache;
router.transcriptMarks = transcriptMarks;
router.watchdogCheck = watchdogCheck;
router.livenessReap = livenessReap;
module.exports = router;
+4 -1
View File
@@ -1937,7 +1937,10 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"EMEMBERGONE",
].includes(err.code)
? 409
: 500;
: // Not the request's fault and not a server bug: the host lacks git.
err.code === "EGITMISSING"
? 503
: 500;
// git's own stderr is the useful half of a failed push or pull (a
// rejection, a missing credential, a diverged branch), so it joins the
// message rather than riding a side field the client would have to know
+66 -41
View File
@@ -1,5 +1,5 @@
/**
* @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions, plus the privacy controls (secret redaction, ignored folders), the push-ingest token and the daily database backup (server/lib/db-backup.js). This allows the frontend to manage and maintain the agent monitoring system effectively.
* @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions, plus the privacy controls (secret redaction, ignored folders), the push-ingest token, the daily database backup (server/lib/db-backup.js) and the opt-in retention + Compact (server/lib/db-retention.js). This allows the frontend to manage and maintain the agent monitoring system effectively.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -28,6 +28,7 @@ const { getCodexHome, setCodexHome } = require("../lib/codex-home");
const { envOwned, applySetting } = require("../lib/settings-env");
const privacy = require("../lib/privacy");
const dbBackup = require("../lib/db-backup");
const retention = require("../lib/db-retention");
const pushToken = require("../lib/push-token");
const pricingSync = require("../lib/pricing-sync");
const {
@@ -678,10 +679,66 @@ function pushTokenWrite(fn) {
router.post("/push-token", sameOriginGuard, pushTokenWrite(pushToken.rotatePushToken));
router.delete("/push-token", sameOriginGuard, pushTokenWrite(pushToken.revokePushToken));
// GET/PUT /api/settings/retention, POST /api/settings/compact — database size
// upkeep (server/lib/db-retention.js). Retention is OFF by default: `days` 0
// turns it off, 1–3650 makes the hourly maintenance purge finished sessions
// older than that, the manual purge's rule. It persists to the settings file;
// one the environment set is `env_owned` and refused. Compact (a full VACUUM
// in a worker) answers 202 and the page polls GET while `compacting`.
function retentionState() {
let space = null;
try {
space = retention.spaceInfo(db);
} catch {
/* pragma unavailable: the card shows no numbers */
}
return {
...retention.retentionConfig(),
max_days: retention.MAX_DAYS,
env_owned: { days: envOwned.has(retention.KEYS.days) },
last_run: retention.lastMaintenance(),
space,
db_size: getDbSize(),
compacting: retention.isCompacting(),
last_compact: retention.lastCompactResult(),
};
}
router.get("/retention", (_req, res) => res.json(retentionState()));
router.put("/retention", sameOriginGuard, (req, res) => {
const days = req.body?.days;
if (!Number.isInteger(days) || days < 0 || days > retention.MAX_DAYS) {
return res.status(400).json({
error: {
code: "INVALID_INPUT",
message: `days must be a whole number from 0 (off) to ${retention.MAX_DAYS}`,
},
});
}
if (envOwned.has(retention.KEYS.days)) {
return res.status(409).json({
error: { code: "EENVOWNED", message: `${retention.KEYS.days} is set by the environment` },
});
}
try {
applySetting(retention.KEYS.days, days === 0 ? "" : String(days));
} catch (err) {
return res.status(500).json({ error: { code: "ESAVE", message: err.message } });
}
res.json({ ok: true, ...retentionState() });
});
router.post("/compact", sameOriginGuard, (req, res) => {
if (req.body?.confirm !== true) {
return res.status(400).json({
error: { code: "ECONFIRM", message: "compacting needs confirm: true" },
});
}
retention.runCompact().catch(() => {}); // the outcome is `last_compact` in GET
res.status(202).json({ ok: true, ...retentionState() });
});
// POST /api/settings/cleanup — abandon stale sessions, purge old data.
// `purge_days` is a whole number ≥ 1 (0.1 days would purge almost everything);
// `dry_run: true` answers the same counts and changes nothing.
const PURGE_CHUNK = 500;
router.post("/cleanup", (req, res) => {
const { abandon_hours, purge_days } = req.body;
const dryRun = req.body.dry_run === true;
@@ -739,11 +796,7 @@ router.post("/cleanup", (req, res) => {
if (purge_days && typeof purge_days === "number" && purge_days > 0) {
const cutoff = new Date(Date.now() - purge_days * 86400 * 1000).toISOString();
// Only purge completed/error/abandoned sessions, never active
const toDelete = db
.prepare(
"SELECT id FROM sessions WHERE status IN ('completed','error','abandoned') AND started_at < ?"
)
.all(cutoff);
const toDelete = retention.purgeableBefore(db, cutoff);
if (dryRun) {
// Exactly the ids the real run deletes: those already purgeable, plus
@@ -752,43 +805,15 @@ router.post("/cleanup", (req, res) => {
...toDelete.map((r) => r.id),
...wouldAbandon.filter((r) => r.started_at < cutoff).map((r) => r.id),
];
for (let i = 0; i < ids.length; i += PURGE_CHUNK) {
const chunk = ids.slice(i, i + PURGE_CHUNK);
const placeholders = chunk.map(() => "?").join(",");
result.purged_events += db
.prepare(`SELECT COUNT(*) AS n FROM events WHERE session_id IN (${placeholders})`)
.get(...chunk).n;
result.purged_agents += db
.prepare(`SELECT COUNT(*) AS n FROM agents WHERE session_id IN (${placeholders})`)
.get(...chunk).n;
}
result.purged_sessions = ids.length;
Object.assign(result, retention.countSessionIds(db, ids));
return res.json({ ok: true, ...result });
}
// One transaction, in chunks: a single IN (...) list of every id would
// pass SQLite's bound-variable limit on a large history.
db.transaction(() => {
const ids = toDelete.map((r) => r.id);
for (let i = 0; i < ids.length; i += PURGE_CHUNK) {
const chunk = ids.slice(i, i + PURGE_CHUNK);
const placeholders = chunk.map(() => "?").join(",");
// Cascading deletes handle agents/events, but token_usage FK might not cascade on all setups
result.purged_events += db
.prepare(`DELETE FROM events WHERE session_id IN (${placeholders})`)
.run(...chunk).changes;
result.purged_agents += db
.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`)
.run(...chunk).changes;
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...chunk);
db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run(
...chunk
);
forgetHourly(chunk);
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...chunk);
}
result.purged_sessions = ids.length;
})();
// One transaction; the deletion itself is shared with the automatic
// retention (server/lib/db-retention.js).
const ids = toDelete.map((r) => r.id);
const purged = db.transaction(() => retention.purgeSessionIds(db, ids))();
Object.assign(result, purged, { purged_sessions: ids.length });
}
res.json({ ok: true, ...result });
+78 -10
View File
@@ -1,5 +1,11 @@
/**
* @file Periodic git upstream check and WebSocket broadcast when update availability changes.
* @file Periodic release/upstream check and WebSocket broadcast when update
* availability changes. Hourly by default (`DASHBOARD_UPDATE_CHECK_INTERVAL_MS`,
* floor 60 s); a tick with no browser connected is skipped — a checkout install
* runs `git fetch` on every check, and nobody would see the answer — and the
* first browser to connect after a skipped or stale check gets one promptly.
* The manual "Check now" (`POST /api/updates/check`, `agenthub update-check`)
* is not routed through here and always runs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -13,11 +19,39 @@ function isUpdateCheckDisabled() {
function intervalMs() {
const n = Number.parseInt(process.env.DASHBOARD_UPDATE_CHECK_INTERVAL_MS || "", 10);
if (Number.isFinite(n) && n >= 60_000) return n;
return 5 * 60 * 1000;
return 60 * 60 * 1000;
}
function startUpdateScheduler({ broadcast }) {
/** Delay before the catch-up check a reconnecting browser triggers. */
const CONNECT_CHECK_DELAY_MS = 3_000;
/**
* @param {object} opts
* @param {Function} opts.broadcast
* @param {() => number} [opts.getConnectionCount] browsers connected now
* (default: server/websocket.js's); 0 skips a scheduled tick.
* @param {(fn: Function) => Function} [opts.onConnection] subscribe to new
* browser connections; returns an unsubscribe.
* @param {Function} [opts.getStatus] the check itself (tests inject it).
* @param {number} [opts.initialDelayMs] first tick after start (default 8 s).
*/
function startUpdateScheduler({
broadcast,
getConnectionCount,
onConnection,
getStatus = getUpdatesStatus,
initialDelayMs = 8_000,
}) {
if (isUpdateCheckDisabled()) return { stop: () => {} };
if (!getConnectionCount || !onConnection) {
const ws = require("./websocket");
getConnectionCount = getConnectionCount || ws.getConnectionCount;
onConnection = onConnection || ws.onConnection;
}
const every = intervalMs();
let lastCheckAt = 0;
let inFlight = false;
let connectTimer = null;
let lastFingerprint = "";
let lastHadUpdate = false;
@@ -25,10 +59,24 @@ function startUpdateScheduler({ broadcast }) {
let initialTimer = null;
let intervalTimer = null;
async function tick() {
// A scheduled tick: skipped while no browser is connected.
function scheduledTick() {
if (stopped) return;
let clients = 1;
try {
const status = await getUpdatesStatus();
clients = getConnectionCount();
} catch {
/* unknown: check as before */
}
if (clients > 0) tick();
}
async function tick() {
if (stopped || inFlight) return;
inFlight = true;
lastCheckAt = Date.now();
try {
const status = await getStatus();
// Fingerprint must change whenever anything user-visible changes,
// including the *shape* of manual_command (e.g. user switches branch
// or adds an upstream remote — same remote_sha / commits_behind, but
@@ -72,15 +120,33 @@ function startUpdateScheduler({ broadcast }) {
}
} catch {
// Non-fatal — never block the server on update checks
} finally {
inFlight = false;
}
}
initialTimer = setTimeout(() => {
tick();
}, 8_000);
// A browser came back after the scheduled checks were skipped (or the last
// one is older than the interval): check once, shortly, so the banner is
// current without waiting up to an hour.
let unsubscribe = null;
try {
unsubscribe = onConnection(() => {
if (stopped || connectTimer || inFlight) return;
if (Date.now() - lastCheckAt < every) return;
connectTimer = setTimeout(() => {
connectTimer = null;
tick();
}, CONNECT_CHECK_DELAY_MS);
if (typeof connectTimer.unref === "function") connectTimer.unref();
});
} catch {
/* no WebSocket server: scheduled ticks only */
}
initialTimer = setTimeout(scheduledTick, initialDelayMs);
if (typeof initialTimer.unref === "function") initialTimer.unref();
intervalTimer = setInterval(tick, intervalMs());
intervalTimer = setInterval(scheduledTick, every);
if (typeof intervalTimer.unref === "function") intervalTimer.unref();
return {
@@ -88,8 +154,10 @@ function startUpdateScheduler({ broadcast }) {
stopped = true;
if (initialTimer) clearTimeout(initialTimer);
if (intervalTimer) clearInterval(intervalTimer);
if (connectTimer) clearTimeout(connectTimer);
if (typeof unsubscribe === "function") unsubscribe();
},
};
}
module.exports = { startUpdateScheduler };
module.exports = { startUpdateScheduler, intervalMs };
+22
View File
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2015-present, Rebilly, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because one or more lines are too long
+102
View File
@@ -0,0 +1,102 @@
/*!
Copyright (c) 2018 Jed Watson.
Licensed under the MIT License (MIT), see
http://jedwatson.github.io/classnames
*/
/*!
* ReDoc - OpenAPI/Swagger-generated API Reference Documentation
* -------------------------------------------------------------
* Version: "2.5.4"
* Repo: https://github.com/Redocly/redoc
*/
/*!
* Stickyfill -- `position: sticky` polyfill
* v. 1.1.1 | https://github.com/wilddeer/stickyfill
* Copyright Oleg Korsunsky | http://wd.dizaina.net/
*
* MIT License
*/
/*!
* perfect-scrollbar v1.5.3
* Copyright 2021 Hyunje Jun, MDBootstrap and Contributors
* Licensed under MIT
*/
/*! @license DOMPurify 3.4.13 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.13/LICENSE */
/*!***************************************************
* mark.js v8.11.1
* https://markjs.io/
* Copyright (c) 2014–2018, Julian Kühnel
* Released under the MIT license https://git.io/vwTVl
*****************************************************/
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* use-sync-external-store-shim.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* Prism: Lightweight, robust, elegant syntax highlighting
*
* @license MIT <https://opensource.org/licenses/MIT>
* @author Lea Verou <https://lea.verou.me>
* @namespace
* @public
*/
/** @license React v16.13.1
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v17.0.2
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
+18
View File
@@ -48,6 +48,13 @@ function initWebSocket(server) {
});
wss.on("connection", (ws, req) => {
for (const fn of connectionListeners) {
try {
fn(ws);
} catch {
/* a listener never breaks a connection */
}
}
// Server mode: who this socket belongs to, for broadcast filtering, and
// the cookie to re-check it by (a logout or a disabled user ends it).
if (isServerMode()) {
@@ -252,6 +259,16 @@ function broadcast(type, data) {
});
}
/**
* Subscribe to new `/ws` browser connections (the update scheduler checks once
* when a browser returns after its ticks were skipped). Returns an unsubscribe.
*/
const connectionListeners = new Set();
function onConnection(fn) {
connectionListeners.add(fn);
return () => connectionListeners.delete(fn);
}
function getConnectionCount() {
if (!wss) return 0;
let count = 0;
@@ -306,6 +323,7 @@ module.exports = {
initPtyWebSocket,
broadcast,
getConnectionCount,
onConnection,
closeWebSocket,
sizeFromQuery,
};
+1 -1
View File
@@ -23,7 +23,7 @@ that is missing the header, add it as part of the same change.
| Included | Excluded |
| -------- | -------- |
| `*.js`, `*.ts`, `*.tsx`, `*.cjs`, `*.mjs` | anything under `node_modules/`, `dist/`, `build/`, `data/` |
| `*.py`, `*.sh` | vendored/minified files (`*.min.js`) |
| `*.py`, `*.sh` | vendored/minified files (`*.min.js`, anything under a `vendor/` directory) |
| `*.css` | generated files (they carry their own AUTO-GENERATED banner) |
| | snapshots (`__snapshots__/`), lockfiles, JSON/YAML/Markdown |
| | anything **gitignored** — local runtime state that happens to live in the checkout (a workspace's own `.agenthub/profile/hooks/*.sh`, a scratch venv) is not this repo's source and can never be made compliant here |
+3 -2
View File
@@ -11,7 +11,8 @@
# are still audited, so a file an agent just wrote is caught before it is
# staged. The extra excludes below are for paths git DOES track (or would list)
# but the rule exempts — notably generated bundles, which are still
# generated tsc output, and snapshots.
# generated tsc output, snapshots, and third-party files vendored under a
# `vendor/` directory (e.g. server/vendor/redoc/), which keep their own licence banner.
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail
@@ -34,7 +35,7 @@ while IFS= read -r f; do
done < <(
git -C "$ROOT" ls-files --cached --others --exclude-standard |
grep -E '\.(js|ts|tsx|cjs|mjs|py|sh|css)$' |
grep -vE '\.min\.js$|(^|/)(__snapshots__|node_modules|dist|build)/|^(data|monitoring/\.(bin|data))/' || true
grep -vE '\.min\.js$|(^|/)(__snapshots__|node_modules|dist|build|vendor)/|^(data|monitoring/\.(bin|data))/' || true
)
if [ "$missing" -eq 0 ]; then
+1 -1
View File
@@ -36,7 +36,7 @@ Update docs **in the same change-set (PR/commit) as the code**, before claiming
| **DB schema** | `docs/DATABASE.md`, `ARCHITECTURE.md` (ERD/schema) |
| **WebSocket message** | `client/README.md` (Event Types), `server/README.md` |
| **Feature / page / background service** | `docs/OVERVIEW.md` (feature table + data-flow list), `ARCHITECTURE.md` (module table), `server/README.md` or `client/README.md` |
| **CLI command / script** | `docs/OVERVIEW.md` commands, `CLAUDE.md` / `AGENTS.md`, `INSTALL.md` / `SETUP.md` |
| **CLI command / script** | `docs/OVERVIEW.md` commands, `CLAUDE.md` / `AGENTS.md`, `INSTALL.md` |
| **New language** | `docs/I18N.md`, `client/src/i18n/locales/<xx>/*`, `client/src/i18n/index.ts` (add to `supportedLngs` AND the `resources` map) |
## Procedure
@@ -46,7 +46,7 @@ i18n architecture: **Supported languages** list, `supportedLngs`, the 15 namespa
## Tier 3 — situational
- `.env.example` — every env var belongs here with a sane default + comment.
- `INSTALL.md`, `SETUP.md` — install/run commands.
- `INSTALL.md` — install/run commands, configuration, troubleshooting (`SETUP.md` is only a pointer to it).
- `CLAUDE.md`, `AGENTS.md` — agent working guides; update when commands, file locations, or workflows change.
- `docs/README.md` — docs index; add a link when a new `docs/*.md` is created.