The residue an interrupted update leaves behind is invisible: the server
runs, every doctor row reads OK, and the unit quietly still names a cache
directory Claude Code deleted during the update. Nothing surfaces it until
a reboot, when the dashboard does not come back and nothing recorded why.
- fastPathOk now rejects a stale unit, so the next SessionStart repairs it
instead of short-circuiting. `unitIsStale` is pure fs on purpose: that
path runs on every session and may not spawn a process.
- Repairing the unit is not restarting the server. A current, healthy
server keeps running and the corrected unit takes effect at the next
start (`pendingSystemdRestart = systemdOwned && needsServer`), and the
detached-spawn fallback is guarded by needsServer so a stale unit alone
cannot start a second server onto a bound port.
- systemdOwned now also requires the unit to be ACTIVE. Installed-but-
stopped owns nothing: skipping the stop there and asking systemd to start
would bind an occupied port and crash-loop under Restart=always.
- /agenthub-doctor gains a systemd row naming the consequence ("will NOT
come back after a reboot") and the fix. WARN, never FAIL — the dashboard
in front of you works, and having no unit at all is a normal setup.
The fast-path test pins the regression against an otherwise fully healthy
install, live server included; without a live server it would have passed
against the unfixed code either way.
20 KiB
Claude Code Working Guide
Last synced: 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 three 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. Cards fetch it themselves every 30s and fail 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
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. - 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. - 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 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).