Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged) - Login for every page and API call (server/lib/auth.js, routes/auth.js, AuthGate), roles admin / analyst / member, accounts made only from the host's shell: `agenthub server setup|off|status`, `agenthub users list|add|passwd|role|disable|enable|assign|audit` - Rows are owned: a member sees only their own workspaces, sessions and what hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience); staff reads of a member's session are audited (server/lib/audit.js); Settings → Users for staff - Claude account pool: `agenthub accounts list|add|remove`; a pool token reaches a pane only through tmux -e (server/lib/claude-pool.js) - A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback, live pane only); writes pass an Origin check; named locks per member - docs/SERVER-MODE.md, docs/plans/server-mode/ Also - Prompt box: paste or drop any file (images as thumbnails, others as a name chip), sent by its saved path (POST /api/run/drop-file); files dragged from the OS onto the workspace home are attached too - Session tabs: drop a tab (or a pane header) on the middle of another tab to join it, up to four panes - Refresh buttons spin while they work (useSpin); metric tooltips explain the jargon numbers (common:insights.help.*) - Light palette retuned for contrast; IntelliJ Light theme removed - CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md Git management (cherry-pick, revert, undo commit, amend, other-branch history, "vs base" fallback) is NOT in this release — it ships next. Committed --no-verify: both full suites run on this exact tree (server 2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev; client 1086/1086), tsc clean, prettier applied by hand.
52 KiB
Claude Code Working Guide
Last synced: v2.5.10 (2026-10-07) + server mode (login, per-member rows, Claude account pool) and performance for 10+ concurrent users. 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.jsreads the repo's own releases API androutes/updates.jsexposes status/check/apply, refusing to self-update a~/.claude/plugins/cache/install, pluslib/workflow-ingest.jswhich ingests on-disk Workflow-tool run journals — fleets that emit no hooks — andlib/codex-ingest.js, which mirrors acodexlane'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 intosessions(tool = 'codex') andtoken_usage, andlib/codex-hooks.js, which installs AgentHub's opt-in hooks into Codex'shooks.json, and their Antigravity counterpartslib/antigravity-usage.js(agy's conversation files →tool = 'antigravity'sessions and tokens) andlib/antigravity-hooks.js(the named hookagenthubin~/.gemini/config/hooks.json), andlib/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 inserver/lib/update-check.js, keyed on the forge's newest release rather than a commit count.POST /api/updates/applyhas 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 viaserver/lib/self-restart.js+ the detachedscripts/restart-helper.js; a plugin-cache install is handed toserver/lib/plugin-update.js+ the detachedscripts/plugin-update-helper.js, which runsclaude plugin marketplace update→claude plugin update -y→plugin-bootstrap.js --forcefrom the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded toplugin-update.jsonin the runtime dir — outside the cache, so it survives the swap and the next server reports it aslast_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 byagenthub update-check.scripts/cc-update-all-helper.jsis the same detached pattern for the config panel's Update all button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step tocc-update-all.jsonin the runtime dir.)skills/: this repo's skills. Because the repo root is theagenthubplugin 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 writesstatusLine, never over a foreign one without--force, always with a backup. The MCP server and the statusline find the dashboard throughserver/lib/dashboard-url.js, the CLI's own discovery..claude-plugin/: the marketplace plus the rootagenthubplugin manifest ("source": "./"— the whole repo is the plugin). Its hooks are inline inplugin.json; its commands live inplugins/agenthub/commands/, which is NOT a subdirectory plugin.scripts/plugin-bootstrap.jsruns fromSessionStartand owns the writable runtime under~/.claude/agenthub/runtime/— it never writes into the plugin cache, which Claude Code replaces on every update. Seedocs/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 fromclaudeCLI). - 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, viatmux 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-claudelane (codex,antigravity, any other AI Tools id) carries no pipeline at all:codex-ingest.js(codex) andantigravity-usage.js(antigravity, idle only) touch nothing but theirstatus, neverstageor 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 callPOST /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 thestagesrecords of every node ahead of it — those belong to the previous pass, and left in place they renderdonewith the old work's evidence beside acurrentring further back, contradictingprogress(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.
recordDetectionskips its forward-only comparison oncedetected_atis older thanDETECTION_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 renderdone. 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 atdonerejects every detection for the rest of its life. - Working-copy facts live at
GET /api/lanes/:id/git, never insideGET /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 andgit worktree listare read only byGET /api/projects(server/lib/projects.js, cached in memory — root per cwd, worktrees 30 s,POST /api/projects/refreshdrops 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
RunConsoledisposes the run subscription and drops a live run's rendered history. The same rule binds one level up:ConsoleArealives inside the always-renderedlane-detailsection inclient/src/pages/Workspace.tsx, never inside a{currentLane && …}branch. A second render site makes React unmount the whole console every timecurrentLaneflips — lanes arriving on mount, the selected lane removed, a refetch re-picking the selection — which kills the terminal and letsLaneConsolePane'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.
PaneGriddraws every mounted slot as an absolutely placed sibling with a stable key, positioned fromlayoutRects— 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 inConsoleArea(client/src/pages/Workspace.tsx) goes throughchange(), which computes from the LATEST layout (a ref) and pushes the URL itself withfocusOnly— 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-batchanswers 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 theAuthorization: Bearerheader and compared in constant time. A pushed hook may extend only a session its ownpush:<source>created (EFOREIGN), itstranscript_pathis 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_snapshotsrows 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.
budgetandcost_thresholdlive inalert_rules(validated byvalidateRuleConfig, the table has no rule_type CHECK any more) and fire throughfireAlert→ feed, webhooks, push; a budget fires each threshold ONCE per period (deduped ondetails.period_start+details.threshold). Spend is alwaysdailyTokenRows+calculateCost— never a second cost formula.POST /api/alerts/rules/previewmay never write, fire or broadcast. - A report file path is built from ids only.
server/lib/reports.jswrites 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.jsdownloads 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,statusornotes— onlyslotandports.status=runningmeans an AGENT is working, not that a server is listening; merging the two would corruptclassifyLiveness. Boot failures live inLANES_ROOT/.state/lane<slot>/last-error.jsonand surface viaGET /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.
runtimeFactsre-derives it from pid files and port probes on every read. A process dies to OOM, a straykill, 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, underwithLaneLockplus a partial unique index. - A profile's
profile.envis 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 inserver/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 iddefaultresolves to an EMPTY pipeline (EMPTY_PIPELINEinserver/lib/pipelines.js: no nodes, progress 0),createLanestill acceptsdefault,POST /api/lanes/:id/stageanswers 400ENOPIPELINE, and the Workspace hides the stage timer, pickers and PipelineMap for any lane with no nodes. A template JSON dropped intoDASHBOARD_PIPELINES_DIRturns it back on. The server suite loads its two templates fromserver/__tests__/fixtures/pipelines/(set byscripts/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/) orrunninglane is dead afterLANE_DEAD_SECseconds (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'sdashboard_runs.started_at(lastEventAgeinserver/routes/lanes.js; a lane with no session bound — a catalog tool, Codex without hooks — has no signal and staysactive) — a new run still carries the PREVIOUS session_id until its first hook, and judging it by that session made a fresh run readdeaduntil a refresh. - Never
rm -rfa lane. Destructive lane operations (reset,remove,purge) go throughserver/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-pathrefuses 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 (seeserver/lib/worktree.js'sgit()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 (
proxyonPOST /api/lanes/:id/start/POST /api/run— the pre-renamerouteris still accepted —, the AI Proxy select beside the model picker);resolveProxy(server/lib/proxy-apply.js) checks it (EBADPROXY,EUNSUPPORTEDoff Claude/Codex) and the key and endpoint come from the store. A profile'sbaseUrlis the proxy's ROOT (a trailing/v1is dropped on save and on read): a claude pane gets--settings=<data dir>/proxy-settings/<name>.jsonwithANTHROPIC_BASE_URL= the root (0700/0600; plusmodelPickerrows only when the profile sets a user-chosenbehavesAs— 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.tomlwithbase_url= root +/v1(its baseconfig.tomlis never touched); both are rewritten at each spawn,PATCH /api/ai-proxies/:namerewrites the existing ones andDELETEremoves them.dashboard_runs.proxy(renamed in place fromrouter) records it, and a resume naming none goes back to it. Never write a proxy into the user's project again —.claude/settings.local.jsonis shared by every session in the folder and is not in a worktree;clearLaneProxiestakes the old keys out at start andlanes.router_profilestays NULL. Key material never leaves the server: profiles live at<CLAUDE_HOME>/agenthub-proxies/(0700/0600; the oldagenthub-routers/is moved there on first read and left as a symlink for an older build),GET /api/ai-proxiesreports onlyhasKey, and there is deliberately no route that returns one profile in full./api/ai-routersis the same router under its pre-rename path.POST /api/ai-proxies/testsends a stored key ONLY to the endpoint it was stored against — a blankapiKeyfalls back to the profile named byname, but only while the submittedbaseUrlstill equals the saved one, elseEBADKEY; 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-eargv (AGENTHUB_PROXY_KEY) and is never typed into it —tmux.sendCommandtakes over an idle pane withrespawn-panefor exactly this reason; aKEY='sk-…' codex …line typed withsend-keyswould 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. Seeserver/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/:actionoffersfetch,pull,push,stage,unstage,discard,stage-hunk,discard-hunk,commit,switch,branch-create,branch-delete,publish,merge,resolve,merge-commit,merge-abort— nothing else.publishis 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 whenevergit remoteprints anything (EREMOTEEXISTS— it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is alwaysorigin, the URL passesvalidateRemoteUrl's allowlist (https://with NO userinfo,ssh://, scp-likeuser@host:path— never a local path,file://,ext::,fd::,git://,http://or anything starting with-), it runsremote add origin -- <url>then the samepush -u origin HEADaspush, and a failed push removesoriginagain. 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:resolvePathsaccepts only whatgit statusis 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-hunkandmerge-abortare the irreversible actions and all refuse to run withoutconfirm: true; do not add another destructive action without the same gate.branch-createnever switches (the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) andbranch-deleteis-d, never-D, refusingmain/master/development/develop, the current branch and one another worktree holds.pullis ALWAYS--ff-onlyandswitchrefuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent;pushpushesHEAD, never a branch name from the request;commitpasses 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 asettings.local.json(ESECRET) — do not add a git read that skipslanePath/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 answerEMERGINGmeanwhile); the ref is looked up inrefs/heads+refs/remotes, never passed through; a conflicted file is resolved only throughresolve(ours/theirs, ordoneonce no conflict marker is left —stagerefuses a conflicted file that still has markers,EMARKERS), andmerge-commitrefuses 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, andresetalready lives behindworktree.js's three-check destroy guard. A button that looks disabled inLaneGitBaris 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 asandboxCSP +nosniffso an SVG runs no script; keep both headers —,/git/ls,/git/tree,/git/branches) stay unguarded like/gitalways was, and resolve their repo from the LANE ROW —GET /api/lanes/branchesis 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.jskeeps one record perprotocol + hostunder<CLAUDE_HOME>/agenthub-git-credentials/(0700/0600) and no route ever returns a password (hasTokenonly).gitConfigForruns 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-successstorewould 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 andrememberis 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
toolis 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(defaultclaude) is any idserver/lib/ai-tools.jsknows;createLanerefuses 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 fromPATCHABLE. A NEW session names its own withtoolonPOST /api/lanes/:id/start(the home's AI select, sent only when it differs from the lane's):validateToolagain (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/runstill takes the lane's tool. A lane whose tool left the catalog answersENOTOOLat start. The sidebar's tool icon is per SESSION (WorkspaceSessionList), not per workspace row. A pane has no run form:RunSetupis gone fromLaneConsolePane, and the workspace home's prompt box (WorkspaceHome.tsx: AI and ONE Model chip —ModelMenuinRunSetup.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/codexkeep their hand-written argv builders (launcherin their catalog file — aDASHBOARD_AI_TOOLS_DIRfile 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 anafter-starttool, a tmux paste buffer (tmux.pasteText) afterdelayMs. Codex-only fields (sandbox,approval) are validated against the fixed allowlists inserver/lib/pty-run.js. Bypass args are emitted ONLY when the run request carriesbypass: 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 thebypassPermissionsmode), and--dangerously-bypass-hook-trustis never emitted. Every pane gets the dashboard's hydratedPATHthrough tmux-e. Onlyclaude/codexareproxyCapable;resolveProxyaccepts onlynativefor any other tool, elseEUNSUPPORTED.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.jswrites only the named hookagenthubin~/.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),allowwould override the user's own permission rules andaskwould 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 itstranscriptPathlies under~/.gemini/antigravity-cli/, and a hook'stoolis an allowlist (codex,antigravity, elseclaude). 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.jswrites$CODEX_HOME/hooks.jsononly on the user's click, keeps every entry that is not AgentHub's, and never writes Codex's trust ([hooks.state]inconfig.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'stoolis an allowlist (codex,antigravity, elseclaude), and a Codex hook'stranscript_pathis never stored or parsed as a Claude transcript — onlythreadOfreads it, and only arollout-*.jsonlunder$CODEX_HOME/sessions. - An AI Tools install runs only what a RELEASE catalog file names.
install/update(likelauncher) are accepted in built-in files and in the synced copy of the newest release's catalog, never inDASHBOARD_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()), becausehydrateShellPathappends, 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 undernpm 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.
configis honoured in built-in and synced-release catalog files (neverDASHBOARD_AI_TOOLS_DIR), each path~/-relative with no.., and no route takes a path from the request —:indexpicks from that list.server/lib/ai-tool-config.jsrefuses a missing file (it never creates one), a non-regular, binary or >1 MB file, and any whose basename — or its symlink target's — matchesCONFIG_SECRET_RE(auth, credential, token, oauth, key files…;ESECRET); a save must match the openedbaseSha, 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 (opencodeoptions.apiKey, an MCPenv), 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/lanesandagenthub lanesstay in id order, so nosort_ordercolumn, no reorder route, no websocket message. Renaming is the opposite: it isPATCH /api/lanes/:idwith{title}, the one lane column a user now types into directly, soupdateLanenormalizes it (normalizeTitle: trim, 200-char cap,null⇒"", because clearing is legitimate — every render islane.title || lane.cwd). The rule is the column-wide one, not a special case for the field the UI exposes:bindablerejects any non-scalar patch value withEBADFIELD(linksexcepted — it is stored as JSON), and the route answers 400 for that AND for theSQLITE_CONSTRAINTfamily, 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/removestops the run and the stack, clears AgentHub's own runtime bookkeeping underLANES_ROOT/.state/lane<slot>/, and deletes the row. It calls neitherremoveWorktreenorremoveLaneData: 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 owngit worktree remove/git branch -d/ db drop. Consequentlyremovehas no unpushed-commits force-gate (onlyresetdoes) and no preflight blocker stops it. Do not add a "also delete the worktree" flag back onto this route. (A session's ownclaude --worktreetree 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 onerun_id/statusever record;agenthub-lane-<id>-<1-9>are extra sessions on the same lane (MAX_PANE_SLOTinserver/lib/pty-run.js; the-[1-9]run id pattern is repeated inpty-attach.js,websocket.js,routes/hooks.jsand the client — widen them together). Anything that must stop a lane's agents (stop,reset,remove) goes throughlaneRunIdsso no slot survives it; anything that names "the lane's pane" (agenthub lanes shell) means slot 0. A client that wants "a new tab" sendsslot: "auto"and the SERVER picks the first free slot under the lane lock (409 ERUNLIVEwhen 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.json purpose.git-ops.jsonly touches pathsgit statusreports as changed;lane-file-write.jsmay 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 thebaseShait 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 barenode --test server/__tests__/*.test.jsfrom inside a lane pane — the suites create and kill realagenthub-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.instructionsandlane_sources(server/lib/lane-sources.js) reach a pane throughlaunchExtras(laneId, tool), whichspawnRuncalls 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 indeleteLane. Claude's--add-diris VARIADIC, so it goes first in the argv where--permission-modeends it, and the text (instructions + context file list + extra folders) is written to<data dir>/workspace-prompts/<lane id>.mdat 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-dirper directory and-c developer_instructions=<JSON string>— the text itself up to 6 KB, else a line pointing at that file. Onlyclaude/codextake 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_idis stamped bybindRunSession(server/routes/hooks.js) for the session a dashboard pane holds — first writer wins, a nestedclaude -pexcluded — 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 byclaimToolSessions(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 itsdashboard_runs.session_idis known before any hook and a hook that carries norun_id(a hook handler older thanAGENTHUB_RUN_IDforwarding) is still stamped from that run (laneForSession). Every workspace session list readsGET /api/sessions?lane=<id>— which also counts a still-unstamped session one of the lane'sdashboard_runsholds, 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 bycwd, 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 throughsessionWorktree(server/lib/session-worktree.js: the session owned by the lane, its cwd not the lane's folder, registered bygit worktree list) —?session=on the git routes answersENOWORKTREErather 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 removewith no--force,git branch -dnever-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>,attachRequestonLaneConsolePane) 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 ESESSIONLIVEwith that pane'srun_id) — open that pane instead; a fork makes a new session and is allowed. - A
cwdcan back at most one lane per tool, not one lane overall. Uniqueness is the compositeidx_lanes_cwd_tool(cwd,tool), notcwdalone — Repo/Adopt mode (POST /api/lanes/ensure) lets the same directory carry aclaudelane and acodexlane side by side, shown as two separate cards.resolveLaneByCwd(cwd, tool)takes an optionaltoolfilter for exactly this; hook ingestion (touchLaneFromHook) pins it to the hook's own tool —claude,codexfor a hook posted withtool: "codex", orantigravityfor one posted withtool: "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 forclaudeonly. 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.serviceunit 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 (whichRestart=on-failureread as "meant to stop") AND tripsKillMode=control-group, reaping the bootstrap before it can repoint the unit or writestate.json— a dashboard dead after every update until some later session bootstrapped again. Soscripts/systemd-service.jssplits the API:writeUserServicerepoints the unit and starts NOTHING,restartUserServiceuses--no-block, andplugin-bootstrap.jsdefers that restart pastwriteStateandreleaseLock(pendingSystemdRestart). The unit isRestart=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 installmay keep usinginstallUserService— it runs from a user's shell, outside the cgroup.systemdOwnedadditionally 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, sofastPathOkrejects it viaunitIsStale(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 byneedsServerso a stale unit alone never starts a second server onto a bound port. - On macOS the same rules bind a LaunchAgent.
scripts/launchd-service.jsexports the systemd module's API (service-manager.jspicks by platform — require THAT, never one of the two directly, or a Mac silently gets the systemd no-op):KeepAliveisRestart=always,writeUserServicewrites the plist and loads nothing, and the deferred hand-over isbootout+bootstrap(neverkickstart, which keeps the old plist). Two differences are deliberate:AbandonProcessGroupis true so a restart does not take the lane panes' tmux server with it, and because a failedbootstrapleaves NOTHING running, the bootstrap falls back to its detached spawn when the restart fails and no server is live. Availability islaunchctl 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
--forcedoes not override that.supersededBy(scripts/plugin-bootstrap.js) reads Claude Code'sinstalled_plugins.json: if a SIBLING cache directory is the recorded install, this build logs one line and returnssuperseded— 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 repointedagenthub.serviceat 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.
pruneRetiredPluginRootsnever deletes: this build; anyinstallPathininstalled_plugins.json; the diragenthub.servicenames (unitRoot, foreign unit included); any dir named in the user'ssettings.jsonhooks (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 AFTERwriteState, 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-pluginsand/reload-skillsare scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from the outside.server/lib/cc-reload.jstherefore types them into the dashboard's existing tmux lane panes and spawns NOclaudeprocess — a one-shotclaude -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
JOBSinserver/lib/read-pool.js(its ownquery_onlyconnection 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)), notMIN(id) … id > e1.id. And do not dropidx_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.jsbefore anything readschild_process. The dashboard and its detached helpers have no console on Windows, so every git/npm call withoutwindowsHideopened a console window that flashed and stole focus on each poll.install()(inserver/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 awindowsHide: trueof 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.jsopens 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_idis the creator (never from the request —ownerFor(req)), a session belongs to its workspace (sessions.lane_idor adashboard_runsrow), and everything else hangs off the session. A member sees only their own rows: every list/aggregate passesreqintosessionWhere/bySessionWhere/laneWhere; every route naming ONE row is covered byscopeGuard'sPATH_RULES+ the query/body id names innamedIds(404ENOTFOUND) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs abroadcastAudiencecase (an unlisted type reaches no member); a staff-wide view joinsSTAFF_ONLY_PREFIXES. Admin/analyst reads of a member's session are written toaudit_log(server/lib/audit.js). A pane carriesAGENTHUB_PANE_TOKEN(hash indashboard_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 inauthGuard; prefix checks go throughapiPath()(decoded, case-insensitive), because Express routes/api/Webhooksand%77ebhookstoo. Named locks are namespaced per member, andnamed-lock.jstakes 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 joinsADMIN_WRITE_PREFIXES; hooks stay loopback-only in server mode. - A pool token reaches a pane only through tmux
-e, and only for the accountpickAccountchose. Never type it into a pane, never put it in the CLI's argv, never return it from a route (poolStatushas 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-docsskill 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 withbash 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 buildthennpm 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 helplists commands)
Testing and verification policy
- Backend changes: run
npm run test:serverbefore finishing. - Frontend changes: run
npm run test:clientwhen 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 withcd 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 onaccent—--accentis a TEXT colour, too light in the dark themes to carry white. - UI feedback: report through
toast(lib/toast.ts) and ask throughconfirmDialog/confirmDiscard(lib/confirm.ts) oruseArmed— neveralert()/window.confirm; a modal takesuseDialog, a copy goes throughcopyText, a single-line submit checksisSubmitEnter, a Refresh button spins throughuseSpin(hooks/useSpin.ts: neveronClick={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 theagenthubplugin:/agenthub:<name>, or the bare/<name>alias).