Files
AgentHub/CLAUDE.md
T
nntrivi2001 8fdda66893 fix: v2.5.9 — Vietnamese in the terminal on macOS, every slash command, the right Claude account
Issue #62: a tmux client whose LC_ALL/LC_CTYPE/LANG names no UTF-8 prints `_`
for every non-ASCII character, and a macOS LaunchAgent starts the dashboard
with no locale at all — Vietnamese text and Claude Code's logo came out as
underscores in every browser terminal.

- shell-path.js: ensureUtf8Locale() defaults LC_CTYPE (en_US.UTF-8 on macOS,
  C.UTF-8 elsewhere) at start, before any tmux call; an explicit LC_ALL and
  Windows are left alone
- RunSetup.tsx: the `/` list now offers Claude Code's bundled skills
  (/code-review, /security-review, /simplify, /loop…) and newer built-ins, and
  every enabled plugin's commands and skills as /<plugin>:<name>
- Claude login decided by the TOKEN's owner (GET /api/oauth/profile, cached
  per token), not by .claude.json: a running session can leave the
  credentials store holding one account's token under another account's
  name. The Profiles modal, its Check quota and the status-bar quota now show
  the account requests are really billed to and warn on the mismatch; a
  switch saves the outgoing token into its OWNER's profile (credentials only)
  instead of overwriting the profile .claude.json names; saving a profile
  while they disagree is refused (EMISMATCH); a saved profile holding another
  account's token reports EWRONGACCOUNT instead of that account's usage
- Running Claude Code sessions follow a switch on their own (they re-read the
  credentials store when it changes), so the "close your sessions first"
  warning is gone
2026-10-07 11:18:10 +07:00

