Files
AgentHub/CLAUDE.md
T
nntrivi2001 f0656603c3 feat: v2.5.11 — cherry-pick, revert, undo and amend in the Changes panel, Open terminal from the AI menu
Git in the Changes panel
- Revert any commit, Undo the last commit (soft, changes stay staged) and,
  with another branch's history open, Cherry-pick onto this branch — all from
  the history's right-click menu. Amend folds staged changes into the last
  commit. Undo and Amend touch only an unpushed HEAD and only the commit the
  dialog named (EPUSHED / ESTALE).
- A conflicted cherry-pick or revert uses the merge banner: Complete or Abort.
  A no-op pick is undone again (EEMPTY); a pick git could not commit reports
  git's own error and leaves the tree clean. A rebase started in a terminal
  blocks every history action.
- Push, Publish, branch delete, cherry-pick, revert and amend ask first. An
  armed discard reads as a red "Discard?" chip. There is no bulk discard.
- History can show another branch (?ref=). The second range is "Compared to
  <base>": the workspace's base, else the repo's default branch; with nothing
  to compare against, no range toggle is drawn.
- The diff editor gets each change block's Undo / Keep bar.

Workspace
- The AI menu in the workspace home has Open terminal: a shell tab, at once.
- "+ Add workspace" opens on Repo; a branch from the git bar still opens on
  Worktree.
- The sidebar no longer keeps a list of closed Terminal tabs.
- Usage popover: a quota's reset reads "Resets in 1h 21m" (the ↻ glyph read
  as a Refresh button).

Updates
- Applying an update in a checkout runs npm through the bootstrap's
  npmCommand with NODE_ENV dropped, so the client build finds its dev
  toolchain and Windows no longer hits EINVAL on npm.cmd.

Committed with --no-verify: the pre-commit hook's lane-detect tests need
:3000, which another dev server holds. Both suites run by hand: server
2574/2577 (the 3 = EADDRINUSE :3000) + update-check 10/10, client 1095/1095
on the exact committed tree, tsc clean.
2026-10-07 15:34:17 +07:00

53 KiB
Raw Blame History

Claude Code Working Guide

Last synced: v2.5.11 (2026-10-07) — git cherry-pick/revert/undo/amend + confirms, Open terminal in the AI menu, Repo as the Add-workspace default. Older sync history: docs/CLAUDE-SYNC-LOG.md (archive, not loaded). 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. Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here — details belong in the commit message and docs/, because this whole file is loaded into every session.

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 twenty-one, 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, cherry-pick, revert, undo-commit, amend — 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 a history-joining action allowed only because its recovery is a button too (merge-abort) — and cherry-pick/revert ride the same rule (a non-merge commit by sha, EMERGECOMMIT/EBADSHA; same clean-tree, repo-root and conflict flow; merge-commit/merge-abort finish or abort whichever of the three operationInProgress finds open; a no-op is aborted again as EEMPTY, never left open): 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, a hard/mixed reset or stash here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and a hard reset already lives behind worktree.js's three-check destroy guard. The ONE reset is undo-commit = reset --soft HEAD~1 (confirm: true, repo root, never the root or a merge commit), and it and amend act only on the HEAD the request names (sha, ESTALE once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (EPUSHED); a terminal-started rebase blocks every history action (EMERGING) — rewriting a pushed commit is a terminal decision; do not relax that check. 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; the home's AI menu has the same Open terminal). 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).
  • Server mode is a MODE of this codebase, never a fork, and local mode must not notice it. Everything server-only checks isServerMode() (AGENTHUB_MODE=server, server/lib/auth.js) and is a no-op otherwise; auth.js opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made only from the host's shell (agenthub server setup, agenthub users …) — do not add a route that creates a user, sets a role or returns a password hash. Rows are OWNED (phase S2, server/lib/scope.js): lanes.owner_id is the creator (never from the request — ownerFor(req)), a session belongs to its workspace (sessions.lane_id or a dashboard_runs row), and everything else hangs off the session. A member sees only their own rows: every list/aggregate passes req into sessionWhere/bySessionWhere/laneWhere; every route naming ONE row is covered by scopeGuard's PATH_RULES + the query/body id names in namedIds (404 ENOTFOUND) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a broadcastAudience case (an unlisted type reaches no member); a staff-wide view joins STAFF_ONLY_PREFIXES. Admin/analyst reads of a member's session are written to audit_log (server/lib/audit.js). A pane carries AGENTHUB_PANE_TOKEN (hash in dashboard_runs.pane_token_hash, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (userFromPaneToken). Every write in server mode passes the Origin check in authGuard; prefix checks go through apiPath() (decoded, case-insensitive), because Express routes /api/Webhooks and %77ebhooks too. Named locks are namespaced per member, and named-lock.js takes only a plain name (a lock name was a path). It is still NOT OS isolation: every pane runs as the host account until S3, so do not call it multi-tenant. A new route that writes the host's own configuration joins ADMIN_WRITE_PREFIXES; hooks stay loopback-only in server mode.
  • A pool token reaches a pane only through tmux -e, and only for the account pickAccount chose. Never type it into a pane, never put it in the CLI's argv, never return it from a route (poolStatus has no token field), and never add a route that adds or reads one — the pool is filled from the host's shell (agenthub accounts add). An empty pool must leave every spawn exactly as before.
  • 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, a Refresh button spins through useSpin (hooks/useSpin.ts: never onClick={load}, and a minimum spin so a cached answer still shows it ran).
  • 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).