diff --git a/.env.example b/.env.example index e33d2be8..8de1736c 100644 --- a/.env.example +++ b/.env.example @@ -131,6 +131,20 @@ # liveness/stale sweeps (which skip them). (default: 600000 = 10min). # DASHBOARD_REMOTE_ACTIVE_WINDOW_MS=600000 +# ── Privacy and push ingest (Settings → Data) ──────────────────────────────── +# All three are normally saved from Settings into the settings file; set here, +# they are pinned and the Settings page shows them read-only. +# Redact secrets (API keys, tokens, bearer headers, private keys, password=…) +# from stored event payloads and summaries: 1 = on (default: off). +# AGENTHUB_REDACT_SECRETS=1 +# Folders whose hooks are dropped whole (and skipped by the history import and +# the Codex reader) — absolute paths, ':'-separated (';' on Windows); a folder +# and everything under it. +# AGENTHUB_IGNORE_CWD=/home/you/clients/secret:/home/you/private +# SHA-256 (hex) of the token POST /api/hooks/ingest-batch accepts. Unset = push +# ingest off. Settings → Data → Push ingest makes the token and stores only this. +# AGENTHUB_PUSH_TOKEN_SHA256= + # ── Lanes (durable parallel-work units and managed git worktrees) ──────────── # Directory dashboard-managed worktrees are provisioned under. It is also the # boundary safety check 2 resolves every destructive lane path against: nothing diff --git a/.gitignore b/.gitignore index 933cd5b8..caa3c260 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,6 @@ __pycache__/ # JetBrains IDE config — machine-specific, never shared .idea/ + +# Prometheus bearer token for docker-compose.monitoring.yml (docs/MONITORING.md) +monitoring/prometheus/dashboard-token diff --git a/.husky/pre-commit b/.husky/pre-commit index 9ee42222..9ea8e16f 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,9 @@ #!/usr/bin/env sh # -# Pre-commit: auto-format staged files, then run the full test suite. -# The commit is ABORTED unless every test passes. +# Pre-commit: auto-format staged files, then run the tests the commit needs — +# both full suites for a big or risky commit, only the related tests for a +# small one, none for docs alone (see step 2). The commit is ABORTED unless +# every test it runs passes. # # Activated via `core.hooksPath=.husky` (set by the package.json "prepare" # script on `npm install`). No husky/lint-staged runtime required. @@ -44,13 +46,13 @@ fi # is deterministic: it fails both runs and still blocks the commit. This keeps # the gate strict without making commits a dice roll on machine load. run_suite() { - # $1 = human label, $2 = npm script + # $1 = human label, $2 = shell command echo "🧪 Running $1 tests..." - if npm run "$2"; then + if sh -c "$2"; then return 0 fi echo "⚠️ $1 suite failed — retrying once to rule out machine-load flakiness..." - if npm run "$2"; then + if sh -c "$2"; then echo "✅ $1 suite passed on retry — treating the first failure as a load flake." echo " If this keeps happening, the flaky test above deserves a real fix." return 0 @@ -59,7 +61,68 @@ run_suite() { return 1 } -run_suite backend test:server -run_suite frontend test:client +# The frontend suite needs NODE_ENV=test: a shell exporting +# NODE_ENV=production gets React's production build, where act() throws. +CLIENT_FULL="NODE_ENV=test npm run test:client" -echo "✅ Formatting applied and all tests passed — proceeding with commit." +# How much to run. A small commit runs only the tests that touch what it +# stages; a big or risky one runs both full suites, as does +# AGENTHUB_FULL_TESTS=1. Docs alone (*.md, docs/) run nothing. +# client/ files → `vitest related`: vitest's own import graph. +# anything else → every server/__tests__ file that names the file's module +# (a string match: wide on purpose, `db` reaches most). +# ponytail: name matching misses a test that reaches a module only through +# another one; the full run before a release still catches that. +SMALL_MAX=10 +CODE="$(git diff --cached --name-only --diff-filter=ACMRD | grep -vE '\.md$|^docs/' || true)" +COUNT=$(printf '%s\n' "$CODE" | grep -c . || true) +FULL=0 +[ "${AGENTHUB_FULL_TESTS:-0}" = "1" ] && FULL=1 +[ "$COUNT" -gt "$SMALL_MAX" ] && FULL=1 +# A dependency, build/test config, this hook or a deleted file can break +# anything, whatever imports it. +if printf '%s\n' "$CODE" | grep -qE '(^|/)package(-lock)?\.json$|(^|/)(vite|vitest|tailwind|postcss|tsconfig)[^/]*$|^\.husky/|^scripts/test-server\.js$'; then + FULL=1 +fi +if git diff --cached --name-only --diff-filter=D | grep -vE '\.md$|^docs/' | grep -q .; then + FULL=1 +fi + +if [ "$COUNT" -eq 0 ]; then + echo "📝 Docs only — no tests to run." +elif [ "$FULL" = "1" ]; then + echo "🧪 Full run ($COUNT code files staged)." + run_suite backend "npm run test:server" + run_suite frontend "$CLIENT_FULL" +else + echo "🧪 Small commit ($COUNT code files) — running the related tests only." + OTHER="$(printf '%s\n' "$CODE" | grep -v '^client/' || true)" + CLIENT="$(printf '%s\n' "$CODE" | grep '^client/' | sed 's|^client/||' || true)" + + if [ -n "$OTHER" ]; then + RELATED="$( + printf '%s\n' "$OTHER" | while IFS= read -r f; do + case "$f" in + server/__tests__/*.test.js) echo "$f" ;; + *) + name="$(basename "$f")" + name="${name%.*}" + grep -lF -- "$name" server/__tests__/*.test.js 2>/dev/null || true + ;; + esac + done | sort -u | tr '\n' ' ' + )" + if [ -n "$RELATED" ]; then + run_suite "backend ($(echo $RELATED | wc -w) files)" "node scripts/test-server.js $RELATED" + else + echo "⏭️ No server test names a staged file — backend skipped." + fi + fi + + if [ -n "$CLIENT" ]; then + LIST="$(printf '%s\n' "$CLIENT" | tr '\n' ' ')" + run_suite "frontend (related)" "cd client && NODE_ENV=test NODE_OPTIONS=--no-experimental-webstorage npx vitest related --run --passWithNoTests $LIST" + fi +fi + +echo "✅ Formatting applied and the tests passed — proceeding with commit." diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 00c23a08..4bc76796 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -325,8 +325,9 @@ graph TD | `routes/analytics.js` | Extended analytics — token totals, tool usage counts, daily event/session trends, agent type distribution. The client-side analytics heatmap grid is aligned to a Sunday start for correct day-of-week positioning | | `routes/pricing.js` | Model pricing CRUD (list/upsert/delete) and per-session / global cost calculation with pattern-based model matching. `PUT /api/pricing` upserts a rule and accepts optional time-limited **introductory** rates (`intro_*_per_mtok` + `intro_until`): usage on/before the cutoff date prices at the intro rate, after it at the standard rate — the calculator picks the effective rate per usage day (`ratesForBucket`), so a promo like Sonnet 5's launch discount is correct before AND after the cutoff, retroactively. Intro columns are written only when the caller sends them (a standard-rate edit never disturbs a promo). Cost is computed per token bucket — keyed by (model, speed, inference_geo, service_tier) — applying fast-mode premium, US data-residency (1.1x), and Batch (0.5x) modifiers, the 5m/1h cache-write split, plus server-tool surcharges (web search $10/1k; code execution estimated by container-time with the monthly free-hours allowance; web fetch free). `attachAgentCosts`/`agentOwnCost` reuse the same calculator to price each agent's `metadata.tokens` for the per-agent `cost` on agent-list responses. Feature rates + modifier math live in `lib/pricing-constants.js`; usage normalization in `lib/token-usage.js` | | `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks | -| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately | -| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Four rule types: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing | +| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`), and the read-only dry run `POST /api/alerts/rules/preview` (`{rule_type, config, days?, cooldown_seconds?}` → what `previewRule` says it would have fired over the last `days`, 1–90, default 7). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately | +| `routes/budgets.js` + `lib/budgets.js` | Spend budgets: a budget IS an alert rule of type `budget` (CRUD through `/api/alerts/rules`). `lib/budgets.js` computes a period's bounds in the user's local time (`tz_offset` saved with the rule; day / week from Monday / month), its spend priced like `GET /api/pricing/cost` by the day tokens were spent (via `dailyTokenRows` + `costWhere`, scoped to `tool`/`lane`), and the pace projection to the period's end; `GET /api/budgets/status` lists every enabled budget with `spend`, `pct`, `projected` and the thresholds `crossed` | +| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Six rule types — `cost_threshold` (a session's cost ≥ `usd`, on token-bearing events like `token_threshold`) and `budget` (on the sweep: each of `thresholds` % of `limit_usd` for the current `period` fires ONCE per period, deduped on `details.period_start` + `details.threshold` and bypassing the cooldown) plus the four original ones: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing | | `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` | | `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes | | `lib/webhooks.js` | Universal webhook delivery engine driven by the provider registry. `buildRequest()` resolves the URL, formats the provider-native payload, and assembles headers (provider auth headers + generic-family custom headers + optional HMAC-SHA256 signature via `X-Webhook-Signature` / `X-Webhook-Timestamp`). `dispatchAlert()` fans a fired alert out to every enabled, in-scope target (optional per-rule scoping via `rule_ids`); each `deliver()` POSTs with an `AbortController` timeout and bounded retry/backoff (retries transport errors / 429 / 5xx, never other 4xx) and records the attempt-chain outcome in `webhook_deliveries` (pruned to the newest 2000 rows). Delivery is detached and fully fail-safe — it never throws into the alert path. Enabled targets are cached like alert rules; tunables (`WEBHOOK_TIMEOUT_MS`, `WEBHOOK_MAX_ATTEMPTS`, `WEBHOOK_RETRY_BASE_MS`) are env-overridable. `sendTest()` awaits a synthetic delivery for the test endpoint | @@ -351,8 +352,8 @@ graph TD | `server/lib/archive.js` | Safe archive extraction: `.zip` via `adm-zip`, `.tar`/`.tar.gz`/`.tgz` via `tar`, plain `.gz` via `zlib` in streaming mode. Every entry is validated through `safeJoin` which rejects absolute paths and `..` traversal before any bytes are written. Enforces a hard extraction cap (`MAX_EXTRACT_BYTES`, default 4 GB, tunable via `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES`) with `ExtractionLimitError` surfaced as HTTP 413 from the upload route — defense against zip/tar/gzip bombs. Also provides `detectKind` for filename-based dispatch and `mkTempDir`/`rmTempDir` helpers | | `server/lib/remote-sync.js` | **Remote Data Sources** — live remote/multi-machine data collection over SSH. Mirrors the remote's `~/.claude/projects` via **scp** (or `wsl.exe` + `tar` for WSL-hosted Claude on Windows SSH) into a **sandboxed per-source staging dir** under the data dir, then feeds it through the **same** importer used for local history (`importFromDirectory` from `scripts/import-history.js`) and tags every imported session with the source id (`sessions.source`). Also runs the connectivity probe behind the test route. Auth **defers entirely to the host SSH stack** (ssh-agent / `~/.ssh/config` / identity file) — **no secrets are ever stored**; every shell-out uses `execFile`/`spawn` argument arrays (never a shell string), and `StrictHostKeyChecking` is left at its SSH default. Background poller `DASHBOARD_REMOTE_SYNC_MS` (default 15000); add/re-enable triggers immediate pull. Sync timeout `DASHBOARD_REMOTE_SYNC_TIMEOUT_MS` (default 600000); connectivity-test timeout `DASHBOARD_REMOTE_TEST_TIMEOUT_MS` (default 15000). Broadcasts `remote_source.status`, `remote_data.updated`, and per-session `session_created`/`session_updated` on successful sync. After each pull, `reconcileRemoteSessionStatus` sets each source session's live status from the mirror — newest JSONL event timestamp within `DASHBOARD_REMOTE_ACTIVE_WINDOW_MS` (default 600000 = 10 min) ⇒ `active`, otherwise `completed`. Remote sessions get no live hooks and are excluded from every local liveness/stale heuristic (all gated on `source = 'local'`), so this reconciliation is the sole owner of their active/completed lifecycle | | `server/routes/remote-sources.js` | HTTP surface for **Remote Data Sources**: `GET /api/remote-sources` (list), `POST /api/remote-sources` (create), `PATCH /api/remote-sources/:id`, `DELETE /api/remote-sources/:id` (`?purge=true` also deletes that source's imported sessions), `POST /api/remote-sources/:id/test` (SSH connectivity probe), and `POST /api/remote-sources/:id/sync` (on-demand pull). Delegates the pull/validation to `remote-sync.js`; broadcasts `remote_source.status` on every transition | -| `server/lib/source-filter.js` | Parses the optional `?sources=` query param (comma-separated source ids) into SQL predicates + bind params, shared by the data endpoints (`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`) so a client **data-scope** selection narrows every query consistently. No filter → the existing unscoped queries run unchanged | -| `server/lib/scoped-stats.js` | Source-scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set | +| `server/lib/source-filter.js` | Parses the optional `?sources=` query param (comma-separated source ids) and `?tool=` (an AI tool id; `parseScope` folds both into one predicate over `sessions`) into SQL predicates + bind params, shared by the data endpoints (`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`) so a client **data-scope** selection narrows every query consistently. No filter → the existing unscoped queries run unchanged | +| `server/lib/scoped-stats.js` | Scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` or `?tool=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set | | `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`/skills//SKILL.md`), subagents (`/agents/*.md`), slash commands (`/commands/*.md`), output styles (`/output-styles/*.md`), plugins (`/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` counts **and** `contributes.items`, which names every skill / subagent / command / output style the plugin ships — each surface read from the conventional install subdirectory *and* from whatever path `plugin.json` declares for that key, with declared paths outside the plugin's own install dir dropped by `isUnder()`, so a plugin that lists its commands explicitly and keeps `commands/` empty no longer reports zero + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects//memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines | | `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `/cc-config-backups//..bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` | | `lib/cc-reload.js` | Types `/reload-plugins` / `/reload-skills` into the Claude Code sessions the dashboard already has open, over `tmux send-keys` (reusing `tmux.js`'s `sendText`). Both slash commands are scoped BY CLAUDE CODE to the session that runs them, so a session cannot be reloaded from the outside and no background process can do it on a session's behalf — typing into a live pane is the only real mechanism. Deliberately spawns NO `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history, so this module creates no sessions at all. Skips panes sitting at a shell prompt (the text would run as a shell command) and addresses `claude` lanes only, since Codex has no such command. Sends to a busy pane on purpose — tmux queues the keystrokes into the REPL's input buffer, so a late reload still lands, where skipping busy panes would leave exactly the sessions in active use running stale code. The command set is a fixed constant, never caller-supplied: a pane's stdin is an arbitrary-code boundary. Never throws | @@ -367,6 +368,7 @@ graph TD | `lib/codex-hooks.js` | AgentHub's hooks in Codex's `$CODEX_HOME/hooks.json`, installed/removed only on a click (`GET/POST/DELETE /api/settings/codex-hooks`): one group per event through the stable forwarder `$CODEX_HOME/agenthub-hook.sh`, whose content `refreshScript()` rewrites at every start; trust is read from Codex's `config.toml`, never written. See **Codex hooks** under Codex Lane Status, Sessions and Hooks. | | `lib/antigravity-home.js` | Where agy keeps its data: `~/.gemini` (`AGENTHUB_GEMINI_DIR` overrides it), the CLI's `antigravity-cli/` under it. agy has no home override of its own. | | `lib/antigravity-usage.js` | Antigravity CLI sessions and tokens from agy's `conversations/.db` (read-only, every `DASHBOARD_ANTIGRAVITY_POLL_MS`): protobuf `ModelUsageStats` per `gen_metadata` row, the workspace from `trajectory_metadata_blob`, name/start from the transcript. Also sets a quiet session's lane idle. See **Antigravity Sessions and Hooks**. | +| `lib/cursor-usage.js` | Cursor sessions (no tokens: Cursor's transcripts carry none) from `~/.cursor/projects/*/agent-transcripts//.jsonl`, every `DASHBOARD_CURSOR_POLL_MS`. See **Cursor Sessions**. | | `lib/antigravity-hooks.js` | AgentHub's named hook `agenthub` in `~/.gemini/config/hooks.json` (`GET/POST/DELETE /api/settings/antigravity-hooks`): PreInvocation, PostToolUse and Stop through the stable forwarder `~/.gemini/config/agenthub-hook.sh`, which answers `{}`. Never PreToolUse. | | `routes/codex-config.js` | HTTP surface for the Codex Config panel. Read endpoints for every surface (skills, agents, rules, memory, config, mcp, plugins, marketplaces, file, overview) plus mutation endpoints (`PUT /file`, `DELETE /file` delegating to `codex-mutate.js`; `POST /mcp/{add,remove}`, `/plugins/action`, `/marketplaces/{add,action}` delegating to `codex-cli.js`), plus `GET /backups`. Every successful mutation broadcasts `codex_config_changed` over the WebSocket so an open Codex settings tab refetches without polling. All errors return structured `{error: {code, message}}` shapes | | `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer | @@ -850,7 +852,7 @@ erDiagram alert_rules { TEXT id PK "UUID" TEXT name "User-facing rule name" - TEXT rule_type "event_pattern|inactivity|status_duration|token_threshold" + TEXT rule_type "event_pattern|inactivity|status_duration|token_threshold|cost_threshold|budget (no CHECK; validated in lib/alerts.js)" TEXT config "Type-specific JSON config" INTEGER enabled "1|0" INTEGER cooldown_seconds "Per-scope dedup window, default 300" @@ -1731,6 +1733,29 @@ is ever inferred. --- +## Cursor Sessions + +`server/lib/cursor-usage.js` (`startCursorUsagePoll`, every +`DASHBOARD_CURSOR_POLL_MS`, default 15000, `0` disables) reads the agent +transcripts Cursor writes — the CLI (`cursor-agent`) and the editor's agent both — +at `~/.cursor/projects//agent-transcripts//.jsonl` +(`AGENTHUB_CURSOR_DIR` overrides `~/.cursor`). Each becomes a `sessions` row with +`tool = 'cursor'`: id = file name, name = the first `` line (60 +chars), start = the file's birth time (mtime where there is none), `active` +while it was written in the last two minutes, else `completed` at its last +write. The cwd is the project slug walked back onto the filesystem (every +non-alphanumeric character is a `-` in the slug, so only a path that still +exists resolves); otherwise it stays NULL. `cursor_transcripts` holds the mtime +read, so a session the user deleted stays deleted until Cursor writes to it again. + +**No tokens, no model, no cost.** A transcript line is `{role, message:{content}}` +or `{type: "turn_ended"}` — no usage, no model id, no timestamps — so a Cursor +session has no `token_usage` rows. The editor's `state.vscdb` keeps a +`tokenCount` per chat bubble and a display model name (`GPT`, `default`, a BYOK +name); AgentHub does not read it. No Cursor hooks are installed. + +--- + ## Agent Extension Layer The repository includes a triple extension strategy: diff --git a/CLAUDE.md b/CLAUDE.md index 06017a16..0ce2d11b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,16 +1,17 @@ # Claude Code Working Guide -_Last synced: v2.3.1 (2026-10-06) + a failed plugin-update step saying WHY (`failureText` in `scripts/plugin-update-helper.js`, also used by `cc-update-all-helper.js`: the tail of the command's stdout and stderr plus its exit code or signal — `plugin-bootstrap.js --force` prints its refusal, `bootstrap: locked`/`superseded`/…, on STDOUT, and the verdict used to keep only stderr, so it read "Command failed: …"; the helper runs `main()` only as a script) and `updates.test.js` never APPLYING an update (it skips when the checkout is behind a release — the route really fast-forwards, runs `npm run setup`, which under NODE_ENV=production pruned the checkout's dev dependencies, and restarts) + the Files tree listing GITIGNORED files and folders too, drawn ORANGE (`addIgnored` in `server/lib/git-diff.js`: each level's directory entries git does not list, read with `readdir` — `ls-files --ignored` cannot list inside an ignored folder — marked `ignored: true`, `.git` and a symlink out of the workspace left out; `text-kept` in `TreeNodes`; an ignored folder stays shut under **Expand all**) and the Changes overview's tree indenting a file one chevron (1rem) inside its folder + a working file's **Changes** view being the EDITOR too, folded to its changes (`collapse` on `DiffEditor` = `collapseUnchanged`; Whole file unfolds it and the toggle keeps the draft; each block's Revert edits the buffer only — the git Stage/Undo bars and line comments stay on the read-only diffs and in Review), and diffs + editor opening SIDE BY SIDE at 50/50 and full width (`wide` from `Workspace.tsx` = `changesPreset !== "compact"`; the editor's split is `MergeView`, original read-only on the left) + a file opening on its CHANGES (the diff; an unchanged Files pick as the whole file) and a **Changes | Whole file** toggle taking it to an EDITOR with its diff inline (`client/src/components/lanes/DiffEditor.tsx`: CodeMirror 6 + `@codemirror/merge` `unifiedMergeView`, lazy-loaded, syntax from our own `lib/highlight.ts` per line; original = the file at HEAD or at `mergeBase`, new on `GET /api/lanes/:id/git/changes?range=branch`; the read view's click-to-edit and `LaneFileView`'s `onEditAt` gone, Cancel reverts, a save stays in the editor; Whole file off = the read-only diff with its review bars) and every diff's colours becoming green = line added whole / red = removed whole / BLUE = edited in place (keeps a letter or digit — `keepsText` there, `emphasis` in `LaneDiffView.tsx`; `--diff-mod` in `index.css`, `bg-diff-mod` tinted in `tailwind.config.js`) + the status bar's right-end PLAN QUOTA chips (`GET /api/pricing/quota`, `server/lib/quota.js`: Claude's `/api/oauth/usage` with the CLI's live token, NEVER refreshed, cached 5 min, plus every other saved auth profile asked with its own stored token only while it is valid — never refreshed, past expiry its last figure with `asOf`; Codex's newest rollout `rate_limits`, no network; hover lists every account per tool with each window's bar, percent, reset time, the account's email and sign-in expiry, in a compact 11px panel that STAYS OPEN under the pointer — leaving the chips closes it after 200 ms, entering it cancels that; only the chips and the panel's **Refresh** button refresh, the button spinning and reading **Refreshing…** while the forced read runs, never `disabled`, whose blur would close the panel) + the Changes panel's History drawn as a COMMIT GRAPH (issue #58: `parents` on `GET /api/lanes/:id/git/log`, now `--date-order`; lanes laid out by `client/src/lib/commitGraph.ts`, one SVG per fixed-height row in `LaneChangesPanel.tsx`) + Settings → **Appearance** holding the LANGUAGE, the theme and the PET (`AppearanceSettings.tsx` renders the language row, the theme card `#theme` and `PetSettings` under `#pet`; the General and Pet pages are gone — `/settings`, `/settings/general`, an unknown section and the legacy `?tool=dashboard` land on `appearance`, `/settings/pet` on `appearance#pet`, `MOVED` in `SettingsHub.tsx`; `Settings.tsx` keeps `data`/`about`/`notifications`) + Ctrl+Shift+C as COPY, not the browser's inspector (`copyOnCtrlShiftC` in `client/src/lib/keys.ts`, a capture-phase `keydown` installed once in `App.tsx`: `preventDefault` + `execCommand("copy")`, which xterm answers with its own selection; F12/Ctrl+Shift+I still open DevTools) + a per-workspace NEVER-STAGE list in the Changes panel (`agenthub.changes.neverStage.` in localStorage: a row's eye-off button or right-click toggles it, **Stage all** and a folder's stage skip those paths, the row is struck through and the Files tree draws the file ORANGE — `--kept` in `index.css`, `text-kept`/`bg-kept` in `tailwind.config.js`, `kept` on `TreeNodes`; git is never told — no `info/exclude`, no `.gitignore` write, and a single file's own Stage still works) + closed Terminal rows past the newest three per workspace DELETED on the run-heal beat (`pruneShellsStmt` in `reconcileOrphans`, `server/lib/dashboard-runs.js`; `CLOSED_SHELLS_KEPT` mirrors the sidebar's `CLOSED_TERMINALS`) — rows the list could not show resurfaced after a remove + an untracked FOLDER (`dir/` from git status) drawn as a `dir/` row in the change tree (`changeTree.ts`) + an AI-suggested COMMIT MESSAGE for what is staged (the overview's sparkle button → `POST /api/lanes/:id/git/commit-message`, same-origin, `server/lib/commit-message.js`: the user's `claude -p --safe-mode --no-session-persistence --tools "" --model haiku` on the lane-scoped staged diff via stdin, secret files left out, 60 KB diff cap, 120 s timeout; `ENOSTAGED`/`ENOTOOL` 400, `EAI` 502 — not a git write, it only reads the index) + the sidebar's session rows showing their last activity and a live **New session**/**Terminal** row its start time (`WorkspaceSessionList.tsx`, like the closed Terminal rows) + the right panel's Files and Changes tabs MERGED into one panel (the activity-bar tab buttons, `PANEL_TABS` and `agenthub.workspace.panelTab` gone from `Workspace.tsx`, `LaneChangesPanel` losing its `tab` prop; its mode switcher reads Changes · Files · Review · History, Files dropped at full width where it is the sidebar) and the overview's groups drawn as a JetBrains-style FOLDER TREE (`client/src/lib/changeTree.ts`: single-child folder chains compacted, per-folder file count, folders and group headers fold, a folder's stage/unstage-all button) + an AI Router profile's optional **Behaves as** (`behavesAs`, a `claude-…` id the user picks in Settings → AI Router; `normalizeBehavesAs` in `server/lib/ai-routers.js`) written by `modelPicker` in `server/lib/router-apply.js` as `modelPicker.options` rows `{ model, behavesAs }` into the router's `--settings` file, one per distinct non-`claude-*` model name — issue #56: Claude Code 2.1.290 warned that a 9Router combo name like `agent` "isn't described by this version's model catalog" and compacted at an assumed 200k; nothing is written without it, because Claude Code takes the first `modelPicker` among policy → flag → user and ours would hide the user's own + a **Browse** button beside every path field (`client/src/components/PathPickButton.tsx`) opening the OS's own picker ON THE SERVER'S DESKTOP (`POST /api/lanes/pick-path`, `server/lib/native-dialog.js`: zenity → kdialog / osascript / WinForms, execFile argv with only the resolved start folder from the request; loopback socket AND no `X-Forwarded-For`/`Forwarded` header, else `EREMOTE`; a display failure is `ENODIALOG`, never a Cancel; the dialog is killed when the request closes; a folder field falls back to `FolderBrowseModal` on any failure) + `/api/workspaces` and `agenthub workspace`/`workspaces` as ALIASES of `/api/lanes` and `agenthub lanes` (the same two routers mounted at both paths in `server/index.js`; `COMMAND_ALIASES`/`canonicalArgv` in `bin/agenthub.js`, applied by `runCommand` and `handleServerDown`), `--workspace ` beside `--lane ` in `resolveLaneArg`; help, usage text, UI hints and user docs now say `agenthub workspace …`/`--workspace`, and the old `agenthub lanes`/`--lane` still run but print one stderr note (`warnOldNames`) — removing them is step 4 of `docs/plans/workspace-rename/README.md`, NOT before the third release after the one that ships the aliases, and it renames routing only: never a table, column, response key, tmux name, env var, data path or storage key + console TABS as COMBOS: a workspace holds up to TEN sessions (`MAX_PANE_SLOT` 9, run ids `agenthub-lane--<1-9>`), and every tab of the session view is a group of one to four split panes (`PaneGroups` in `client/src/lib/paneLayout.ts`, stored per workspace in `agenthub.workspace.paneLayout` as `{groups, active}` — a pre-tab single layout reads as one tab; `agenthub.workspace.tabOrder` is gone): Ctrl/Cmd+click or dropping a one-pane tab on a pane merges it into the tab on screen, a pane's close takes it out into its own tab, a pane header dropped on the bar detaches there, and a tab's × stops every session in it + the uncommitted-counts chip ON THE CLOSED Changes rail, vertical, with the per-file list on hover (`LaneGitRailCounts`/`LaneGitCounts` in `client/src/components/lanes/LaneGitHeader.tsx`, its own facts read only while the rail shows) and the OPEN panel's header showing no counts at all, since the panel lists the files below + `/agenthub-doctor` OFFERING to fix what it reports (asks, lists the exact steps, runs them only after a second yes — `plugin-doctor.js` stays read-only) and `/agenthub-open` running that doctor flow when `plugin-open.js` exits non-zero, which it now also does when its bootstrap does not answer `ok` (it printed the URL with exit 0 after a refused bootstrap) + `/agenthub-update` running `plugin-bootstrap.js --force` and the doctor from the INSTALLED build (`supersededBy()` of the session's root, else that root) — from the old root it always answered `superseded` right after pulling an update — and the `repo-onboarding`/`debug-live-issue` plugin skills deleted (with the `.codex`/`.agents` copies of `repo-onboarding`) + a **New terminal** pane's shell started only after the pane's first resize (`spawnShell` in `server/lib/pty-run.js` runs `sh -c` that polls `#{session_attached}` until a client is attached, ≤ 2 s, then `exec "$SHELL" -l` — not the first SIGWINCH, which `enableMouse`'s scrollbar fires before any attach — issue #55: a prompt drawn at tmux's initial 80 columns and redrawn by readline after the attach resize left the cursor on top of the prompt) + AI CLI detection on WINDOWS (`which` in `server/lib/ai-tools.js` tries each `PATHEXT` extension — the bare name only when it already has one, since npm's extensionless shim beside `codex.cmd` is a sh script — so `GET /api/ai-tools` had answered `installed: false` for every tool; `spawnTarget` there gives a `.cmd`/`.bat` `shell: true` and a quoted path for `ai-tool-jobs.js` and `ai-tool-versions.js`, catalog argv only) + a dashboard pane's tmux SCROLLBAR that is actually there (`pane-scrollbars-style fg=brightwhite,bg=black,width=1` in `enableMouse`, `server/lib/tmux.js` — the default track drew in the theme's border colour, and `width=2` left a never-painted column of stale cells that read as a second scrollbar, since tmux 3.7c paints only the first column; and a claude pane no longer forced inline — `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1` duplicated its banner and transcript on every pane narrowing (tmux re-wraps the full-width rules into history Claude's redraw cannot clear), so the user's `"tui"` decides, and every pane's CLI starts only once a client is attached (`WAIT_FOR_CLIENT` in `server/lib/pty-run.js`)) + a CODEX session's conversation in `GET /api/sessions/:id/transcript` (`codexMessage` in `server/routes/sessions.js`: its root thread's rollout from `codex_rollouts`, mapped to the Claude message shape; developer messages and injected context left out — the route had answered every Codex session with no messages) + `server/compat-sqlite.js` gaining `function`, `stmt.pluck()` and `readonly`/`fileMustExist` — a plugin install has no better-sqlite3 build, so HEAD's `db.function("trim_event_data")` crashed every such dashboard at boot — with `db.js` exporting its chosen `Database` for `antigravity-usage.js` (which required better-sqlite3 directly and skipped every agy file in production) and `pbFields` turning node:sqlite's Uint8Array blobs into Buffers; `compat-sqlite.test.js` boots db.js with better-sqlite3 blocked + the terminal ATTACH running the live tmux server's own binary (`attachBinary` in `server/lib/pty-attach.js` → `/proc//exe`, else PATH's `tmux`) — tmux 3.7c installed to `/usr/local/bin` beside a running 3.4 server failed every attach with `open terminal failed: not a terminal` while every other tmux command still worked + EVENT-LOOP FIXES (`GET /api/workflows` froze the server for minutes on a large session: its "next tool event" correlated subquery went quadratic once `ad6e801` dropped `idx_events_session` — now one `LEAD()` window pass, `server/lib/workflow-stats.js`, run in the read worker `server/lib/read-pool.js` with `?sections=` (Health asks for seven), shared in-flight runs, `AGENTHUB_READ_WORKER`/`AGENTHUB_READ_TIMEOUT_MS`, 503 `EREADTIMEOUT`; `idx_events_session(session_id, id)` restored for the per-hook duplicate guard and `idx_events_tool(tool_name)` added; `installHooks` adding nothing and removing its `hook-handler.js` entries while settings.json enables `agenthub@…`, which every checkout server start had re-added — ~40% of events doubled — and `getHookStatus` answering `source: "plugin"`) + a Codex/Antigravity session tied to its workspace without hooks (`claimToolSessions` in `server/lib/dashboard-runs.js`: tool + cwd + the run's live window; also fills an empty `dashboard_runs.session_id`) — it had vanished from the workspace once its tab closed + UI-AUDIT FIXES (shared primitives replacing per-page copies: `client/src/lib/toast.ts` + `components/Toaster.tsx` — `toast.success|error|info`, an error stays until dismissed —, `lib/confirm.ts` `confirmDialog`/`confirmDiscard` + `components/ConfirmHost.tsx` replacing every `window.confirm`, `lib/clipboard.ts` `copyText` (falls back to `execCommand` off a secure origin, toasts the outcome), `lib/keys.ts` `isSubmitEnter` ignoring an IME-confirming Enter, `hooks/useArmed.ts` two-click actions that DISARM after 3 s, `hooks/useDialog.ts` the one modal focus contract — focus in, Tab trapped, Escape, focus back, dialogs STACK so only the top one answers —, `hooks/useUnsavedGuard.ts` (`beforeunload` + `confirmDiscard`), `lib/throttle.ts` + `hooks/useLiveRefresh.ts` a leading+trailing throttled WebSocket refresh that skips hidden tabs and refetches on visibility and reconnect; Toaster and ConfirmHost mounted once in `App.tsx`; the `accent-solid` token (`--accent-solid`/`-hover` in every theme, `tailwind.config.js`) as the fill behind white text, `--accent` now a TEXT colour — dark Default `#70b8ff`/solid `#0076d1`, Orca Dark `#6aa0f8`/`#3472d8`, warm light `#924224` = its own solid —; `Workspace`/`SettingsHub`/`SessionDetail` lazy routes under `` inside `components/RouteErrorBoundary.tsx` (a stale chunk after a self-update reloads once, `vite:preloadError` too) — keep heavy page code out of anything the sidebar imports: the start helpers live in `components/lanes/startSession.ts`, not `WorkspaceHome.tsx`, or react-markdown lands in the main bundle; the Dashboard's `components/NeedsYouStrip.tsx`; Sessions filters (`awaiting=1`, `from`/`to` on `GET /api/sessions`, kept in the URL) and CSV/JSON export (`lib/exportData.ts`); Settings → General split into General / Data / About (`Settings.tsx` `section`; General since folded into Appearance), Pricing in its own **Cost** nav group, a browser-preferences backup (`lib/prefsBackup.ts`, allowlisted keys only), and the settings search deep-linking a row's `?tab=` and `#anchor`, which the hub scrolls to and highlights; the terminal (`TerminalView.tsx`): Ctrl+M toggles Tab-moves-focus, Cmd+F (macOS)/Ctrl+Shift+F a find bar (`@xterm/addon-search`), `@xterm/addon-web-links`, a WRITE-only `@xterm/addon-clipboard` (OSC 52 in, never a read of the user's clipboard), a **Reconnecting…** overlay, `agenthub.terminal.screenReader` and `agenthub.terminal.selectHintSeen`; `Alt+1–4` / `Alt+\` / `Alt+W` focus, split right and close a pane (session view only, never from a terminal or a field); a draggable Changes splitter storing a NUMERIC fraction in `agenthub.workspace.changesSize` beside the presets; `panePaste` pasting only into a VISIBLE pane; `POST /api/lanes/:id/start` `slot: "auto"` (the server picks the free tab under the lane lock — every start from the home, a replay and Resume/Fork sends it), `409 ESESSIONLIVE` + `run_id` for a resume of a session a live pane holds, `400 EUNSUPPORTED` for a catalog tool's `resumeSessionId`; `GET /api/agents?session_ids=`, `tool_call_count`/`distinct_tool_count` on session stats, label columns on `GET /api/events` rows, `POST /api/settings/cleanup` refusing a non-whole `purge_days` (`INVALID_PURGE_DAYS`) and answering `dry_run`; the client's `EBADRESPONSE` for a non-JSON 200) + the tab bar's **Tools** menu GONE (`LaneToolsMenu` deleted with its MCP sync / dev-sync check / Create-update readme items, the sidebar row menu's readme item, `api.lanes.syncBaseCheck` and the `actions.*` strings; the routes stay, reached by `agenthub lanes mcp sync` / `agenthub lanes sync-base`) + a pane's split right/down menus offering the AI tools beside **New terminal** (`newTabTools`/`newTabBody` exported from `ConsoleTabs.tsx`, shared with the `+` menu) + Settings → **Pet** (since folded into Appearance as `#pet`; `components/settings/PetSettings.tsx`: show, speech bubbles, size with preview, the pet grid with download/upload/import/delete; the Sagi block left General; the mascot menu's **Pet settings…** opens it; list + actions shared with `PetMenu` through `components/Sagi/usePets.ts`) + LONG-TERM DB SAFETY with no data dropped (`server/db.js`: `synchronous = NORMAL`, `journal_size_limit` 64 MB, hourly `maintainDatabase` — PASSIVE checkpoint + `PRAGMA optimize`, never VACUUM —, `idx_events_session_created` replacing `idx_events_session`; the watchdog reading `sessions.transcript_path`; the hook route warming `transcriptCache` BEFORE `processEvent`'s write transaction; `GET /api/sessions/:id?events=0` for the web page, which reloads only on its own session's messages, debounced; `GET /api/settings/export` streamed in keyset pages; clear data in one transaction, cleanup chunked; the subagent importer reading an agent's `tool_use_id`s once; runtime logs rotated past 5 MB by `scripts/log-rotate.js`. `events.data` TRIMMED at every insert through the SQL function `trim_event_data(?)` (`server/lib/event-payload.js`, registered in `server/db.js` — never insert an event without it: Edit/Write drop `originalFile`/`oldString`/`newString`/Write `content`, Read drops `file.content`/`base64`, strings in `tool_input`/`tool_response` over 8 KB keep head + tail, `structuredPatch` and every other field kept, `_trimmed` names the drops; the full text stays in the transcript and its snapshot) plus a one-time chunked sweep of stored rows (`sweepEventPayloads`, `user_version` 4) — the user's choice of 2026-10-05, replacing "keep every payload"; still nothing prunes rows automatically, and the session list now prices every token column like `/api/pricing/cost/:id` (`tokensForSessions`)) + the status bar's **Workspace $** and **Session $** on the Workspace page (`StatusBar.tsx`: `?lane=` on `GET /api/pricing/cost` — the sessions the workspace owns, same rule as `GET /api/sessions?lane=` — and `GET /api/pricing/cost/:id` for the URL's `session` or the focused pane's session from `LIVE_RUNS_EVENT`; `selectedFrom` exported from `WorkspaceTree.tsx`) + RIGHT-CLICK MENUS on every item with actions (`client/src/components/ContextMenu.tsx` — one portal shell + `useContextMenu`/`menuPoint`/`MenuItem`, which `WorkspaceRowMenu` now uses; `lanes/SessionMenu.tsx` for session rows in the sidebar, Recents and the archived fold; console tabs, pane headers, the Changes panel's file rows/review headers/tree entries/commits, the home's title and context rows; then `components/SessionRowMenu.tsx` for sessions outside a workspace — Sessions table, Dashboard agent cards/events, Activity, Workflow runs —, every Settings list and every Claude Code/Codex config list — `FileItemMenu` in `config/MdItemList.tsx`; the buttons stay, a menu repeats them, a delete there takes the button's own confirm, text areas keep the browser's menu, and the shell stops its clicks bubbling through the portal into the card that opened it) + the DAILY COST split by the day tokens were SPENT, not the day a session started (`token_usage_hourly` — one row per UTC hour per bucket, from each Claude transcript message's timestamp via `TranscriptCache.tokensByHour`, written by the hook writer and back-filled at start by `server/lib/hourly-usage.js`; `dailyTokenRows` in `server/routes/pricing.js` books the rest of a session's total to its start date, so the days still add up — the status bar's **Today** read ≈ $0 on a session started days earlier) + CUSTOM PETS for the mascot (`server/lib/pets.js` + `server/routes/pets.js` behind `/api/pets`, `/pets//`; the `.codex-pet.zip` format — `pet.json` + a 1536×1872 `spritesheet.webp`, 8×9 frames of 192×208, one row per state — played by mood in `client/src/components/Sagi/PetSprite.tsx`; the right-click `PetMenu.tsx`: hide, size, choose, upload an image, import a bundle; three featured pets DOWNLOADED from codex-pets.net on a pick, never bundled; worn pet and size in `agenthub.pet`/`agenthub.pet.size`) + a sidebar workspace row opening the workspace's HOME, live tabs or not (a live tab opens from its session row; **Home** at the tab bar's left end) + drag-to-reorder dropping ANYWHERE by the pointer's position, with an insertion line (`insertionIndex`/`moveBefore` in `client/src/lib/laneOrderStorage.ts`): the sidebar's whole Workspaces section takes a row's drop (a row only starts the drag), and the console tab bar reorders its tabs, kept per workspace in `agenthub.workspace.tabOrder`, a bare tab listed at once as **New session** from its run (`WorkspaceSessionList`) and a live Terminal tab as **Terminal**, with the workspace's last three CLOSED Terminal tabs under its sessions (`GET /api/run/history?laneId=&tool=shell`; a click opens a NEW shell — a shell cannot be resumed, and its output is not recorded; its hover X or right-click **Remove from list** deletes that `tool = 'shell'` row through `DELETE /api/run/history/:id`, refused for a live pane or any other tool), and the sidebar's live panes following the Workspace page's poll (`LIVE_RUNS_EVENT` in `WorkspaceTree.tsx`) + the session view's console SPLITTING like VS Code (`client/src/components/run/PaneGrid.tsx` over `client/src/lib/paneLayout.ts`: a binary split tree of slots, ≤ 4 panes, each an absolutely placed sibling keyed by slot; Ctrl/Cmd+click on a tab, a `+` item or a sidebar session row splits — router state `split`, never the URL; pane headers drag to move/swap, pick another live session, split right/down, close the pane only; dividers drag or take arrow keys; `?run=` = the focused pane, pushed with `focusOnly`; layout per workspace in `agenthub.workspace.paneLayout`) and the session view going EDGE TO EDGE (no page padding, no cards, the tab bar and the right panel's activity bar one `h-10` header strip, the terminal on the page colour) + the session view's console as TABS instead of a 1/2/4 split (`client/src/components/run/ConsoleTabs.tsx`: one tab per live tmux slot 0–3, the active one = `?run=`, inactive ones mounted and hidden with CSS; `+` opens **New terminal** — `shell: true` on `POST /api/lanes/:id/start`, `spawnShell` in `server/lib/pty-run.js`, tool `shell`, no `run_id`/`status` change — or a bare AI tool, and links Settings → Agents; `splitViewStorage.ts` and `&pane=` gone — the sidebar's Ctrl/Cmd+click came back as a split, see above; `LaneConsolePane`'s `homeOnExit` → `onRunExit`), the tab bar then becoming the session view's ONLY header (the status/name/back-arrow row gone — Tools at the tab bar's right end, since removed, the pipeline/feature pickers only with a template —, `LaneConsolePane` reduced to the terminal: no title, no **Active runs** — `RunHistory.tsx` deleted with the client's `api.run.history`/`DashboardRunHistoryItem` and the page's active-sessions poll —, no **New run**) + an ENDED session opening in its workspace (`&session=`, `SessionReplay.tsx`: conversation + prompt box, sending resumes) instead of `/sessions/`, which the right panel's **Analyst** button opens + a session's AI TOOL chosen per session (`tool` on `POST /api/lanes/:id/start`, the home's **AI** select; the sidebar's tool icon moved from the workspace row to each session row) and the pane's run form GONE (`RunSetup` keeps only `PromptEditor`/`CwdAutocomplete`/`ModelPicker`; New run/exit/an empty pane go to the workspace home) + the DESKTOP-PARITY plan (`docs/plans/desktop-parity/`, phases 1–7; the multi-user server plan lives in the AgentHub-Server repo): an AI Router chosen PER SESSION (the **AI Router** select beside the model picker in the workspace home and a pane's run form, `client/src/components/run/RouterPicker.tsx`, `router` on both start routes; Claude `--settings=/router-settings/.json`, Codex `-p `; `dashboard_runs.router` for a resume; `POST /api/lanes/:id/router/apply`, `LaneRouterRow`, the Add-workspace router radio and the claude-/codex-home `routers` answers gone; `clearLaneRouters` takes the old keys out of `settings.local.json` at start) + Settings and the collapse toggle pinned to the sidebar's foot + shipped SVG marks for claude/codex/gemini/openclaw/hermes (Gemini's from zonalogo.com) (`client/public/ai-tools/.svg`, `BUNDLED` in `AiToolIcon.tsx`; the server's favicon route stays raster-only for the rest) + a workspace OWNS its sessions and lists only those (`sessions.lane_id`, stamped by `bindRunSession`, backfilled only from `dashboard_runs`; `GET /api/sessions?lane=&archived=0|1`; `PATCH /api/sessions/:id {archived|name}`), `dashboard_runs` keeping a pane's previous run as `@` instead of overwriting it and flagging a run `abandoned` only once its tmux session is gone (`reconcileOrphans(hasSession)` at boot and on the run-heal beat), a session row's state dot (`sessionDot`), double-click rename and archive (`sessions.archived_at`, hides only; the home folds the archived with Unarchive), the activity bar's Sessions tab and `WorkspaceSessionsTab.tsx` gone with `LaneConsolePane`'s `resumeRequest`, **Fork** beside Resume (`forkSession` → `--fork-session`, Claude only, `EUNSUPPORTED` → 400 on start), Ctrl/Cmd+click a live session → `&pane=1`, and a diff line's number opening a comment pasted into the workspace's last-focused pane (`client/src/lib/panePaste.ts`) + the sidebar losing its footer, language and theme controls (the collapse toggle sits beside Settings at the sidebar's foot; the version and **Check for updates** moved into the connection details modal, the status bar showing a green dot while a release is out; the language is Settings → Appearance, `SettingsSegmented`) + a session in its OWN WORKTREE, Claude's way (desktop-parity phase 6: the home's **Local / New worktree** select → `claude --worktree=`, `dashboard_runs.worktree`, `?session=` on every lane git route via `server/lib/session-worktree.js`, Resume/Fork into the worktree, `ENOWORKTREE` when it is gone, Archive = `POST /api/lanes/:id/session-worktree/remove` never forced; `listWorktrees` now reads `locked `) + ONE `client/src/components/Switch.tsx` for every on/off switch and the themed `Checkbox.tsx` (now with `tone`/`disabled`) for every checkbox — no native ``, no hand-rolled switch + the AI Tools catalog TRIMMED to 10 (`server/data/ai-tools/`: claude, codex, gemini, antigravity, copilot, opencode, cursor — labelled **Cursor CLI**, `--continue` resume, `cursor-agent update` — omp, hermes, openclaw; the other 28 deleted; the argv-separator / fixed-args / after-start+bypassEnv prompt modes no built-in uses any more stay supported for custom files and are tested through `server/__tests__/fixtures/ai-tools/`) + the workspace-sessions plan's leftovers (a sidebar TOOL FILTER, `agenthub.sidebar.projects` → `tool`; search also finding a workspace by a session's name; the home naming its repository from `GET /api/projects` and Recents showing cost; the transcript page's explicit **Resume** in `lanes/SessionResumeButton.tsx`) + a WORKSPACE HOME like a Claude Desktop project page (`client/src/components/lanes/WorkspaceHome.tsx`: `/workspace?lane=` opens it and hides — CSS — the session view, which `&run=` opens; its prompt box starts a new session in the first free pane slot; Recents; Instructions / Memory / Context / Folder over `server/lib/lane-sources.js` + `server/routes/lane-sources.js`: `lanes.instructions`, the new `lane_sources` table, uploaded files under `/workspace-context//`, read-only memory, and `spawnRun` adding them to Claude/Codex argv itself) + Settings losing its Projects group (`pages/ProjectSettings.tsx` and the per-project override gone; Worktrees now picks the home's folder suggestions) + notifications opening the lane's pane (`&run=`) + the sidebar becoming a Claude-Desktop-style WORKSPACE LIST (`client/src/components/WorkspaceTree.tsx` replaces `ProjectTree.tsx`: one flat row per workspace in the stored order, no repo grouping and no untracked-worktree rows; a row's chevron expands its sessions, `lanes/WorkspaceSessionList.tsx`; a session with a live pane opens `/workspace?lane=&run=`, which only ATTACHES pane 0 — `attachRequest` on `LaneConsolePane` — and any other one its read-only transcript; the sidebar never starts a resume; expanded rows in `agenthub.sidebar.projects` → `expanded`) + the Workflows page folding into Dashboard → Analytics → **Workflow** (`Dashboard.tsx` renders `pages/Workflows.tsx` under that sub-tab, `?tab=`/`?sub=` deep-link the Dashboard, `/workflows` redirects to `/?tab=analytics&sub=workflow`, no Workflows item in the sidebar) + the named themes gaining `orca-dark`/`orca-light` (Orca's neutral palette in `client/src/index.css`; Orca Dark's accent is two shades of `#3b82f6` — `--accent` `#6aa0f8` for text, `--accent-solid` `#3472d8` behind white text —, not Orca's near-white primary) + a sound per notification type (`sound` + `soundByType` in `client/src/lib/notifPrefs.ts`, default `"none"`; `playSound` in `lib/notify.ts`, played by `useNotifications` only when a notification was actually shown; the WAVs in `client/public/sounds/` are generated by `scripts/gen-notification-sounds.js` — edit the script, never the files) + Settings becoming `/settings/:section` with a grouped left nav (`client/src/pages/SettingsHub.tsx`, route `settings/*`: General / Appearance / Notifications — `Settings.tsx` now takes `section`, its TOC gone —, Agents with `agents/` per tool — a tool's own settings live there, the AI Tools modal is now the `agents` page and `AiToolsModal.tsx` is gone —, Routing (AI Router, an all-vendor Pricing page), Git (credentials, **Worktrees** = `WorktreeVisibilitySettings.tsx`), Projects = `pages/ProjectSettings.tsx` per repo with a per-project `projectHideSources` override and Forget all; `?tool=`/`#ai-tools` redirect to the path form; the whole-page search index is `SETTINGS_INDEX` in `client/src/lib/settingsSearch.ts`, ranked by `rankSettings`, Ctrl/Cmd+F focuses it; row primitives in `components/settings/SettingsRow.tsx`) + the Workspace page losing its lane STRIP to a sidebar PROJECT TREE (`client/src/components/ProjectTree.tsx` + `lanes/WorkspaceRow.tsx`/`WorkspaceRowMenu.tsx`: workspaces grouped by git common dir from the new `GET /api/projects` (`server/lib/projects.js`, `server/routes/projects.js`, `POST /api/projects/refresh` same-origin), untracked worktrees with **Adopt** (`ensure` `exact: true`), double-click rename, drag/Alt+↑/↓ order still in `agenthub.workspace.laneOrder`, a right-click menu with Start/Stop/Rename/router/New here/Copy path/Reset/Remove; prefs in `agenthub.sidebar.projects`, `client/src/lib/projectTreePrefs.ts`), the selected lane being the URL's `?lane=` (mirrored to `agenthub.workspace.selected`), ONE shared lane list (`client/src/hooks/useLanes.ts`), a fixed bottom `StatusBar.tsx` (connection, version, running/needs-you counts — of SESSIONS, `GET /api/sessions?status=active` minus `?awaiting=1`, never lanes —, today's cost from `GET /api/pricing/cost?date=`; every cost figure shows its token total and lists tokens + cost per model on hover) taking the sidebar's connection footer, the Changes panel gaining an activity bar (Files / Changes / **Sessions** = `lanes/WorkspaceSessionsTab.tsx`, a session resumed through `LaneConsolePane`'s `resumeRequest`; `agenthub.workspace.panelTab`), and a per-pane header in split view + the user-facing word "lane" becoming "workspace" in UI copy (en + vi), CLI prose, server error messages and docs, every identifier unchanged (`/api/lanes`, `agenthub lanes`, `lanes.json`, `Lane*` components; `docs/LANES.md` keeps its name) + `server/db.js` running every migration under a cross-process lock (`BEGIN IMMEDIATE` on `-migrate.lock`, held from before the legacy-DB copy until just before `stmts`) — two dashboards started at once on a fresh DB raced the probe-then-ALTER and the second died on `duplicate column name`; never wrap those migrations in a transaction on the main DB instead, since several rebuilds open their own `BEGIN` and turn `foreign_keys` off + a second dashboard on the SAME DB re-broadcasting the lanes the other one changed (`broadcastForeignLaneChanges` in `server/routes/lanes.js`, `PRAGMA data_version` every `DASHBOARD_LANE_SYNC_MS`=2 s from `server/index.js` — hooks reach only the lowest port, so a browser on the other port saw a lane's status only after F5) + the pricing sync never adding a RETIRED Claude model in advance (`RETIRED_PRICING_PATTERNS` moved to `server/lib/pricing-sync.js`, imported by `db.js`; one is priced only once used) + a just-started run no longer reading `dead` (`lastEventAge` also counts the run's `dashboard_runs.started_at`) + a Settings TAB PER CATALOG TOOL (`client/src/pages/AiToolConfig.tsx` for every installed, enabled tool with no `launcher`, built in `SettingsHub.tsx` from `useAiTools`, `?tool=`; `AntigravityConfig.tsx` deleted, its pricing + hooks cards living on there): path, homepage, an **Update…** link into the AI Tools modal, `GET /api/ai-tools/:id/usage` sessions/cost, the tool's lanes, a **Default model** saved through `PUT {defaultModel}` (`ModelPicker` now exported from `RunSetup.tsx`), `PricingSettings` for the catalog's `model.vendor` (antigravity now `google`), and the catalog's trusted `config` files edited through `server/lib/ai-tool-config.js` behind `GET/PUT /api/ai-tools/:id/config[/:index]`; the view gains `modelVendor`/`configFiles` + MODEL LISTS from each tool itself (`server/lib/ai-tool-models.js` behind `GET /api/ai-tools/:id/models`: Codex's `models_cache.json` listed slugs — its `identity` never read, Claude's aliases labelled with the newest priced version plus pinned priced versions, a catalog tool's vendor-priced ids plus the models its sessions used), a catalog `model: { flag, vendor? }` field (antigravity, gemini, opencode, cursor) putting ` ` before the prompt, `validModel` refusing a flag- or shell-shaped id (`EBADMODEL`) at the route AND in `spawnRun`, a per-tool `defaultModel` in `ai-tools.json` (`PUT /api/ai-tools/:id {defaultModel}`) that `LaneConsolePane` starts each run on, the run form's picker fetching that list (`RUN_MODEL_CHOICES` only as Claude's offline fallback, `RUN_MODEL_CHOICES_CODEX` deleted) + AUTOMATIC pricing that ADDS and FOLLOWS: `pricing-sync.js` fetches LiteLLM at most daily, inserts every current-generation Claude/OpenAI (gpt ≥ 5)/Gemini (≥ 3) chat model before it is used (`isCurrent`, `MIN_GEN`), keeps its own rows (`model_pricing.source = 'sync'`, the **Auto** badge in `PricingSettings`) at the published rate, adopts a pre-column row only while it equals the list, and never touches a `source = 'user'` row (any `PUT /api/pricing`) or a deleted pattern (`model_pricing_dismissed`, emptied by a reset for its scope) + a plugin build installed BESIDE the running one (a terminal `claude plugin update` starts nothing) being reported: `installed_not_running` + `update_available` in `pluginStatus` (`server/lib/update-check.js`, via `supersededBy`, no network), the sidebar's ** installed, not running**, the update modal keyed on it, `plugin-update-helper.js` handing over to the recorded install first; and a plugin install whose release lookup failed reading **Could not check for updates** instead of **Up to date** (`Sidebar.tsx`) + a markdown PREVIEW (`client/src/components/MarkdownPreview.tsx`: react-markdown + remark-gfm, raw HTML never run, frontmatter as a code block) behind a **Preview**/**Raw** toggle in the config `FileViewer` and the Changes panel's whole-file view + AI Tools moving from a Settings → Dashboard section to a MODAL behind a button at the Settings tab bar's right edge (`client/src/components/settings/AiToolsModal.tsx`, `actions` on `PageTabs`, opened by `/settings#ai-tools` too); its rows an enable switch + Default badge/Set default + **Update** for EVERY tool whose catalog names an updater (no version lookup on open — `api.aiTools.versions` left the client, `GET /api/ai-tools/versions` stays), the not-installed tools folded behind a disclosure, a finished job's log folded to one line; `codex` updating through its own `codex update` (standalone or npm) and `openclaw`/`hermes` gaining `update --yes`; update-all running every installed updater, skipping an npm one only when npm does not manage the binary, with no `npm view`; the shared `client/src/components/AiToolIcon.tsx`; the Add-lane tool picker being ONE button showing the chosen tool that opens the enabled tools as buttons (no Default/Other… select); and the Claude Code / Codex **AgentHub** sub-tab folded into their **Overview** (home editor, hooks, pricing under the overview card; the overview's path list drops CLAUDE_HOME/CODEX_HOME; `HomeDirSetting` shows the saved value only while the field differs) + ANTIGRAVITY PARITY (#40 part D: `server/lib/antigravity-usage.js` reads agy's own per-conversation SQLite files `~/.gemini/antigravity-cli/conversations/.db` read-only every `DASHBOARD_ANTIGRAVITY_POLL_MS` — tokens from `gen_metadata`'s protobuf `ModelUsageStats`, cwd from `trajectory_metadata_blob`, name/start from `brain//…/transcript_full.jsonl` — into `tool = 'antigravity'` sessions, mtime per file in the new `antigravity_conversations` table, and sets idle the lane of a session gone quiet that the hooks left running; `server/lib/antigravity-hooks.js` installs on a click the named hook `agenthub` (PreInvocation, PostToolUse, Stop — NEVER PreToolUse) into the global `~/.gemini/config/hooks.json` through `~/.gemini/config/agenthub-hook.sh`, `GET/POST/DELETE /api/settings/antigravity-hooks`; `routes/hooks.js` `antigravityHookInput` maps a CLI hook — `conversationId` → session, `workspacePaths[0]` → cwd, first PreInvocation → UserPromptSubmit — and ignores the IDE's; `server/lib/antigravity-home.js` (`AGENTHUB_GEMINI_DIR`); Gemini families in `pricing-sync.js`; the new Settings → **Antigravity** tab `client/src/pages/AntigravityConfig.tsx` with `PricingSettings vendor="google"` and `AntigravityHooksSettings.tsx`. Its review fixed: an empty `-wal` (our own read-only open leaves one) is not activity; activity counts the session's newest hook event, with a 10-minute window mid-turn; an agy Stop carrying an error lands as `error`, one with `fullyIdle: false` is dropped, and the first PreInvocation after a Stop is a prompt; `agyCliDirs()` for a symlinked `~/.gemini`; both tools' hook installers refuse to overwrite or remove a forwarder that is not theirs (`EFOREIGN`) and keep hooks.json's mode. The C2 review also fixed: Codex hooks matched by exact command, one hook at a time, so a user's hook in our group or with a similar name survives; `sameOriginGuard` on the codex-hooks writes; `codexHomes()` in `codex-home.js` for a symlinked CODEX_HOME; a missing main agent created for a poll-imported Codex session; `refreshScript` leaving a foreign file alone; Refresh on the card. The whole-change review also fixed: settings load real env > repo `.env` > the settings file (located after `.env` loads, so `AGENTHUB_SETTINGS_FILE` may live there), a `.env` key `env_owned` too, `writeSetting` replacing every earlier line for its key and writing through a symlink, `validateHomeDir` comparing device+inode with the home and each ancestor, `sameOriginGuard` on both home PUTs, a claude-home PUT answering `routers.missing` (`lanesWithMissingRouter` in `router-apply.js`), CLAUDE_HOME-derived paths read per call in the hook status, `install-hooks.js` and `import-history.js`; `reset-pricing` `{scope: "claude"}` from the Claude tab; `GET /api/sessions?tool=` before the limit; `clear-data` emptying `codex_rollouts` and `antigravity_conversations`, plus `idx_codex_rollouts_thread`; Compaction tagging for claude only, a Codex sub-agent's folded tool event (`_codexChild`) moving no session, agent or lane status, and hooks owning a Codex session's status only while current (`hooksCurrent`: newest event ≥ newest journal write − 2 min, also in `settleActive`); `scriptIsOurs` treating other content, an unreadable file or a directory as foreign, a symlinked hooks.json emptied through the link; agy's quiet window 10 min for any session with hook events (2 min without), a settle completing its agents and clearing waiting flags, a PreInvocation a prompt also with no prior event, a non-active session or a last event >2 min old, a `terminationReason` matching /error|fail/ an error Stop, a file that is not agy's recorded with `session_id ''` and retried on change or at start (anything else next pass), and the session id taken from the file name) + CODEX HOOKS (#40 part C2: `server/lib/codex-hooks.js` installs — only on a click in Settings → Codex → AgentHub → **Codex hooks**, `GET/POST/DELETE /api/settings/codex-hooks` — one group per event (SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, Stop, Interrupt, SessionEnd) into `$CODEX_HOME/hooks.json`, all through the stable forwarder `$CODEX_HOME/agenthub-hook.sh` (content rewritten each start by `refreshScript`, command never changes, because Codex trust is keyed on the definition); `scripts/hook-handler.js codex` posts `tool: "codex"`; `routes/hooks.js` creates `tool = 'codex'` sessions with no `transcript_path`, skips every Claude-transcript read for them, maps `PermissionRequest` → `Notification` and `Interrupt` → `Stop`, folds a sub-agent thread's hooks into the root session (`threadOf` in `codex-usage.js`), and `touchLaneFromHook` resolves the lane by (cwd, tool) with detection for claude only; a Codex session with events keeps its hook-given status in `codex-usage.js`, and `codex-ingest.js` skips a lane whose hooks are current. Also from the C1 review: one `codex_rollouts` row per thread, the resume picker lists Claude sessions only, a failed price fetch waits 15 min, a fork skipped until its first own total, 16 MB per read, usage rewritten whole, root-thread cwd/start/model, the startup cleanup and a lane purge scoped by tool, and the codex lane mirror deciding once per lane with a second of future-mtime slack) + CODEX SESSIONS + TOKEN COST (#40 part C1: `server/lib/codex-usage.js` reads every Codex rollout journal into `sessions` (new column `tool`, `'claude'` default, `'codex'` here) and `token_usage` — offset + parse state per file in the new `codex_rollouts` table, a fork's copied parent history skipped by total vector, sub-agent threads summed into the root session, input = input − cached, exact totals via `setTokenUsage`, no `transcript_path`; `startCodexUsagePoll` on the `DASHBOARD_CODEX_POLL_MS` beat; `allRolloutFiles()` exported from `codex-ingest.js`; `pricing-sync.js` also prices `gpt-…`/`o…`/`codex-…` from LiteLLM's `openai` entries and `reset-pricing` syncs right after reseeding; `AGENTHUB_PRICING_SYNC=0` in `scripts/test-server.js`; `client/src/components/settings/PricingSettings.tsx` = the pricing table with a `vendor` filter + **Show all models**, in both tools' AgentHub sub-tabs; `SessionToolBadge.tsx`; the Workspace attach/resume list offering `tool = 'claude'` rows only) + per-tool SETTINGS (#40 part B: Model Pricing, Hook Configuration and CLAUDE_HOME moved from Settings → Dashboard to the Claude Code tab's **AgentHub** sub-tab, `client/src/components/settings/ClaudeAgentHubSettings.tsx` — a tool's own settings go in that tool's tab; `setClaudeHome`/`setCodexHome` refuse any dir that is or contains the OS home (`EBADHOME`, `validateHomeDir` in `server/lib/settings-env.js`) and persist to AgentHub's settings file `~/.config/agenthub/settings.env` (`AGENTHUB_SETTINGS_FILE`; real env > repo `.env` > that file, loaded only when `server/index.js` is main — never the plugin cache's `.env`, which an update wipes), a first CLAUDE_HOME change pinning `DASHBOARD_DATA_DIR` so the DB stays put; CODEX_HOME editable in Codex → AgentHub (`/api/settings/codex-home`, `HomeDirSetting.tsx`, `reapplyCodexLayers` re-writing codex lanes' router layers into the new home); a codex pane is started with the dashboard's `CODEX_HOME` through tmux `-e`, in `server/lib/pty-run.js`) + AI Tools INSTALL/UPDATE (#40 part A2: catalog fields `install`/`update` honoured only in built-in and release files; `server/lib/ai-tool-jobs.js` runs ONE job at a time as an argv — the login shell's npm, a downloaded vendor script via `bash `, or a tool's own updater; `server/lib/ai-tool-versions.js` offers an npm update only when the binary resolves under `npm prefix -g`; `server/lib/ai-tools-sync.js` copies the newest release's catalog daily when it is newer than the build, off with `AGENTHUB_AI_TOOLS_SYNC=0`; `server/lib/ai-tool-icons.js` caches raster favicons server-side; `shellPath()` in `shell-path.js`; `repoUrlFor` in `update-check.js`; six `/api/ai-tools` routes; Install/Update/Update tools in `AiToolsSettings.tsx`) + the pipeline layer going DORMANT (no template ships: `server/data/pipelines/` deleted, `EMPTY_PIPELINE`, `ENOPIPELINE`, test templates under `server/__tests__/fixtures/pipelines/`) and the workflow skills leaving the plugin (`skills/ship-feature`, `skills/ship-feature-lane`, the 18 stage/ship commands, all four `agents/`, `agenthub skills install`, `agenthub lanes agents install`, `agenthub ship-full|ship-quick`, `POST /api/skills/install`, `POST /api/lanes/:id/agents/install`, and the Superpowers auto-install with `server/lib/skill-plugins.js` and its doctor row) + the AI TOOLS catalog (#40 part A1: `server/lib/ai-tools.js` loads one JSON per AI coding CLI from `server/data/ai-tools/` — 38 tools at the time, data from Orca, MIT, trimmed since to 10 — plus overrides in `DASHBOARD_AI_TOOLS_DIR`, detects each by walking PATH, and keeps enable/default in `/ai-tools.json`; `server/lib/shell-path.js` merges the login shell's PATH (`$SHELL -ilc`, 5 s, off with `AGENTHUB_SHELL_PATH=0`) at startup and on Refresh; `/api/ai-tools` GET/refresh/PUT; **AI Tools** (`client/src/components/settings/AiToolsSettings.tsx`, now a modal opened from the Settings tab bar's right edge, `AiToolsModal.tsx`) and the shared `client/src/hooks/useAiTools.ts`, which replaced `LANE_TOOL_LABEL`; the Add-lane modal's **Default ()** + **Other…** picker; the built-in router profile now labelled plain `Native`, rendered **Native ()**; the Claude Code/Codex settings tabs hidden while that tool is disabled; a per-run **Bypass permissions** switch; `POST /api/run` now forwarding `sandbox`/`approval`/`resumeLast`, which it had dropped) + `antigravity` lanes (a third `lanes.tool`, running Antigravity's `agy` CLI; no pipeline, no AI Router, no model/effort picker; the lane card no longer renders a bare tool chip, and the prompt box names the lane's CLI via `LANE_TOOL_LABEL` in `client/src/lib/types.ts`) + a lane whose pane EXITED being released on a server beat (`healAllRunIds` in `server/routes/lanes.js`, every `DASHBOARD_RUN_HEAL_MS`=10 s from `server/index.js`) — a codex/antigravity pane fires no hook, so nothing else re-read it and the lane stayed `running` + a codex Start no longer sending `resumeLast` (only **Resume last session** does) + per-CHANGE-BLOCK review bars (issue #50: `stage-hunk`/`discard-hunk` take an optional `block` — the index of a run of consecutive +/- lines inside the `@@` hunk — and `onlyBlock` in `server/lib/git-diff.js` cuts git's fresh hunk down to it, folding every other block into the side being checked; `changeBlocks`/`LaneDiffChange` in `LaneDiffView.tsx` hang one bar per block, and the arrows land on `data-change-index`) + `codex` lanes losing their pipeline (`codex-ingest.js` mirrors only idle↔running — no stage detection or auto-advance, no `keepDetection`; `codex-nudge.js` deleted; every server start runs `clearCodexPipelines` (`server/lib/lanes.js`: a codex lane's stage columns, `lane_stage_events` and `lane_features` wiped) and `removeStageDoc` (`server/lib/lane-agents.js`: the old `` block taken out of each codex lane's `AGENTS.md`, the file deleted when nothing else is left); `agenthub lanes agents install` installs nothing on a codex lane; the Workspace hides stage, time-on-stage, pipeline/feature pickers and PipelineMap for one) + `info/exclude` entries AgentHub writes into a lane going to the COMMON git dir with the lane's path prefix (`server/lib/git-exclude.js`, used by `router-apply.js` and `lane-mcp.js` — a linked worktree's own `info/exclude` is never read by git, and a subdir lane has no `.git`) + macOS start-at-login (`scripts/launchd-service.js`: the LaunchAgent `~/Library/LaunchAgents/io.smartgift.agenthub.plist`, same API as `systemd-service.js`, chosen by `scripts/service-manager.js` — which the bootstrap, `agenthub service` and `/agenthub-doctor` now require instead of `systemd-service` directly) + v1.8.6 (2026-09-24) + four fixes found by the #37 evaluation, shipped without #37 itself: tmux targets are EXACT (`=name` for has-/kill-/attach-session, `=name:` for pane and option targets, in `server/lib/tmux.js`, `pty-attach.js` and `scripts/cc-update-all-helper.js`) because a bare `-t agenthub-lane-1` resolves by prefix to `agenthub-lane-12` once lane 1 is gone; `getRun`/`killRun` in `server/lib/pty-run.js` refuse any id that is not `agenthub-lane-`, so `DELETE /api/run/main` can no longer kill a user's own tmux session; `POST /api/lanes/:id/router/apply` reports `restart_required` only when the pane's status is `running` (it was always true, since `getRun` returns a `gone` handle); and a lane's `needs_action` is cleared only by the session that raised it (new nullable `lanes.needs_action_session`, `server/routes/hooks.js`) + macOS support, code-audited and untested on a Mac (#22): `server/index.js` deletes a DENYLIST of parent-session variables at startup (`PARENT_SESSION_VARS`: CLAUDECODE, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_MESSAGING_*, CLAUDE_PLUGIN_ROOT, AI_AGENT, …, plus GIT_EDITOR only when it is Claude Code's `true`; user config such as CLAUDE_CONFIG_DIR/CLAUDE_CODE_USE_BEDROCK/ANTHROPIC_* passes through), because a server started from a Claude Code session handed them through tmux to every lane pane; the `~/.local/bin/agenthub` launcher falling back to `node` on PATH when its pinned node is gone (`linkCli` in `scripts/plugin-bootstrap.js`); `liveServers` ignoring a discovery entry whose `startedAt` predates this boot, so a recycled pid is never a live dashboard; lane cwds stored by real path (`canonicalCwd` in `server/lib/lanes.js`, used by `createLane`, `POST /api/lanes/ensure` and the liveness reaper — `/tmp` becomes `/private/tmp`, a not-yet-existing path through its deepest existing ancestor, existing rows untouched); an `__setExecImpl` seam in `server/lib/session-liveness.js`; and the `INSTALL.md` § macOS section with the Mac checklist + issue #34's empty-repo half — `publish` as a git write (`server/lib/git-ops.js`: `remote add origin -- ` + `push -u origin HEAD`, only in a repo with a commit and NO remote, URL allowlisted by `validateRemoteUrl` to https-without-userinfo/ssh:///scp-like, `origin` removed again on a failed push; `ENOCOMMITS`/`EREMOTEEXISTS`/`EBADURL`), an unborn repo being readable (`gitFacts`/`statusCounts` in `server/lib/worktree.js`: `head: null`, new `unborn`, branch via `symbolic-ref`), `POST /api/lanes/worktree` answering `400 EEMPTYREPO` instead of leaving a `failed` row, and `LaneGitBar.tsx` gaining **Publish…** (no remote), **Publish branch** (remote but no upstream) and a commit-first hint (unborn) while `AddLaneModal.tsx` steers an empty repo to Repo mode + a dashboard fetch/pull/push that needs a git LOGIN asking for one in the git bar (issue #45: `409 EGITAUTH` with `auth`, resent with `credentials`; AgentHub's own shared store `server/lib/git-credentials.js` + helper `server/lib/git-credential-helper.js`, `/api/git-credentials`, Settings → **Git credentials** = `client/src/pages/GitCredentialsConfig.tsx`) + MERGE and conflict resolution from the dashboard (issue #45 part B: `merge`/`resolve`/`merge-commit`/`merge-abort` in `server/lib/git-ops.js`, `gitFacts.merging`/`conflicts`, the git bar's **Merge into current** and diverged-upstream **Merge**, the Changes panel's merge banner and **Merge conflicts** group, and the per-block conflict view `client/src/components/lanes/LaneConflictView.tsx` over the pure parser `client/src/lib/conflicts.ts` — keep mine/theirs/both per block, saved through the whole-file editor's `PUT /git/file` + `resolve done`, no new API) + drag-and-drop of an image onto the lane terminal (`TerminalView.tsx` uploads to `POST /api/run/drop-image` in `server/routes/run.js` — raw body, png/jpeg/gif/webp only, random name under `/agenthub-drops-/`, never a name from the request — then `term.paste()`s the returned path, which is how `claude` attaches it) + Claude Opus 5.5 getting its own pricing row (`claude-opus-5-5%` at 4/20/0.2/5/8, fast 8/40 — `claude-opus-5%` had been pricing it as Opus 5) + AUTOMATIC pricing (`server/lib/pricing-sync.js`, started in `startBackgroundServices`: a model family used in `token_usage` with no `%` row of its own is INSERTED from LiteLLM's public price list — since followed daily for the rows it owns, see the entry above — off with `AGENTHUB_PRICING_SYNC=0`) + the Changes panel at **Full screen** moving the whole Files view (finder + expand-all + tree, one `browsePane`) into a left SIDEBAR and dropping the Files tab and in-place editing (`full` prop on `LaneChangesPanel` from `Workspace.tsx`; a whole file opens for READING and enters the editor only on a click on its text — `onEditAt` on `LaneFileView`, caret at the clicked line, a drag-selection is not a click; Cancel returns to reading, and only a diff keeps an Edit button) and height-locking the page (`viewportLocked` also true at full, else the panel shrank to content with no live run) + the overview splitting into **Staged Changes** / **Changes** groups in the `work` range + a changed tree row tinted whole (`treeMark` tone carries a `bg-status-*/10`) + the Files tree listing untracked-not-ignored files (and, since, ignored ones marked `ignored`) and carrying each entry's git status (`treeStatus`/`treeEntries`/`TREE_LS` in `server/lib/git-diff.js`: file `status` = porcelain code, folder `changed: true`, from ONE `laneStatus` per tree read; `treeMark` in `LaneChangesPanel.tsx` paints M/U/A/D/R) + split panes on ONE lane (every Workspace pane shows the strip's selected lane, each in its own tmux session: `slot` 0–3 on `POST /api/lanes/:id/start`, `agenthub-lane-` for slot 0 = `run_id`, `agenthub-lane--` for 1–3 — `runIdForLane(id, slot)`/`MAX_PANE_SLOT` in `server/lib/pty-run.js`, the `-[1-3]` suffix accepted by `pty-attach.js` and `websocket.js`; `stop`/`reset`/`remove` kill every slot via `laneRunIds`; a pane's Active runs hides what the other panes show; no per-pane lane picker) + the server suite running on a PRIVATE tmux server (`npm run test:server` = `scripts/test-server.js`: drops `TMUX`, sets `TMUX_TMPDIR` to a temp dir, kills that server after — suites spawn and kill real `agenthub-lane-` sessions with ids from their own DB, and on the default server that killed the user's real lane panes, including the one running the tests) + the whole-file EDITOR (`PUT /api/lanes/:id/git/file`, `server/lib/lane-file-write.js`: the read path's guards plus existing-text-file-only and a `baseSha` stale check → `409 ESTALE`; `readFileAt` returns that `sha`) + the per-hunk controls moving into an editor-style review bar under each hunk's last changed line (`HunkBar` in `LaneDiffView.tsx`: ^/v, `n of N`, Undo, Keep) + **Unstage all** on the ONE commit/Stage-all row + Changes sizes Narrow (30%) / Medium (50/50) / Full screen, no 70/30 + the lane card's needs-you banner gone (the strip tile keeps it) + per-HUNK Stage/Undo in the Changes panel's Review and single-file views (`stage-hunk`/`discard-hunk` in `server/lib/git-ops.js`: the hunk is named by its `@@` line and re-matched against a FRESH `git diff -U HEAD` by `hunkPatch` in `git-diff.js`, then `git apply --cached` / `git apply -R` from a temp file — `409 EHUNKGONE` when it moved, `400 EUNTRACKED` on an untracked file; `discard-hunk` is the SECOND irreversible git action and carries the same `confirm: true` gate and two-click UI; the controls reach every `HunkHeader` through a context in `LaneDiffView.tsx`), each Review file header sticky with its own stage/unstage/discard (`FileActions`, shared with the overview row) + the Changes panel gaining a FULL-width step (`CHANGES_SIZES` in `Workspace.tsx`: narrow → 50/50 → 70/30 → full → closed; at full the `lane-detail` section gets `hidden` — CSS, so the one console render site stays mounted), **Stage all** plus the staged-only commit box ABOVE the overview list and sticky (as is the Files finder), and **Expand all folders** being ONE `GET /api/lanes/:id/git/tree-all` (`listTreeAll` in `server/lib/git-diff.js`: one `ls-files`, every level shaped like `listTree`'s) instead of a request — and a `git ls-files` — per folder + the lane's git section MOVING from the lane card to the top of the Changes panel (`client/src/components/lanes/LaneGitHeader.tsx`: `useLaneGitFacts`, the working-copy line, `LaneGitFilesPanel` and `LaneGitBar`, sharing ONE facts read with `LaneChangesPanel`, which refreshes it after every stage/unstage/discard/commit while a git bar action re-reads the diff; `onNewLane` comes from `Workspace.tsx`, and `LaneCard` has no git row and no `onNewLaneFromRepo` any more) and the git bar losing its commit UI — the overview's staged-only box is the ONE commit path, the server's `commit` without `only` is unchanged; the git line and its icon-only actions are ONE row in one card, the panel toolbar ONE wrapping row, and `LaneCard` hides itself (`empty:hidden`) when it has nothing to show + the run form's `/` and `@` autocomplete being REAL again (`PromptEditor` in `client/src/components/run/RunSetup.tsx` — the `2f39f4e` rewrite had left a bare textarea: `/` offers the run cwd's project + user commands and skills, via `api.ccConfig.commands|skills(scope, cwd)`, plus the CLI built-ins, claude lanes only; `@` offers `GET /api/run/files`; Enter picks while the list is open and sends otherwise) + the console's **Active runs** listing only the CURRENT lane's runs and external sessions (`LaneConsolePane.tsx`: runs by `laneId`, sessions by the longest path-boundary lane cwd, `owningLaneId`) + v1.8.3 (2026-09-23) + `GET /api/settings/info` reporting `server.memory.heapLimit` (V8's `heap_size_limit`), which the Dashboard's V8 Heap bar and Health Score now divide by instead of `heapTotal` + every `claude plugin install|update -y` (`cc-plugin-cli.js`, `skill-plugins.js`, both detached update helpers, `/agenthub-update`) retrying WITHOUT `-y` when an older Claude Code answers `unknown option '-y'` + `LaneDiffView.tsx` WINDOWING a diff above `WINDOW_THRESHOLD` (500) items at a fixed `ROW_PX` row height (`visibleRange`, spacers, a `ch` min-width off the longest line; at or below the threshold the DOM is unchanged) and clamping its mirrored split scroll (`clampScroll`/`follow`) + a git action that git itself refuses (a numeric exec exit status) answering 409 `EGIT` instead of 500 with a number for a code (`gitErrorCode` in `server/routes/lanes.js`), and one `parseNumstat` (`server/lib/git-diff.js`) shared by the Changes panel and `gitFacts.files` + the Dashboard heatmap ramp mixing `--accent` into `--surface-0` so it reads on every theme + the lane git surface being HARDENED (`server/lib/git-diff.js`, `server/lib/git-ops.js`, `server/lib/worktree.js`: every lane git call runs with `GIT_LITERAL_PATHSPECS=1` and `-z` porcelain, is scoped to the LANE's cwd — a subdir lane sees and acts on only its own paths, `gitFacts.files` included — and reads through `lanePath` at every rev; any file whose basename is `settings.local.json`, the lane router key file, is refused as `ESECRET`/403 on disk and at every rev; `commit` refuses with `ESTAGEDOUTSIDE` while anything outside the lane is staged, checked with `--no-renames`; `push` without a resolvable remote is `ENOREMOTE`; git writes serialize on the repo's absolute git dir, not the lane id) + the xterm terminal following the selected DARK theme (`client/src/lib/terminalTheme.ts`, live via `term.options.theme`) and chart colours using structural tokens + the Add-lane modal asking `POST /api/lanes/ensure` with `exact: true` (#42) — without it a directory NESTED inside an existing lane resolves to that parent lane with `created: false`, so the modal closed on the parent and no new card ever appeared; `exact` counts only a lane on that very directory, and a `created: false` answer now names the lane that owns the folder instead of closing. The CLI, the run console and Sagi keep the parent lookup + Settings → **Appearance** (`client/src/components/settings/AppearanceSettings.tsx`: mode Dark/Light/**System** — `prefers-color-scheme`, followed live — plus one NAMED theme remembered per mode: dark Default/Darcula/Apple Dark, light Default/Apple Light/IntelliJ Light/Canvas). A theme is a CSS-variable override block in `client/src/index.css` keyed by `data-theme` on `` (`.dark[data-theme=…]` / `[data-mode="light"][data-theme=…]`, `data-mode` is set on `` too), and `--tint-base`/light `--syn-plain` are now `var(--surface-3)`/`var(--fg-primary)` so no theme restates them; `hooks/useTheme.ts` is a `useSyncExternalStore` reader over `localStorage` (`theme` = `dark|light|system`, `agenthub.theme.dark|light`) that caches NOTHING in the module, so the Sidebar and Settings can never disagree — do not reintroduce per-instance `useState` there; an inline `