109 lines
139 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Claude Code Working Guide
_Last synced: v2.5.9 (2026-10-07) + tmux attach under a UTF-8 locale, plugin + bundled slash commands, the Claude login decided by its token's owner; v2.5.8: the browser terminal ATTACHING on Windows (`attachBinary` in `server/lib/pty-attach.js` names tmux by its full file name there — `ai-tools.which("tmux")`, else `tmux.exe` —, because node-pty on Windows looks a file up on PATH exactly as given and answered "File not found" for a bare `tmux`: the socket opened, closed and the terminal reconnected every 1.5 s; `websocket.js` now logs `[WS] attach <run> failed: …` for any attach that throws) + v2.5.7 (2026-10-06) + tmux TARGETS that also work on psmux, the WinGet `tmux` on Windows (`session`/`pane` in `server/lib/tmux.js`, now exported and used by `pty-attach.js` and `cc-update-all-helper.js`: the first live session is probed once — `has-session -t =<name>` failing where the bare name answers means no exact-match syntax, and every target is then the bare name; real tmux keeps `=name`/`=name:`. psmux answered "no server running on session '=agenthub-lane-1'" for a live pane, so every Windows start reported `EPANEEXITED` while its session ran on; `__setExecImpl(fn, {exact})` pins the answer in tests) + v2.5.6 (2026-10-06) + the plugin's UI build needing no `node` on PATH (`buildClient` in `scripts/plugin-open.js`: client deps installed with `--ignore-scripts` — esbuild, the only one with an install script, finds its binary without it — and `tsc -b` / `vite build` run by `process.execPath` itself instead of `npm run build`, kept equal to `client/package.json`'s `build` by a test; on Windows a lifecycle script or `.bin/*.cmd` shim goes through cmd.exe, the server's PATH had no `node`, and every Update now kept the previous UI — "UI build v<old> (rebuild client to match)") + a Windows pane started WITHOUT `sh` (`paneArgv` in `server/lib/pty-run.js`: on win32 the CLI's argv itself and, for a Terminal, nothing after `-c <cwd>` so tmux runs its default shell — `newSession` in `server/lib/tmux.js` drops the `--` then; a WinGet tmux runs native programs and there is no `sh`, so every Windows pane died at once with "Command failed: tmux set-option … mouse on"; no wait-for-attach there, so a Windows pane may first draw at 80×24) + `cc-update-all-helper.js` pausing with `Atomics.wait` instead of a `sleep` binary Windows lacks + v2.5.5 (2026-10-06) + AI Router RENAMED to **AI Proxy** everywhere (`server/lib/ai-proxies.js`, `server/lib/proxy-apply.js`, `server/routes/ai-proxies.js` mounted at `/api/ai-proxies` AND the old `/api/ai-routers`; `proxy` on the start routes with `router` still accepted; `EBADPROXY`, `proxyCapable`, `AGENTHUB_PROXY_KEY`, `<data dir>/proxy-settings/`; profiles in `<CLAUDE_HOME>/agenthub-proxies/`, the old `agenthub-routers/` moved on first read and symlinked back; `dashboard_runs.router` renamed in place to `proxy` — an older build on the SAME DB fails on that column, so dev and prod on one DB update together; `client/src/pages/AiProxyConfig.tsx` at `/settings/ai-proxy`, `/settings/ai-router` redirecting via `MOVED`; `components/run/ProxyPicker.tsx`, localStorage `agenthub.session.proxy` falling back to `agenthub.session.router`) with a proxy's `baseUrl` stored as its ROOT (a `/v1` dropped; Claude gets the root, the Codex layer root + `/v1` — a typed `/v1` had made `/v1/v1/messages`), **Test** probing Claude's `/v1/messages` AND Codex's `/v1/responses` (`{claude, codex}`), and the built-in Native carrying no model ids + every tab RELOADING onto the new UI build after an update (`client/src/lib/newBuildReload.ts`, mounted once in `App.tsx`: on a WebSocket RE-connect, `GET /api/health`'s `version` ≠ `__APP_VERSION__` → `location.reload()`, once per version via sessionStorage `agenthub.reloadedFor`) + **Update now** no longer spinning forever on a plugin chain that left the old server up (`pollUntilBack` in `UpdateNotifier.tsx` stops on this chain's own `last_plugin_update` verdict — `ok:false` or `already_current`, `finished_at` after the click — and reloads as soon as a different `local_version` answers) + a pane whose command exits at once reported as such (`newSession` in `server/lib/tmux.js`: `EPANEEXITED` naming the command, instead of "Command failed: tmux set-option -t =agenthub-lane-<n>: mouse on") + v2.5.4 (2026-10-06) + the Query Explorer FOLDED into the Sessions page (`/sessions` tabs Sessions · Events · **Agents** in `client/src/pages/History.tsx`; `pages/QueryExplorer.tsx` became `pages/AgentsTable.tsx`, agents only — sessions and events already had their tabs; `/query` redirects to `/sessions?view=agents`, its sidebar item is gone; `GET /api/query` still answers all three entities) + the session page's Resume/Fork/Open pane matching a Windows folder (`owningLane` in `SessionResumeButton.tsx` and `sessionWorktreeName` in `WorkspaceSessionList.tsx` take `\` as a separator too) + the Windows picker made the FOREGROUND window (`dialogCommands` in `server/lib/native-dialog.js`, now `-EncodedCommand`: a throwaway `ShowWindow` spends the hide that `windowsHide` gives a process's first one, then the TopMost owner is brought forward by joining the foreground thread's input — `AttachThreadInput` —, since v2.5.3's owner was itself hidden and the dialog still opened at the bottom) + v2.5.3 (2026-10-06) + a Windows workspace folder (`D:\…`) being accepted (`createLane`/`canonicalCwd` in `server/lib/lanes.js` test `path.isAbsolute` instead of a leading `/`, `canonicalCwd` stops at any root — a missing drive looped forever —, `resolveLaneByCwd` and `/ensure` take `\` as a separator too) and a TopMost owner for the Windows native picker (`dialogCommands`, `server/lib/native-dialog.js`) + every child process on Windows started with `windowsHide: true` (`server/lib/windows-hide.js`, required first by the server and the detached scripts — the console-less dashboard flashed a console window per git/npm call) + v2.5.2 (2026-10-06) + `scripts/plugin-bootstrap.js` assigning `module.exports` BEFORE its entry block, like `plugin-open.js` (run as the entry, `bootstrap()` → `plugin-open.js` requires it back and got `{}` — v2.5.1's every update died on "boot.npmCommand is not a function"; an order test in `plugin-bootstrap.test.js` guards both files) + v2.5.1 (2026-10-06) + every plugin npm run (the runtime install, the UI build in `plugin-open.js`, the doctor's npm row) going through `npmCommand` in `scripts/plugin-bootstrap.js` — the running node's bundled `npm-cli.js`, no shell, never a PATH lookup unless that node ships no npm: a Windows self-update failed with "'npm.cmd' is not recognized" because the server's PATH did not reach it + v2.5.0 (2026-10-06) + START-AT-LOGIN made OPT-IN, off by default (Settings → About → **Start AgentHub automatically**, `AutostartSettings.tsx` over `GET/PUT /api/settings/autostart`, `server/lib/autostart.js`: the switch IS the unit's enabled state — `unitIsEnabled`/`setUnitEnabled` in both service modules —, on writes a unit for the running server when none exists and enables it, off disables it, neither starts or stops anything; `plugin-bootstrap.js` installs or starts the unit only while it is enabled and rewrites it with `writeUserService({ enable })` keeping that state — it used to install one on every plugin install) + the session view's height subtracting the drawer's 2.75rem menu bar below `md` (issue #61, `Workspace.tsx`) + v2.4.0 (2026-10-06) + ANALYTICS PARITY with the upstream agent monitor (hoangsonww) 523170e (`docs/plans/analytics-parity/`, phases 2–7, ported onto v2.3.1 and released as v2.4.0; phase 1 had shipped already): opt-in secret redaction chained in front of `trim_event_data` plus `redact_text(?)` on every events `summary` insert; `?tool=` on cost / analytics / stats / workflows (`parseTool`/`parseScope` in `server/lib/source-filter.js`, `scoped-stats.js` taking a `{where, params}` predicate over `sessions`) with `by_tool` and an AI-tool select + **Cost by AI tool** card on Analytics, `/api/analytics` `total_cost` now equal to `/api/pricing/cost`, Codex fast/priority tier → `speed = fast` priced with LiteLLM `*_priority` rates synced into the fast columns, flex → `service_tier = flex` (half price), `agenthub_cost_usd_total{tool,model}` (long-context >200k/>272k rates deliberately NOT modelled — see `pricing-sync.js`); `from`/`to` date ranges (`parseRange`) on cost / analytics / workflows with the Analytics `?range=` select (Today / This week from Monday / This month from the 1st / 7d / 30d / 90d / All / Custom, `client/src/lib/dateRange.ts`), the `/query` Query Explorer (`server/routes/query.js`, allowlisted sort, CSV/JSON export ≤ 50k rows) and scheduled reports (`server/lib/reports.js`, Settings → Reports, files under `<data dir>/reports/`); spend BUDGETS and dollar alerts as alert rule types `budget` / `cost_threshold` (`server/lib/budgets.js`, `GET /api/budgets/status`, each threshold once per period, `alert_rules` rebuilt without its rule_type CHECK), `POST /api/alerts/rules/preview` (dry run over history), Spend pace + Budgets cards and a status-bar budget chip; session REPLAY tab and immutable SNAPSHOTS (`session_snapshots`, `/api/snapshots`, `/snapshot/:token`, Settings → Snapshots); remote Codex sync, push ingest `POST /api/hooks/ingest-batch` (`server/lib/push-token.js`), privacy redaction + ignored folders (`server/lib/privacy.js`, Settings → Data → Privacy and → Push ingest); CURSOR sessions (`server/lib/cursor-usage.js`: `~/.cursor/projects/<slug>/agent-transcripts/<id>/<id>.jsonl` — CLI and editor alike — into `tool = 'cursor'` sessions every `DASHBOARD_CURSOR_POLL_MS`, `AGENTHUB_CURSOR_DIR`, the `cursor_transcripts` table; NO tokens, model or cost — the transcripts record none and `state.vscdb` (which holds auth tokens) is never read; no Cursor hooks, whose `before*` events are permission gates); the read-only MCP server (`mcp/server.js`, `agenthub mcp`), the statusline (`agenthub statusline install`) and `monitoring/` Prometheus/Grafana files + the pre-commit hook running only the RELATED tests for a small commit (`.husky/pre-commit`: ≤ 10 staged code files and no dependency/config/hook/deleted file → `vitest related` for `client/` and the `server/__tests__` files naming each other staged module; docs alone run nothing; `AGENTHUB_FULL_TESTS=1` or a bigger commit runs both full suites, the frontend one with `NODE_ENV=test`) + 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.<lane id>` 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 <id>` beside `--lane <id>` 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-<id>-<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/<tmux.serverPid()>/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 `<Suspense>` 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`, `<data dir>/pets/<id>/`; 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/<id>`, 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=<data dir>/router-settings/<name>.json`, Codex `-p <name>`; `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/<id>.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 `<pane>@<started_at>` 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=<name>`, `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 <reason>`) + 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 `<input type="checkbox">`, 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=<id>` opens it and hides — CSS — the session view, which `&run=<runId>` 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 `<data dir>/workspace-context/<lane id>/`, 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=<id>&run=<runId>`, 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/<id>` 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=<id>`/`#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=<local 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 `<db>-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=<id>`; `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 `<flag> <id>` 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 **<version> 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/<id>.db` read-only every `DASHBOARD_ANTIGRAVITY_POLL_MS` — tokens from `gen_metadata`'s protobuf `ModelUsageStats`, cwd from `trajectory_metadata_blob`, name/start from `brain/<id>/…/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 <Event> 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<n>…`/`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 <file>`, 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 `<data dir>/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 (<tool>)** + **Other…** picker; the built-in router profile now labelled plain `Native`, rendered **Native (<tool>)**; 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 `<!-- agenthub:stage:start -->` 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-<n>`, 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 -- <url>` + `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 `<tmpdir>/agenthub-drops-<uid>/`, 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 `<family>%` 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-<id>` for slot 0 = `run_id`, `agenthub-lane-<id>-<slot>` 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-<id>` 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<context> 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 `<html>` (`.dark[data-theme=…]` / `[data-mode="light"][data-theme=…]`, `data-mode` is set on `<html>` 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 `<script>` in `client/index.html` reads the same keys before first paint, so a key rename touches BOTH + the diff's +/- rows (`LaneDiffView.tsx` `rowTint`/`markerClass`, `highlight.ts` `diff-add`/`diff-del`) taking a /20 status tint, a 3px status bar and a coloured marker, and the changed-span emphasis `bg-fg-primary/15` instead of `bg-white/15`, which vanished on every light theme + the light theme going WARM (`client/src/index.css`: the whole light ramp is Claude's ivory/terracotta family — page `#e8e5da`, `.card` `#f5f3ec` (one notch below Claude's own `#faf9f5` ivory), warm borders and a warm `--shadow-card`, `--accent` `#924224` (was `#a34a28`) which is the brand `#cc785c` three steps down because the brand value is 3.1:1 on ivory and 3.3:1 under white text, and `#a34a28` missed 4.5:1 on its own selected chip — dark mode is untouched and stays Radix slate + blue) + the Add-lane modal having NO pipeline picker and no Advanced disclosure (`AddLaneModal.tsx`: every lane is created on the server's own `default`, changed afterwards from the lane's detail header or at creation via `agenthub lanes add --pipeline <id>`; the router radio is the one per-lane extra left and it sits in the form) + the lane-detail header dropping its stage and `auto: <stage>` chips (the PipelineMap directly below already draws both) + the Changes panel's ONE resize control being icon-only, drawing the width the next click lands on + status/accent FILLS being pre-mixed instead of translucent (`tint()` in `client/tailwind.config.js` + `--tint-base` in both themes of `client/src/index.css`: `bg-status-success/10` and `border-status-danger/40` now compile to `color-mix(…, rgb(var(--tint-base)))`, because `rgb(… / 0.1)` composites with whatever sits behind it — one "success" chip rendered as five different colours over the five surface steps and washed out on light cards. Scoped to `backgroundColor`/`borderColor` ONLY: `text-status-warning/80` and `ring-accent/40` sit on top of a fill and must stay translucent, and a translucent `surface` is what every scrim is made of. Light `--status-*` also dropped two steps below Radix 11 so a label clears 4.5:1 on its own densest chip, not just on the card) + the LIGHT theme being rebuilt around the rule that the surface ramp rises in lightness with its index in BOTH themes (`client/src/index.css`: page `surface-0` #eef0f3 under a brighter `.card` `surface-3` #f8f9fb, so hovers lift; light `--accent` #0068d6 because dark's #0090ff is 3.3:1 on white; `--fg-muted` held at ≥4.5:1; new `--syn-*` syntax tokens + `syntax.*` in `tailwind.config.js` consumed by `tokenClass` in `client/src/lib/highlight.ts`, dark keeping its pastels and light taking the same hues four steps darker) — a hardcoded near-black panel, a `hover:text-white` or a global `filter: invert(1)` is a light-mode BUG, token it or scope it under `.dark` + `client/src/components/Tooltip.tsx` — `GlobalTooltip`, mounted once in `App.tsx`, upgrades EVERY native `title` in the app into an anchored, themed bubble (parks the attribute in `data-tip` while the bubble is up and restores it after, so the accessible name and the no-JS fallback survive; `data-tip-off` opts out, `Tip.tsx` shares its `TOOLTIP_SURFACE`) — do NOT add a per-call-site tooltip wrapper, keep writing `title=` + the lane git surface of issues #34 and #35 — `server/lib/git-diff.js` (a lane's changes as parsed hunks, `range=work|branch|commit`, a caller-chosen `context`, three caps, plus `listCommits`/`readFileAt`/`listPaths`/`listTree`) behind `GET /api/lanes/:id/git/changes|log|file|ls|tree`, `server/lib/git-ops.js` (fetch/pull/push/stage/unstage/discard/commit/switch/branch-create/branch-delete — a per-file path must be one `git status` reports as changed, and `discard` needs `confirm: true`) behind `POST /api/lanes/:id/git/:action`, the Changes panel's five modes (**Files** first) with a staged-only commit box in its overview and per-file collapse in its review list (the header folds, only the PATH opens the file; plus collapse/expand-all) and a **back** that returns to the originating mode AND scroll offset, ONE header control that steps narrow/50-50/70-30/closed (`agenthub.workspace.changesSize` beside `changesOpen`), and its VS Code-shaped viewer (`LaneDiffView.tsx`: a diff bounded to its own box and scrolling on BOTH axes — a horizontal bar at the foot of a 2000-line container is only reachable at the end of the file — unified/side-by-side as two independently scrolling columns with MIRRORED `scrollLeft` — halves inside one scroller painted over each other with nothing to scroll — highlighting reused from `client/src/lib/highlight.ts`, intra-line emphasis, expand AND collapse context, whole-file), the Add-lane modal's third branch answer **Current branch** (it switches the modal to Repo mode — a lane that simply IS the repo on `main`, which a worktree could not do anyway because git holds one worktree per branch) and its `prefill` prop, driven by the git bar's **New lane here**, `gitFacts` gaining `hasRemote`/`upstream`/`ahead`/`behind`, `GET /api/lanes/branches` gaining `worktrees`, `GET /api/run/cwds` gaining a `repo` kind (the `source_repo` of existing lanes), the Add-lane modal's new/existing branch toggle + worktree list + Advanced disclosure (`AddLaneModal.tsx`), and the two new client components `LaneGitBar.tsx` (now in the Changes panel, via `LaneGitHeader`) and `LaneChangesPanel.tsx` (the collapsible Changes card beside `lane-detail` in `Workspace.tsx`, `agenthub.workspace.changesOpen`) + v1.6.17 (2026-09-18) + the run's permission mode defaulting to `auto` and the model picker sending ALIASES (`ALLOWED_PERMISSION_MODES` in `server/lib/pty-run.js` + `server/routes/run.js` gained `auto`, which is also the fallback `buildClaudeArgv` passes to `--permission-mode`, the `RunSetup` select's first option and `LaneConsolePane`'s initial state; and `RUN_MODEL_CHOICES` in `client/src/lib/api.ts` now holds `fable`/`opus`/`sonnet`/`haiku` instead of pinned ids — `claude --model` resolves an alias to the latest model itself, so a release like Fable 5.1 needs no edit here, and pricing is unaffected because it keys off the resolved id the transcript reports) + `convertEol` being GONE from the xterm terminal (`TerminalView.tsx`) — it rewrote every bare LF as CR+LF, so a tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the column it was on) had the rest of that draw land at column 0, stamping a line's first characters into the left margin of the rows below: the torn left edge of issue #33, which the attach-size fix alone did NOT cure + the lane pane inheriting the graphical session's `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (`server/lib/desktop-env.js`, merged into `spawnRun`'s tmux `-e` env) — a systemd user service has none of them, so `claude` in a dashboard-started pane could not read the clipboard and answered "no image found in clipboard" on every image paste + the PTY attach carrying the client's REAL size (`sizeFromQuery` in `server/websocket.js`, `?cols=&rows=` on the `/ws-pty/:runId` URL built in `TerminalView.tsx`) — `attach()` was spawning `tmux attach-session` at a hardcoded 80×24 on EVERY attach, and tmux sizes the window to the newest client, so `claude` redrew its whole UI at 80 columns until the first `resize` message landed and that width stayed in the scrollback as the torn left-margin fragments of issue #33 + the Workspace's no-lane state being a real banner (`lane-empty` in `client/src/pages/Workspace.tsx`, `empty.title`/`empty.body` in `lanes.json` replacing `emptyState`) — the lane strip, add tile included, now renders only when a lane exists + the **Create/update readme file** action living in the lane's **Tools** menu (both since removed; `LaneToolsMenu` in `client/src/components/lanes/LaneCard.tsx`, `actions.writeDocs*` in `lanes.json`) instead of being a checkbox in the Add-lane modal — same `api.run.start({initialPrompt: "/sg-all:sg-md-writing"})`, claude lanes only, but re-runnable long after creation + the lane terminal taking focus on mount and reporting its own death (`TerminalView.tsx`: `term.focus()` after the first fit, so typing reaches tmux without clicking the pane, and a new `onExit` prop — read through a ref so an inline parent callback never re-attaches the socket — fired on the server's `exit` frame; `LaneConsolePane` runs `newRun()` there, so a double Ctrl+C out of `claude` lands back on the run setup form instead of a dead pane behind a **New run** button) + the run console pre-filling NOTHING into the prompt box and a resume sending NO prompt (`LaneConsolePane.tsx`'s `promptPrefill` is gone — picking a pipeline used to write its driving slash command into the box on a claude lane and a stage-declaration checklist on a codex one, and whatever sits there is sent as a real prompt the moment the run starts; `handleStart` in `RunSetup.tsx` now drops `initialPrompt` entirely when a session is being resumed, since a resume carries its own transcript and anything typed would fire an extra turn into it) + browser notifications falling back when the push landed NOWHERE (`notify()` in `client/src/hooks/useNotifications.ts`): `POST /api/push/send` answers 200 with `{native:false, pushed:0}` whenever this browser has no push subscription — no service worker, a non-secure origin like `http://<lan-ip>:4820`, an unreachable push service — and the client counted that as delivered, so nothing ever appeared; delivery is now `native || pushed > 0` and anything else shows the notification locally (skipped when the push DID land, or every one would be doubled). The `Notification` API still needs a secure context, so a bare LAN IP can show nothing at all + auth-profile switching reading and writing the macOS **login Keychain** (`readCredentials`/`writeCredentials` in `server/lib/cc-profiles.js`): on darwin the `claude` CLI keeps `claudeAiOauth` in the generic-password item `Claude Code-credentials` (account `$USER`), never in `<CLAUDE_HOME>/.credentials.json` — so a switch rewrote a file nothing reads and the CLI kept running as the previous account. The Keychain wins when it holds an item, the file stays the fallback and is kept in step only when it already exists, and `AGENTHUB_CC_KEYCHAIN=0` pins the file (the test suite sets it so a Mac dev run never touches the real Keychain) + turn bookkeeping being idempotent (`stmts.insertEventAt` in `server/db.js`, used for `TurnDuration` with the TRANSCRIPT's timestamp — its dedup guard matches on `created_at`, so a NOW stamp made every hook re-insert the session's whole turn history: 587k rows, 75% of the events table — and `meta.thinking_blocks`/`turn_count`/`total_turn_duration_ms` now ASSIGNED from the full parse instead of `+=`, since `extract()` returns whole-transcript totals every time) + `hasLegacyHooks` blocking `fastPathOk` (`scripts/plugin-bootstrap.js`): leftover checkout hook entries double EVERY event and subagent row, `stripLegacyHooks` only ever ran inside `bootstrap()`, and the fast path skipped it on exactly the healthy installs where it mattered + the one-time repair marker moving to `user_version` 3 (`server/index.js`) — it now also deletes the duplicated `TurnDuration` rows, and 2 could not be trusted because an old build still ingesting hooks re-inflated whatever that pass corrected + a router switch on a LIVE pane reporting itself (`POST /api/lanes/:id/router/apply` now answers `restart_required`/`run_id` when `runs.getRun(runIdForLane(id))` is live, and `LaneRouterRow` renders a **Restart** button that kills that pane and restarts it from the lane id alone — Claude Code re-applies `env` to a running session but keeps the model it resolved at startup, so the old router's model name reaches the new endpoint and the next prompt fails) + the sidebar brand being the SmartGift wordmark with the `AgentHub` caption under it and the vendored `client/public/favicon.ico` (`Sidebar.tsx`, `client/index.html`, `client/public/manifest.json`) + the two "Claude is waiting for YOU" notification types (`client/src/lib/notifPrefs.ts` — the ONE definition of the prefs shape/defaults/storage, now imported by both `Settings.tsx` and `useNotifications.ts`; `onAwaitingInput` and `onTurnComplete` default ON, issue #29) + per-message token counting (`lastUsageMsgId` in `server/lib/transcript-cache.js` and both parsers in `scripts/import-history.js`: Claude Code writes ONE JSONL line per content block of an assistant message and each repeats the same message-level `usage`, so summing lines over-counted tokens and cost ~1.5–2× on tool-heavy sessions) + `stmts.setTokenUsage` (`server/db.js`) — the EXACT counterpart of `replaceTokenUsage`, used only by `reconcileTokens`, because the high-water `baseline_*` columns make a downward CORRECTION impossible; the server runs that reconcile once in the background at the first start after upgrading (`user_version` 2, `server/index.js`) + v1.6.12 (2026-09-18) + the same three rules in `skills/ship-feature-lane/SKILL.md` (three scheduled touchpoints now: Stage 0 Q&A, Stage 0b plan approval, Stage 7 acceptance before the PR is published — the publish rule and the turn-liveness rule name them) + the `default` 8-node pipeline asking the user twice and looping on a NO-GO (`skills/ship-feature/SKILL.md` + `server/data/pipelines/default.json`: `intake` now DECLARES `/superpowers:brainstorming` and detects it, the `plan` phase blocks on an explicit approval, `ship-feature` gained the four sections that make a long run followable and affordable (**Right-size the run** — trivial work does not enter the pipeline and a small change skips the plan-debate/independent-test/gate agents, **Dispatching a judgement** — the fresh-context/foreground/never-fork/never-dispatch-what-a-command-answers contract written ONCE instead of re-argued per phase, **Resuming after a compaction**, and a per-phase **Leave when:**), `review` DECLARES the bare `/code-review` (Claude Code's own, no `plugin:` prefix — `requiredSkillPlugins` skips it, so it adds no install requirement) and runs it always and a domain reviewer only for the layers `git diff --name-only` shows, and `gate` requires the user's acceptance — a NO-GO re-declares `implementing` or `plan` instead of proceeding) + `KEEP_RETIRED_ROOTS` dropping to 1 (`scripts/plugin-bootstrap.js`) + lane removal being record-only (the `remove` branch in `server/routes/lanes.js` no longer calls `removeWorktree`/`removeLaneData`, so a worktree, its branch and its database survive forgetting a lane — see the rule under **Lanes**) + v1.6.10 (2026-09-17) + per-profile Claude quota (`server/lib/cc-usage.js`, `POST /api/cc-config/profiles/usage`, the **Check quota** button in the Profiles modal) — a POST because a saved profile's token is normally expired, so answering refreshes it and the refresh ROTATES the credentials written back to disk, which is also why it never runs on a poll; the LIVE account is the exception and is read from `.credentials.json` via `cc-profiles.liveCredentialsFor`, never refreshed, because the CLI has already retired the refresh token its profile copy holds + the settings patch that changes nothing now writing nothing (`patchSettings` in `server/lib/cc-mutate.js`, plus `.claude/.cc-config-backups/` in a lane's git exclude) + the Add-lane modal closing itself on a clean setup and offering a real Close when a step failed + a lane's router pick no longer landing on whichever lane the user switched to mid-apply (`LaneRouterRow`), nor reverting on the next lane switch — `POST /api/lanes/:id/router/apply` now `broadcastLane`s like every other mutating lane route, so the client's cached row stops disagreeing with what was written + a run being opened by a DECLARATION only — a backwards DETECTION now continues the run in flight (`appendStageEvent` in `server/lib/lanes.js`), because detection bounces within one feature and `runs` is what every pass rate on the Workflows page is divided by + `SETTLE_MS` in `server/lib/cc-reload.js` — the two reload commands are typed a second apart, and `reloadOpenSessions` is now async, because Claude Code swallows the Enter after a slash command and back-to-back sends merged into one composer line + v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel`, now in `LaneGitHeader`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
- Preserve real-time behavior (hooks -> API -> SQLite -> WebSocket -> UI).
## Repo map
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions, no tokens)).
- `client/`: React + Vite UI.
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` + the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
- `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:<name>` (bare `/<name>` works as an alias). Put a skill here only if it should reach plugin users. The plugin ships no subagents and no pipeline skill, and depends on no other plugin (Superpowers included) — do not add an auto-install of one back into the bootstrap.
- `mcp/`, `statusline/`, `monitoring/`: opt-in integrations that only READ the running dashboard over HTTP — the read-only MCP server (`agenthub mcp`, docs/MCP.md), the Claude Code statusline (`agenthub statusline install|uninstall`, docs/STATUSLINE.md) and Prometheus/Grafana files for `/api/metrics` (`docker-compose.monitoring.yml`, docs/MONITORING.md). Neither the MCP server nor the statusline is ever registered by the bootstrap; only the user's own command writes `statusLine`, never over a foreign one without `--force`, always with a backup. The MCP server and the statusline find the dashboard through `server/lib/dashboard-url.js`, the CLI's own discovery.
- `.claude-plugin/`: the marketplace plus the root `agenthub` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/agenthub/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agenthub/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
## Workspaces (lanes)
A **workspace** — called a **lane** in the API, CLI, database and code, which is why every identifier below still says `lane`; `/api/workspaces`, `agenthub workspace(s)` and `--workspace` are the user-facing names over the same handlers (plan: `docs/plans/workspace-rename/README.md`) — is a durable unit of parallel agent work: one working directory, many Claude Code sessions over time. Lanes are keyed by `cwd`, not `session_id`, so they survive session restarts and allow the dashboard to show a pipeline that persists across runs.
**Key points:**
- Sessions are bound to lanes by longest path-boundary prefix match on their `cwd` (set in hook data from `claude` CLI).
- A lane's stage is **declared** by the driving Claude session (via `agenthub stage <stage> [--evidence "..."]`), not inferred by the dashboard.
- **A CLI command resolves its lane by (cwd, tool), never cwd alone.** `resolveLaneArg` (`bin/agenthub.js`) answers in order: `--lane`/leading id → `AGENTHUB_LANE_ID` (every tmux pane the dashboard starts carries it, via `tmux new-session -e`) → `--tool`, else the harness marker (`CLAUDECODE` ⇒ claude; Codex has NO reliable env marker, so it passes `--tool codex`) → the owning lane. Two candidates at one directory and no signal is an ERROR that names both, never a pick — writing a codex session's stage onto the claude lane's card is a lie about which agent did the work.
- **The console never writes a lane's stage.** Declared stages come from `agenthub stage` (a skill calling the CLI command), and inferred stages come from tool-event detection only. **Every non-`claude` lane (`codex`, `antigravity`, any other AI Tools id) carries no pipeline at all:** `codex-ingest.js` (codex) and `antigravity-usage.js` (antigravity, idle only) touch nothing but their `status`, never `stage` or a detection, and the Workspace hides stage, pipeline/feature pickers and PipelineMap for them. Do not bring stage inference back for them. The UI's run console and the Workspace page never call `POST /api/lanes/:id/stage` — that boundary exists because the console observes tool calls but cannot see their outcomes. A lane's stage is the session's truth about progress; the console's observation of tool execution would not be proof of completion.
- **Going back is a declaration, and it takes the nodes ahead with it.** A declaration landing on a node BEFORE the lane's current one (a fix-loop re-entry, or a new request on a lane that already reached `done`) clears the detection columns like every real transition AND drops the `stages` records of every node ahead of it — those belong to the previous pass, and left in place they render `done` with the old work's evidence beside a `current` ring further back, contradicting `progress` (computed from the declared index alone). Records under a key matching no node are left alone: unplaceable, so unprovably ahead. `agenthub feature activate <slug>` is the other half of this — a feature switch archives the outgoing pipeline and clears detection too, because a detection belongs to the feature that produced it.
- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. Declared-wins has exactly ONE exception: a declaration on the pipeline's LAST node (`done`) stops vetoing, because the pipeline is over there and a later detection is new work — without it a lane left at `done` rejects every detection for the rest of its life.
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git five times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. The same goes for a lane's PROJECT: repo roots and `git worktree list` are read only by `GET /api/projects` (`server/lib/projects.js`, cached in memory — root per cwd, worktrees 30 s, `POST /api/projects/refresh` drops both), read on demand by Settings and the folder suggestions, never on a poll. The Changes panel fetches it itself (`useLaneGitFacts`, every 30s and after each git write) and fails silently.
- **The Workspace console collapses with CSS, never by unmounting — and it has exactly ONE render site.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history. The same rule binds one level up: `ConsoleArea` lives inside the always-rendered `lane-detail` section in `client/src/pages/Workspace.tsx`, never inside a `{currentLane && …}` branch. A second render site makes React unmount the whole console every time `currentLane` flips — lanes arriving on mount, the selected lane removed, a refetch re-picking the selection — which kills the terminal and lets `LaneConsolePane`'s lane-switch effect wipe a half-typed prompt. Header and info block are the conditional parts; the console is not.
- **A split pane never moves to another parent, and the focus never syncs through an effect.** `PaneGrid` draws every mounted slot as an absolutely placed sibling with a stable key, positioned from `layoutRects` — nest panes in split containers instead and React remounts a terminal on every split, swap or close. `?run=` names the focused pane; every layout change in `ConsoleArea` (`client/src/pages/Workspace.tsx`) goes through `change()`, which computes from the LATEST layout (a ref) and pushes the URL itself with `focusOnly` — a navigation that may only focus a pane already shown, never open one. An effect watching the focus fought the open request in the same commit and ping-ponged forever.
- **Push ingest takes only what a token holder may claim.** `POST /api/hooks/ingest-batch` answers 503 until a token exists; the token (`server/lib/push-token.js`) is shown ONCE and stored as a SHA-256 hash, accepted only from the `Authorization: Bearer` header and compared in constant time. A pushed hook may extend only a session its own `push:<source>` created (`EFOREIGN`), its `transcript_path` is dropped so no local file is ever read from it, and it never touches a lane or a pane. Do not add a GET that returns the token or a route that takes it from a query string.
- **A snapshot is immutable and named by an unguessable token.** `session_snapshots` rows are written once (a trigger refuses UPDATE) under 24 random bytes base64url; redaction is applied AT CAPTURE so a stored blob can never be un-redacted; writes are same-origin. Do not add an update route or a token derived from the session id.
- **A budget is an alert rule, not a parallel system.** `budget` and `cost_threshold` live in `alert_rules` (validated by `validateRuleConfig`, the table has no rule_type CHECK any more) and fire through `fireAlert` → feed, webhooks, push; a budget fires each threshold ONCE per period (deduped on `details.period_start` + `details.threshold`). Spend is always `dailyTokenRows` + `calculateCost` — never a second cost formula. `POST /api/alerts/rules/preview` may never write, fire or broadcast.
- **A report file path is built from ids only.** `server/lib/reports.js` writes under `<data dir>/reports/<id>/` and the download route resolves a run by its ids; no route takes a path or file name from a request, and every value in a report's HTML is escaped.
- **AgentHub ships no third-party pet art, and a pet comes only from an upload or a fixed featured id.** The featured pets are codex-pets.net uploads with no redistribution licence (one is a copyrighted character), so `server/lib/pets.js` downloads them into the user's data dir on a pick — never commit their files, and never add a pet route that takes a URL or a path. Uploads are raster only (an SVG served from the dashboard's origin runs script), a bundle's entries are size-checked from the zip directory before they are inflated, and pet ids are made by the server.
- **AgentHub does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`agenthub lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them.
- **The runtime never writes `stage`, `status` or `notes`** — only `slot` and `ports`. `status=running` means an AGENT is working, not that a server is listening; merging the two would corrupt `classifyLiveness`. Boot failures live in `LANES_ROOT/.state/lane<slot>/last-error.json` and surface via `GET /api/lanes/:id/runtime`. Same boundary as the console-never-writes-stage rule above.
- **A lane's stack is up or down as a computed fact, never a stored one.** `runtimeFacts` re-derives it from pid files and port probes on every read. A process dies to OOM, a stray `kill`, a reboot — caching a truth AgentHub does not control buys ghost state. Slot allocation is the opposite (fully controlled, must be race-free), so that one does live in the DB, under `withLaneLock` plus a partial unique index.
- **A profile's `profile.env` is parsed, never sourced.** Hooks are executed deliberately; config is only read. Sourcing arbitrary shell from a user's repository into the dashboard process would be a code-execution path. Hook names always come from the fixed allowlist in `server/lib/lane-profile.js`, never from a request.
- **Pipelines are DORMANT: AgentHub ships no template.** The stage machinery (`agenthub stage`, stage detection, features, PipelineMap, `agenthub lanes pipeline|node`) is kept for later development. With no template loaded, pipeline id `default` resolves to an EMPTY pipeline (`EMPTY_PIPELINE` in `server/lib/pipelines.js`: no nodes, progress 0), `createLane` still accepts `default`, `POST /api/lanes/:id/stage` answers 400 `ENOPIPELINE`, and the Workspace hides the stage timer, pickers and PipelineMap for any lane with no nodes. A template JSON dropped into `DASHBOARD_PIPELINES_DIR` turns it back on. The server suite loads its two templates from `server/__tests__/fixtures/pipelines/` (set by `scripts/test-server.js`).
- Nodes render in five states: `failed` (rejected), `current` (now), `done` (with evidence), `passed-no-evidence` (claimed or skipped, amber), `pending` (not reached).
- Liveness: a silent **watcher** (stage matching `/watch|poll/`) or `running` lane is dead after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead. Silence counts from the newer of the session's last event and the current run's `dashboard_runs.started_at` (`lastEventAge` in `server/routes/lanes.js`; a lane with no session bound — a catalog tool, Codex without hooks — has no signal and stays `active`) — a new run still carries the PREVIOUS session_id until its first hook, and judging it by that session made a fresh run read `dead` until a refresh.
- **Never `rm -rf` a lane.** Destructive lane operations (`reset`, `remove`, `purge`) go through `server/lib/worktree.js`/`server/lib/lanes.js`, never a raw filesystem delete — that's what keeps the three-check destroy guard and the DB bookkeeping in lockstep.
- **The native picker opens only for a browser on this machine, and nothing from the request but a start folder reaches its argv.** `POST /api/lanes/pick-path` refuses a non-loopback socket and any request a proxy forwarded (`EREMOTE`) — behind nginx every request comes from loopback, and the dialog would open on a screen nobody watches. Do not relax that check, and do not let the request name the dialog program, a title or any other argument.
- **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell.
- **An AI Proxy (called AI Router before v2.5.5) is a SESSION's choice, and only its name comes from the request.** A new session names a profile (`proxy` on `POST /api/lanes/:id/start` / `POST /api/run` — the pre-rename `router` is still accepted —, the **AI Proxy** select beside the model picker); `resolveProxy` (`server/lib/proxy-apply.js`) checks it (`EBADPROXY`, `EUNSUPPORTED` off Claude/Codex) and the key and endpoint come from the store. A profile's `baseUrl` is the proxy's ROOT (a trailing `/v1` is dropped on save and on read): a **claude** pane gets `--settings=<data dir>/proxy-settings/<name>.json` with `ANTHROPIC_BASE_URL` = the root (0700/0600; plus `modelPicker` rows only when the profile sets a user-chosen `behavesAs` — never guess one, it sets the context window Claude Code compacts at; the command-line layer outranks project-local and user settings, so only the PATH is in argv), a **codex** pane gets `-p <name>` and `$CODEX_HOME/<name>.config.toml` with `base_url` = root + `/v1` (its base `config.toml` is never touched); both are rewritten at each spawn, `PATCH /api/ai-proxies/:name` rewrites the existing ones and `DELETE` removes them. `dashboard_runs.proxy` (renamed in place from `router`) records it, and a resume naming none goes back to it. **Never write a proxy into the user's project again** — `.claude/settings.local.json` is shared by every session in the folder and is not in a worktree; `clearLaneProxies` takes the old keys out at start and `lanes.router_profile` stays NULL. Key material never leaves the server: profiles live at `<CLAUDE_HOME>/agenthub-proxies/` (0700/0600; the old `agenthub-routers/` is moved there on first read and left as a symlink for an older build), `GET /api/ai-proxies` reports only `hasKey`, and there is deliberately no route that returns one profile in full. `/api/ai-routers` is the same router under its pre-rename path. **`POST /api/ai-proxies/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`; it probes `/v1/messages` (Claude) and `/v1/responses` (Codex) and answers `{claude, codex}`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Codex's key reaches the pane through tmux's `-e` argv (`AGENTHUB_PROXY_KEY`) and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there, and do not put a Claude key in argv either. See `server/lib/ai-proxies.js`, `server/lib/proxy-apply.js`.
- **The dashboard's git writes are these seventeen, and the guards are IN `git-ops.js`, not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort` — nothing else. **`publish` is the ONE write that takes a destination from the request** (`{url}`), so it can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- <url>` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let it touch a repo that already has a remote. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk` and `merge-abort` are the irreversible actions and all refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches (the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is the one history-joining action, and only because its recovery is a button too** (`merge-abort`): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). Do not add rebase, reset or stash here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and `reset` already lives behind `worktree.js`'s three-check destroy guard. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet.
- **AgentHub's git credentials are its OWN store, shared by every user of the install, and git is given ONLY that store when it holds a login for the host.** `server/lib/git-credentials.js` keeps one record per `protocol + host` under `<CLAUDE_HOME>/agenthub-git-credentials/` (0700/0600) and no route ever returns a password (`hasToken` only). `gitConfigFor` runs a network action with `-c credential.helper= -c credential.helper=<ours>` whenever AgentHub holds a login for a repo remote's host or the request carries one — the machine's helpers are left out on purpose: a stale token there would win over the typed one, and git's post-success `store` would copy a use-once token into the OS user's helper. The secret travels in the helper's env or the store file, never argv. A typed credential is accepted only for one of the repo's OWN remote hosts (`EBADHOST`), so a login is never sent to a server it was not typed for, and it is saved only after git SUCCEEDED and `remember` is set. Do not add a route that returns a stored password or a path that forwards one to a host other than its own.
- **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]`, every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the run form's red per-run switch, never remembered — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request.
- **Antigravity hooks are observational only, and never answer a permission.** `server/lib/antigravity-hooks.js` writes only the named hook `agenthub` in `~/.gemini/config/hooks.json`, only on a click, and leaves every other name alone. It installs PreInvocation, PostToolUse and Stop and NEVER PreToolUse: agy reads a PreToolUse answer as a permission decision and DENIES the tool on `{}` (verified on 1.2.13), `allow` would override the user's own permission rules and `ask` would force a prompt — there is no neutral answer. The forwarder must keep printing `{}` and nothing else. The file is global (the Antigravity IDE loads it too), so the route accepts a hook only when its `transcriptPath` lies under `~/.gemini/antigravity-cli/`, and a hook's `tool` is an allowlist (`codex`, `antigravity`, else `claude`). AgentHub never reads the IDE's data.
- **Codex hooks run only because the user trusted them in Codex, and AgentHub never decides that for them.** `server/lib/codex-hooks.js` writes `$CODEX_HOME/hooks.json` only on the user's click, keeps every entry that is not AgentHub's, and never writes Codex's trust (`[hooks.state]` in `config.toml` — read only, to report); nothing may pass `--dangerously-bypass-hook-trust`. The hook command is the STABLE `'<CODEX_HOME>/agenthub-hook.sh' <Event>` because Codex keys trust on the definition: change what the script runs (`refreshScript`), never the command, the timeout or the event list without expecting every user to re-trust. A hook's `tool` is an allowlist (`codex`, `antigravity`, else `claude`), and a Codex hook's `transcript_path` is never stored or parsed as a Claude transcript — only `threadOf` reads it, and only a `rollout-*.jsonl` under `$CODEX_HOME/sessions`.
- **An AI Tools install runs only what a RELEASE catalog file names.** `install`/`update` (like `launcher`) are accepted in built-in files and in the synced copy of the newest release's catalog, never in `DASHBOARD_AI_TOOLS_DIR`, and no route takes a package, URL or argv from a request. Jobs are argv-only (`ai-tool-jobs.js`), stdin closed, one at a time; npm is resolved on the login shell's OWN PATH first (`shellPath()`), because `hydrateShellPath` appends, and the pinned Node's npm would install where the user's PATH never looks; an npm update is refused (`ENOTNPM`) for a tool whose binary is not under `npm prefix -g` (a standalone Codex, say) — it would install a shadowed second copy. A script URL must be https on the homepage's site and is re-checked after redirects. Icons are raster only — an SVG served from the dashboard's origin runs script when opened. Do not add an install source that a browser or a custom catalog file can name.
- **A tool's config editor opens only what a RELEASE catalog file lists, and only an existing text file.** `config` is honoured in built-in and synced-release catalog files (never `DASHBOARD_AI_TOOLS_DIR`), each path `~/`-relative with no `..`, and no route takes a path from the request — `:index` picks from that list. `server/lib/ai-tool-config.js` refuses a missing file (it never creates one), a non-regular, binary or >1 MB file, and any whose basename — or its symlink target's — matches `CONFIG_SECRET_RE` (auth, credential, token, oauth, key files…; `ESECRET`); a save must match the opened `baseSha`, parse when JSON/JSONC, and back up the old text under `<data dir>/ai-tool-config-backups/` first. Do not add a create, a delete, a request-supplied path, or a catalog entry naming a login file. The guard is by NAME only: a listed settings file may still hold an API key (opencode `options.apiKey`, an MCP `env`), and the unguarded GET returns it whole, like the cc-config reads do — do not describe this route as secret-free.
- **A lane's card position is a viewer preference; its name is a lane fact.** The sidebar workspace list's drag/Alt+↑/↓ order lives in `localStorage` (`agenthub.workspace.laneOrder`, `client/src/lib/laneOrderStorage.ts`) and writes NOTHING server-side — `GET /api/lanes` and `agenthub lanes` stay in id order, so no `sort_order` column, no reorder route, no websocket message. Renaming is the opposite: it is `PATCH /api/lanes/:id` with `{title}`, the one lane column a user now types into directly, so `updateLane` normalizes it (`normalizeTitle`: trim, 200-char cap, `null` ⇒ `""`, because clearing is legitimate — every render is `lane.title || lane.cwd`). The rule is the column-wide one, not a special case for the field the UI exposes: `bindable` rejects any non-scalar patch value with `EBADFIELD` (`links` excepted — it is stored as JSON), and the route answers 400 for that AND for the `SQLITE_CONSTRAINT` family, rather than handing better-sqlite3 a datatype throw and reporting 500 on plain bad input.
- **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it.
- **Removing a lane deletes the lane's RECORD and nothing else — for every kind, managed included.** `POST /api/lanes/:id/remove` stops the run and the stack, clears AgentHub's own runtime bookkeeping under `LANES_ROOT/.state/lane<slot>/`, and deletes the row. It calls neither `removeWorktree` nor `removeLaneData`: the working tree, its branch and the lane's database are the USER's data, and a mis-click on a dashboard row may never cost a branch's unpushed code. Deleting those is the user's own `git worktree remove` / `git branch -d` / db drop. Consequently `remove` has no unpushed-commits force-gate (only `reset` does) and no preflight blocker stops it. Do not add a "also delete the worktree" flag back onto this route. (A session's own `claude --worktree` tree is a different thing with its own guarded route — see the session-worktree rule below; a lane's folder is never it.)
- **A lane can hold up to ten tmux sessions (slots 0–9; the Workspace groups them into tabs of up to four split panes) — and only slot 0 is the lane's run.** `agenthub-lane-<id>` is slot 0 and the only one `run_id`/`status` ever record; `agenthub-lane-<id>-<1-9>` are extra sessions on the same lane (`MAX_PANE_SLOT` in `server/lib/pty-run.js`; the `-[1-9]` run id pattern is repeated in `pty-attach.js`, `websocket.js`, `routes/hooks.js` and the client — widen them together). Anything that must stop a lane's agents (`stop`, `reset`, `remove`) goes through `laneRunIds` so no slot survives it; anything that names "the lane's pane" (`agenthub lanes shell`) means slot 0. A client that wants "a new tab" sends `slot: "auto"` and the SERVER picks the first free slot under the lane lock (`409 ERUNLIVE` when all ten are busy) — never a slot computed from the client's own list, which may already be stale.
- **The whole-file editor is the one non-git write on the lane file surface, and it lives outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at. Do not let it create files or touch a binary.
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions.
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written to `<data dir>/workspace-prompts/<lane id>.md` at every spawn and passed as `--append-system-prompt-file=<path>` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot) or the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane.
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
## Non-negotiable engineering rules
- **When a `agenthub.service` unit of ours owns the server, nothing else may stop it.** The bootstrap that performs a self-update is spawned by the running server, so it lives in that server's systemd cgroup: SIGTERM-ing the server from there exits 0 (which `Restart=on-failure` read as "meant to stop") AND trips `KillMode=control-group`, reaping the bootstrap before it can repoint the unit or write `state.json` — a dashboard dead after every update until some later session bootstrapped again. So `scripts/systemd-service.js` splits the API: `writeUserService` repoints the unit and starts NOTHING, `restartUserService` uses `--no-block`, and `plugin-bootstrap.js` defers that restart past `writeState` and `releaseLock` (`pendingSystemdRestart`). The unit is `Restart=always`. Do not add a path that stops the dashboard from inside its own cgroup, and do not move the restart earlier. Start-at-login is the USER's switch (`/api/settings/autostart`): no bootstrap or update may enable a unit the user left disabled, and the switch only enables/disables — it never stops the server answering it. `agenthub service install` may keep using `installUserService` — it runs from a user's shell, outside the cgroup. `systemdOwned` additionally requires the unit to be ACTIVE (an installed-but-stopped unit owns nothing; treating it as owner skips the stop and then binds an occupied port). A unit naming a RETIRED plugin root is invisible until a reboot, so `fastPathOk` rejects it via `unitIsStale` (pure fs — that path may never spawn a process) and the bootstrap rewrites it; repairing the unit is NOT restarting the server (`pendingSystemdRestart = systemdOwned && needsServer`), and the detached-spawn fallback is guarded by `needsServer` so a stale unit alone never starts a second server onto a bound port.
- **On macOS the same rules bind a LaunchAgent.** `scripts/launchd-service.js` exports the systemd module's API (`service-manager.js` picks by platform — require THAT, never one of the two directly, or a Mac silently gets the systemd no-op): `KeepAlive` is `Restart=always`, `writeUserService` writes the plist and loads nothing, and the deferred hand-over is `bootout` + `bootstrap` (never `kickstart`, which keeps the old plist). Two differences are deliberate: `AbandonProcessGroup` is true so a restart does not take the lane panes' tmux server with it, and because a failed `bootstrap` leaves NOTHING running, the bootstrap falls back to its detached spawn when the restart fails and no server is live. Availability is `launchctl print gui/<uid>` — no GUI login (SSH only) means the detached spawn, as before.
- **A bootstrap that is no longer the installed build does NOTHING, and `--force` does not override that.** `supersededBy` (`scripts/plugin-bootstrap.js`) reads Claude Code's `installed_plugins.json`: if a SIBLING cache directory is the recorded install, this build logs one line and returns `superseded` — no deps, no unit, no server. A Claude Code process that was not restarted after an update keeps loading the OLD cache dir, and without this its every session repointed `agenthub.service` at that old dir and restarted the dashboard onto the version the user just upgraded away from, once per session, forever. It may not hand over to the new build either — the process it would replace is the one loading it. Do not add a path that lets a retired root write the unit, stop the server, or "just" refresh deps.
- **Deleting retired cache copies is the ONE thing here that touches the plugin cache, and four protections gate it.** `pruneRetiredPluginRoots` never deletes: this build; any `installPath` in `installed_plugins.json`; the dir `agenthub.service` names (`unitRoot`, foreign unit included); any dir named in the user's `settings.json` hooks (a un-restarted Claude Code is still executing from there — deleting it breaks that session's hooks). Plus the newest survivor as a rollback cushion (`KEEP_RETIRED_ROOTS`, 1 — a full copy of this repo is ~130 MB, so the cushion is one build deep, not two), and nothing at all outside `~/.claude/plugins/cache/`. It runs AFTER `writeState`, so a bad prune cannot also cost the record of a good bootstrap. Adding a fifth thing that pins a cache dir means adding a fifth protection here.
- **A session reload is typed into a live pane, never spawned.** `/reload-plugins` and `/reload-skills` are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from the outside. `server/lib/cc-reload.js` therefore types them into the dashboard's existing tmux lane panes and spawns NO `claude` process — a one-shot `claude -p "/reload-plugins"` reloads only its own short-lived session, exits, and leaves a junk session in the dashboard's own history. Do not add a path that starts a session in order to reload one.
- **A read that scans the events table never runs on the event loop, and never runs a correlated subquery per event.** Node serves every page, hook and websocket from one thread, and better-sqlite3/node:sqlite block it for the whole query — one slow aggregate freezes the dashboard for every user. Put an aggregate that takes more than a few hundred ms at real size into `JOBS` in `server/lib/read-pool.js` (its own `query_only` connection in a worker, timeout, shared in-flight runs); write "the next event of the session" as a window function (`LEAD() OVER (PARTITION BY session_id ORDER BY id)`), not `MIN(id) … id > e1.id`. And do not drop `idx_events_session(session_id, id)` as a "prefix" of another index: only it orders a session's events by id.
- **A headless entry point requires `server/lib/windows-hide.js` before anything reads `child_process`.** The dashboard and its detached helpers have no console on Windows, so every git/npm call without `windowsHide` opened a console window that flashed and stole focus on each poll. `install()` (in `server/index.js`, `plugin-bootstrap.js`, `plugin-update-helper.js`, `cc-update-all-helper.js`, `restart-helper.js`) forces it for every spawn; a new detached script needs the same first line, or a `windowsHide: true` of its own (the statusline's one git call has that).
- Preserve existing behavior unless explicitly asked to change it.
- Prefer minimal, reversible diffs.
- Never silently weaken safety controls around destructive actions.
- Keep docs updated when behavior, commands, file locations, or workflows change — apply the `update-project-docs` skill automatically at the end of every change-set that alters behavior, config, interfaces, events, schema, CLI commands, or features (do not wait to be asked).
- Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the copyright/authorship header — file overview + the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. Formats and audit script: `skills/file-headers/` (verify with `bash skills/file-headers/scripts/check-headers.sh`). This binds every coding agent (Claude Code, Codex, or others).
## Commands you should know
- Setup: `npm run setup`
- Dev: `npm run dev`
- Prod build/start: `npm run build` then `npm start`
- Server tests: `npm run test:server`
- Client tests: `npm run test:client`
- CLI (after setup): `agenthub <command>` — terminal access to the full dashboard surface (`bin/agenthub.js`; `agenthub help` lists commands)
## Testing and verification policy
- Backend changes: run `npm run test:server` before finishing.
- Frontend changes: run `npm run test:client` when relevant. This includes per-screen render snapshots (`client/src/pages/__tests__/screens.snapshot.test.tsx`). If a UI change is intentional, review the snapshot diff and regenerate baselines with `cd client && npx vitest run -u`; never blindly update snapshots to make tests pass.
- If you cannot run a verification step, state exactly what was not run and why.
## Change guidelines by area
- API routes: preserve response shapes unless change is requested and documented.
- Database: avoid schema changes without migration-safe logic.
- Hooks: keep fail-safe and non-blocking behavior.
- WebSocket: keep message types stable and backward-compatible.
- UI theming: white text sits on `accent-solid` (`bg-accent-solid`, `.btn-primary`, a Switch's on state, a Checkbox tick), never on `accent` — `--accent` is a TEXT colour, too light in the dark themes to carry white.
- UI feedback: report through `toast` (`lib/toast.ts`) and ask through `confirmDialog`/`confirmDiscard` (`lib/confirm.ts`) or `useArmed` — never `alert()`/`window.confirm`; a modal takes `useDialog`, a copy goes through `copyText`, a single-line submit checks `isSubmitEnter`.
- Documentation: include exact commands and paths; keep markdown examples runnable.
## Agent behavior
- Explore first, then implement.
- For larger tasks, propose/check a short plan before broad edits.
- Use file-specific rules in `.claude/rules/` when working in scoped areas.
- Use project skills from `skills/` for repeatable workflows (they ship with the `agenthub` plugin: `/agenthub:<name>`, or the bare `/<name>` alias).