- The run form's prompt box offers suggestions again: `/` lists the run
cwd's project and user commands and skills plus the CLI built-ins
(claude lanes only), `@` lists files under the cwd; Enter picks while
the list is open and sends otherwise. The 2f39f4e rewrite had left a
bare textarea that ignored both.
- Active runs lists only the current lane's runs and external sessions
(sessions matched to the lane by longest path-boundary cwd), instead of
every lane's.
- The Changes panel's git line and its actions are one compact row in one
card, and the mode/range toolbar is one wrapping row.
- The lane card hides itself when it has nothing to show, instead of
leaving an empty frame where the git row used to be.
50 KiB
Claude Code Working Guide
Last synced: v1.8.6 (2026-09-24) + the lane's git section MOVING from the lane card to the top of the Changes panel (client/src/components/lanes/LaneGitHeader.tsx: useLaneGitFacts, the working-copy line, LaneGitFilesPanel and LaneGitBar, sharing ONE facts read with LaneChangesPanel, which refreshes it after every stage/unstage/discard/commit while a git bar action re-reads the diff; onNewLane comes from Workspace.tsx, and LaneCard has no git row and no onNewLaneFromRepo any more) and the git bar losing its commit UI — the overview's staged-only box is the ONE commit path, the server's commit without only is unchanged; the git line and its icon-only actions are ONE row in one card, the panel toolbar ONE wrapping row, and LaneCard hides itself (empty:hidden) when it has nothing to show + the run form's / and @ autocomplete being REAL again (PromptEditor in client/src/components/run/RunSetup.tsx — the 2f39f4e rewrite had left a bare textarea: / offers the run cwd's project + user commands and skills, via api.ccConfig.commands|skills(scope, cwd), plus the CLI built-ins, claude lanes only; @ offers GET /api/run/files; Enter picks while the list is open and sends otherwise) + the console's Active runs listing only the CURRENT lane's runs and external sessions (LaneConsolePane.tsx: runs by laneId, sessions by the longest path-boundary lane cwd, owningLaneId) + v1.8.3 (2026-09-23) + GET /api/settings/info reporting server.memory.heapLimit (V8's heap_size_limit), which the Dashboard's V8 Heap bar and Health Score now divide by instead of heapTotal + every claude plugin install|update -y (cc-plugin-cli.js, skill-plugins.js, both detached update helpers, /agenthub-update) retrying WITHOUT -y when an older Claude Code answers unknown option '-y' + LaneDiffView.tsx WINDOWING a diff above WINDOW_THRESHOLD (500) items at a fixed ROW_PX row height (visibleRange, spacers, a ch min-width off the longest line; at or below the threshold the DOM is unchanged) and clamping its mirrored split scroll (clampScroll/follow) + a git action that git itself refuses (a numeric exec exit status) answering 409 EGIT instead of 500 with a number for a code (gitErrorCode in server/routes/lanes.js), and one parseNumstat (server/lib/git-diff.js) shared by the Changes panel and gitFacts.files + the Dashboard heatmap ramp mixing --accent into --surface-0 so it reads on every theme + the lane git surface being HARDENED (server/lib/git-diff.js, server/lib/git-ops.js, server/lib/worktree.js: every lane git call runs with GIT_LITERAL_PATHSPECS=1 and -z porcelain, is scoped to the LANE's cwd — a subdir lane sees and acts on only its own paths, gitFacts.files included — and reads through lanePath at every rev; any file whose basename is settings.local.json, the lane router key file, is refused as ESECRET/403 on disk and at every rev; commit refuses with ESTAGEDOUTSIDE while anything outside the lane is staged, checked with --no-renames; push without a resolvable remote is ENOREMOTE; git writes serialize on the repo's absolute git dir, not the lane id) + the xterm terminal following the selected DARK theme (client/src/lib/terminalTheme.ts, live via term.options.theme) and chart colours using structural tokens + the Add-lane modal asking POST /api/lanes/ensure with exact: true (#42) — without it a directory NESTED inside an existing lane resolves to that parent lane with created: false, so the modal closed on the parent and no new card ever appeared; exact counts only a lane on that very directory, and a created: false answer now names the lane that owns the folder instead of closing. The CLI, the run console and Sagi keep the parent lookup + Settings → Appearance (client/src/components/settings/AppearanceSettings.tsx: mode Dark/Light/System — prefers-color-scheme, followed live — plus one NAMED theme remembered per mode: dark Default/Darcula/Apple Dark, light Default/Apple Light/IntelliJ Light/Canvas). A theme is a CSS-variable override block in client/src/index.css keyed by data-theme on <html> (.dark[data-theme=…] / [data-mode="light"][data-theme=…], data-mode is set on <html> too), and --tint-base/light --syn-plain are now var(--surface-3)/var(--fg-primary) so no theme restates them; hooks/useTheme.ts is a useSyncExternalStore reader over localStorage (theme = dark|light|system, agenthub.theme.dark|light) that caches NOTHING in the module, so the Sidebar and Settings can never disagree — do not reintroduce per-instance useState there; an inline <script> in client/index.html reads the same keys before first paint, so a key rename touches BOTH + the diff's +/- rows (LaneDiffView.tsx rowTint/markerClass, highlight.ts diff-add/diff-del) taking a /20 status tint, a 3px status bar and a coloured marker, and the changed-span emphasis bg-fg-primary/15 instead of bg-white/15, which vanished on every light theme + the light theme going WARM (client/src/index.css: the whole light ramp is Claude's ivory/terracotta family — page #e8e5da, .card #f5f3ec (one notch below Claude's own #faf9f5 ivory), warm borders and a warm --shadow-card, --accent #a34a28 which is the brand #cc785c two steps down because the brand value is 3.1:1 on ivory and 3.3:1 under white text — dark mode is untouched and stays Radix slate + blue) + the Add-lane modal having NO pipeline picker and no Advanced disclosure (AddLaneModal.tsx: every lane is created on the server's own default, changed afterwards from the lane's detail header or at creation via agenthub lanes add --pipeline <id>; the router radio is the one per-lane extra left and it sits in the form) + the lane-detail header dropping its stage and auto: <stage> chips (the PipelineMap directly below already draws both) + the Changes panel's ONE resize control being icon-only, drawing the width the next click lands on + status/accent FILLS being pre-mixed instead of translucent (tint() in client/tailwind.config.js + --tint-base in both themes of client/src/index.css: bg-status-success/10 and border-status-danger/40 now compile to color-mix(…, rgb(var(--tint-base))), because rgb(… / 0.1) composites with whatever sits behind it — one "success" chip rendered as five different colours over the five surface steps and washed out on light cards. Scoped to backgroundColor/borderColor ONLY: text-status-warning/80 and ring-accent/40 sit on top of a fill and must stay translucent, and a translucent surface is what every scrim is made of. Light --status-* also dropped two steps below Radix 11 so a label clears 4.5:1 on its own densest chip, not just on the card) + the LIGHT theme being rebuilt around the rule that the surface ramp rises in lightness with its index in BOTH themes (client/src/index.css: page surface-0 #eef0f3 under a brighter .card surface-3 #f8f9fb, so hovers lift; light --accent #0068d6 because dark's #0090ff is 3.3:1 on white; --fg-muted held at ≥4.5:1; new --syn-* syntax tokens + syntax.* in tailwind.config.js consumed by tokenClass in client/src/lib/highlight.ts, dark keeping its pastels and light taking the same hues four steps darker) — a hardcoded near-black panel, a hover:text-white or a global filter: invert(1) is a light-mode BUG, token it or scope it under .dark + client/src/components/Tooltip.tsx — GlobalTooltip, mounted once in App.tsx, upgrades EVERY native title in the app into an anchored, themed bubble (parks the attribute in data-tip while the bubble is up and restores it after, so the accessible name and the no-JS fallback survive; data-tip-off opts out, Tip.tsx shares its TOOLTIP_SURFACE) — do NOT add a per-call-site tooltip wrapper, keep writing title= + the lane git surface of issues #34 and #35 — server/lib/git-diff.js (a lane's changes as parsed hunks, range=work|branch|commit, a caller-chosen context, three caps, plus listCommits/readFileAt/listPaths/listTree) behind GET /api/lanes/:id/git/changes|log|file|ls|tree, server/lib/git-ops.js (fetch/pull/push/stage/unstage/discard/commit/switch/branch-create/branch-delete — a per-file path must be one git status reports as changed, and discard needs confirm: true) behind POST /api/lanes/:id/git/:action, the Changes panel's five modes (Files first) with a staged-only commit box in its overview and per-file collapse in its review list (the header folds, only the PATH opens the file; plus collapse/expand-all) and a back that returns to the originating mode AND scroll offset, ONE header control that steps narrow/50-50/70-30/closed (agenthub.workspace.changesSize beside changesOpen), and its VS Code-shaped viewer (LaneDiffView.tsx: a diff bounded to its own box and scrolling on BOTH axes — a horizontal bar at the foot of a 2000-line container is only reachable at the end of the file — unified/side-by-side as two independently scrolling columns with MIRRORED scrollLeft — halves inside one scroller painted over each other with nothing to scroll — highlighting reused from client/src/lib/highlight.ts, intra-line emphasis, expand AND collapse context, whole-file), the Add-lane modal's third branch answer Current branch (it switches the modal to Repo mode — a lane that simply IS the repo on main, which a worktree could not do anyway because git holds one worktree per branch) and its prefill prop, driven by the git bar's New lane here, gitFacts gaining hasRemote/upstream/ahead/behind, GET /api/lanes/branches gaining worktrees, GET /api/run/cwds gaining a repo kind (the source_repo of existing lanes), the Add-lane modal's new/existing branch toggle + worktree list + Advanced disclosure (AddLaneModal.tsx), and the two new client components LaneGitBar.tsx (now in the Changes panel, via LaneGitHeader) and LaneChangesPanel.tsx (the collapsible Changes card beside lane-detail in Workspace.tsx, agenthub.workspace.changesOpen) + v1.6.17 (2026-09-18) + the run's permission mode defaulting to auto and the model picker sending ALIASES (ALLOWED_PERMISSION_MODES in server/lib/pty-run.js + server/routes/run.js gained auto, which is also the fallback buildClaudeArgv passes to --permission-mode, the RunSetup select's first option and LaneConsolePane's initial state; and RUN_MODEL_CHOICES in client/src/lib/api.ts now holds fable/opus/sonnet/haiku instead of pinned ids — claude --model resolves an alias to the latest model itself, so a release like Fable 5.1 needs no edit here, and pricing is unaffected because it keys off the resolved id the transcript reports) + convertEol being GONE from the xterm terminal (TerminalView.tsx) — it rewrote every bare LF as CR+LF, so a tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the column it was on) had the rest of that draw land at column 0, stamping a line's first characters into the left margin of the rows below: the torn left edge of issue #33, which the attach-size fix alone did NOT cure + the lane pane inheriting the graphical session's DISPLAY/WAYLAND_DISPLAY/XAUTHORITY/XDG_RUNTIME_DIR (server/lib/desktop-env.js, merged into spawnRun's tmux -e env) — a systemd user service has none of them, so claude in a dashboard-started pane could not read the clipboard and answered "no image found in clipboard" on every image paste + the PTY attach carrying the client's REAL size (sizeFromQuery in server/websocket.js, ?cols=&rows= on the /ws-pty/:runId URL built in TerminalView.tsx) — attach() was spawning tmux attach-session at a hardcoded 80×24 on EVERY attach, and tmux sizes the window to the newest client, so claude redrew its whole UI at 80 columns until the first resize message landed and that width stayed in the scrollback as the torn left-margin fragments of issue #33 + the Workspace's no-lane state being a real banner (lane-empty in client/src/pages/Workspace.tsx, empty.title/empty.body in lanes.json replacing emptyState) — the lane strip, add tile included, now renders only when a lane exists + the Create/update readme file action living in the lane's Tools menu (LaneToolsMenu in client/src/components/lanes/LaneCard.tsx, actions.writeDocs* in lanes.json) instead of being a checkbox in the Add-lane modal — same api.run.start({initialPrompt: "/sg-all:sg-md-writing"}), claude lanes only, but re-runnable long after creation + the lane terminal taking focus on mount and reporting its own death (TerminalView.tsx: term.focus() after the first fit, so typing reaches tmux without clicking the pane, and a new onExit prop — read through a ref so an inline parent callback never re-attaches the socket — fired on the server's exit frame; LaneConsolePane runs newRun() there, so a double Ctrl+C out of claude lands back on the run setup form instead of a dead pane behind a New run button) + the run console pre-filling NOTHING into the prompt box and a resume sending NO prompt (LaneConsolePane.tsx's promptPrefill is gone — picking a pipeline used to write its driving slash command into the box on a claude lane and a stage-declaration checklist on a codex one, and whatever sits there is sent as a real prompt the moment the run starts; handleStart in RunSetup.tsx now drops initialPrompt entirely when a session is being resumed, since a resume carries its own transcript and anything typed would fire an extra turn into it) + browser notifications falling back when the push landed NOWHERE (notify() in client/src/hooks/useNotifications.ts): POST /api/push/send answers 200 with {native:false, pushed:0} whenever this browser has no push subscription — no service worker, a non-secure origin like http://<lan-ip>:4820, an unreachable push service — and the client counted that as delivered, so nothing ever appeared; delivery is now native || pushed > 0 and anything else shows the notification locally (skipped when the push DID land, or every one would be doubled). The Notification API still needs a secure context, so a bare LAN IP can show nothing at all + auth-profile switching reading and writing the macOS login Keychain (readCredentials/writeCredentials in server/lib/cc-profiles.js): on darwin the claude CLI keeps claudeAiOauth in the generic-password item Claude Code-credentials (account $USER), never in <CLAUDE_HOME>/.credentials.json — so a switch rewrote a file nothing reads and the CLI kept running as the previous account. The Keychain wins when it holds an item, the file stays the fallback and is kept in step only when it already exists, and AGENTHUB_CC_KEYCHAIN=0 pins the file (the test suite sets it so a Mac dev run never touches the real Keychain) + turn bookkeeping being idempotent (stmts.insertEventAt in server/db.js, used for TurnDuration with the TRANSCRIPT's timestamp — its dedup guard matches on created_at, so a NOW stamp made every hook re-insert the session's whole turn history: 587k rows, 75% of the events table — and meta.thinking_blocks/turn_count/total_turn_duration_ms now ASSIGNED from the full parse instead of +=, since extract() returns whole-transcript totals every time) + hasLegacyHooks blocking fastPathOk (scripts/plugin-bootstrap.js): leftover checkout hook entries double EVERY event and subagent row, stripLegacyHooks only ever ran inside bootstrap(), and the fast path skipped it on exactly the healthy installs where it mattered + the one-time repair marker moving to user_version 3 (server/index.js) — it now also deletes the duplicated TurnDuration rows, and 2 could not be trusted because an old build still ingesting hooks re-inflated whatever that pass corrected + a router switch on a LIVE pane reporting itself (POST /api/lanes/:id/router/apply now answers restart_required/run_id when runs.getRun(runIdForLane(id)) is live, and LaneRouterRow renders a Restart button that kills that pane and restarts it from the lane id alone — Claude Code re-applies env to a running session but keeps the model it resolved at startup, so the old router's model name reaches the new endpoint and the next prompt fails) + the sidebar brand being the SmartGift wordmark with the AgentHub caption under it and the vendored client/public/favicon.ico (Sidebar.tsx, client/index.html, client/public/manifest.json) + the two "Claude is waiting for YOU" notification types (client/src/lib/notifPrefs.ts — the ONE definition of the prefs shape/defaults/storage, now imported by both Settings.tsx and useNotifications.ts; onAwaitingInput and onTurnComplete default ON, issue #29) + per-message token counting (lastUsageMsgId in server/lib/transcript-cache.js and both parsers in scripts/import-history.js: Claude Code writes ONE JSONL line per content block of an assistant message and each repeats the same message-level usage, so summing lines over-counted tokens and cost ~1.5–2× on tool-heavy sessions) + stmts.setTokenUsage (server/db.js) — the EXACT counterpart of replaceTokenUsage, used only by reconcileTokens, because the high-water baseline_* columns make a downward CORRECTION impossible; the server runs that reconcile once in the background at the first start after upgrading (user_version 2, server/index.js) + v1.6.12 (2026-09-18) + the same three rules in skills/ship-feature-lane/SKILL.md (three scheduled touchpoints now: Stage 0 Q&A, Stage 0b plan approval, Stage 7 acceptance before the PR is published — the publish rule and the turn-liveness rule name them) + the default 8-node pipeline asking the user twice and looping on a NO-GO (skills/ship-feature/SKILL.md + server/data/pipelines/default.json: intake now DECLARES /superpowers:brainstorming and detects it, the plan phase blocks on an explicit approval, ship-feature gained the four sections that make a long run followable and affordable (Right-size the run — trivial work does not enter the pipeline and a small change skips the plan-debate/independent-test/gate agents, Dispatching a judgement — the fresh-context/foreground/never-fork/never-dispatch-what-a-command-answers contract written ONCE instead of re-argued per phase, Resuming after a compaction, and a per-phase Leave when:), review DECLARES the bare /code-review (Claude Code's own, no plugin: prefix — requiredSkillPlugins skips it, so it adds no install requirement) and runs it always and a domain reviewer only for the layers git diff --name-only shows, and gate requires the user's acceptance — a NO-GO re-declares implementing or plan instead of proceeding) + KEEP_RETIRED_ROOTS dropping to 1 (scripts/plugin-bootstrap.js) + lane removal being record-only (the remove branch in server/routes/lanes.js no longer calls removeWorktree/removeLaneData, so a worktree, its branch and its database survive forgetting a lane — see the rule under Lanes) + v1.6.10 (2026-09-17) + per-profile Claude quota (server/lib/cc-usage.js, POST /api/cc-config/profiles/usage, the Check quota button in the Profiles modal) — a POST because a saved profile's token is normally expired, so answering refreshes it and the refresh ROTATES the credentials written back to disk, which is also why it never runs on a poll; the LIVE account is the exception and is read from .credentials.json via cc-profiles.liveCredentialsFor, never refreshed, because the CLI has already retired the refresh token its profile copy holds + the settings patch that changes nothing now writing nothing (patchSettings in server/lib/cc-mutate.js, plus .claude/.cc-config-backups/ in a lane's git exclude) + the Add-lane modal closing itself on a clean setup and offering a real Close when a step failed + a lane's router pick no longer landing on whichever lane the user switched to mid-apply (LaneRouterRow), nor reverting on the next lane switch — POST /api/lanes/:id/router/apply now broadcastLanes like every other mutating lane route, so the client's cached row stops disagreeing with what was written + a run being opened by a DECLARATION only — a backwards DETECTION now continues the run in flight (appendStageEvent in server/lib/lanes.js), because detection bounces within one feature and runs is what every pass rate on the Workflows page is divided by + SETTLE_MS in server/lib/cc-reload.js — the two reload commands are typed a second apart, and reloadOpenSessions is now async, because Claude Code swallows the Enter after a slash command and back-to-back sends merged into one composer line + v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (client/src/lib/laneOrderStorage.ts, LaneStripCard's index/onReorder props, the Workspace detail header's rename field, and normalizeTitle/bindable in server/lib/lanes.js) + the uncommitted-files hover panel (LaneGitFilesPanel, now in LaneGitHeader, and files from GET /api/lanes/:id/git now being {path,status,added,deleted} records off one extra git diff --numstat HEAD) + AI Router profiles (server/lib/ai-routers.js, server/lib/router-apply.js, the Settings tab /settings?tool=ai-router, its Test button / POST /api/ai-routers/test, the per-lane router select — labelled Model, LaneRouterRow exported from LaneCard and rendered in the Workspace lane-detail header row — and lanes.router_profile) + the bootstrap's superseded-build guard and plugin-cache pruner (supersededBy/pruneRetiredPluginRoots in scripts/plugin-bootstrap.js, unitRoot in scripts/systemd-service.js) + v1.5.6 (2026-09-11) + the systemd handover rule below (scripts/systemd-service.js's writeUserService/restartUserService split and plugin-bootstrap.js's deferred pendingSystemdRestart) + eb63569 (2026-09-10) + release-based update detection (server/lib/update-check.js reads the forge releases API — a checkout via its origin remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no .git; a plugin_managed install self-updates via the detached plugin-update chain) + the install-wide Update all batch and session reload (server/lib/cc-update-all.js, server/lib/cc-reload.js). A change that makes any statement in this file, AGENTS.md, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
- Preserve real-time behavior (hooks -> API -> SQLite -> WebSocket -> UI).
Repo map
server/: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection —lib/update-check.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, the same idea forcodexlanes: it tails Codex's own rollout journals because Codex emits no hooks at all).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;agenthub skills installremains a fallback that copiesship-feature-laneinto~/.claude/skills/for plain checkouts. -agents/: the same story for subagents — plugin root, so all four (qc-local,senior-gate-reviewer,backend-reviewer,frontend-reviewer) ship asagenthub:<name>. This is also the sourceagenthub lanes agents installcopies the two pipeline agents from into a lane's.claude/agents/, which is what keeps the baresubagent_type: qc-localworking there. One file per agent, never a second copy..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.- Reporting a required plugin is derived; installing one is allowlisted.
server/lib/skill-plugins.jsreads the/plugin:skillstrings out of the loaded pipeline templates — right for the/agenthub-doctorrow, wrong for an install: a template is a file anyone can drop intoDASHBOARD_PIPELINES_DIR, so installing whatever name it contains would make "edit a JSON file" mean "run this vendor's install command next session". The bootstrap installs only whatAUTO_INSTALLABLE(a constant in that file,superpowerstoday) lists, in its DETACHED worker, never in the 10 s hook, recorded once asstate.skillPluginsSettled(also gatingfastPathOk), never throwing, and off underAGENTHUB_AUTO_INSTALL_PLUGINS=0|false|off. Do not derive an install target from a template.
Lanes
A lane 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. One exception,codexlanes only:codex-ingest.jsadvancesstagefrom its own written detection (setStage(..., { keepDetection: true })) — a codex session has no hook and rarely callsagenthub stage, so nothing else would ever move its map. It passes NO evidence, so the node renderspassed-no-evidence(amber) and neverdone, andkeepDetectionpreserves the detection columns so the card still shows the command that moved it. Do not extend this toclaudelanes or to the console. 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 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. - 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. - Pipeline templates are JSON files (
server/data/pipelines/) with node definitions; custom templates override built-ins whenDASHBOARD_PIPELINES_DIRis set. - 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/) is dead afterLANE_DEAD_SECseconds (default 300); a silent idle lane is at rest, not dead. - 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. - 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. - A lane's AI Router profile is a lane fact too, and it never travels alone.
lanes.router_profile(nullable; null means the same asnative) is written ONLY bysetRouterProfile, called beside the disk write inPOST /api/lanes/:id/router/apply— never throughPATCHABLE. The column and the lane's own config must move together, or the row claims a router the lane'ssettings.local.jsonhas never heard of. What is written comes from the lane row, never the request: a claude lane gets eightenvkeys merged into<cwd>/.claude/settings.local.json(.localbecause the value carries an API key; every other key in that file is preserved and a non-JSON file is refused, not rewritten), a codex lane gets$CODEX_HOME/<name>.config.toml— the layercodex -p <name>loads — because Codex has NO project-level config, and its baseconfig.tomlis never touched. Key material never leaves the server: profiles live at<CLAUDE_HOME>/agenthub-routers/(0700/0600),GET /api/ai-routersreports onlyhasKey, and there is deliberately no route that returns one profile in full.POST /api/ai-routers/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. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. Re-pointing a lane is allowed and is not a PATCH: the lane card's AI Router select callsPOST /api/lanes/:id/router/apply(column + disk together, same rule as above), neverPATCH /api/lanes/:id. Codex's key reaches the pane through tmux's-eargv 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 attaching COPIES a profile, so the store stops being the single source of truth the moment a lane exists:PATCH/DELETE /api/ai-routers/:namemust push through to the lanes naming it (reapplyToLanes/detachFromLanes), or a rotated key silently never reaches the lane still using the revoked one. Seeserver/lib/ai-routers.js,server/lib/router-apply.js. - The dashboard's git writes are the boring ten, and the guards are IN
git-ops.js, not in the UI.POST /api/lanes/:id/git/:actionoffersfetch,pull,push,stage,unstage,discard,commit,switch,branch-create,branch-delete— nothing else. 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).discardis the ONE irreversible action and it refuses to run withoutconfirm: true; do not add a second 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. Do not add merge, rebase, reset, stash or conflict resolution 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/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. - A lane's
toolis a lane fact, immutable, and never read from a request.lanes.tool(claude|codex, defaultclaude) is set bycreateLaneonly — it is deliberately absent fromPATCHABLE, so no PATCH can flip the CLI under a live pane. Every spawn path (POST /api/lanes/:id/start,POST /api/run) takes the tool from the lane row and ignores whatever the body says; the run panel's Codex-only fields (sandbox,approval,resumeLast) are validated against the fixed allowlists inserver/lib/pty-run.jsbefore they reach an argv, and the bypass flags (--dangerously-bypass-approvals-and-sandbox,--dangerously-bypass-hook-trust) are never emitted.GET /api/run/binary?tool=resolves the probed binary name from that same allowlist — the query string reacheswhich. - A lane's card position is a viewer preference; its name is a lane fact. The Workspace strip's drag/Alt-arrow 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
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,resolveLaneForWork) always pins it to"claude"because Codex has no hook mechanism in this codebase, so a hook event must never touch acodexlane sharing the path. 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.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. - 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. - 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.
- 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). - Use
agents/subagents for focused review or investigation passes (they ship with the plugin:agenthub:backend-reviewer, etc.). - Declare lane stage even in plain chat, not just inside skills.
agenthub stage(seedocs/LANES.md§ Reporting a stage) is a reporting command, not a skill-only ritual — any Claude session working inside an adopted lane's cwd should call it on real stage transitions (starting to plan, starting to implement, running tests, opening the PR, etc.), whether or not a skill is driving. Tool-event detection (server/lib/stage-detect.js) only ever paints the amber "detected" badge, never the bluecurrentring — a lane worked entirely through plain chat with noagenthub stagecalls will show a stalecurrentstage no matter how much real work happens. Skip it only whenagenthub stagereports no lane owns the cwd (not adopted). - Prefer
agenthub:ship-feature(-lane) over invokingsuperpowers:brainstorming/writing-plans/executing-plans/subagent-driven-development/test-driven-developmentdirectly for implementation work inside a lane's cwd. Those Superpowers skills know nothing about AgentHub lanes and never callagenthub stagethemselves — going straight to them (skipping the AgentHub wrapper) is exactly how a lane'scurrentstage goes stale mid-implementation whileagenthub stageis never called for real transitions.agenthub:ship-feature/ship-feature-lanedrive the same underlying Superpowers skills phase-by-phase and declareagenthub stageat every boundary — pick one of them first for any multi-phase feature/bugfix inside a lane; reach for a bare Superpowers skill directly only for work with no lane cwd to report against (e.g. a scratch repo, or explicitly told to skip the AgentHub wrapper).