f514602697
Part A — a fetch/pull/push against a remote that needs a login no longer dies on "terminal prompts disabled": the server answers 409 EGITAUTH with the host, the git bar asks for a username + password/access token and resends the same action. AgentHub keeps its OWN credential store (<CLAUDE_HOME>/agenthub-git-credentials/, 0700/0600), one login per host, shared by every user of the install, managed from Settings -> Git credentials. A login is only sent to one of the repo's own remote hosts, saved only after git accepted it, never returned by any route, and reaches git through an exclusive credential helper (never argv). Part B — merge a branch (or the diverged upstream) from the git bar; a merge that stops on conflicts lists them in the Changes panel with Keep mine / Take theirs / Mark resolved, then Complete merge or Abort merge (confirm gate). Clean tree, repo-root lane and an existing ref only; pull/switch/hunk actions are blocked mid-merge; staging a conflicted file with markers left is refused. Rebase/reset/stash stay out.
92 lines
57 KiB
Markdown
92 lines
57 KiB
Markdown
# Claude Code Working Guide
|
||
|
||
_Last synced: v1.8.6 (2026-09-24) + a dashboard fetch/pull/push that needs a git LOGIN asking for one in the git bar (issue #45: `409 EGITAUTH` with `auth`, resent with `credentials`; AgentHub's own shared store `server/lib/git-credentials.js` + helper `server/lib/git-credential-helper.js`, `/api/git-credentials`, Settings → **Git credentials** = `client/src/pages/GitCredentialsConfig.tsx`) + MERGE and conflict resolution from the dashboard (issue #45 part B: `merge`/`resolve`/`merge-commit`/`merge-abort` in `server/lib/git-ops.js`, `gitFacts.merging`/`conflicts`, the git bar's **Merge into current** and diverged-upstream **Merge**, the Changes panel's merge banner and **Merge conflicts** group) + drag-and-drop of an image onto the lane terminal (`TerminalView.tsx` uploads to `POST /api/run/drop-image` in `server/routes/run.js` — raw body, png/jpeg/gif/webp only, random name under `<tmpdir>/agenthub-drops-<uid>/`, never a name from the request — then `term.paste()`s the returned path, which is how `claude` attaches it) + Claude Opus 5.5 getting its own pricing row (`claude-opus-5-5%` at 4/20/0.2/5/8, fast 8/40 — `claude-opus-5%` had been pricing it as Opus 5) + AUTOMATIC pricing (`server/lib/pricing-sync.js`, started in `startBackgroundServices`: a model family used in `token_usage` with no `<family>%` row of its own is INSERTED from LiteLLM's public price list, never updated, off with `AGENTHUB_PRICING_SYNC=0`) + the Changes panel at **Full screen** moving the whole Files view (finder + expand-all + tree, one `browsePane`) into a left SIDEBAR and dropping the Files tab and in-place editing (`full` prop on `LaneChangesPanel` from `Workspace.tsx`; a whole file opens straight into the editor, Save/Cancel enabled only when dirty, Cancel reverts) and height-locking the page (`viewportLocked` also true at full, else the panel shrank to content with no live run) + the overview splitting into **Staged Changes** / **Changes** groups in the `work` range + a changed tree row tinted whole (`treeMark` tone carries a `bg-status-*/10`) + the Files tree listing untracked-not-ignored files and carrying each entry's git status (`treeStatus`/`treeEntries`/`TREE_LS` in `server/lib/git-diff.js`: file `status` = porcelain code, folder `changed: true`, from ONE `laneStatus` per tree read; `treeMark` in `LaneChangesPanel.tsx` paints M/U/A/D/R) + split panes on ONE lane (every Workspace pane shows the strip's selected lane, each in its own tmux session: `slot` 0–3 on `POST /api/lanes/:id/start`, `agenthub-lane-<id>` for slot 0 = `run_id`, `agenthub-lane-<id>-<slot>` for 1–3 — `runIdForLane(id, slot)`/`MAX_PANE_SLOT` in `server/lib/pty-run.js`, the `-[1-3]` suffix accepted by `pty-attach.js` and `websocket.js`; `stop`/`reset`/`remove` kill every slot via `laneRunIds`; a pane's Active runs hides what the other panes show; no per-pane lane picker) + the server suite running on a PRIVATE tmux server (`npm run test:server` = `scripts/test-server.js`: drops `TMUX`, sets `TMUX_TMPDIR` to a temp dir, kills that server after — suites spawn and kill real `agenthub-lane-<id>` sessions with ids from their own DB, and on the default server that killed the user's real lane panes, including the one running the tests) + the whole-file EDITOR (`PUT /api/lanes/:id/git/file`, `server/lib/lane-file-write.js`: the read path's guards plus existing-text-file-only and a `baseSha` stale check → `409 ESTALE`; `readFileAt` returns that `sha`) + the per-hunk controls moving into an editor-style review bar under each hunk's last changed line (`HunkBar` in `LaneDiffView.tsx`: ^/v, `n of N`, Undo, Keep) + **Unstage all** on the ONE commit/Stage-all row + Changes sizes Narrow (30%) / Medium (50/50) / Full screen, no 70/30 + the lane card's needs-you banner gone (the strip tile keeps it) + per-HUNK Stage/Undo in the Changes panel's Review and single-file views (`stage-hunk`/`discard-hunk` in `server/lib/git-ops.js`: the hunk is named by its `@@` line and re-matched against a FRESH `git diff -U<context> HEAD` by `hunkPatch` in `git-diff.js`, then `git apply --cached` / `git apply -R` from a temp file — `409 EHUNKGONE` when it moved, `400 EUNTRACKED` on an untracked file; `discard-hunk` is the SECOND irreversible git action and carries the same `confirm: true` gate and two-click UI; the controls reach every `HunkHeader` through a context in `LaneDiffView.tsx`), each Review file header sticky with its own stage/unstage/discard (`FileActions`, shared with the overview row) + the Changes panel gaining a FULL-width step (`CHANGES_SIZES` in `Workspace.tsx`: narrow → 50/50 → 70/30 → full → closed; at full the `lane-detail` section gets `hidden` — CSS, so the one console render site stays mounted), **Stage all** plus the staged-only commit box ABOVE the overview list and sticky (as is the Files finder), and **Expand all folders** being ONE `GET /api/lanes/:id/git/tree-all` (`listTreeAll` in `server/lib/git-diff.js`: one `ls-files`, every level shaped like `listTree`'s) instead of a request — and a `git ls-files` — per folder + 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 `broadcastLane`s 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.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, the same idea for `codex` lanes: 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 in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` + the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
|
||
- `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:<name>` (bare `/<name>` works as an alias). Put a skill here only if it should reach plugin users; `agenthub skills install` remains a fallback that copies `ship-feature-lane` into `~/.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 as `agenthub:<name>`. This is also the source `agenthub lanes agents install` copies the two pipeline agents from into a lane's `.claude/agents/`, which is what keeps the bare `subagent_type: qc-local` working there. One file per agent, never a second copy.
|
||
- `.claude-plugin/`: the marketplace plus the root `agenthub` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/agenthub/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agenthub/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
|
||
- **Reporting a required plugin is derived; installing one is allowlisted.** `server/lib/skill-plugins.js` reads the `/plugin:skill` strings out of the loaded pipeline templates — right for the `/agenthub-doctor` row, wrong for an install: a template is a file anyone can drop into `DASHBOARD_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 what `AUTO_INSTALLABLE` (a constant in that file, `superpowers` today) lists, in its DETACHED worker, never in the 10 s hook, recorded once as `state.skillPluginsSettled` (also gating `fastPathOk`), never throwing, and off under `AGENTHUB_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 from `claude` CLI).
|
||
- A lane's stage is **declared** by the driving Claude session (via `agenthub stage <stage> [--evidence "..."]`), not inferred by the dashboard.
|
||
- **A CLI command resolves its lane by (cwd, tool), never cwd alone.** `resolveLaneArg` (`bin/agenthub.js`) answers in order: `--lane`/leading id → `AGENTHUB_LANE_ID` (every tmux pane the dashboard starts carries it, via `tmux new-session -e`) → `--tool`, else the harness marker (`CLAUDECODE` ⇒ claude; Codex has NO reliable env marker, so it passes `--tool codex`) → the owning lane. Two candidates at one directory and no signal is an ERROR that names both, never a pick — writing a codex session's stage onto the claude lane's card is a lie about which agent did the work.
|
||
- **The console never writes a lane's stage.** Declared stages come from `agenthub stage` (a skill calling the CLI command), and inferred stages come from tool-event detection only. **One exception, `codex` lanes only:** `codex-ingest.js` advances `stage` from its own written detection (`setStage(..., { keepDetection: true })`) — a codex session has no hook and rarely calls `agenthub stage`, so nothing else would ever move its map. It passes NO evidence, so the node renders `passed-no-evidence` (amber) and never `done`, and `keepDetection` preserves the detection columns so the card still shows the command that moved it. Do not extend this to `claude` lanes or to the console. The UI's run console and the Workspace page never call `POST /api/lanes/:id/stage` — that boundary exists because the console observes tool calls but cannot see their outcomes. A lane's stage is the session's truth about progress; the console's observation of tool execution would not be proof of completion.
|
||
- **Going back is a declaration, and it takes the nodes ahead with it.** A declaration landing on a node BEFORE the lane's current one (a fix-loop re-entry, or a new request on a lane that already reached `done`) clears the detection columns like every real transition AND drops the `stages` records of every node ahead of it — those belong to the previous pass, and left in place they render `done` with the old work's evidence beside a `current` ring further back, contradicting `progress` (computed from the declared index alone). Records under a key matching no node are left alone: unplaceable, so unprovably ahead. `agenthub feature activate <slug>` is the other half of this — a feature switch archives the outgoing pipeline and clears detection too, because a detection belongs to the feature that produced it.
|
||
- **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. Declared-wins has exactly ONE exception: a declaration on the pipeline's LAST node (`done`) stops vetoing, because the pipeline is over there and a later detection is new work — without it a lane left at `done` rejects every detection for the rest of its life.
|
||
- **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git five times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. The Changes panel fetches it itself (`useLaneGitFacts`, every 30s and after each git write) and fails silently.
|
||
- **The Workspace console collapses with CSS, never by unmounting — and it has exactly ONE render site.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history. The same rule binds one level up: `ConsoleArea` lives inside the always-rendered `lane-detail` section in `client/src/pages/Workspace.tsx`, never inside a `{currentLane && …}` branch. A second render site makes React unmount the whole console every time `currentLane` flips — lanes arriving on mount, the selected lane removed, a refetch re-picking the selection — which kills the terminal and lets `LaneConsolePane`'s lane-switch effect wipe a half-typed prompt. Header and info block are the conditional parts; the console is not.
|
||
- **AgentHub does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`agenthub lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them.
|
||
- **The runtime never writes `stage`, `status` or `notes`** — only `slot` and `ports`. `status=running` means an AGENT is working, not that a server is listening; merging the two would corrupt `classifyLiveness`. Boot failures live in `LANES_ROOT/.state/lane<slot>/last-error.json` and surface via `GET /api/lanes/:id/runtime`. Same boundary as the console-never-writes-stage rule above.
|
||
- **A lane's stack is up or down as a computed fact, never a stored one.** `runtimeFacts` re-derives it from pid files and port probes on every read. A process dies to OOM, a stray `kill`, a reboot — caching a truth AgentHub does not control buys ghost state. Slot allocation is the opposite (fully controlled, must be race-free), so that one does live in the DB, under `withLaneLock` plus a partial unique index.
|
||
- **A profile's `profile.env` is parsed, never sourced.** Hooks are executed deliberately; config is only read. Sourcing arbitrary shell from a user's repository into the dashboard process would be a code-execution path. Hook names always come from the fixed allowlist in `server/lib/lane-profile.js`, never from a request.
|
||
- Pipeline templates are JSON files (`server/data/pipelines/`) with node definitions; custom templates override built-ins when `DASHBOARD_PIPELINES_DIR` is 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 after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead.
|
||
- **Never `rm -rf` a lane.** Destructive lane operations (`reset`, `remove`, `purge`) go through `server/lib/worktree.js`/`server/lib/lanes.js`, never a raw filesystem delete — that's what keeps the three-check destroy guard and the DB bookkeeping in lockstep.
|
||
- **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell.
|
||
- **A lane's AI Router profile is a lane fact too, and it never travels alone.** `lanes.router_profile` (nullable; null means the same as `native`) is written ONLY by `setRouterProfile`, called beside the disk write in `POST /api/lanes/:id/router/apply` — never through `PATCHABLE`. The column and the lane's own config must move together, or the row claims a router the lane's `settings.local.json` has never heard of. What is written comes from the lane row, never the request: a **claude** lane gets eight `env` keys merged into `<cwd>/.claude/settings.local.json` (`.local` because 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 layer `codex -p <name>` loads — because Codex has NO project-level config, and its base `config.toml` is never touched. Key material never leaves the server: profiles live at `<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers` reports only `hasKey`, and there is deliberately no route that returns one profile in full. **`POST /api/ai-routers/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`. 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 calls `POST /api/lanes/:id/router/apply` (column + disk together, same rule as above), never `PATCH /api/lanes/:id`. **Codex's key reaches the pane through tmux's `-e` argv and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there. **And attaching COPIES a profile**, so the store stops being the single source of truth the moment a lane exists: `PATCH`/`DELETE /api/ai-routers/:name` must push through to the lanes naming it (`reapplyToLanes`/`detachFromLanes`), or a rotated key silently never reaches the lane still using the revoked one. See `server/lib/ai-routers.js`, `server/lib/router-apply.js`.
|
||
- **The dashboard's git writes are these sixteen, and the guards are IN `git-ops.js`, not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `merge`, `resolve`, `merge-commit`, `merge-abort` — nothing else. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk` and `merge-abort` are the irreversible actions and all refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches (the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is the one history-joining action, and only because its recovery is a button too** (`merge-abort`): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). Do not add rebase, reset or stash here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and `reset` already lives behind `worktree.js`'s three-check destroy guard. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/ls`, `/git/tree`, `/git/branches`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet.
|
||
- **AgentHub's git credentials are its OWN store, shared by every user of the install, and git is given ONLY that store when it holds a login for the host.** `server/lib/git-credentials.js` keeps one record per `protocol + host` under `<CLAUDE_HOME>/agenthub-git-credentials/` (0700/0600) and no route ever returns a password (`hasToken` only). `gitConfigFor` runs a network action with `-c credential.helper= -c credential.helper=<ours>` whenever AgentHub holds a login for a repo remote's host or the request carries one — the machine's helpers are left out on purpose: a stale token there would win over the typed one, and git's post-success `store` would copy a use-once token into the OS user's helper. The secret travels in the helper's env or the store file, never argv. A typed credential is accepted only for one of the repo's OWN remote hosts (`EBADHOST`), so a login is never sent to a server it was not typed for, and it is saved only after git SUCCEEDED and `remember` is set. Do not add a route that returns a stored password or a path that forwards one to a host other than its own.
|
||
- **A lane's `tool` is a lane fact, immutable, and never read from a request.** `lanes.tool` (`claude` | `codex`, default `claude`) is set by `createLane` only — it is deliberately absent from `PATCHABLE`, 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 in `server/lib/pty-run.js` before 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 reaches `which`.
|
||
- **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/lanes` and `agenthub lanes` stay in id order, so no `sort_order` column, no reorder route, no websocket message. Renaming is the opposite: it is `PATCH /api/lanes/:id` with `{title}`, the one lane column a user now types into directly, so `updateLane` normalizes it (`normalizeTitle`: trim, 200-char cap, `null` ⇒ `""`, because clearing is legitimate — every render is `lane.title || lane.cwd`). The rule is the column-wide one, not a special case for the field the UI exposes: `bindable` rejects any non-scalar patch value with `EBADFIELD` (`links` excepted — it is stored as JSON), and the route answers 400 for that AND for the `SQLITE_CONSTRAINT` family, rather than handing better-sqlite3 a datatype throw and reporting 500 on plain bad input.
|
||
- **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it.
|
||
- **Removing a lane deletes the lane's RECORD and nothing else — for every kind, managed included.** `POST /api/lanes/:id/remove` stops the run and the stack, clears AgentHub's own runtime bookkeeping under `LANES_ROOT/.state/lane<slot>/`, and deletes the row. It calls neither `removeWorktree` nor `removeLaneData`: the working tree, its branch and the lane's database are the USER's data, and a mis-click on a dashboard row may never cost a branch's unpushed code. Deleting those is the user's own `git worktree remove` / `git branch -d` / db drop. Consequently `remove` has no unpushed-commits force-gate (only `reset` does) and no preflight blocker stops it. Do not add a "also delete the worktree" flag back onto this route.
|
||
- **A lane can hold up to four tmux sessions, one per Workspace split pane — and only slot 0 is the lane's run.** `agenthub-lane-<id>` is slot 0 and the only one `run_id`/`status` ever record; `agenthub-lane-<id>-<1-3>` are extra panes on the same lane. Anything that must stop a lane's agents (`stop`, `reset`, `remove`) goes through `laneRunIds` so no slot survives it; anything that names "the lane's pane" (router restart, codex nudge, `agenthub lanes shell`) means slot 0.
|
||
- **The whole-file editor is the one non-git write on the lane file surface, and it lives outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at. Do not let it create files or touch a binary.
|
||
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions.
|
||
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`, `resolveLaneForWork`) always pins it to `"claude"` because Codex has no hook mechanism in this codebase, so a hook event must never touch a `codex` lane 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.service` unit of ours owns the server, nothing else may stop it.** The bootstrap that performs a self-update is spawned by the running server, so it lives in that server's systemd cgroup: SIGTERM-ing the server from there exits 0 (which `Restart=on-failure` read as "meant to stop") AND trips `KillMode=control-group`, reaping the bootstrap before it can repoint the unit or write `state.json` — a dashboard dead after every update until some later session bootstrapped again. So `scripts/systemd-service.js` splits the API: `writeUserService` repoints the unit and starts NOTHING, `restartUserService` uses `--no-block`, and `plugin-bootstrap.js` defers that restart past `writeState` and `releaseLock` (`pendingSystemdRestart`). The unit is `Restart=always`. Do not add a path that stops the dashboard from inside its own cgroup, and do not move the restart earlier. `agenthub service install` may keep using `installUserService` — it runs from a user's shell, outside the cgroup. `systemdOwned` additionally requires the unit to be ACTIVE (an installed-but-stopped unit owns nothing; treating it as owner skips the stop and then binds an occupied port). A unit naming a RETIRED plugin root is invisible until a reboot, so `fastPathOk` rejects it via `unitIsStale` (pure fs — that path may never spawn a process) and the bootstrap rewrites it; repairing the unit is NOT restarting the server (`pendingSystemdRestart = systemdOwned && needsServer`), and the detached-spawn fallback is guarded by `needsServer` so a stale unit alone never starts a second server onto a bound port.
|
||
- **A bootstrap that is no longer the installed build does NOTHING, and `--force` does not override that.** `supersededBy` (`scripts/plugin-bootstrap.js`) reads Claude Code's `installed_plugins.json`: if a SIBLING cache directory is the recorded install, this build logs one line and returns `superseded` — no deps, no unit, no server. A Claude Code process that was not restarted after an update keeps loading the OLD cache dir, and without this its every session repointed `agenthub.service` at that old dir and restarted the dashboard onto the version the user just upgraded away from, once per session, forever. It may not hand over to the new build either — the process it would replace is the one loading it. Do not add a path that lets a retired root write the unit, stop the server, or "just" refresh deps.
|
||
- **Deleting retired cache copies is the ONE thing here that touches the plugin cache, and four protections gate it.** `pruneRetiredPluginRoots` never deletes: this build; any `installPath` in `installed_plugins.json`; the dir `agenthub.service` names (`unitRoot`, foreign unit included); any dir named in the user's `settings.json` hooks (a un-restarted Claude Code is still executing from there — deleting it breaks that session's hooks). Plus the newest survivor as a rollback cushion (`KEEP_RETIRED_ROOTS`, 1 — a full copy of this repo is ~130 MB, so the cushion is one build deep, not two), and nothing at all outside `~/.claude/plugins/cache/`. It runs AFTER `writeState`, so a bad prune cannot also cost the record of a good bootstrap. Adding a fifth thing that pins a cache dir means adding a fifth protection here.
|
||
- **A session reload is typed into a live pane, never spawned.** `/reload-plugins` and `/reload-skills` are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from the outside. `server/lib/cc-reload.js` therefore types them into the dashboard's existing tmux lane panes and spawns NO `claude` process — a one-shot `claude -p "/reload-plugins"` reloads only its own short-lived session, exits, and leaves a junk session in the dashboard's own history. Do not add a path that starts a session in order to reload one.
|
||
- Preserve existing behavior unless explicitly asked to change it.
|
||
- Prefer minimal, reversible diffs.
|
||
- Never silently weaken safety controls around destructive actions.
|
||
- Keep docs updated when behavior, commands, file locations, or workflows change — apply the `update-project-docs` skill automatically at the end of every change-set that alters behavior, config, interfaces, events, schema, CLI commands, or features (do not wait to be asked).
|
||
- Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the copyright/authorship header — file overview + the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. Formats and audit script: `skills/file-headers/` (verify with `bash skills/file-headers/scripts/check-headers.sh`). This binds every coding agent (Claude Code, Codex, or others).
|
||
|
||
## Commands you should know
|
||
- Setup: `npm run setup`
|
||
- Dev: `npm run dev`
|
||
- Prod build/start: `npm run build` then `npm start`
|
||
- Server tests: `npm run test:server`
|
||
- Client tests: `npm run test:client`
|
||
- CLI (after setup): `agenthub <command>` — terminal access to the full dashboard surface (`bin/agenthub.js`; `agenthub help` lists commands)
|
||
|
||
## Testing and verification policy
|
||
- Backend changes: run `npm run test:server` before finishing.
|
||
- Frontend changes: run `npm run test:client` when relevant. This includes per-screen render snapshots (`client/src/pages/__tests__/screens.snapshot.test.tsx`). If a UI change is intentional, review the snapshot diff and regenerate baselines with `cd client && npx vitest run -u`; never blindly update snapshots to make tests pass.
|
||
- If you cannot run a verification step, state exactly what was not run and why.
|
||
|
||
## Change guidelines by area
|
||
- API routes: preserve response shapes unless change is requested and documented.
|
||
- Database: avoid schema changes without migration-safe logic.
|
||
- Hooks: keep fail-safe and non-blocking behavior.
|
||
- WebSocket: keep message types stable and backward-compatible.
|
||
- Documentation: include exact commands and paths; keep markdown examples runnable.
|
||
|
||
## Agent behavior
|
||
- Explore first, then implement.
|
||
- For larger tasks, propose/check a short plan before broad edits.
|
||
- Use file-specific rules in `.claude/rules/` when working in scoped areas.
|
||
- Use project skills from `skills/` for repeatable workflows (they ship with the `agenthub` plugin: `/agenthub:<name>`, or the bare `/<name>` alias).
|
||
- 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` (see `docs/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 blue `current` ring — a lane worked entirely through plain chat with no `agenthub stage` calls will show a stale `current` stage no matter how much real work happens. Skip it only when `agenthub stage` reports no lane owns the cwd (not adopted).
|
||
- **Prefer `agenthub:ship-feature`(`-lane`) over invoking `superpowers:brainstorming`/`writing-plans`/`executing-plans`/`subagent-driven-development`/`test-driven-development` directly for implementation work inside a lane's cwd.** Those Superpowers skills know nothing about AgentHub lanes and never call `agenthub stage` themselves — going straight to them (skipping the AgentHub wrapper) is exactly how a lane's `current` stage goes stale mid-implementation while `agenthub stage` is never called for real transitions. `agenthub:ship-feature`/`ship-feature-lane` drive the same underlying Superpowers skills phase-by-phase and declare `agenthub stage` at 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).
|