Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
70 KiB
Claude Code Working Guide
Last synced: v2.8.2 (2026-10-15) — SSH key sections per host, CodeCommit SSH key ID (plain ssh:// clone). 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— through our own unit when this process runs in one (systemctl restart --no-block, launchd's KeepAlive; a detached replacement beside aRestart=alwaysunit crash-loops it), else 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 twenty-two, 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,cherry-pick,revert,undo-commit,amend,ignore— nothing else.commit/amendwithpaths(the Changes panel's ticked files, JetBrains-style) rungit commit --only -- <paths>overresolvePaths-checked paths, never a conflicted one, and withhunks(ticked change blocks) commit from a TEMPORARYGIT_INDEX_FILEbuilt from git's own fresh diff (selectionPatch, never request text), then reset only those paths' real index entries;ignoreappends an anchored/pathline to the lane's OWN.gitignorefor an untracked path only (ETRACKED), never through a symlink.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 an extra folder (?source=<id>, resolved bysourceFolderfrom afolderrow of THIS lane'slane_sources, never a path —404 ENOSOURCEotherwise) is its own boundary under every guard here; no read may return asettings.local.json(ESECRET) — do not add a git read that skipslanePath/isSecret. A merge is a history-joining action allowed only because its recovery is a button too (merge-abort) — andcherry-pick/revertride the same rule (a non-merge commit by sha,EMERGECOMMIT/EBADSHA; same clean-tree, repo-root and conflict flow;merge-commit/merge-abortfinish or abort whichever of the threeoperationInProgressfinds open; a no-op is aborted again asEEMPTY, 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 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, 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 hardresetalready lives behindworktree.js's three-check destroy guard. The ONE reset isundo-commit=reset --soft HEAD~1(confirm: true, repo root, never the root or a merge commit), and it andamendact only on the HEAD the request names (sha,ESTALEonce 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 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 (or its session /lane_sourcesrow) —GET /api/lanes/branchesis the only one that takes a path from the request, because its lane does not exist yet. - A clone runs as its folder's account with THAT account's own identity, and only the public key ever leaves the server.
POST /api/lanes/clonetakes a URL only throughvalidateRemoteUrl(thepublishallowlist) and a parent held to the user's own folder like New folder;server/lib/git-identity.jsrunsgit cloneas the folder's account (S3: the member) and writes the identity into the CLONE's config (core.sshCommand/credential.helper=store --file=), never the user's global config. A member's SSH key and HTTPS store live in their home under~/.config/agenthub/, written only throughwrite-as(secrets on stdin, never sudo argv/env), the host's under<CLAUDE_HOME>/agenthub-ssh/+ the shared store below;/api/me/gitreturns onlypublicKey/hasToken. The git bar's fetch/pull/push in a member's folder uses that same store (memberHttpsConfig; a typed login goes in throughwrite-asstdin for the one call), and a store that cannot be read is never rewritten (ESTOREUNREADABLE); the private key is onlylstated, never read. The commit author is the ACCOUNT'sgit config --global(PUT /api/me/git/author). Do not add a route that returns a private key or a token, or one that writes a git identity outside the clone's own config or the account's own~/.gitconfig. - 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; the home's AI menu has the same Open terminal).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 File Explorer (
server/lib/file-explorer.js,/api/lanes/:id/fs/*) reuses the read path's guards and never replaces silently. Every path islanePath+ the PARENTresolveInLane-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under.git, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only throughwrite-as(move/copymodes;writeAsAsyncstreams an upload to its stdin) — and a move/copy never crosses accounts (sameOwner,EFOREIGN). A taken name is settled byconflict(fail→ 409 withnames,skip,keep,replace= the old one to the trash first). Delete is a move into the account's trash (member:~/.local/share/agenthub/trash/, host:<data dir>/trash/<user id>/), restorable 7 days.write-asis root-installed: a change to it needssudo agenthub server install-systemagain (EUPGRADEuntil then). A user's HOME is browsed through the same module insideinHome(/api/homes/:who/fs/*,server/lib/homes.jspicks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (EREADONLY). Do not add a delete that skips the trash, an overwrite withoutconflict, or a request path that is not run throughlocate/folder. - The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live 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.lane-file-transfer.js's upload (POST /api/lanes/:id/git/upload, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (EEXISTS, a symlink included) and anything into or named.git(a hook there runs on the dashboard's next git call), and writes as the folder's member throughwrite-asunder S3 — do not let it overwrite, make folders or take a path from anything butdir+ one plain name. ItsGET …/git/downloadtwin streams any file throughopenSafeas asandboxed attachment. The tree, expand-all and quick-open read the disk (walkFolder) for a folder that is not a repo. - 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. - An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.
server/lib/artifacts.js(/api/artifacts): a pinned file or a skill is copied once into<data dir>/artifacts/<random>/and every viewer reads only that copy —rawserves only a file the snapshot recorded, under asandboxCSP +nosniff; apageis ahttps://claude.ai/…link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into the CALLER's~/.claude/skills/(S3: as them throughwrite-as), never over an existing one withoutreplace(the old one is moved to~/.claude/agenthub-replaced-skills/, never deleted), and runs a plugin'sclaude plugin marketplace add/install --scope useras the caller, never with-y; an admin's switch (Settings → AI tools,ArtifactsHubSettings) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no.., no secret names, zip sizes read before inflating).POST /api/artifacts/plugin/inspectclones a user-named https link only to readmarketplace.json, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request. - A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows. The one addition is the workspace OWNER's own agent instructions (
user_prefs.instructions,PUT /api/me/instructions, Settings → AI tools), whichlaunchExtrasputs first — keyed bylanes.owner_id(localin local mode), never by whoever clicked Start.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), the transcript page's Resume/Fork (SessionResumeButton.tsx, behind a warning), or the ticked sessions of the Resume interrupted sessions? dialog (InterruptedSessionsPrompt.tsx,GET /api/run/interrupted— runs whose pane died under them; opening it resumes nothing). 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=alwaysandKillMode=process— an update never closes a lane pane, only Quit does: tmux may have been forked by the dashboard into the unit's cgroup, andcontrol-groupkilled every pane on each restart; do not drop it, nor the transientsystemd-run --user --scopethe FIRST session starts the tmux server in (server/lib/tmux.js— the server would otherwise share the cgroup of the dashboard or of the terminal it was started from), and do not add akillRun/killSessionto any update, restart or shutdown path. 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. - Quit stops the dashboard through its service manager, and only a server STARTING ends it.
POST /api/settings/quit/agenthub stop(server/lib/quit.js) writes<data dir>/runtime/stopped, kills everyagenthub-lane-*pane, then stops a running unit of ours withsystemctl --user stop --no-block/launchctl bootout(async —bootoutwaits for this very process) and never by SIGTERM, whichRestart=always/KeepAlivewould undo; with no unit running it SIGTERMs itself. While the flag exists the bootstrap starts no server (fastPathOk,needsServer; only--forceoverrides). The flag is cleared ONLY by a server that starts listening (andagenthub start) — never by a bootstrap — so an enabled autostart unit still brings the dashboard back at boot/login, and a SessionStart never does. - The status-area icon is its own process, and only the user hides or shows it.
scripts/tray.js(systray2) is started and kept alive by a production server (startTrayWatchinserver/lib/tray.js), never in server mode; it stops and starts AgentHub only through the CLI's ownagenthub stop|start, so a Quit from the icon is a Quit. It is on by default;<data dir>/runtime/tray-off(Settings → System, or the icon's Hide) is the user's, and nothing else may remove it. A server never kills the icon on restart or Quit — after a Quit it is the way back — and only signals a pid whosetray.jsonheartbeat is fresh. - 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 during normal operation (the other is Uninstall, below), 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. - Uninstall removes only what is AgentHub's, and never the user's work.
scripts/uninstall.js(agenthub uninstall --yes,POST /api/settings/uninstall—confirm: true, refused in server mode, run as a detached worker in its ownsystemd-run --user --scope, so no unit stop can reap it) goes in a fixed order: panes → unit (before the server, orRestart=alwaysrevives it) → servers → icon → stacks → config entries →claude plugin uninstall→ files → plugin cache → data dir. In a shared file it removes only entries it can prove are its own (theisOurEntry/bootstrap hooks,isOursstatusline, the Codex/agyremove()s, the# Written by AgentHubTOMLs, the launcher marker); it removes only the marketplace namedagenthub, never a shared one; it deletes the plugin cache only onceinstalled_plugins.jsonno longer recordsagenthub@*; andisSafeDataDirrefuses a data dir that is or contains the home,CLAUDE_HOMEor the checkout, or is not recognisably AgentHub's. The AI tools' conversations, lane worktrees and branches, and anything in a user's repo are never touched. Every module is required up front, because the cache it runs from is deleted mid-run. - 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 from the host's shell (agenthub server setup,agenthub users …) or by an admin on the Users page (server/routes/users.js), where EVERY change needs the admin's own password again (confirmPasswordinauth.js, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written toaudit_log, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that 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. Adevelopersees only their own rows, and ananalystreads every row but writes only their own (memberIdscopes an analyst's non-GET requests like a developer's —memberwas renameddeveloper,db.jsmigrates it): 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 developer); a staff-wide view joinsSTAFF_ONLY_PREFIXES. Admin/analyst reads of a developer'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 user, andnamed-lock.jstakes only a plain name (a lock name was a path). It is NOT OS isolation unless S3 is installed (next rule); without it every pane runs as the host account, so do not call that multi-tenant. A new route that writes the host's own configuration joinsADMIN_WRITE_PREFIXES, and its controls hide behindusePermissions().isAdmin(client/src/components/AuthGate.tsx) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode. - With S3 installed (server mode +
/etc/agenthub/server.conf,s3Enabled), a pane runs as its workspace owner's Linux user, and the dashboard reaches a member only through sudo.ah-<name>, home<USERS_ROOT>/<name>; the FOLDER decides (accountForPathinserver/lib/run-as.js), and a folder under the users root with no enabled, provisioned user isEMEMBERGONE— never fall back to the host there. Every tmux call on a member's socket (attach included), every git/ghcall in a member's folder and every write issudo -n -u ah-<name> <tmux|git|gh|write-as>by the absolute paths inserver.conf(sudoArgv); sudoers allows exactly those, plus/opt/agenthub/tools/bin/claudefor the opt-in plugin baseline (applyPluginBaseline), as%agenthub-membersand NEVER root as a run-as target. The only root commands are/opt/agenthub/sbin/agenthub-provision(create/disable/enable/delete —deleteis an admin's step-up-confirmed Delete user (DELETE /api/users/:id,auth.deleteUser: Linux account + home FIRST, rows only after it succeeded) and removes only anagenthub-membersaccount and exactly<USERS_ROOT>/<name>as a real directory,rm -rf --one-file-system, never through a link; it never adopts a non-member account, and otherwise root never touches a path inside an existing home) andsystemctl start|stop|restart|enable|disable agenthub.service; do not add a third — a host compromise is already every member's. sudo logs argv, so nothing secret or private crosses it: a member pane's env (pane token, pool token) and command (prompt) go in a launch file written bywrite-asfrom stdin and run bypane-launch; file content always goes on stdin. The host never writes, chmods or deletes inside a member's home itself — only throughwrite-asas the member (writeFileAs,handoffWrite,mkdirAs,removeAs,scratchFile): the member can swap any name there for a link, and the host's write would follow it. A member's file is READ only throughopenSafe/readFileSafe*/readStreamSafe(open, thenfstat: a regular file that member owns,O_NONBLOCK), never a plainfsread. Hooks are authenticated by the peer uid of the loopback connection (/proc/net/tcp*,server/lib/hook-auth.js), never by anything in the body: host or root = trusted, an enabledah-*= only work, sessions, runs and transcript paths inside its own home (EFOREIGN, foreign paths dropped BEFORE any tool mapping reads them), anyone elseEPEER.hidepid=invisible(gidagenthub-procexempt) is required — panes' argv/env would otherwise be readable by every account — andinstall-systemstops without it. The unit's ambientCAP_DAC_READ_SEARCHis dropped for every child (server/lib/drop-caps.js, installed first inserver/index.js, before anything readschild_process) — keep it there, and a spawn outsidechild_process(node-pty) takesptyArgv. The account comes from a path's TEXT, so a path a member can redirect is checked before it is used: a workspace's real path (laneRootingit-diff.js,EFOREIGN) and a git dir their git prints (git-exclude.js) must stay the same account's (sameOwner), a member's home is no workspace, and a READ usesownerForRead, never the throwingaccountForPath. A workspace inside a member's home is owned by that member only, and one outside the users root (it runs as the HOST) by an admin only, by the LIVE role (assertOwnerFits: create, assign, every pane start —EOWNERFOLDER; demoting or disabling a user ends their host panes): its panes run as the FOLDER's account but carry the OWNER's pane token, which that account can read; a pane's cwd must be that same account's (ECWDACCOUNT), and a pane token is accepted only from that account's uid (peerIsPaneAccount). A path a non-admin names in ANY request (an analyst's GET too) stays in their own folder or workspaces (ownFolder,insideOwnWorkspace,scope.ownCwdQuery), and the configfilereads never serve a credential file or anything in the data dir (isSecretConfigPath). A member can stall or flood what the dashboard reads, and it all runs on one thread: every synchronous sudo into a member is time-bounded (MEMBER_EXEC, tmuxmemberCallwith a back-off) and a member's tmux failure makes only THEIR panes unknown, never everyone's; a member's file is read with a size cap (readFileSafe*max), line by line only throughreadLines(neverreadline, which a one-line 512 MB file crashes), and a sparse one is never copied out as real blocks. S3 fails closed, never to the host:serverConfkeeps the last goodserver.confin server mode if the file stops reading,tmux.accountOfthrows (never "the host's server") when it cannot tell a session's account under S3, and every non-GET request naming a workspace re-checksassertOwnerFitsby the LIVE role (scopeGuard,409 EOWNERFOLDER) — a demoted admin keeps READING their host-folder workspaces but/up,/hook, a commit or a file write there no longer runs as the host. The AI Proxy gateway marks what it relays (x-agenthub-via-gateway) and the dashboard refuses any request so marked (403 EGATEWAY), since a personal profile may point back at it from our own trusted uid; a member's launch file refuses a NUL in argv/env (EBADARG). Operator steps:docs/SERVER-MODE.md§ Operator runbook. - A pool token reaches a pane only through tmux
-e(a member's pane under S3: its launch file), 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 reads one back — the pool is filled from the host's shell (agenthub accounts add [--tool codex]) or by an admin on the Users page (POST/DELETE /api/accounts,/accountsinADMIN_WRITE_PREFIXES, their own password again, audited by account NAME), where the token only goes in. An empty pool must leave every spawn exactly as before. The two exceptions are a Claude login (server/lib/claude-pool.jskindlogin, moved in from a saved switch-user profile — the host CLI's own moves with its live login, and the host's home becomes one more placement — or a pasted.credentials.json) and a Codex ChatGPT login (server/lib/codex-pool.js), which neither CLI reads from an env var: it is written asauth.jsoninto a MEMBER's own CODEX_HOME, as the member (write-as, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one;syncBackcopies the newest back. A Claude login's refresh token is single use, so the server is its one refresher (refreshLogins, ahead of expiry, pushing the new pair into every home it placed it in), and a pair a member's Claude refreshed first is copied back only once Anthropic confirms the same account (fetchOwner). A Codex API key is-e AGENTHUB_POOL_KEYplus a-cprovider whoseenv_keyonly names it. - 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
- Run only the tests the change touches — never a full suite by default. Full suites take 5–6 minutes. Backend:
npm run test:server -- server/__tests__/<name>.test.js(the test files for, or naming, each module you changed). Frontend: inclient/,NODE_ENV=test npx vitest related <changed files> --run. Docs-only changes run no tests. - Run a full suite (
npm run test:server/npm run test:client) ONLY when it is genuinely needed: the change touches something every screen uses (Layout,Sidebar,StatusBar,tailwind.config.js, global CSS, shared hooks/lib — the per-screen snapshotsclient/src/pages/__tests__/screens.snapshot.test.tsxcatch that), a shared server core (server/db.js,server/index.js,server/lib/auth.js/scope.js, middleware), a dependency or build/test config, or more than ~10 code files; or the user asks for it. Do not re-run a full suite "once more before committing" — the pre-commit hook (.husky/pre-commit) already runs the related tests for a small commit and both full suites for a big or risky one. - If a UI change is intentional, review the snapshot diff and regenerate baselines with
cd client && npx vitest run -u <test file>; 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).