Measured on a copy of a real 1.2 GB / 253K-event database, 10 simulated
browsers + 10 hooks/s for 90 s: event-loop delay p99 1239 ms → 32 ms (max
2.0 s → 94 ms), 266 failed requests → 0, 74 → 111 requests/s served, server
RSS 633 → 346 MB. Entry bundle 975 KB raw → 394 KB (106 KB brotli).
Server
- /api/analytics: the events-table scans run in the read worker
(scoped-stats eventAggregates, read-pool job analyticsEvents); runRead takes
maxAgeMs — analytics 10 s, /api/workflows 30 s — keyed by the args
- /api/workflows/skill-usage: +event_type and a tool_name range instead of
LIKE, so it uses idx_events_tool (784 ms → a few ms)
- idx_events_session_type_tool (session_id, event_type, tool_name,
created_at) replaces idx_events_session_type: covering reads for session
stats, the hook dedupes and the import resume point (1.2 s → 20 ms); 64 MB
cache_size and 256 MB mmap_size on both connections
- WAL: the server sets wal_autocheckpoint = 0 and checkpoints PASSIVE every
DASHBOARD_WAL_CHECKPOINT_MS (5 s) from the read worker's own writable
connection; a failed checkpoint restores the automatic one (hook COMMIT p95
109 ms → 15 ms at 100 hooks/s)
- tmux: one list-sessions answers a whole GET /api/lanes and the heal beat
(liveSessions), shared for 1 s and cleared by this process's new/kill —
GET /api/run's sync spawns had blocked 60 s of a 90 s run; the heal beat
skips tmux when nothing runs; attach options chained into 2 spawns
- hooks: TurnDuration/APIError deduped against one read per hook instead of a
query per transcript entry (23 ms/hook on a 763-turn session); one lane
scan per hook; hourly upserts skip rows that did not grow; frames are sent
after the transaction commits (and after the pane binding); new_event
carries its row id; a split pane's (slot 1–9) hooks no longer rebind the
lane's session_id
- watchdog liveness probe runs ps asynchronously; transcript newline scan via
indexOf; startup subagent backfill fingerprints the files it already checked
- pty attach: backpressure (pause past 1 MB unsent, resume under 256 KB)
- static: hashed assets served from their pre-compressed .br/.gz sibling
Client
- hidden tabs neither refetch on WS events nor poll (useLiveRefresh, panes,
lane cards, git header, changes panel, settings); no per-tab hourly
/updates/check (the server already checks)
- refetch storms throttled (workspace tree/session list 3 s, /api/run only on
a run-key change, notification pane lookup only when shown); WS reconnect
jitter; terminal retry backoff 1.5 → 30 s, stops on 1008
- heavier throttles for aggregates (15 s) and detail views; Monitor keepIfSame;
GET in-flight dedupe in request()
- lazy Dashboard/History, xterm (with the first terminal), lane modals, Sagi
(only while enabled); only the active locale loads; Workspace chunk
prefetched on a /workspace deep link
- Sagi: no work while disabled, pupil via CSS variables, memo avatar
- build: .br/.gz siblings, sourcemap hidden; service worker no longer caches
/assets/ (cache v3 drops the old one)
Tests: scripts/test-server.js drops the GIT_DIR-family variables a hook
exports, so a suite run from a linked worktree's pre-commit can no longer
commit into the real repository or set core.bare.
Docs: ARCHITECTURE, DATABASE, API, DEPLOYMENT, client README,
docs/plans/perf/README.md (audit, checklist, results).
Issue #62: a tmux client whose LC_ALL/LC_CTYPE/LANG names no UTF-8 prints `_`
for every non-ASCII character, and a macOS LaunchAgent starts the dashboard
with no locale at all — Vietnamese text and Claude Code's logo came out as
underscores in every browser terminal.
- shell-path.js: ensureUtf8Locale() defaults LC_CTYPE (en_US.UTF-8 on macOS,
C.UTF-8 elsewhere) at start, before any tmux call; an explicit LC_ALL and
Windows are left alone
- RunSetup.tsx: the `/` list now offers Claude Code's bundled skills
(/code-review, /security-review, /simplify, /loop…) and newer built-ins, and
every enabled plugin's commands and skills as /<plugin>:<name>
- Claude login decided by the TOKEN's owner (GET /api/oauth/profile, cached
per token), not by .claude.json: a running session can leave the
credentials store holding one account's token under another account's
name. The Profiles modal, its Check quota and the status-bar quota now show
the account requests are really billed to and warn on the mismatch; a
switch saves the outgoing token into its OWNER's profile (credentials only)
instead of overwriting the profile .claude.json names; saving a profile
while they disagree is refused (EMISMATCH); a saved profile holding another
account's token reports EWRONGACCOUNT instead of that account's usage
- Running Claude Code sessions follow a switch on their own (they re-read the
credentials store when it changes), so the "close your sessions first"
warning is gone
node-pty on Windows looks the file it starts up on PATH exactly as given and
never adds `.exe`, so `tmux attach-session` started as a bare `tmux` failed
with "File not found" on every attach: the browser's terminal socket opened,
closed, and reconnected every 1.5 s.
- pty-attach.js: attachBinary names tmux by its full file name on Windows
(ai-tools.which("tmux"), else tmux.exe)
- websocket.js: an attach that throws is logged as `[WS] attach <run> failed`,
the one place that says why the terminal keeps reconnecting
psmux (the WinGet `tmux` on Windows) has no `=name` exact-match targets: it
answered "no server running on session '=agenthub-lane-1'" for a live pane,
so every Windows start reported "the session's command exited as soon as it
started" while the session ran on, and the browser could not attach.
- server/lib/tmux.js: `session`/`pane` targets probe the first live session
once — `=name` failing where the bare name answers means bare names from
then on; real tmux keeps `=name` / `=name:` (it matches by prefix otherwise)
- pty-attach.js and cc-update-all-helper.js use the same targets
- Windows panes: a WinGet tmux runs native programs and there is no `sh`, so
every pane's `sh -c` wrapper died at once ("Command failed: tmux set-option
… mouse on"). paneArgv runs the CLI itself on win32, and a Terminal gets
tmux's own default shell (newSession drops the `--` for an empty argv).
- Plugin UI build: client deps install with --ignore-scripts and tsc/vite run
by the running node instead of `npm run build`. On Windows esbuild's
postinstall went through cmd.exe with no `node` on the server's PATH, both
installs failed, and Update now kept the previous UI. A test keeps the two
build steps equal to client/package.json's build script.
- Update all: Atomics.wait instead of a `sleep` binary Windows lacks.
AI Proxy (was AI Router):
- server/lib/ai-proxies.js, proxy-apply.js, routes/ai-proxies.js mounted at
/api/ai-proxies and the old /api/ai-routers; `router` still accepted on the
start routes; profiles move to <CLAUDE_HOME>/agenthub-proxies/
- dashboard_runs.router renamed in place to proxy: an older build on the same
DB fails on that column, so dev and prod sharing one DB update together
- a proxy's baseUrl stored as its root (a typed /v1 made /v1/v1/messages);
Test probes Claude /v1/messages and Codex /v1/responses
- Settings → AI Proxy (/settings/ai-router redirects), ProxyPicker
Updates:
- every tab reloads onto the new UI build when its WebSocket reconnects to a
server of another version (lib/newBuildReload.ts, once per version)
- Update now stops waiting on a plugin chain that failed or pulled nothing
(the old server never goes down), and reloads as soon as a new version answers
Also:
- a pane whose command exits at once says so (EPANEEXITED) instead of
"Command failed: tmux set-option … mouse on"
- the pane drag preview draws where the pane actually lands
- Sessions page tabs: Sessions · Events · Agents. QueryExplorer.tsx became
AgentsTable.tsx (agents only — sessions and events already had tabs);
/query redirects to /sessions?view=agents and leaves the sidebar.
GET /api/query is unchanged and still answers all three entities.
- Windows picker opens in front: -EncodedCommand script spends the hidden
first ShowWindow that windowsHide gives the process, then brings the TopMost
owner forward via AttachThreadInput (v2.5.3's owner was itself hidden).
- Session page Resume/Fork/Open pane: owningLane and sessionWorktreeName
match Windows folders (either separator), with a test.
A workspace could not be created on Windows: "cwd must be an absolute path"
for D:\…, because createLane/canonicalCwd tested for a leading "/".
- lanes.js: path.isAbsolute/path.resolve; canonicalCwd stops at any root (a
missing drive looped forever); resolveLaneByCwd and /ensure accept "\" too
- native-dialog.js: the Windows picker gets an invisible TopMost owner form,
shown first — an ownerless dialog from the background server opened behind
the browser, or hidden
- windows-hide.js: every child process on Windows starts with windowsHide,
required first by the server and the detached scripts (a console window
flashed per git/npm call)
- Confirm/update/clear-data modals and the quota panel scroll instead of
overflowing a short window
v2.5.1's update died at "bootstrap" on every platform: boot.npmCommand is
not a function. Run as the entry, plugin-bootstrap.js calls bootstrap() →
plugin-open.js, which requires plugin-bootstrap back and got the module.exports
of that moment — {} — because it was assigned at the bottom, below the entry
block. plugin-open.js already exported first for the same reason.
- module.exports moved above the entry block in plugin-bootstrap.js
- A test checks both entry scripts assign module.exports before their entry
block (it fails on v2.5.1's file); requiring the module, as the other tests
do, never takes the entry path
A Windows self-update failed at "bootstrap": 'npm.cmd' is not recognized.
The update chain runs with the dashboard server's environment, whose PATH
need not reach npm.cmd, and the bootstrap looked npm up through cmd.exe
(shell: true, which also printed DEP0190).
- npmCommand() in scripts/plugin-bootstrap.js runs the npm bundled with the
running node (npm-cli.js beside process.execPath, Windows and Unix
layouts) with no shell, and puts that node's directory first on PATH (kept
under the env's own Path/PATH key) so npm scripts find the same node; npm
on PATH only when that node ships none
- Used by the runtime install, the UI build (plugin-open.js) and the
doctor's npm row (plugin-doctor.js)
- Settings → About → "Start AgentHub automatically" (AutostartSettings.tsx,
GET/PUT /api/settings/autostart, server/lib/autostart.js). The switch is
the systemd --user unit's / LaunchAgent's enabled state: on writes a unit
for the running server when none exists and enables it, off disables it;
neither starts or stops the server. A foreign unit is never touched
(409 EFOREIGN)
- The plugin bootstrap no longer installs a unit on every install: it
installs or starts one only while the user has it enabled, and a rewrite
keeps the enabled state (writeUserService({ enable })). An install that
already had the unit enabled stays on. A stale-but-enabled unit beside a
live detached server is repointed, no longer started onto the bound port
- unitIsEnabled / setUnitEnabled in systemd-service.js and launchd-service.js
Also:
- fix(workspace): status bar no longer covers the chat box below md (#61)
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files
Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
- v2.3.0's tree reported 2.2.1, so every install saw an update forever;
package.json, the lockfile, openapi.yaml, the CLAUDE.md marker and the
snapshot now read 2.3.1
- A failed plugin-update step records the tail of the command's stdout and
stderr plus its exit code or signal (failureText); plugin-bootstrap.js
prints its refusal on stdout, which the verdict used to drop. Update all
uses the same text
- updates.test.js never applies an update: it skips when the checkout is
behind a release (apply ran npm run setup and pruned dev dependencies)
- Files tree lists gitignored files and folders, named in orange
(addIgnored in git-diff.js); change-tree rows indent in whole steps
- Changes panel: a working file opens in a CodeMirror editor with its diff
inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the
unchanged lines, Whole file shows them; side by side at 50/50 and full
width; a Revert per block; original from HEAD or the branch's merge base
(`mergeBase` on GET /git/changes?range=branch)
- Every diff: green = line added whole, red = removed whole, blue = edited
in place (--diff-mod)
- History drawn as a commit graph (#58: `parents` on GET /git/log,
--date-order; lib/commitGraph.ts)
- Per-workspace never-stage list, drawn orange in the Files tree (--kept);
closed Terminal rows past three deleted
- Status bar plan quota for Claude (per account) and Codex
(GET /api/pricing/quota, server/lib/quota.js); compact hover panel that
stays open, Refresh button with a spinner
- Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies;
compact sidebar navigation and short session times
- Changes panel: Files and Changes merged into one panel; the overview's
groups drawn as a folder tree (compacted chains, file counts, fold,
stage/unstage a whole folder)
- AI-suggested commit message for what is staged
(POST /api/lanes/:id/git/commit-message, claude -p --safe-mode, haiku)
- AI Router: optional "Behaves as" Claude model id, written as
modelPicker options into the router's --settings file (issue #56)
- Sidebar: session rows show last activity, live rows their start time;
a closed Terminal row can be removed (DELETE /api/run/history/:id)
- Every pane's CLI starts only once a client is attached; claude panes
no longer forced inline; tmux scrollbar one column wide
- Update notifier: "Check for updates" reopens a closed notice
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of
/api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note.
REPL `help lanes|workspace|workspaces` resolves through the aliases.
- A workspace holds up to ten sessions (slots 0-9); every console tab is a
group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout);
parseGroups keeps the stored active tab when an earlier one is dropped.
- Browse button beside path fields: POST /api/lanes/pick-path opens the OS
picker on the server's desktop — loopback only and no forwarding header
(EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is
killed when the request closes, a folder field falls back to the in-app
browser on any failure.
- Changes rail counts chip, image viewer (/git/raw), status bar session
counts and per-day cost (`date=` on /api/pricing/cost).
- /agenthub-open and /agenthub-doctor run from the INSTALLED build
(supersededBy), doctor offers fixes behind two confirmations; plugin-open
treats a locked bootstrap as non-fatal.
- New-terminal shell waits for the first attach (issue #55).
- repo-onboarding / debug-live-issue skills removed.
- OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history,
/api/workspaces mount.
Several suites start real `agenthub-lane-<id>` tmux sessions and then kill
or reset them, with lane ids from their own throwaway database. On the
user's default tmux server those names collide with the dashboard's real
lane panes, so a test run — including the one the pre-commit hook runs on
every commit — killed whichever live session shared an id, the pane the
commit was made from included.
`npm run test:server` now goes through scripts/test-server.js, which drops
TMUX, points TMUX_TMPDIR at a fresh directory and kills that server after.
Also fixes the ERUNTIMEOUT test's tmux mock, which had the wrong (async,
cmd+args) signature and only passed by accident.