Compare commits

..

12 Commits

Author SHA1 Message Date
nntrivi2001 59b3ba9e39 chore(release): bump version to 1.6.14 2026-09-18 10:40:24 +07:00
nntrivi2001 eab7c437df feat(skills,pipelines): make the review node review, and make a long run followable and affordable
Closes #24.

**The review node ran nothing.** Both templates declared
`/superpowers:requesting-code-review` — the skill about how to HAND OVER a
review, not one that reviews anything — so `agenthub lanes node review`
performed no review at all. They now declare the bare `/code-review`, Claude
Code's own. It carries no `plugin:` prefix, so `requiredSkillPlugins` skips it
and the doctor's required-plugin row is unchanged (verified against the live
function: still exactly the five superpowers skills the templates name).

**The rest is the brainstorm → implement → review span, made cheaper to run and
harder to lose track of.** `ship-feature` gains four sections it never had:

- **Right-size the run.** The pipeline is not free — every dispatching phase
  costs a model call and minutes, and the fix-loop pays it again each lap. A
  typo does not enter the pipeline at all; a small single-layer change skips the
  plan-debate, independent-test and gate agents and lets the user's acceptance
  be the gate; medium/large runs everything. Sizing is a claim made out loud,
  and a task that grows re-sizes upward.
- **Dispatching a judgement.** The fresh-context/FOREGROUND/never-`fork`/
  hand-only-verifiable-artifacts/one-parseable-line contract, written once
  instead of re-argued in three phases — plus the rule that was missing
  entirely: never dispatch what a command answers. An agent is for judgement,
  not for typing.
- **Resuming after a compaction.** Long runs outlive the context window; the
  lane skill had this and this one did not. Re-derive position from the declared
  stage and the diff, and treat a phase whose evidence you cannot find as one
  that did not happen.
- **A `Leave when:` line on every phase.** Objective exit criteria are what stop
  both the silent skip and the endless polish: intake leaves when the answers
  are in hand, review leaves when the latest pass over the CURRENT diff is
  clean, the gate leaves when verification really ran AND the user said yes.

And, narrower: intake asks only what the repo cannot answer (a question the code
could have answered spends the user's round and earns a worse answer);
implement works the approved plan task by task, re-checks each acceptance
criterion, heartbeats on long work, and treats a plan that turns out wrong as a
return to `plan` rather than a quiet substitution; tests runs the suite the diff
needs rather than both by reflex.
2026-09-18 10:39:01 +07:00
nntrivi2001 e217314248 chore(release): bump version to 1.6.13 2026-09-18 10:22:49 +07:00
nntrivi2001 26b0fe53c7 feat(skills,pipelines): ask the user at the two points being wrong is cheap, and stop paying for reviews the diff does not need
The `default` 8-node pipeline walked past its own intake: the node declared
`agenthub stage intake`, a stage report with no work attached, so "intake" was
a label the run passed through without ever asking a question. It now declares
`/superpowers:brainstorming` (and detects it — that rule moves off `plan`),
and both driving skills make the brainstorm mandatory, asked with
AskUserQuestion and waited on rather than answered on the user's behalf.

Three stops replace three silent slides:

- After the plan is written and adversarially challenged, it goes to the USER
  for approval. Implementation does not start without it, and work that turns
  out to need a different approach comes back for a fresh one.
- Review picks its reviewers from `git diff --name-only`: Claude Code's own
  `code-review` always, a domain reviewer only for a layer the diff actually
  touched, and — new — only once the change is big enough to have seams. Three
  agents re-reading twenty lines buys one opinion at three times the tokens, on
  every fix-loop lap. Review is also a loop now: it ends on a clean pass over
  the current diff, not on one pass having happened.
- The gate asks the user to accept before anything is claimed done (and in the
  lane pipeline, before the PR is published — publishing is outward-facing).
  A NO-GO re-enters by SIZE: a defect goes back to implement, a wrong approach
  back to plan. Nothing carries a NO-GO forward with a note about it.

Also here, from a real stall seen on a lane: `ship-feature` had no waiting rule
at all, and a session at the gate invented one with `ScheduleWakeup` — a
/loop-only primitive that paces nothing here and is refused outright when
`auto` mode cannot reach its safety classifier. The rule the lane skill already
had now exists in both.

Cost, measured in what each agent is actually for: the suite run leaves its
agent (nothing is judged, so nothing is delegated — an agent there buys a
second model's tokens to read output the session can read for free), and
`backend-reviewer` drops from opus to sonnet like its frontend twin; both read
a scoped diff against a fixed checklist, and the senior gate above them is
still opus.

Skill bugs found in the same sweep: doc-map told agents not to recreate the
removed translations and then, three lines later, to mirror every README change
into them; the docs mapping still carried `+VN+CN` and the punctuation left by
their removal; Procedure jumped 2 → 6; Verify required `scripts/doc-coverage.sh`,
which does not exist in this repo.
2026-09-18 10:21:03 +07:00
nntrivi2001 4227a97ab1 chore(release): bump version to 1.6.12 2026-09-18 09:45:50 +07:00
nntrivi2001 0004cf7036 fix(lanes): removing a lane deletes its record only, never the worktree
A lane's `remove` tore down what the lane pointed at: `git worktree remove
--force`, a branch delete, and a drop of the lane's own database. One
mis-click on a dashboard row cost a whole branch's unpushed code — and the
row is the only thing AgentHub actually owns. `remove` now stops the run and
the stack, clears AgentHub's own runtime bookkeeping under
`LANES_ROOT/.state/lane<slot>/`, and deletes the row. The working tree, its
branch, git's worktree records and the database stay exactly where they are,
for the user to delete by hand when they mean to.

That also fixes #28. Removal used to route through `assertDestroyable`, whose
third check demands the cwd appear in `git worktree list`; a managed lane
whose directory git no longer listed answered `ENOTWORKTREE` ("lane is not
listed as a worktree in …") and could never be dropped from the dashboard at
all. Removal no longer asks git anything, so no preflight blocker stops it,
and the unpushed-commits force-gate now applies to `reset` alone — nothing
`remove` does can discard a commit.

The UI follows: one label for both lane kinds ("Delete lane record"), copy
that says what survives, and no Force checkbox on removal.
`removeWorktree`/`removeLaneData` remain in place as guarded primitives; no
route calls them any more.
2026-09-18 09:44:17 +07:00
nntrivi2001 75a2de60b2 chore(release): bump version to 1.6.11 2026-09-17 23:20:19 +07:00
nntrivi2001 1aee080fca fix(cc-config): read the live account's quota from the CLI, and ask for every account at once
The quota check failed with `invalid_grant` for the account the user was
logged into - the one profile they would try first. An OAuth refresh token is
single use, so every refresh Claude Code performs on its own account retires
the copy sitting in that account's saved profile: the stored credentials of
the LIVE account are the one set guaranteed to be rejected, however recently
the profile was saved. Confirmed on a real install - the saved profile held a
refresh token three rotations behind `.credentials.json`.

`cc-profiles.liveCredentialsFor` now matches a profile's accountUuid against
the live credentials and, on a hit, the usage call uses what the CLI is
holding. That token is never refreshed and that file is never written:
rotating it would log the user out of their own terminal. An expired live
token answers ELIVEEXPIRED - using Claude Code once makes the CLI refresh it -
and a genuinely spent refresh token is now reported as ESTALE with what to do
about it, instead of echoing Anthropic's raw 400.

Opening the Profiles modal also asks for every saved account at once, rather
than one button per row: comparing accounts is the only reason the panel
exists. The guard against rewriting credentials over and over is no longer
"one row at a time" but the rules above - the live account costs a plain read,
and any other profile refreshes only once its token has actually expired,
which then leaves it valid for hours of further opens. Each row resolves on
its own, so a superseded account still lets its neighbours report.
2026-09-17 23:17:06 +07:00
nntrivi2001 470f9909b4 chore(release): bump version to 1.6.10 2026-09-17 22:22:15 +07:00
nntrivi2001 5ba9e07d78 feat(cc-config,lanes): per-profile quota, and three fixes that made a write look lost
Closes #19, #20, #21, #27.

#19 — a saved auth profile now reports how much of its account's quota is
already spent, which is the only number that decides which one is worth
switching to. Nothing under CLAUDE_HOME records it, so it comes from the same
endpoint the CLI's own /usage reads, answered for whichever account's OAuth
token is presented. It is a POST and a button, never a poll: every profile that
is not the active one is normally expired, so answering means refreshing it
first, and a refresh ROTATES the stored refresh token. The rotated pair is
written straight back — dropping it would leave the profile holding a
credential Anthropic has already retired, i.e. an account needing a fresh
login. The token is read, put in an Authorization header, and never returned,
logged or echoed; what leaves the server is two percentages and two reset
times.

#20 — a settings patch that changes nothing wrote nothing but still took a
timestamped backup, so opening the config panel quietly grew a backup
directory inside every lane. patchSettings now compares the rendered output
with what is already on disk and returns early, fixed once where all callers
route through. A lane's git exclude also picks up .claude/.cc-config-backups/
beside settings.local.json.

#21 — the router apply route was the only mutating lane route that never
broadcast its lane. The write reached disk and the column, but the client's
cached row kept the old router_profile forever: the picker looked right until
the user switched lanes and back, at which point the effect re-read the stale
row and flipped the selection to the previous profile. One broadcastLane, plus
a lane-switch guard so an apply that resolves after the user has moved on no
longer paints its result onto whichever lane is now on screen.

#27 — Add-lane in worktree mode left the dialog up after a clean setup with a
dead confirm button and no obvious way out. It closes itself when nothing
failed, and when something did, the summary stays and the confirm becomes a
real Close.
2026-09-17 22:20:32 +07:00
nntrivi2001 b0e8368c1d chore(release): bump version to 1.6.9 2026-09-17 16:16:39 +07:00
nntrivi2001 686b80bd99 fix(lanes,reload): count a pipeline run by declarations, and stop the two reload commands merging
Closes #26, #25.

`runs` is the denominator every pass rate on the Workflows page is read
against, and any backwards move opened a new one. Stage detection bounces by
design — one feature reads `implement → tests → implement` as the session
edits, runs the suite and edits again — so a single feature was recorded as
five or six runs, and a lane in constant use reported a handful of passes
spread across dozens of phantom runs. A backwards move now opens a run only
when it is DECLARED; a backwards detection continues the run in flight.
Reaching the final node still closes the run for either, since work after
`done` is new work however it was observed. Replayed over this repo's own lane
the rule takes 26 recorded runs down to the 5 features that actually shipped
through it. The panel itself is unchanged: passes are still declarations only.

`/reload-plugins` and `/reload-skills` were typed back to back. Claude Code
opens its slash-command menu the moment `/` is typed and the Enter that follows
lands on that menu, so the second command was appended to the line still in the
composer and the pane submitted `/reload-plugins/reload-skills` — which Claude
Code runs as `/reload-plugins` with an argument, never reloading skills, and
leaving a stray prompt turn in the scrollback. Reproduced and fixed against a
live pane: SETTLE_MS (1s) between the two sends, which makes
`reloadOpenSessions` async rather than block the event loop, and the same pause
in the detached update-all worker.
2026-09-17 16:16:31 +07:00
55 changed files with 2031 additions and 264 deletions
+2 -1
View File
@@ -346,8 +346,9 @@ graph TD
| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` | | `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `<memory-dir>/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `<CLAUDE_HOME>/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` |
| `lib/cc-reload.js` | Types `/reload-plugins` / `/reload-skills` into the Claude Code sessions the dashboard already has open, over `tmux send-keys` (reusing `tmux.js`'s `sendText`). Both slash commands are scoped BY CLAUDE CODE to the session that runs them, so a session cannot be reloaded from the outside and no background process can do it on a session's behalf — typing into a live pane is the only real mechanism. Deliberately spawns NO `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history, so this module creates no sessions at all. Skips panes sitting at a shell prompt (the text would run as a shell command) and addresses `claude` lanes only, since Codex has no such command. Sends to a busy pane on purpose — tmux queues the keystrokes into the REPL's input buffer, so a late reload still lands, where skipping busy panes would leave exactly the sessions in active use running stale code. The command set is a fixed constant, never caller-supplied: a pane's stdin is an arbitrary-code boundary. Never throws | | `lib/cc-reload.js` | Types `/reload-plugins` / `/reload-skills` into the Claude Code sessions the dashboard already has open, over `tmux send-keys` (reusing `tmux.js`'s `sendText`). Both slash commands are scoped BY CLAUDE CODE to the session that runs them, so a session cannot be reloaded from the outside and no background process can do it on a session's behalf — typing into a live pane is the only real mechanism. Deliberately spawns NO `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history, so this module creates no sessions at all. Skips panes sitting at a shell prompt (the text would run as a shell command) and addresses `claude` lanes only, since Codex has no such command. Sends to a busy pane on purpose — tmux queues the keystrokes into the REPL's input buffer, so a late reload still lands, where skipping busy panes would leave exactly the sessions in active use running stale code. The command set is a fixed constant, never caller-supplied: a pane's stdin is an arbitrary-code boundary. Never throws |
| `lib/cc-update-all.js` | Starts and reports on the "Update all" batch, whose work lives in the detached `scripts/cc-update-all-helper.js`. Detached for a structural reason: the batch ends with the plugin THIS dashboard runs from, and that step's `plugin-bootstrap.js --force` SIGTERMs this very server, so an in-process loop would die with the remaining plugins unrun and nothing left to report. Order is marketplaces → every other plugin → AgentHub last → one reload. The worker journals every step transition to `cc-update-all.json` under the runtime dir — outside the plugin cache the update replaces — so whichever server is alive can serve progress from it; the POST's response is only an acknowledgement. `readUpdateAll()` DERIVES an `abandoned` status for a `running` journal older than `STALE_MS` (1 h), because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would otherwise stay wedged forever. `isRunning()` gates a concurrent batch, since two `claude plugin update` chains over one config dir race | | `lib/cc-update-all.js` | Starts and reports on the "Update all" batch, whose work lives in the detached `scripts/cc-update-all-helper.js`. Detached for a structural reason: the batch ends with the plugin THIS dashboard runs from, and that step's `plugin-bootstrap.js --force` SIGTERMs this very server, so an in-process loop would die with the remaining plugins unrun and nothing left to report. Order is marketplaces → every other plugin → AgentHub last → one reload. The worker journals every step transition to `cc-update-all.json` under the runtime dir — outside the plugin cache the update replaces — so whichever server is alive can serve progress from it; the POST's response is only an acknowledgement. `readUpdateAll()` DERIVES an `abandoned` status for a `running` journal older than `STALE_MS` (1 h), because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would otherwise stay wedged forever. `isRunning()` gates a concurrent batch, since two `claude plugin update` chains over one config dir race |
| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`, plus the install-wide update surface: `GET/POST /plugins/update-all` (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and `POST /reload` (types `/reload-plugins`+`/reload-skills` into the live lane panes; see `cc-reload.js`). After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses | | `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/PATCH/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`, plus `POST /profiles/usage` (a POST because answering it refreshes and rewrites that profile's stored OAuth tokens — see `cc-usage.js`), plus the install-wide update surface: `GET/POST /plugins/update-all` (a DETACHED batch — every marketplace, then every plugin, then a reload, with THIS dashboard's own plugin last because its bootstrap restarts the server; progress is polled from an on-disk journal, never from the POST's response) and `POST /reload` (types `/reload-plugins`+`/reload-skills` into the live lane panes; see `cc-reload.js`). After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses |
| `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` | | `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` |
| `lib/cc-usage.js` | How much of an account's quota is spent, for the profile switcher — the number that decides which saved profile is worth switching to. No local source exists (the credentials file has `subscriptionType`/`rateLimitTier` and nothing else), so it asks `GET https://api.anthropic.com/api/oauth/usage`, the same endpoint the CLI's own `/usage` reads, with that profile's OAuth token. Two rules: a profile's token is sent to Anthropic and nowhere else — what leaves the module is two percentages and two reset times, never token material — and **nothing here runs on a poll**. Any profile that is not the active one normally holds an expired access token, so answering means refreshing it, and the refresh ROTATES the stored refresh token; the new pair is written back through `cc-profiles.writeProfile()` because a dropped refresh leaves an account that needs a fresh `claude auth login`. A still-valid token is used as-is and rewrites nothing; a 401 on a token that looked valid is retried once through the refresh |
| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down | | `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down |
| `lib/codex-discovery.js` | Read-only discovery of the Codex CLI's file-backed config surfaces for the Codex Config panel — the Codex counterpart of `cc-discovery.js`. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), agents (`<root>/agents/*.toml`), rules (`<root>/rules/*`), memory (`AGENTS.md` at user `~/.codex` + project root), config (`config.toml` at user + project `.codex/`). No filesystem watcher counterpart to `cc-watcher.js` exists for Codex. `readFileSafe()` re-validates path containment (leaf/ancestor symlink-escape guard) independent of the write side's own check. Codex's scope union is `"user"` \| `"project"` only — it has no `"project-local"` settings layer and no per-project file-based auto-memory store | | `lib/codex-discovery.js` | Read-only discovery of the Codex CLI's file-backed config surfaces for the Codex Config panel — the Codex counterpart of `cc-discovery.js`. Surfaces: skills (`<root>/skills/<name>/SKILL.md`), agents (`<root>/agents/*.toml`), rules (`<root>/rules/*`), memory (`AGENTS.md` at user `~/.codex` + project root), config (`config.toml` at user + project `.codex/`). No filesystem watcher counterpart to `cc-watcher.js` exists for Codex. `readFileSafe()` re-validates path containment (leaf/ancestor symlink-escape guard) independent of the write side's own check. Codex's scope union is `"user"` \| `"project"` only — it has no `"project-local"` settings layer and no per-project file-based auto-memory store |
| `lib/codex-mutate.js` | Create / overwrite / delete for the Codex file-backed artifacts (skills, agents, rules, memory, config) — the Codex counterpart of `cc-mutate.js`. `name` is required for skills/agents/rules and rejected (`EBADNAME`) for memory/config, since each is a single fixed file per scope. Every mutation writes a timestamped backup first (`listBackups()` powers `GET /backups`); `project` scope always resolves against the server's own `cwd`, never a caller-supplied override | | `lib/codex-mutate.js` | Create / overwrite / delete for the Codex file-backed artifacts (skills, agents, rules, memory, config) — the Codex counterpart of `cc-mutate.js`. `name` is required for skills/agents/rules and rejected (`EBADNAME`) for memory/config, since each is a single fixed file per scope. Every mutation writes a timestamped backup first (`listBackups()` powers `GET /backups`); `project` scope always resolves against the server's own `cwd`, never a caller-supplied override |
+3 -2
View File
File diff suppressed because one or more lines are too long
+5 -3
View File
@@ -219,9 +219,11 @@ A lane is a working directory the dashboard watches. Two kinds:
- **adopted** — a directory you already had. The dashboard only reads it; it is - **adopted** — a directory you already had. The dashboard only reads it; it is
never reset or deleted. never reset or deleted.
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It owns - **managed** — a git worktree the dashboard created under `LANES_ROOT`. It may
the full lifecycle and may reset or remove it, behind a three-check destroy reset that worktree, behind a three-check destroy guard and a counted preflight
guard and a counted preflight the caller has to echo back. the caller has to echo back. Removing a lane — either kind — deletes only the
dashboard's record of it: the working tree, its branch and its database stay,
for you to delete yourself.
```bash ```bash
agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt
+1 -1
View File
@@ -2,7 +2,7 @@
name: backend-reviewer name: backend-reviewer
description: Review backend route and hook logic for regressions, data integrity risks, and missing tests. description: Review backend route and hook logic for regressions, data integrity risks, and missing tests.
tools: Read, Grep, Glob, Bash tools: Read, Grep, Glob, Bash
model: opus model: sonnet
memory: project memory: project
--- ---
+10 -5
View File
@@ -1799,9 +1799,14 @@ async function cmdLanesLifecycle(action, args) {
printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields); printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
const keepDb = action === "reset" && args.includes("--keep-db"); const keepDb = action === "reset" && args.includes("--keep-db");
if (preflight.database) { if (preflight.database) {
const fate = action === "remove" ? "dropped" : keepDb ? "kept as-is" : "dropped and recreated"; const fate = action === "remove" ? "kept" : keepDb ? "kept as-is" : "dropped and recreated";
console.log(` database: ${preflight.database} (${fate})`); console.log(` database: ${preflight.database} (${fate})`);
} }
if (action === "remove") {
console.log(
" Only the dashboard's record is dropped — the working tree, its branch and the database are left for you to delete yourself."
);
}
if (Array.isArray(preflight.warnings) && preflight.warnings.length) { if (Array.isArray(preflight.warnings) && preflight.warnings.length) {
console.log("Warnings:"); console.log("Warnings:");
for (const warning of preflight.warnings) console.log(` ${warning}`); for (const warning of preflight.warnings) console.log(` ${warning}`);
@@ -1813,9 +1818,9 @@ async function cmdLanesLifecycle(action, args) {
return; return;
} }
// Only `reset` is impossible for an adopted lane. `remove` IS supported by the // Only `reset` is impossible for an adopted lane. `remove` IS supported by the
// server: it drops the dashboard's record and never touches the directory // server for every lane kind: it drops the dashboard's record and never touches
// (server/routes/lanes.js scopes the worktree teardown to managed lanes), so // the directory, so refusing it here would make a permitted action unreachable
// refusing it here would make a permitted action unreachable from the CLI. // from the CLI.
if ( if (
action === "reset" && action === "reset" &&
Array.isArray(preflight.blocked) && Array.isArray(preflight.blocked) &&
@@ -2936,7 +2941,7 @@ const COMMAND_GROUPS = [
[ [
"lanes reset|remove|purge", "lanes reset|remove|purge",
"<id> [--force] [--keep-db] --yes", "<id> [--force] [--keep-db] --yes",
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only)", "Show preflight facts, then perform a destructive lane action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)",
], ],
[ [
"lanes up|down", "lanes up|down",
+22 -10
View File
@@ -297,7 +297,7 @@ export function AddLaneModal({
api.lanes.mcpSync(result.lane.id), api.lanes.mcpSync(result.lane.id),
applyRouter(result.lane.id), applyRouter(result.lane.id),
]); ]);
setSetupResult({ const outcome = {
profile: profile:
profileOutcome.status === "fulfilled" profileOutcome.status === "fulfilled"
? profileOutcome.value.scaffolded ? profileOutcome.value.scaffolded
@@ -308,7 +308,9 @@ export function AddLaneModal({
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed", mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
router: router:
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok", routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
}); } as const;
const anyFailed = Object.values(outcome).includes("failed");
setSetupResult(outcome);
if (writeDocs && tool === "claude") { if (writeDocs && tool === "claude") {
await startDocsRun(result.lane.id, provisionedLane.cwd); await startDocsRun(result.lane.id, provisionedLane.cwd);
@@ -326,10 +328,17 @@ export function AddLaneModal({
}); });
} }
// Leave the modal open so the setup summary below stays on screen; the // A clean setup has nothing to tell anyone, so the lane is made and the
// user dismisses it themselves (Cancel/X) once they've seen it, rather // dialog is done — leaving it up read as "still working" (the confirm
// than racing a timer that can close before they've looked at it. // button is dead from here, since the lane already exists) with no
// obvious way out. A summary is kept on screen only when a step FAILED,
// and then the confirm button becomes a plain close, because that is the
// one case where the user has something to read before dismissing it.
setBusy(false); setBusy(false);
if (!anyFailed) {
reset();
onClose();
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
setBusy(false); setBusy(false);
@@ -354,21 +363,24 @@ export function AddLaneModal({
onClose(); onClose();
}, [onClose, browseOpen]); }, [onClose, browseOpen]);
// A summary on screen means the lane already exists and a step failed, so
// the confirm button has no second creation to run: it closes instead. That
// is the difference between a dialog that is finished and one that is stuck.
const done = !!setupResult;
const disabled = const disabled =
!!setupResult || !done &&
!sourceRepo.trim() || (!sourceRepo.trim() || (mode === "worktree" && (!title.trim() || !branches || !branch.trim())));
(mode === "worktree" && (!title.trim() || !branches || !branch.trim()));
return ( return (
<ConfirmModal <ConfirmModal
open={open} open={open}
title={t("addLane")} title={t("addLane")}
confirmLabel={t("add")} confirmLabel={done ? t("addLaneSetupClose") : t("add")}
cancelLabel={t("destructive.cancel")} cancelLabel={t("destructive.cancel")}
destructive={false} destructive={false}
busy={busy} busy={busy}
disabled={disabled} disabled={disabled}
onConfirm={submit} onConfirm={done ? handleCancel : submit}
onCancel={handleCancel} onCancel={handleCancel}
> >
<div className="space-y-3"> <div className="space-y-3">
@@ -123,16 +123,15 @@ export function DestructiveLaneModal({
const blocked = blockingReason(preflight); const blocked = blockingReason(preflight);
// Mirrors the server's gate exactly (server/routes/lanes.js): force is required // Mirrors the server's gate exactly (server/routes/lanes.js): force is required
// for `reset`, and for `remove` only when a real worktree is at risk. Forgetting // for `reset` alone. `remove` forgets the lane's row and deletes nothing on
// an adopted lane risks nothing on disk, so it needs no force even with unpushed // disk — no worktree, no branch, no database — so there is no unpushed work it
// commits. Never offered alongside a hard blocker, where confirming is // could discard. Never offered alongside a hard blocker, where confirming is
// impossible anyway. // impossible anyway.
const requiresForce = const requiresForce =
!blocked && !blocked &&
preflight !== null && preflight !== null &&
preflight.action !== "purge" && preflight.action === "reset" &&
preflight.blocked.includes("unpushed-commits") && preflight.blocked.includes("unpushed-commits");
(preflight.action === "reset" || preflight.kind === "managed");
const disabled = const disabled =
loading || !preflight || Boolean(error) || Boolean(blocked) || (requiresForce && !force); loading || !preflight || Boolean(error) || Boolean(blocked) || (requiresForce && !force);
const facts = preflight ? Object.entries(expectFor(preflight)) : []; const facts = preflight ? Object.entries(expectFor(preflight)) : [];
+16 -1
View File
@@ -168,6 +168,16 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
setNote(null); setNote(null);
}, [lane.id, lane.router_profile]); }, [lane.id, lane.router_profile]);
// One instance of this row serves every lane — it lives in the Workspace's
// lane-detail header, and switching lanes swaps its `lane` prop rather than
// remounting it. So an apply that is still in flight when the user moves on
// would land its rollback and its note on whatever lane is showing by then:
// lane B's failed switch putting lane B's old selection into lane A's picker.
const shownLane = useRef(lane.id);
useEffect(() => {
shownLane.current = lane.id;
}, [lane.id]);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
api.aiRouters api.aiRouters
@@ -188,17 +198,22 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
if (!routers || routers.length <= 1) return null; if (!routers || routers.length <= 1) return null;
const change = async (name: string) => { const change = async (name: string) => {
const laneId = lane.id;
const previous = current; const previous = current;
setCurrent(name); setCurrent(name);
setBusy(true); setBusy(true);
setNote(null); setNote(null);
try { try {
await api.lanes.routerApply(lane.id, name); await api.lanes.routerApply(laneId, name);
if (shownLane.current !== laneId) return;
setNote({ ok: true, text: t("router.applied") }); setNote({ ok: true, text: t("router.applied") });
} catch (err) { } catch (err) {
if (shownLane.current !== laneId) return;
setCurrent(previous); setCurrent(previous);
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) }); setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
} finally { } finally {
// `busy` belongs to the control on screen, not to the request, so it is
// cleared either way — a lane switch must never leave the picker locked.
setBusy(false); setBusy(false);
} }
}; };
@@ -242,9 +242,10 @@ describe("AddLaneModal — worktree mode (default)", () => {
expect(onAdded).toHaveBeenCalledWith( expect(onAdded).toHaveBeenCalledWith(
expect.objectContaining({ id: 9, status: "provisioning" }) expect.objectContaining({ id: 9, status: "provisioning" })
); );
// The modal stays open showing the setup summary until dismissed - it // Setup came back clean, so there is nothing to report and the dialog is
// does not close itself just because the lane was added. // done. Leaving it up read as "still working" — its confirm button is dead
expect(onClose).not.toHaveBeenCalled(); // once the lane exists — which is how it looked stuck.
await waitFor(() => expect(onClose).toHaveBeenCalled());
}); });
it("shows a server error and leaves the modal open instead of closing silently", async () => { it("shows a server error and leaves the modal open instead of closing silently", async () => {
@@ -305,11 +306,12 @@ describe("AddLaneModal — worktree mode (default)", () => {
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
}); });
it("shows the setup summary and lets the user dismiss it manually", async () => { it("keeps the summary up when a setup step FAILED, and closes on the button that replaces Add lane", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" });
vi.mocked(api.lanes.worktree).mockResolvedValue({ vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane, lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
}); });
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("no mcp"));
const onClose = vi.fn(); const onClose = vi.fn();
renderModal({ onClose }); renderModal({ onClose });
const user = userEvent.setup(); const user = userEvent.setup();
@@ -317,12 +319,13 @@ describe("AddLaneModal — worktree mode (default)", () => {
await fillWorktreeForm(user, { title: "demo3" }); await fillWorktreeForm(user, { title: "demo3" });
await user.click(screen.getByRole("button", { name: "Add lane" })); await user.click(screen.getByRole("button", { name: "Add lane" }));
// A failure is the one case worth reading before the dialog goes away.
expect(await screen.findByText("Setup")).toBeInTheDocument(); expect(await screen.findByText("Setup")).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
// And then the primary button must close, not sit there disabled: the lane
const [dismissButton] = screen.getAllByRole("button", { name: "Cancel" }); // already exists, so "Add lane" has nothing left to do.
if (!dismissButton) throw new Error("Cancel button not found"); expect(screen.queryByRole("button", { name: "Add lane" })).toBeNull();
await user.click(dismissButton); await user.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });
@@ -135,7 +135,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm} onConfirm={onConfirm}
/> />
); );
const confirmButton = await screen.findByRole("button", { name: "Remove lane" }); const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled()); await waitFor(() => expect(confirmButton).not.toBeDisabled());
// "adopted" is shown as context, not as a refusal. // "adopted" is shown as context, not as a refusal.
expect(screen.getByText(/Only the dashboard's record of it is dropped/)).toBeInTheDocument(); expect(screen.getByText(/Only the dashboard's record of it is dropped/)).toBeInTheDocument();
@@ -167,7 +167,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm} onConfirm={onConfirm}
/> />
); );
const confirmButton = await screen.findByRole("button", { name: "Remove lane" }); const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled()); await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
@@ -191,7 +191,7 @@ describe("DestructiveLaneModal", () => {
await waitFor(() => expect(confirmButton).toBeDisabled()); await waitFor(() => expect(confirmButton).toBeDisabled());
}); });
it("ENABLES remove when the worktree directory is missing — the server takes the prune path", async () => { it("ENABLES remove when the worktree directory is missing — the row goes, git's records stay", async () => {
preflightMock.mockResolvedValue( preflightMock.mockResolvedValue(
worktreePreflight({ worktreePreflight({
action: "remove", action: "remove",
@@ -212,7 +212,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm} onConfirm={onConfirm}
/> />
); );
const confirmButton = await screen.findByRole("button", { name: "Remove lane" }); const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled()); await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.getByText(/The lane directory is already gone/)).toBeInTheDocument(); expect(screen.getByText(/The lane directory is already gone/)).toBeInTheDocument();
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
@@ -238,7 +238,7 @@ describe("DestructiveLaneModal", () => {
await waitFor(() => expect(confirmButton).toBeDisabled()); await waitFor(() => expect(confirmButton).toBeDisabled());
}); });
it("ENABLES remove when the worktree directory is unreadable — the server attempts removal and falls back to deregistering it", async () => { it("ENABLES remove when the worktree directory is unreadable — nothing on disk is touched either way", async () => {
preflightMock.mockResolvedValue( preflightMock.mockResolvedValue(
worktreePreflight({ worktreePreflight({
action: "remove", action: "remove",
@@ -259,7 +259,7 @@ describe("DestructiveLaneModal", () => {
onConfirm={onConfirm} onConfirm={onConfirm}
/> />
); );
const confirmButton = await screen.findByRole("button", { name: "Remove lane" }); const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
await waitFor(() => expect(confirmButton).not.toBeDisabled()); await waitFor(() => expect(confirmButton).not.toBeDisabled());
expect(screen.getByText(/cannot be read as a Git worktree/)).toBeInTheDocument(); expect(screen.getByText(/cannot be read as a Git worktree/)).toBeInTheDocument();
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
@@ -268,23 +268,34 @@ describe("DestructiveLaneModal", () => {
}); });
}); });
it("shows the Force checkbox only when the sole blocker is unpushed commits, and ticking it enables confirm", async () => { it("never offers Force for a managed removal with unpushed commits — it deletes the row, not the branch", async () => {
preflightMock.mockResolvedValue( preflightMock.mockResolvedValue(
worktreePreflight({ action: "remove", unpushed: 3, blocked: ["unpushed-commits"] }) worktreePreflight({
action: "remove",
head: "abc0000",
dirty: 0,
untracked: 0,
unpushed: 3,
blocked: ["unpushed-commits"],
})
); );
const onConfirm = vi.fn();
render( render(
<DestructiveLaneModal <DestructiveLaneModal
lane={makeLane()} lane={makeLane()}
action="remove" action="remove"
open open
onClose={vi.fn()} onClose={vi.fn()}
onConfirm={vi.fn()} onConfirm={onConfirm}
/> />
); );
const confirmButton = await screen.findByRole("button", { name: "Remove lane" }); const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
await waitFor(() => expect(confirmButton).toBeDisabled()); await waitFor(() => expect(confirmButton).not.toBeDisabled());
fireEvent.click(screen.getByRole("checkbox")); expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(confirmButton).not.toBeDisabled(); fireEvent.click(confirmButton);
expect(onConfirm).toHaveBeenCalledWith({
expect: { head: "abc0000", dirty: 0, untracked: 0, unpushed: 3 },
});
}); });
it("a local-only managed lane (no-remote warning, unpushed-commits blocker) is resettable with Force", async () => { it("a local-only managed lane (no-remote warning, unpushed-commits blocker) is resettable with Force", async () => {
@@ -353,10 +364,10 @@ describe("DestructiveLaneModal", () => {
}); });
}); });
it("confirming an unpushed-commits removal with Force ticked sends force:true plus the same expect block", async () => { it("confirming an unpushed-commits RESET with Force ticked sends force:true plus the same expect block", async () => {
preflightMock.mockResolvedValue( preflightMock.mockResolvedValue(
worktreePreflight({ worktreePreflight({
action: "remove", action: "reset",
head: "abc0000", head: "abc0000",
dirty: 0, dirty: 0,
untracked: 0, untracked: 0,
@@ -368,13 +379,13 @@ describe("DestructiveLaneModal", () => {
render( render(
<DestructiveLaneModal <DestructiveLaneModal
lane={makeLane()} lane={makeLane()}
action="remove" action="reset"
open open
onClose={vi.fn()} onClose={vi.fn()}
onConfirm={onConfirm} onConfirm={onConfirm}
/> />
); );
const confirmButton = await screen.findByRole("button", { name: "Remove lane" }); const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
fireEvent.click(screen.getByRole("checkbox")); fireEvent.click(screen.getByRole("checkbox"));
await waitFor(() => expect(confirmButton).not.toBeDisabled()); await waitFor(() => expect(confirmButton).not.toBeDisabled());
fireEvent.click(confirmButton); fireEvent.click(confirmButton);
@@ -9,7 +9,7 @@
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard"; import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
import type { Lane, LaneRuntime } from "../../../lib/types"; import type { Lane, LaneRuntime } from "../../../lib/types";
@@ -513,6 +513,60 @@ describe("LaneRouterRow — the Model picker", () => {
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument(); expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
}); });
it("does not carry a pick made on one lane back to the lane selected before it", async () => {
// One LaneRouterRow instance serves every lane (it lives in the Workspace's
// lane-detail header), so its selected value is component state that
// outlives a lane switch unless the sync effect puts the new lane's own
// profile back.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockResolvedValue({
tool: "claude",
file: "/work/b/.claude/settings.local.json",
profile: "9router",
});
const laneA = makeLane({ id: 1, router_profile: null });
const laneB = makeLane({ id: 2, router_profile: null });
const { rerender } = render(<LaneRouterRow lane={laneA} />);
await screen.findByDisplayValue("Native (Claude / Codex)");
rerender(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
rerender(<LaneRouterRow lane={laneA} />);
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
});
it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => {
// The row is one instance shared by every lane, so an apply still in flight
// when the user moves on would paint its rollback and its error on whoever
// is on screen by then — lane B's failure changing lane A's picker.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
let reject: (e: Error) => void = () => {};
vi.mocked(api.lanes.routerApply).mockImplementation(
() => new Promise((_, r) => (reject = r)) as ReturnType<typeof api.lanes.routerApply>
);
const laneA = makeLane({ id: 1, router_profile: null });
const laneB = makeLane({ id: 2, router_profile: null });
const { rerender } = render(<LaneRouterRow lane={laneB} />);
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
"9router",
]);
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
rerender(<LaneRouterRow lane={laneA} />);
await act(async () => {
reject(new Error("EBADJSON"));
await Promise.resolve();
});
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
expect(screen.queryByTitle("EBADJSON")).toBeNull();
// And the picker must not be left locked by a request for another lane.
expect(screen.getByRole("combobox")).not.toBeDisabled();
});
it("puts the selection back when the apply fails", async () => { it("puts the selection back when the apply fails", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] }); vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON")); vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
+8
View File
@@ -235,6 +235,14 @@
"filterAll": "All types" "filterAll": "All types"
}, },
"profiles": { "profiles": {
"usageCheck": "Check quota",
"usageChecking": "Checking…",
"usageRefresh": "Refresh",
"usageRetry": "Try again",
"usageFiveHour": "5h",
"usageSevenDay": "7d",
"usageResets": "Resets {{time}}",
"usageHint": "Asks Anthropic what this account has spent. Refreshes this profile's stored login token to do it.",
"title": "Claude Code profiles", "title": "Claude Code profiles",
"openButton": "Switch profile", "openButton": "Switch profile",
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.", "subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
+6 -6
View File
@@ -2,7 +2,6 @@
"action.clear": "clear state", "action.clear": "clear state",
"action.forget": "Delete lane", "action.forget": "Delete lane",
"action.purge": "delete history", "action.purge": "delete history",
"action.remove": "delete lane + worktree",
"action.reset": "reset worktree", "action.reset": "reset worktree",
"action.start": "start", "action.start": "start",
"action.stop": "stop", "action.stop": "stop",
@@ -32,6 +31,7 @@
"addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.", "addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.",
"addLaneRepoLabel": "Source repository", "addLaneRepoLabel": "Source repository",
"addLaneRepoLabelAdopt": "Directory", "addLaneRepoLabelAdopt": "Directory",
"addLaneSetupClose": "Close",
"addLaneSetupAgents": "Agents", "addLaneSetupAgents": "Agents",
"addLaneSetupMcp": "MCP servers", "addLaneSetupMcp": "MCP servers",
"addLaneSetupProfile": "Profile", "addLaneSetupProfile": "Profile",
@@ -79,14 +79,14 @@
"destructive.loading": "Loading current facts…", "destructive.loading": "Loading current facts…",
"destructive.notice.activeSessionSkipped": "One or more sessions in this lane are still active and will be kept, not purged.", "destructive.notice.activeSessionSkipped": "One or more sessions in this lane are still active and will be kept, not purged.",
"destructive.notice.adopted": "This lane points at a directory you own. Only the dashboard's record of it is dropped — the directory and its files are left untouched.", "destructive.notice.adopted": "This lane points at a directory you own. Only the dashboard's record of it is dropped — the directory and its files are left untouched.",
"destructive.notice.missing": "The lane directory is already gone. Removing it drops the dashboard's record and clears Git's stale worktree entry; nothing is deleted from disk.", "destructive.notice.missing": "The lane directory is already gone. Deleting the lane drops the dashboard's record only; nothing on disk and no Git record is touched.",
"destructive.notice.unreadable": "The lane directory cannot be read as a Git worktree. Removal is attempted; if Git itself refuses, its worktree record is cleared directly instead. Either way, the directory itself is never touched.", "destructive.notice.unreadable": "The lane directory cannot be read as a Git worktree. That changes nothing here: only the dashboard's record is deleted, the directory is never touched.",
"destructive.purge.confirm": "Purge history", "destructive.purge.confirm": "Purge history",
"destructive.purge.message": "Approve the current counts before deleting stored session history.", "destructive.purge.message": "Approve the current counts before deleting stored session history.",
"destructive.purge.title": "Purge lane history?", "destructive.purge.title": "Purge lane history?",
"destructive.remove.confirm": "Remove lane", "destructive.remove.confirm": "Delete lane record",
"destructive.remove.message": "Approve the current counts before this action.", "destructive.remove.message": "Only the dashboard's record of this lane is deleted. Its working tree, its branch and its database are left exactly as they are — delete them yourself if you want them gone.",
"destructive.remove.title": "Remove lane?", "destructive.remove.title": "Delete lane record?",
"destructive.reset.confirm": "Reset worktree", "destructive.reset.confirm": "Reset worktree",
"destructive.reset.message": "Approve the current counts before resetting this worktree.", "destructive.reset.message": "Approve the current counts before resetting this worktree.",
"destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.", "destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.",
+8
View File
@@ -230,6 +230,14 @@
"filterAll": "Tất cả loại" "filterAll": "Tất cả loại"
}, },
"profiles": { "profiles": {
"usageCheck": "Kiểm tra quota",
"usageChecking": "Đang kiểm tra…",
"usageRefresh": "Làm mới",
"usageRetry": "Thử lại",
"usageFiveHour": "5h",
"usageSevenDay": "7 ngày",
"usageResets": "Reset lúc {{time}}",
"usageHint": "Hỏi Anthropic tài khoản này đã dùng bao nhiêu. Thao tác này làm mới token đăng nhập đã lưu của profile.",
"title": "Profile Claude Code", "title": "Profile Claude Code",
"openButton": "Đổi profile", "openButton": "Đổi profile",
"subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.", "subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.",
+7 -7
View File
@@ -2,7 +2,6 @@
"action.clear": "Dọn trạng thái", "action.clear": "Dọn trạng thái",
"action.forget": "Xóa lane", "action.forget": "Xóa lane",
"action.purge": "Xóa lịch sử", "action.purge": "Xóa lịch sử",
"action.remove": "Xóa làn + worktree",
"action.reset": "Đặt lại worktree", "action.reset": "Đặt lại worktree",
"action.start": "Bắt đầu", "action.start": "Bắt đầu",
"action.stop": "Dừng", "action.stop": "Dừng",
@@ -32,6 +31,7 @@
"addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.", "addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.",
"addLaneRepoLabel": "Repo nguồn", "addLaneRepoLabel": "Repo nguồn",
"addLaneRepoLabelAdopt": "Thư mục", "addLaneRepoLabelAdopt": "Thư mục",
"addLaneSetupClose": "Đóng",
"addLaneSetupAgents": "Agent", "addLaneSetupAgents": "Agent",
"addLaneSetupMcp": "MCP server", "addLaneSetupMcp": "MCP server",
"addLaneSetupProfile": "Profile", "addLaneSetupProfile": "Profile",
@@ -78,15 +78,15 @@
"destructive.force": "Tôi hiểu các commit chưa đẩy này sẽ bị loại bỏ.", "destructive.force": "Tôi hiểu các commit chưa đẩy này sẽ bị loại bỏ.",
"destructive.loading": "Đang tải trạng thái hiện tại…", "destructive.loading": "Đang tải trạng thái hiện tại…",
"destructive.notice.activeSessionSkipped": "Một hoặc nhiều phiên trong làn đường này vẫn đang hoạt động và sẽ được giữ lại, không bị dọn.", "destructive.notice.activeSessionSkipped": "Một hoặc nhiều phiên trong làn đường này vẫn đang hoạt động và sẽ được giữ lại, không bị dọn.",
"destructive.notice.adopted": "Làn đường này trỏ tới thư mục của bạn. Chỉ bản ghi trên bảng điều khiển bị xóa — thư mục và các tệp bên trong không bị thay đổi.", "destructive.notice.adopted": "Lane này trỏ tới thư mục của bạn. Chỉ bản ghi trên bảng điều khiển bị xóa — thư mục và các tệp bên trong không bị thay đổi.",
"destructive.notice.missing": "Thư mục làn đường đã không còn. Việc xóa chỉ bỏ bản ghi trên bảng điều khiển và dọn mục worktree cũ trong Git; không tệp nào bị xóa khỏi đĩa.", "destructive.notice.missing": "Thư mục lane đã không còn. Việc xóa chỉ bỏ bản ghi trên bảng điều khiển; không tệp nào trên đĩa và không bản ghi Git nào bị đụng tới.",
"destructive.notice.unreadable": "Không thể đọc thư mục làn đường như một Git worktree. Hệ thống sẽ thử xóa; nếu Git tự chối, mục ghi worktree của nó sẽ được dọn trực tiếp thay thế. Trong mọi trường hợp, thư mục vẫn không bị thay đổi.", "destructive.notice.unreadable": "Không thể đọc thư mục lane như một Git worktree. Điều đó không ảnh hưởng gì: chỉ bản ghi trên bảng điều khiển bị xóa, thư mục không bao giờ bị đụng tới.",
"destructive.purge.confirm": "Xóa lịch sử", "destructive.purge.confirm": "Xóa lịch sử",
"destructive.purge.message": "Hãy xác nhận các số liệu hiện tại trước khi xóa lịch sử phiên đã lưu.", "destructive.purge.message": "Hãy xác nhận các số liệu hiện tại trước khi xóa lịch sử phiên đã lưu.",
"destructive.purge.title": "Xóa lịch sử làn đường?", "destructive.purge.title": "Xóa lịch sử làn đường?",
"destructive.remove.confirm": "Xóa làn đường", "destructive.remove.confirm": "Xóa bản ghi lane",
"destructive.remove.message": "Hãy xác nhận các số liệu hiện tại trước khi thực hiện thao tác này.", "destructive.remove.message": "Chỉ bản ghi của lane trên bảng điều khiển bị xóa. Working tree, nhánh và cơ sở dữ liệu của lane được giữ nguyên — bạn tự xóa nếu thực sự muốn bỏ chúng.",
"destructive.remove.title": "Xóa làn đường?", "destructive.remove.title": "Xóa bản ghi lane?",
"destructive.reset.confirm": "Đặt lại worktree", "destructive.reset.confirm": "Đặt lại worktree",
"destructive.reset.message": "Hãy xác nhận các số liệu hiện tại trước khi đặt lại worktree này.", "destructive.reset.message": "Hãy xác nhận các số liệu hiện tại trước khi đặt lại worktree này.",
"destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.", "destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.",
+34
View File
@@ -1584,6 +1584,21 @@ export const api = {
"/cc-config/profiles/activate", "/cc-config/profiles/activate",
{ method: "POST", body: JSON.stringify({ name }) } { method: "POST", body: JSON.stringify({ name }) }
), ),
/**
* POST /api/cc-config/profiles/usage - how much of this profile's account
* quota is already spent.
*
* A POST because answering it is a write: a saved profile's access token has
* almost always expired, so the server refreshes it against Anthropic first
* and the refresh rotates the credentials stored on disk. That is also why
* this is never called on a timer — only when the user asks for one profile.
* No token material crosses the boundary in either direction.
*/
profileUsage: (name: string) =>
request<CcProfileUsage>("/cc-config/profiles/usage", {
method: "POST",
body: JSON.stringify({ name }),
}),
/** DELETE /api/cc-config/profiles - forget a saved profile (never the active one). */ /** DELETE /api/cc-config/profiles - forget a saved profile (never the active one). */
deleteProfile: (name: string) => deleteProfile: (name: string) =>
request<{ ok: true; name: string }>("/cc-config/profiles", { request<{ ok: true; name: string }>("/cc-config/profiles", {
@@ -2656,6 +2671,25 @@ export interface CcProfile extends CcAccountIdentity {
active: boolean; active: boolean;
} }
/** One quota window as POST /api/cc-config/profiles/usage reports it. */
export interface CcUsageWindow {
/** Percent of the window's allowance already spent, 0-100. */
utilization: number | null;
/** ISO timestamp the window rolls over at, when the account reports one. */
resetsAt: string | null;
}
/** An account's spent quota, for the profile switcher. Percentages and reset
* times only — the OAuth token stays on the server. */
export interface CcProfileUsage {
name: string;
/** True when the stored token had expired and was refreshed to answer this. */
refreshed: boolean;
fiveHour: CcUsageWindow | null;
sevenDay: CcUsageWindow | null;
checkedAt: string;
}
/** Scope filter accepted by most `ccConfig` list endpoints; "all" merges /** Scope filter accepted by most `ccConfig` list endpoints; "all" merges
* user + project scope in one response. */ * user + project scope in one response. */
export type CcScope = "user" | "project" | "all"; export type CcScope = "user" | "project" | "all";
+134
View File
@@ -141,6 +141,8 @@ import type {
CcUpdateAllBatch, CcUpdateAllBatch,
CcProfile, CcProfile,
CcAccountIdentity, CcAccountIdentity,
CcProfileUsage,
CcUsageWindow,
CcScope, CcScope,
CcSettingsSource, CcSettingsSource,
CcStatusline, CcStatusline,
@@ -4232,6 +4234,18 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
/** `name` of the profile whose label is being edited inline, plus its draft. */ /** `name` of the profile whose label is being edited inline, plus its draft. */
const [renaming, setRenaming] = useState<string | null>(null); const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
/** Quota per profile, keyed by `name`, fetched for every profile once the
* list arrives — the whole point of the panel is comparing accounts, and one
* button per row made the user do that by hand. The cost is bounded: the
* live account is read from the CLI and never refreshed, and any other
* profile is refreshed only when its token has actually expired, which
* leaves it valid for hours of further opens. */
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
const [usageError, setUsageError] = useState<Record<string, string>>({});
/** Per profile, so one slow account does not freeze the other rows. */
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
/** Names already auto-fetched, so the effect below fires once per profile. */
const autoFetched = useRef<Set<string>>(new Set());
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@@ -4276,6 +4290,42 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
[load, onToast] [load, onToast]
); );
const checkUsage = useCallback(async (name: string) => {
setUsageBusy((prev) => ({ ...prev, [name]: true }));
setUsageError((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
try {
const result = await api.ccConfig.profileUsage(name);
setUsage((prev) => ({ ...prev, [name]: result }));
} catch (err: unknown) {
setUsageError((prev) => ({
...prev,
[name]: err instanceof Error ? err.message : "unknown error",
}));
} finally {
setUsageBusy((prev) => {
const next = { ...prev };
delete next[name];
return next;
});
}
}, []);
// Every profile's quota, as soon as there is a list to ask about. Once per
// profile per opening: a retry is the row's own button, so a failed account
// is not re-asked on every render.
useEffect(() => {
if (!items) return;
for (const p of items) {
if (autoFetched.current.has(p.name)) continue;
autoFetched.current.add(p.name);
void checkUsage(p.name);
}
}, [items, checkUsage]);
const activeSaved = items?.some((p) => p.active) ?? false; const activeSaved = items?.some((p) => p.active) ?? false;
/** How many saved profiles hold the same account as `p`. */ /** How many saved profiles hold the same account as `p`. */
@@ -4441,6 +4491,13 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
)} )}
</div> </div>
<IdentityLine identity={p} /> <IdentityLine identity={p} />
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={!!usageBusy[p.name]}
disabled={busy !== null || !!usageBusy[p.name]}
onCheck={() => void checkUsage(p.name)}
/>
{p.savedAt && ( {p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1"> <div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })} {t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
@@ -4459,6 +4516,83 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
); );
} }
/**
* One profile's quota: a button until asked, then the two windows Anthropic
* reports — the 5-hour session allowance and the 7-day one.
*
* Deliberately not fetched with the profile list. Reading a saved profile's
* usage means refreshing its OAuth token, and that rotates the credentials on
* disk (see `server/lib/cc-usage.js`), so it happens once per click and never
* on a render or a poll.
*/
function ProfileUsageLine({
usage,
error,
busy,
disabled,
onCheck,
}: {
usage?: CcProfileUsage;
error?: string;
busy: boolean;
disabled: boolean;
onCheck: () => void;
}) {
const { t } = useTranslation("ccConfig");
const pct = (w: CcUsageWindow | null | undefined) =>
w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—";
const resetTitle = (w: CcUsageWindow | null | undefined) =>
w && w.resetsAt
? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() })
: undefined;
if (error) {
return (
<div className="mt-1 flex items-center gap-2 text-[10px]">
<span className="text-status-danger">{error}</span>
<button
onClick={onCheck}
disabled={disabled}
className="text-fg-muted hover:text-accent disabled:opacity-50"
>
{t("profiles.usageRetry")}
</button>
</div>
);
}
if (!usage) {
return (
<button
onClick={onCheck}
disabled={disabled}
title={t("profiles.usageHint")}
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
>
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
</button>
);
}
return (
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
<span title={resetTitle(usage.fiveHour)}>
{t("profiles.usageFiveHour")}{" "}
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
</span>
<span title={resetTitle(usage.sevenDay)}>
{t("profiles.usageSevenDay")}{" "}
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
</span>
<button
onClick={onCheck}
disabled={disabled}
className="hover:text-accent disabled:opacity-50"
>
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
</button>
</div>
);
}
/** Org / seat / plan line shared by the active card and each profile row. */ /** Org / seat / plan line shared by the active card and each profile row. */
function IdentityLine({ identity }: { identity: CcAccountIdentity }) { function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter( const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
+3 -3
View File
@@ -742,9 +742,9 @@ export function Workspace() {
onClick={() => setRemoveConfirmOpen(true)} onClick={() => setRemoveConfirmOpen(true)}
className="rounded px-2 py-0.5 text-xs text-status-danger transition-colors hover:bg-status-danger/15" className="rounded px-2 py-0.5 text-xs text-status-danger transition-colors hover:bg-status-danger/15"
> >
{currentLane.kind === "adopted" {/* One label for both kinds: removal forgets the lane's row
? tLanes("action.forget") and never deletes a worktree, a branch or a database. */}
: tLanes("action.remove")} {tLanes("action.forget")}
</button> </button>
)} )}
</div> </div>
@@ -0,0 +1,146 @@
/**
* @file Covers the quota lines in the Claude Code auth-profile modal: opening
* it asks for EVERY saved account's quota, not one row at a time.
*
* The one-at-a-time version made the user click through their own accounts to
* answer the only question the panel exists for — which account still has room.
* The other half is that each row fails on its own: an account whose saved
* login has been superseded must not stop its neighbours from reporting.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
const window = (utilization: number) => ({
utilization,
resetsAt: "2026-09-18T08:00:00Z",
});
return {
window,
overview: r({
roots: {
claudeHome: "/home/.claude",
projectClaudeDir: "/repo/.claude",
projectRoot: "/repo",
},
counts: {
skills: { user: 0, project: 0 },
agents: { user: 0, project: 0 },
commands: { user: 0, project: 0 },
outputStyles: { user: 0, project: 0 },
mcpServers: { user: 0, project: 0 },
hooks: {},
plugins: 0,
marketplaces: 0,
keybindings: 0,
settingsFiles: 0,
memory: 0,
},
}),
skills: r({ items: [] }),
agents: r({ items: [] }),
commands: r({ items: [] }),
outputStyles: r({ items: [] }),
memory: r({ items: [] }),
plugins: r({ manifestPath: "", manifestExists: false, plugins: [] }),
marketplaces: r({ marketplaces: [] }),
mcp: r({ mcp: [] }),
hooks: r({ hooks: [] }),
settings: r({ sources: [] }),
keybindings: r({ groups: [] }),
statusline: r({}),
hookScripts: r({ items: [] }),
backups: r({ items: [] }),
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
pluginsAvailable: r({ installed: [], available: [] }),
updateAllStatus: r({ batch: null }),
// The modal's own list: two saved accounts, one of them live.
profiles: r({
items: [
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
],
active: { accountUuid: "u1", email: "a@b.c" },
}),
profileUsage: vi.fn().mockImplementation(async (name: string) => {
if (name === "spare") {
throw new Error("this profile's saved login has been superseded");
}
return {
name,
refreshed: false,
fiveHour: window(8),
sevenDay: window(70),
checkedAt: "2026-09-17T16:00:00Z",
};
}),
};
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { CcConfig } from "../CcConfig";
async function settle() {
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
await new Promise((res) => setTimeout(res, 0));
});
}
async function openProfiles() {
render(<CcConfig />);
await settle();
await userEvent.click(screen.getByRole("button", { name: /profile/i }));
await settle();
await settle();
}
afterEach(() => vi.clearAllMocks());
describe("CcConfig — auth profile quotas", () => {
it("asks for every saved account's quota when the modal opens", async () => {
await openProfiles();
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("work");
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("spare");
expect(await screen.findByText("8%")).toBeInTheDocument();
expect(await screen.findByText("70%")).toBeInTheDocument();
});
it("reports the account that failed without costing the others their numbers", async () => {
await openProfiles();
expect(await screen.findByText(/saved login has been superseded/i)).toBeInTheDocument();
expect(screen.getByText("8%")).toBeInTheDocument();
});
it("asks once per profile, however many times the panel re-renders", async () => {
await openProfiles();
await settle();
await settle();
const asked = ccConfigMock.profileUsage.mock.calls.filter((c) => c[0] === "work");
expect(asked).toHaveLength(1);
});
});
@@ -0,0 +1,223 @@
/**
* @file Workspace.laneRouter.test.tsx
* @description The Model (AI Router) picker in the Workspace lane-detail
* header, exercised through the page rather than through `LaneRouterRow` on its
* own — one row instance serves every lane, so the bug this pins is a lane
* switch carrying the previous lane's selection with it: pick a router on one
* lane, come back to a lane that is on native, and see the router still
* selected there.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, act, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
type LaneFixture = {
id: number;
cwd: string;
title: string;
pipeline: string;
pipeline_name: string;
pipeline_nodes: never[];
detected_signal: string | null;
run_id: string | null;
router_profile: string | null;
};
const lane = (id: number, title: string): LaneFixture => ({
id,
cwd: `/workspace/${id}`,
title,
pipeline: "default",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
router_profile: null,
});
const h = vi.hoisted(() => {
const nativeProfile = {
name: "native",
label: "Native (Claude / Codex)",
builtin: true,
baseUrl: "",
hasKey: false,
model: "claude-sonnet-5",
models: {
opus: "claude-opus-5",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5-20251001",
smallFast: "claude-haiku-4-5-20251001",
subagent: "claude-haiku-4-5-20251001",
},
};
return {
lanes: [] as { id: number; router_profile: string | null }[],
routerApply: vi.fn(),
profiles: [
nativeProfile,
{ ...nativeProfile, name: "9router", label: "9Router", builtin: false },
],
};
});
const routerApply = h.routerApply;
let lanesToReturn: LaneFixture[] = [];
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
lanes: {
list: vi.fn().mockImplementation(async () => ({
lanes: h.lanes,
counts: { total: h.lanes.length, running: 0, needs_you: 0, dead: 0 },
})),
ensure: vi.fn(),
action: vi.fn(),
git: vi.fn().mockResolvedValue({ available: false }),
runtime: vi.fn().mockResolvedValue({ available: false }),
stage: vi.fn(),
update: vi.fn(),
pipelines: vi
.fn()
.mockResolvedValue({ pipelines: [{ id: "default", name: "default", nodes: [] }] }),
// The route the picker must use — never a lane PATCH.
routerApply: vi.fn().mockImplementation(async (id: number, profile: string) => {
h.routerApply(id, profile);
// The row moving with the call is the server's broadcast, not a test
// convenience: the apply route broadcasts the lane so the cached row
// stops disagreeing with what was just written. Drop the broadcast
// server-side and the next lane switch re-reads a stale profile.
const row = h.lanes.find((l) => l.id === id);
if (row) row.router_profile = profile === "native" ? null : profile;
return { tool: "claude", file: `/workspace/${id}/.claude/settings.local.json`, profile };
}),
features: { list: vi.fn().mockResolvedValue({ features: [] }), show: vi.fn() },
proof: {
list: vi.fn().mockResolvedValue({ features: [] }),
imageUrl: vi.fn(),
delete: vi.fn(),
},
},
aiRouters: {
list: vi.fn().mockImplementation(async () => ({ items: h.profiles })),
},
run: {
list: vi.fn().mockResolvedValue({ runs: [], items: [] }),
history: vi.fn().mockResolvedValue({ items: [] }),
binary: vi.fn().mockResolvedValue({ found: true, path: "/usr/bin/claude" }),
cwds: vi.fn().mockResolvedValue({ items: [] }),
files: vi.fn().mockResolvedValue({ items: [] }),
start: vi.fn(),
get: vi.fn(),
send: vi.fn(),
kill: vi.fn(),
},
ccConfig: {
commands: vi.fn().mockResolvedValue({ items: [] }),
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
file: vi.fn().mockResolvedValue({ text: "", content: "" }),
},
sessions: {
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
transcript: vi.fn().mockResolvedValue({ messages: [] }),
},
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
vi.mock("../../components/run/TerminalView", () => ({
TerminalView: ({ runId }: { runId: string }) => (
<div data-testid="terminal-view" data-run-id={runId} />
),
}));
import { Workspace } from "../Workspace";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
beforeEach(() => {
routerApply.mockClear();
lanesToReturn = [lane(1, "Lane A"), lane(2, "Lane B")];
h.lanes = lanesToReturn as unknown as typeof h.lanes;
});
async function renderWorkspace() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
await settle();
return utils;
}
/** Click the strip tile for a lane, which is how a lane is selected. */
async function selectLane(user: ReturnType<typeof userEvent.setup>, title: string) {
const tile = screen.getAllByText(title)[0];
if (!tile) throw new Error(`no strip tile for ${title}`);
await user.click(tile);
await settle();
}
describe("Workspace — the lane Model picker across a lane switch", () => {
it("leaves a lane on native after another lane was pointed at a router", async () => {
await renderWorkspace();
const user = userEvent.setup();
await selectLane(user, "Lane A");
expect(await screen.findByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
await selectLane(user, "Lane B");
await user.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
await waitFor(() => expect(routerApply).toHaveBeenCalledWith(2, "9router"));
await settle();
expect(screen.getByDisplayValue("9Router")).toBeInTheDocument();
// Back to the lane that was never switched. It is still native server-side,
// so the picker must say so — the row is one instance shared by every lane.
await selectLane(user, "Lane A");
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
// And nothing may have been written to lane A on the way.
expect(routerApply).not.toHaveBeenCalledWith(1, expect.anything());
});
});
@@ -9578,7 +9578,7 @@ exports[`screen snapshots > Settings 1`] = `
<p <p
class="text-[10px] text-status-warning/90 mt-1" class="text-[10px] text-status-warning/90 mt-1"
> >
UI build v1.6.8 (rebuild client to match) UI build v1.6.14 (rebuild client to match)
</p> </p>
</div> </div>
<div <div
+11 -2
View File
@@ -1648,6 +1648,7 @@ GET /api/cc-config/profiles
POST /api/cc-config/profiles Body: { name? } POST /api/cc-config/profiles Body: { name? }
PATCH /api/cc-config/profiles Body: { name, label } PATCH /api/cc-config/profiles Body: { name, label }
POST /api/cc-config/profiles/activate Body: { name } POST /api/cc-config/profiles/activate Body: { name }
POST /api/cc-config/profiles/usage Body: { name }
DELETE /api/cc-config/profiles Body: { name } DELETE /api/cc-config/profiles Body: { name }
PUT /api/cc-config/file Body: { scope, type, name?, content } PUT /api/cc-config/file Body: { scope, type, name?, content }
DELETE /api/cc-config/file Body: { scope, type, name? } DELETE /api/cc-config/file Body: { scope, type, name? }
@@ -1677,7 +1678,7 @@ That indirection is structural, not stylistic: the plugin this dashboard *itself
Batch `status` is `running`, `done`, `partial` (finished, at least one step failed), `failed`, or `abandoned`. `abandoned` is **derived, not stored**: the worker is detached, so a reboot leaves a `running` journal with nobody to finish it, and after an hour it is reported as abandoned so the button is usable again. Batch `status` is `running`, `done`, `partial` (finished, at least one step failed), `failed`, or `abandoned`. `abandoned` is **derived, not stored**: the worker is detached, so a reboot leaves a `running` journal with nobody to finish it, and after an hour it is reported as abandoned so the button is usable again.
`POST /api/cc-config/reload` is the reload step on its own. It types `/reload-plugins` and `/reload-skills` into the dashboard's live lane panes via `tmux send-keys`. **Both commands are scoped by Claude Code to the session that runs them** (`/reload-plugins` activates pending plugin changes "in the current session"; `/reload-skills` picks up skills changed on disk "during this session"), so nothing can reload a session from the outside — typing into a session that is already running is the only real mechanism, and it is what this does. It never spawns a `claude` process: a one-shot `claude -p "/reload-plugins"` would reload its own short-lived session, exit, and leave a junk session behind. Panes sitting at a shell prompt are skipped, since the text would run as a shell command instead; only `claude` lanes are addressed, as Codex has no equivalent. `lanes_total: 0` is a normal outcome, not an error. Sessions running outside the dashboard cannot be reached and must run the command themselves. `POST /api/cc-config/reload` is the reload step on its own. It types `/reload-plugins` and `/reload-skills` into the dashboard's live lane panes via `tmux send-keys`. **Both commands are scoped by Claude Code to the session that runs them** (`/reload-plugins` activates pending plugin changes "in the current session"; `/reload-skills` picks up skills changed on disk "during this session"), so nothing can reload a session from the outside — typing into a session that is already running is the only real mechanism, and it is what this does. It never spawns a `claude` process: a one-shot `claude -p "/reload-plugins"` would reload its own short-lived session, exit, and leave a junk session behind. Panes sitting at a shell prompt are skipped, since the text would run as a shell command instead; only `claude` lanes are addressed, as Codex has no equivalent. The two commands are typed **one second apart** (`SETTLE_MS` in `server/lib/cc-reload.js`), which is why the route takes about that long to answer: Claude Code opens its slash-command menu the moment `/` is typed and the Enter that follows lands on the menu, so back-to-back sends left both commands on one composer line and the pane submitted `/reload-plugins/reload-skills` — run as `/reload-plugins` with an argument, never reloading skills at all. `lanes_total: 0` is a normal outcome, not an error. Sessions running outside the dashboard cannot be reached and must run the command themselves.
`POST /api/cc-config/plugins/action` accepts `reload: true` for the counterpart behaviour: update one plugin, reload immediately. The batch reloads once at the end instead — one reload picks up every pending change, where N of them would re-scan the same directory N times. A reload is never sent after a *failed* action. `POST /api/cc-config/plugins/action` accepts `reload: true` for the counterpart behaviour: update one plugin, reload immediately. The batch reloads once at the end instead — one reload picks up every pending change, where N of them would re-scan the same directory N times. A reload is never sent after a *failed* action.
@@ -1697,6 +1698,14 @@ Batch `status` is `running`, `done`, `partial` (finished, at least one step fail
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`). Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`).
**`POST /profiles/usage` (how much quota this account has spent).** Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, where each window is `{ utilization, resetsAt }` — a percentage and a rollover time, nothing else. There is no local source for this: nothing under `CLAUDE_HOME` records a utilization, and the credentials file carries only `subscriptionType` and `rateLimitTier`, so the server asks the same endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.
It is a **POST**, and behind the same-origin guard, because answering it is a write. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage means refreshing it first, and the refresh **rotates the stored credentials** — Anthropic retires the refresh token it was handed. The new pair is therefore written straight back into the profile file (atomic temp + rename at `0600`, every other field preserved), because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A token that is still valid is used as-is and nothing is rewritten; a token that looks valid but is refused (401) is retried once through the refresh path.
**The account that is logged in right now is the exception, and it is read from the CLI.** A refresh token is single use, so every refresh Claude Code performs on its own account retires the copy sitting in that account's profile file — the saved credentials of the LIVE account are the one set guaranteed to be rejected, with `invalid_grant`, however recently the profile was saved. `cc-profiles.liveCredentialsFor` therefore matches the profile's `accountUuid` against `.credentials.json` and, on a hit, uses what the CLI is holding. That token is never refreshed and that file is never written: rotating it would log the user out of their own terminal. An expired live token answers `ELIVEEXPIRED` (409) — using Claude Code once makes the CLI refresh it.
**Nothing calls this on a timer**, but opening the Profiles modal does ask for every saved account at once — comparing accounts is the only reason the panel exists, and a button per row made the user do that by hand. What keeps that affordable is the pair of rules above: the live account costs a plain read, and any other profile is refreshed only when its token has actually expired, which leaves it valid for hours of further opens. Each row resolves on its own, so one superseded account still lets its neighbours report. Errors: `ENOTFOUND` (404), `ENOREFRESH`/`ENOAUTH` (409 — the profile has no usable token, re-save it from a login), `ESTALE` (409 — the refresh token was already spent by a newer login or refresh; activate the account once and save the profile again), `ELIVEEXPIRED` (409), `EUNAUTHORIZED` (401), anything upstream 502. See `server/lib/cc-usage.js`.
A profile has two names, and the split is what lets a profile be called `Tài khoản chính`. `name` is the internal identifier every mutation route takes, and it is the filename, so it must match `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$`; callers normally never choose it, because `POST /profiles` derives it from the account. `label` is the display name the user edits with `PATCH /profiles`: free-form 1-60 characters, spaces and diacritics allowed since it is never used as a path, stored trimmed, rejected for control characters (`EBADLABEL`), and unique across profiles case-insensitively (`ELABELTAKEN`). A rename rewrites the label in place and **does not move `name`**, so it cannot half-fail and leave a profile unreachable. Profiles written before labels existed report `label` equal to their `name`. A profile has two names, and the split is what lets a profile be called `Tài khoản chính`. `name` is the internal identifier every mutation route takes, and it is the filename, so it must match `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$`; callers normally never choose it, because `POST /profiles` derives it from the account. `label` is the display name the user edits with `PATCH /profiles`: free-form 1-60 characters, spaces and diacritics allowed since it is never used as a path, stored trimmed, rejected for control characters (`EBADLABEL`), and unique across profiles case-insensitively (`ELABELTAKEN`). A rename rewrites the label in place and **does not move `name`**, so it cannot half-fail and leave a profile unreachable. Profiles written before labels existed report `label` equal to their `name`.
One account is one profile: `POST /profiles` refuses with `EDUPLICATE` when another profile already holds the logged-in account, naming the one that does. Two profiles of a single account would both report `active: true`, leaving nothing to switch between — and, under a blanket active-guard on delete, neither would ever be removable. Re-posting an account into its OWN profile is the token-refresh path, stays allowed, and preserves that profile's label. One account is one profile: `POST /profiles` refuses with `EDUPLICATE` when another profile already holds the logged-in account, naming the one that does. Two profiles of a single account would both report `active: true`, leaving nothing to switch between — and, under a blanket active-guard on delete, neither would ever be removable. Re-posting an account into its OWN profile is the token-refresh path, stays allowed, and preserves that profile's label.
@@ -1797,7 +1806,7 @@ The stored key is only ever sent to the endpoint it was stored against. A blank
Attaching to a **native** lane is not a no-op for claude: `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are written as empty strings (removing them would let a *user-level* router apply to a lane that asked for native), while the six model names are removed (writing them would override the user's own global model choice). Attaching to a **native** lane is not a no-op for claude: `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are written as empty strings (removing them would let a *user-level* router apply to a lane that asked for native), while the six model names are removed (writing them would override the user's own global model choice).
The lane's `router_profile` column is set in the same call. Like `tool`, it is absent from the lane `PATCH` allowlist: the row and the config on disk may only move together. The lane's `router_profile` column is set in the same call. Like `tool`, it is absent from the lane `PATCH` allowlist: the row and the config on disk may only move together. The updated lane is then broadcast as `lane_update`, same as every other mutating lane route — without it a client's cached row keeps the old `router_profile`, and re-selecting the lane re-reads that stale row and flips the picker back to the previous profile.
**An edit reaches the lanes that already use the profile.** Attaching *copies* a profile — a claude lane carries the key and base URL in its own settings file from then on — so a store-only write would be inert exactly where it matters: rotating a leaked key in Settings would leave every claude lane authenticating with the revoked one. `PATCH` therefore re-applies the profile to each lane naming it, and `DELETE` resets those lanes to native (on disk and in the DB) and removes the Codex layer file. Both report `lanes: { updated: <n>, failed: ["<id> (<title>): <code>", …] }`; a lane whose directory is gone or whose settings file a human has broken is reported there, never fatal to the edit. **An edit reaches the lanes that already use the profile.** Attaching *copies* a profile — a claude lane carries the key and base URL in its own settings file from then on — so a store-only write would be inert exactly where it matters: rotating a leaked key in Settings would leave every claude lane authenticating with the revoked one. `PATCH` therefore re-applies the profile to each lane naming it, and `DELETE` resets those lanes to native (on disk and in the DB) and removes the Codex layer file. Both report `lanes: { updated: <n>, failed: ["<id> (<title>): <code>", …] }`; a lane whose directory is gone or whose settings file a human has broken is reported there, never fatal to the edit.
+32 -17
View File
@@ -118,18 +118,18 @@ agenthub lanes remove <id> [--force] --yes
agenthub lanes purge <id> --yes agenthub lanes purge <id> --yes
``` ```
Each command first fetches and prints its preflight counts. `reset` and `remove` show `head`, `dirty`, `untracked`, and `unpushed` (plus `database`, the name a data-isolated lane's reset/remove will drop — see "Data isolation" below); `purge` shows `sessions`, `events`, and `tokenRows`. The action refuses to run without `--yes`, and sends those exact facts back as its confirmation. Use `--force` only when the preflight reports unpushed commits. `--keep-db` (reset only) skips the whole drop-recreate-migrate-reseed sequence, leaving the lane's database exactly as it was. An adopted lane points at a directory you own, so the CLI refuses to `reset` it. `remove` IS allowed for an adopted lane: it drops only the dashboard's record and leaves the directory, its contents, and its database (if any) untouched. Each command first fetches and prints its preflight counts. `reset` and `remove` show `head`, `dirty`, `untracked`, and `unpushed` (plus `database`, the name a data-isolated lane's **reset** will drop — `remove` drops no database at all; see "Data isolation" below); `purge` shows `sessions`, `events`, and `tokenRows`. The action refuses to run without `--yes`, and sends those exact facts back as its confirmation. Use `--force` only when the preflight reports unpushed commits — it applies to `reset` alone, since `remove` deletes nothing that could hold them. `--keep-db` (reset only) skips the whole drop-recreate-migrate-reseed sequence, leaving the lane's database exactly as it was. An adopted lane points at a directory you own, so the CLI refuses to `reset` it. `remove` is allowed for **every** lane kind, adopted or managed, because it drops only the dashboard's record and leaves the working tree, its branch, and its database (if any) untouched — deleting those is yours to do by hand.
## Lifecycle ## Lifecycle
### The two lane kinds ### The two lane kinds
- **`managed`** — a git worktree the dashboard itself created (`agenthub lanes add --repo`), rooted under `LANES_ROOT`. The dashboard owns its full lifecycle: it may reset the worktree to its base branch or remove it entirely. - **`managed`** — a git worktree the dashboard itself created (`agenthub lanes add --repo`), rooted under `LANES_ROOT`. The dashboard may reset that worktree to its base branch. It does NOT delete it: removing the lane forgets the dashboard's record and leaves the directory and its branch on disk.
- **`adopted`** — an existing directory you already had (`agenthub lanes add --cwd`). The dashboard only ever tracks it; it never resets or deletes that worktree. "Removing" an adopted lane drops the dashboard's record of it and leaves the directory untouched. - **`adopted`** — an existing directory you already had (`agenthub lanes add --cwd`). The dashboard only ever tracks it; it never resets or deletes that worktree. "Removing" an adopted lane drops the dashboard's record of it and leaves the directory untouched.
### The three independent safety checks ### The three independent safety checks
Before `reset` or `remove` touches a worktree, `assertDestroyable` (`server/lib/worktree.js`) runs three checks, all of which must pass: Before `reset` touches a worktree, `assertDestroyable` (`server/lib/worktree.js`) runs three checks, all of which must pass:
1. **`lane.kind === "managed"`.** An adopted lane fails immediately with `ENOTMANAGED` — this check alone is what keeps a directory you own off the destroy path. 1. **`lane.kind === "managed"`.** An adopted lane fails immediately with `ENOTMANAGED` — this check alone is what keeps a directory you own off the destroy path.
2. **The lane's `cwd`, resolved with `fs.realpathSync`, must sit inside the resolved `LANES_ROOT` on a path boundary.** `path.relative(root, cwd)` must not start with `..` or be absolute — `EOUTSIDEROOT` otherwise. This guards against a lane row that was hand-edited or drifted to point somewhere else. 2. **The lane's `cwd`, resolved with `fs.realpathSync`, must sit inside the resolved `LANES_ROOT` on a path boundary.** `path.relative(root, cwd)` must not start with `..` or be absolute — `EOUTSIDEROOT` otherwise. This guards against a lane row that was hand-edited or drifted to point somewhere else.
@@ -137,14 +137,14 @@ Before `reset` or `remove` touches a worktree, `assertDestroyable` (`server/lib/
These checks run unconditionally inside `resetWorktree`/`removeWorktree` themselves, not only when a confirmation is present — there is no code path to a git-destructive operation that skips them. These checks run unconditionally inside `resetWorktree`/`removeWorktree` themselves, not only when a confirmation is present — there is no code path to a git-destructive operation that skips them.
One documented exception, which does not weaken them: when a managed lane's directory has already been deleted by hand, `removeWorktree` takes a **prune path** instead. Checks 1 and 2 still hold (an adopted lane is refused outright; the recorded `cwd` must still be inside `LANES_ROOT`, verified lexically because a vanished path cannot be resolved), and check 3 is what the prune replaces — a worktree git no longer lists needs no removal. That path runs `git worktree prune` plus a safe branch delete against the source repo's bookkeeping and touches no directory at all. `removeWorktree` (with its prune path for a hand-deleted directory) still lives in `server/lib/worktree.js` and still runs those checks, but **no route calls it any more**: the lane `remove` action no longer destroys anything. It is kept as a guarded primitive, not a reachable lane verb.
### Each verb ### Each verb
- **`add`** — either adopts an existing directory (`--cwd`, kind `adopted`) or provisions a new managed worktree (`--repo`, kind `managed`, created under `LANES_ROOT` on branch `feat/<slug>`). - **`add`** — either adopts an existing directory (`--cwd`, kind `adopted`) or provisions a new managed worktree (`--repo`, kind `managed`, created under `LANES_ROOT` on branch `feat/<slug>`).
- **`clear`** (UI/API action only, no CLI verb) — resets the lane's own bookkeeping (stage, status, gate/CI fields, notes) back to idle, **including the detected columns** (`detected_stage`/`detected_signal`/`detected_at`), since detection is forward-only and a surviving detection would both claim progress the reset tree no longer has and block every later detection. Touches only the `lanes` row; no git operation, no session/event data is deleted. - **`clear`** (UI/API action only, no CLI verb) — resets the lane's own bookkeeping (stage, status, gate/CI fields, notes) back to idle, **including the detected columns** (`detected_stage`/`detected_signal`/`detected_at`), since detection is forward-only and a surviving detection would both claim progress the reset tree no longer has and block every later detection. Touches only the `lanes` row; no git operation, no session/event data is deleted.
- **`reset`** (managed lanes only) — checks out the base branch, `git reset --hard` to it, then `git clean -fd` (no `-x`, so **gitignored files such as `node_modules` and `.env` survive**; only untracked-but-not-ignored files are deleted), then recreates the feature branch off the base and clears the lane's bookkeeping. Requires `force: true` when the preflight reports unpushed commits. - **`reset`** (managed lanes only) — checks out the base branch, `git reset --hard` to it, then `git clean -fd` (no `-x`, so **gitignored files such as `node_modules` and `.env` survive**; only untracked-but-not-ignored files are deleted), then recreates the feature branch off the base and clears the lane's bookkeeping. Requires `force: true` when the preflight reports unpushed commits.
- **`remove`** — for a `managed` lane whose directory exists: `git worktree remove --force`, `git worktree prune`, then a safe branch delete, then the lane row is deleted. For a `managed` lane whose directory was deleted by hand: the prune path above (git's stale record and the branch are cleaned up, then the row is deleted) — a `missing` worktree does NOT make a lane unremovable. For an `adopted` lane: only the lane row is deleted (`removeWorktree` is never invoked for `adopted`, so the directory is never touched). The unpushed-commits force-gate only applies when the worktree is actually touched, i.e. `action === "remove" && kind === "managed"` — forgetting an adopted lane needs no `force` even with unpushed commits, since nothing on disk is at risk. - **`remove`** — **deletes the dashboard's record of the lane and nothing else**, for every lane kind. The working tree stays on disk, its branch stays in the source repo, git's own worktree bookkeeping is untouched, and the lane's database (if any) is left in place; deleting any of them is the user's own call (`git worktree remove`, `git branch -d`, dropping the database). Stopping the lane's run and stack still happens first — those are processes, not data — and the runtime bookkeeping under `LANES_ROOT/.state/lane<slot>/` (pid files, hook logs) is cleared, because it is AgentHub's own and would otherwise be inherited by the next lane to claim that slot. Consequently there is **no unpushed-commits force-gate on `remove`**: nothing it does can discard a commit.
- **`purge`** — deletes the lane's own `sessions`/`events`/`token_usage` rows (excluding the lane's currently-bound or any still-active session), for its own `cwd` and subdirectories. No git operation, no worktree change; not gated on unpushed commits. - **`purge`** — deletes the lane's own `sessions`/`events`/`token_usage` rows (excluding the lane's currently-bound or any still-active session), for its own `cwd` and subdirectories. No git operation, no worktree change; not gated on unpushed commits.
### The preflight contract ### The preflight contract
@@ -156,11 +156,11 @@ One documented exception, which does not weaken them: when a managed lane's dire
`blocked[]` can contain: `adopted` (not a managed worktree), `missing` (the directory is gone), `unreadable` (directory exists but git failed against it), and `unpushed-commits` (unpushed count > 0 — the only one a `force: true` can override). `blocked[]` can contain: `adopted` (not a managed worktree), `missing` (the directory is gone), `unreadable` (directory exists but git failed against it), and `unpushed-commits` (unpushed count > 0 — the only one a `force: true` can override).
**Which entries actually prevent the action depends on the action, and the server is the authority.** All of them block `reset`. **None of them blocks `remove`** — the server supports removing an adopted lane (forget the row), a hand-deleted worktree (prune path), and an unreadable one: it attempts `git worktree remove --force`, and if git itself refuses (e.g. a corrupt worktree `.git` pointer, which git validates and rejects even with a second `--force`), it deregisters the worktree directly from the source repo's bookkeeping instead — the directory itself is never touched either way. A surface that greys out confirmation on any `blocked` entry regardless of action makes a documented capability unreachable; `client/src/components/lanes/DestructiveLaneModal.tsx` encodes the per-action set in `HARD_BLOCKERS` and renders the rest as context. **Which entries actually prevent the action depends on the action, and the server is the authority.** All of them block `reset`. **None of them blocks `remove`**, including `unpushed-commits`: removal only deletes the lane's row, so an adopted lane, a hand-deleted directory and an unreadable one are all removable and none of them risks anything on disk. For `remove` the preflight counts are context, not a destruction manifest. A surface that greys out confirmation on any `blocked` entry regardless of action makes a documented capability unreachable; `client/src/components/lanes/DestructiveLaneModal.tsx` encodes the per-action set in `HARD_BLOCKERS` and renders the rest as context.
`unpushed` is the count of commits the action would really discard: with remotes, commits on no remote; without remotes, the commits ahead of the lane's `base_branch`, which is this lane's own work. A freshly provisioned worktree in a local-only repository reports `0`, not that repository's whole history — the `no-remote` warning, not an inflated count, is what tells the user nothing is backed up. A lane with no `base_branch` at all (an adopted one) falls back to the total commit count. `warnings[]` holds purely informational facts that never gate the action: currently just `no-remote` (no git remote configured at all — nothing here is backed up remotely, but the action proceeds normally). Both arrays are data, not an exception. `unpushed` is the count of commits the action would really discard: with remotes, commits on no remote; without remotes, the commits ahead of the lane's `base_branch`, which is this lane's own work. A freshly provisioned worktree in a local-only repository reports `0`, not that repository's whole history — the `no-remote` warning, not an inflated count, is what tells the user nothing is backed up. A lane with no `base_branch` at all (an adopted one) falls back to the total commit count. `warnings[]` holds purely informational facts that never gate the action: currently just `no-remote` (no git remote configured at all — nothing here is backed up remotely, but the action proceeds normally). Both arrays are data, not an exception.
`database` is the database name a `reset` (unless `--keep-db`) or `remove` will drop — `null` when the lane has no slot yet or its profile declares no `DB_PREFIX`. It is derived, not stored, the same way `slot`/`ports` are. `database` is the database name a `reset` (unless `--keep-db`) will drop — `remove` never drops one — `null` when the lane has no slot yet or its profile declares no `DB_PREFIX`. It is derived, not stored, the same way `slot`/`ports` are.
`bytesEstimate` is a **rough estimate**, not a measured disk size: it is derived as `(events + tokenRows) * 512`, a flat per-row byte guess, purely to give the confirmation dialog an order-of-magnitude sense of what purging will reclaim. `bytesEstimate` is a **rough estimate**, not a measured disk size: it is derived as `(events + tokenRows) * 512`, a flat per-row byte guess, purely to give the confirmation dialog an order-of-magnitude sense of what purging will reclaim.
@@ -362,7 +362,7 @@ Parsed with the same literal `KEY=VALUE` reader as `profile.env` — never sourc
- **Provisioning** a new managed worktree (`agenthub lanes add --repo`) — after the worktree is created: seed `.env`, run `bootstrap`, create the database, `migrate`, `seed`. Runs once. - **Provisioning** a new managed worktree (`agenthub lanes add --repo`) — after the worktree is created: seed `.env`, run `bootstrap`, create the database, `migrate`, `seed`. Runs once.
- **`up`** — repair `.env` (a hand-edited or never-seeded file gets fixed), ensure the database exists (cheap when it already does), `migrate` on every boot (a lane's schema drifts while it sits idle), and `seed` only on the boot that actually created the database. - **`up`** — repair `.env` (a hand-edited or never-seeded file gets fixed), ensure the database exists (cheap when it already does), `migrate` on every boot (a lane's schema drifts while it sits idle), and `seed` only on the boot that actually created the database.
- **`reset`** — refresh `.env` with `--force` (preserving `ENV_PRESERVE` keys from the lane's own file — swapping in the source's `JWT_SECRET` would 401 a running lane's tokens until reboot), re-run `bootstrap` (a reset can land on a branch with new dependencies), clear the declared `LANE_DIRS`, then drop, recreate, migrate and reseed the database — unless `--keep-db`, which skips that whole block. - **`reset`** — refresh `.env` with `--force` (preserving `ENV_PRESERVE` keys from the lane's own file — swapping in the source's `JWT_SECRET` would 401 a running lane's tokens until reboot), re-run `bootstrap` (a reset can land on a branch with new dependencies), clear the declared `LANE_DIRS`, then drop, recreate, migrate and reseed the database — unless `--keep-db`, which skips that whole block.
- **`remove`** — drops the database and its `_test` sibling before the rest of teardown. Best-effort: a failed drop is logged, never blocks removing the lane's record. **Never runs for an adopted lane** — its data was never AgentHub's to create, so it is never AgentHub's to destroy, the same invariant that protects an adopted lane's worktree. - **`remove`** — drops **nothing**. `removeLaneData` still exists in `server/lib/lane-runtime.js` (and still refuses an adopted lane), but the `remove` action no longer calls it: a lane's database is the user's data and outlives the dashboard's record of it. Drop it yourself when you actually want it gone.
A missing source `.env` falls back to `.env.example` with a loud warning, never a silent success. And any hook output that echoes a value from `secrets.env` (the password, specifically) is redacted before it reaches `$LOG_DIR/<hook>.log` or the `lane_hook_output` WebSocket message — that stream reaches a browser tab, and a hook debugging its own environment must not publish a database password to everyone watching. A missing source `.env` falls back to `.env.example` with a loud warning, never a silent success. And any hook output that echoes a value from `secrets.env` (the password, specifically) is redacted before it reaches `$LOG_DIR/<hook>.log` or the `lane_hook_output` WebSocket message — that stream reaches a browser tab, and a hook debugging its own environment must not publish a database password to everyone watching.
@@ -556,6 +556,7 @@ The lane is resolved from the working directory, so no id is needed (pass one to
agenthub lanes node # list every node of this lane's pipeline and its command agenthub lanes node # list every node of this lane's pipeline and its command
agenthub lanes node intake # → claude "/superpowers:brainstorming" agenthub lanes node intake # → claude "/superpowers:brainstorming"
agenthub lanes node intake the export button # extra words are appended to the prompt agenthub lanes node intake the export button # extra words are appended to the prompt
agenthub lanes node review # → claude "/code-review"
agenthub lanes node gates # → agenthub lanes hook ci-gate agenthub lanes node gates # → agenthub lanes hook ci-gate
agenthub lanes node pr-open --print # show the argv, run nothing agenthub lanes node pr-open --print # show the argv, run nothing
``` ```
@@ -599,6 +600,19 @@ contributes at most one record per node, however many times it ran — so this
narrows the history, it does not skew a rate. The Workflows page renders this as one bar per node (see narrows the history, it does not skew a rate. The Workflows page renders this as one bar per node (see
`client/src/components/workflows/LanePipelinePanel.tsx`). `client/src/components/workflows/LanePipelinePanel.tsx`).
**A new run is opened by a DECLARATION, never by a detection alone.** `runs` is
the denominator every rate on that page is read against, so what counts as a run
decides whether the page is worth reading. Stage detection bounces by design —
one feature's work reads `implement → tests → implement → tests` as the session
edits, runs the suite, and edits again — and when any backwards move opened a
run, a single feature was recorded as five or six of them. A real pass ends
where the session says it ends: a backwards move only starts a new run when it
is declared (`agenthub stage`, or a codex lane's ingest-driven declaration). A
detection that moves backwards continues the run in flight. Reaching the last
node still closes the run for both — work after `done` is new work by anyone's
reading. On this repo's own lane the rule took a 26-run history down to 5, which
is the number of features that were actually shipped through it.
`?lane=<id>` narrows the same aggregation to one lane and its own archived `?lane=<id>` narrows the same aggregation to one lane and its own archived
features, which is what the panel's lane picker sends. The `lanes` array in the features, which is what the panel's lane picker sends. The `lanes` array in the
response is never narrowed — it populates that picker, so it has to keep response is never narrowed — it populates that picker, so it has to keep
@@ -1057,11 +1071,12 @@ command: it's whichever of the fields above the matching rule's `tool` carried.
### Superpowers skill invocations ### Superpowers skill invocations
The built-in `default` pipeline's `plan`, `implement`, `review`, and `ship` The built-in `default` pipeline's `intake`, `plan`, `implement`, `review`, and
nodes each carry a `{"tool": "Skill", "match": "..."}` rule matching the `ship` nodes each carry a `{"tool": "Skill", "match": "..."}` rule matching the
Superpowers workflow skill names (`brainstorming`/`writing-plans`, workflow skill names they expect (`brainstorming`, `writing-plans`,
`executing-plans`/`subagent-driven-development`, `code-review`/ `executing-plans`/`subagent-driven-development`, Claude Code's own
`requesting-code-review`, `finishing-a-development-branch`). Invoking one of `code-review` plus `requesting-code-review`/`receiving-code-review`,
`finishing-a-development-branch`). Invoking one of
these skills is a much stronger signal than a matched Bash command, but it is these skills is a much stronger signal than a matched Bash command, but it is
still detection, not declaration — it renders dashed amber and never `done`, still detection, not declaration — it renders dashed amber and never `done`,
same as every other detected stage. same as every other detected stage.
@@ -1110,8 +1125,8 @@ a `detect` array on each node. The built-in `default.json`
| Node | Detect rules | | Node | Detect rules |
|---|---| |---|---|
| `intake` | `Agent` matching subagent `Explore` — a lane sitting at nothing else yet, but already exploring, shows *something* instead of idle | | `intake` | `Agent` matching subagent `Explore` — a lane sitting at nothing else yet, but already exploring, shows *something* instead of idle; `Skill` matching `brainstorming`, which is intake's own declared command |
| `plan` | `Skill` matching `brainstorming\|writing-plans`; `Write` matching `docs/.*plan.*\.md`; `Agent` matching `Plan\|Software Architect\|Backend Architect\|UX Architect`; `TaskCreate` (any) | | `plan` | `Skill` matching `writing-plans`; `Write` matching `docs/.*plan.*\.md`; `Agent` matching `Plan\|Software Architect\|Backend Architect\|UX Architect`; `TaskCreate` (any) |
| `implement` | `Skill` matching `executing-plans\|subagent-driven-development\|test-driven-development`; `Edit` (any); `Write` to any path NOT under a `docs/` directory (`^(?!.*(?:^\|/)docs/)`) | | `implement` | `Skill` matching `executing-plans\|subagent-driven-development\|test-driven-development`; `Edit` (any); `Write` to any path NOT under a `docs/` directory (`^(?!.*(?:^\|/)docs/)`) |
| `tests` | `Bash` matching `\b(npm (run )?test\|pytest\|vitest\|jest\|go test\|cargo test\|node\s+--test\|rspec\|phpunit\|dotnet test\|mvn test\|gradle test)\b`; `Agent` matching `Test Results Analyzer\|API Tester\|Performance Benchmarker` | | `tests` | `Bash` matching `\b(npm (run )?test\|pytest\|vitest\|jest\|go test\|cargo test\|node\s+--test\|rspec\|phpunit\|dotnet test\|mvn test\|gradle test)\b`; `Agent` matching `Test Results Analyzer\|API Tester\|Performance Benchmarker` |
| `review` | `Skill` matching `code-review\|requesting-code-review\|receiving-code-review`; `Bash` matching `\bgh\b[^;&\|]*\bpr diff\b`; `Agent` matching a name containing `reviewer`/`Reviewer` (excluding `senior-gate-reviewer`, a lookbehind), `Evidence Collector`, or `Accessibility Auditor` | | `review` | `Skill` matching `code-review\|requesting-code-review\|receiving-code-review`; `Bash` matching `\bgh\b[^;&\|]*\bpr diff\b`; `Agent` matching a name containing `reviewer`/`Reviewer` (excluding `senior-gate-reviewer`, a lookbehind), `Evidence Collector`, or `Accessibility Auditor` |
@@ -1308,7 +1323,7 @@ curl -X POST http://localhost:4820/api/lanes/5/clear
### remove ### remove
Delete the lane entirely, including all its history. **Requires confirmation** (`confirm: true`): Delete the dashboard's record of the lane, including its recorded history. The working tree, its branch and its database are left untouched. **Requires confirmation** (`confirm: true`):
```bash ```bash
curl -X POST http://localhost:4820/api/lanes/5/remove \ curl -X POST http://localhost:4820/api/lanes/5/remove \
@@ -1316,7 +1331,7 @@ curl -X POST http://localhost:4820/api/lanes/5/remove \
-d '{"confirm": true}' -d '{"confirm": true}'
``` ```
**Actions gated behind confirmation:** `remove` requires the `confirm` flag to prevent accidental deletion. **Actions gated behind confirmation:** `remove` requires the `confirm` flag to prevent accidentally dropping a lane's record. It deletes that record only — never the worktree, the branch or the database.
## Proof gallery ## Proof gallery
+6 -4
View File
@@ -212,8 +212,8 @@ any of them is ever deleted:
| The directory `agenthub.service` names (`unitRoot`, **foreign unit included**) | A service is executing from it. Not ours to rewrite is still not ours to delete. | | The directory `agenthub.service` names (`unitRoot`, **foreign unit included**) | A service is executing from it. Not ours to rewrite is still not ours to delete. |
| Any directory named in the user's `settings.json` hooks | A Claude Code process that has not been restarted is still executing from there — this is the same directory the section above describes, seen from the other side. Deleting it would break that session's hooks mid-flight. | | Any directory named in the user's `settings.json` hooks | A Claude Code process that has not been restarted is still executing from there — this is the same directory the section above describes, seen from the other side. Deleting it would break that session's hooks mid-flight. |
Plus the newest `KEEP_RETIRED_ROOTS` (2) of whatever is left, as a rollback Plus the newest `KEEP_RETIRED_ROOTS` (1) of whatever is left, as a rollback
cushion. Outside `~/.claude/plugins/cache/` the pruner does nothing at all — for cushion — one build deep, because a full copy of this repo is ~130 MB. Outside `~/.claude/plugins/cache/` the pruner does nothing at all — for
a git checkout the parent directory holds the user's own work. Best effort a git checkout the parent directory holds the user's own work. Best effort
throughout: a directory that will not delete is left alone, never an error. throughout: a directory that will not delete is left alone, never an error.
@@ -312,8 +312,10 @@ string a template names — as the pipeline's own `skill` or as one node's
`command` — has to come from an installed, enabled plugin. Today that means `command` — has to come from an installed, enabled plugin. Today that means
**`superpowers`**, which `/agenthub:ship-feature` and `/agenthub:ship-feature-lane` **`superpowers`**, which `/agenthub:ship-feature` and `/agenthub:ship-feature-lane`
drive step by step (`brainstorming`, `writing-plans`, drive step by step (`brainstorming`, `writing-plans`,
`test-driven-development`, `requesting-code-review`, `test-driven-development`, `verification-before-completion`,
`verification-before-completion`, `finishing-a-development-branch`). Install it `finishing-a-development-branch`). The `review` node is the exception: it names
the bare `/code-review`, Claude Code's own review skill, which carries no
`plugin:` prefix and so requires nothing to be installed. Install it
with `claude plugin install superpowers@claude-plugins-official`. with `claude plugin install superpowers@claude-plugins-official`.
The row is a **WARN, never a FAIL**: the dashboard, hooks and CLI all work The row is a **WARN, never a FAIL**: the dashboard, hooks and CLI all work
+83 -2
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Agent Dashboard for Claude Code API title: Agent Dashboard for Claude Code API
version: 1.6.8 version: 1.6.14
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence. description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact: contact:
name: Nguyễn Ngọc Trí Vĩ name: Nguyễn Ngọc Trí Vĩ
@@ -3026,6 +3026,21 @@ components:
example: example:
- /Users/son/.claude/.credentials.json - /Users/son/.claude/.credentials.json
- /Users/son/.claude.json - /Users/son/.claude.json
CcConfigUsageWindow:
type: object
nullable: true
description: One quota window as the account reports it.
properties:
utilization:
type: number
nullable: true
description: Percent of the window's allowance already spent, 0-100.
example: 48
resetsAt:
type: string
format: date-time
nullable: true
description: When the window rolls over, when the account reports one.
CodexConfigScope: CodexConfigScope:
type: string type: string
enum: enum:
@@ -8044,7 +8059,7 @@ paths:
tags: tags:
- CcConfig - CcConfig
summary: Tell the open Claude Code sessions to re-read plugins and skills summary: Tell the open Claude Code sessions to re-read plugins and skills
description: 'Types `/reload-plugins` and `/reload-skills` into the dashboard''s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.' description: 'Types `/reload-plugins` and `/reload-skills` into the dashboard''s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. The two commands are typed one second apart (`SETTLE_MS`): Claude Code opens its slash-command menu as soon as `/` is typed and swallows the Enter that follows, so back-to-back sends merged into the single line `/reload-plugins/reload-skills`, which runs as `/reload-plugins` with an argument and never reloads skills. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.'
operationId: ccConfigReload operationId: ccConfigReload
responses: responses:
'200': '200':
@@ -8979,6 +8994,72 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
/api/cc-config/profiles/usage:
post:
tags:
- CcConfig
summary: How much of a saved profile's account quota is already spent
description: |-
Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, each window being `{ utilization, resetsAt }` - a percentage and a rollover time. There is no local source for it: nothing under `CLAUDE_HOME` records a utilization and the credentials file carries only `subscriptionType`/`rateLimitTier`, so the server asks the endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.
A POST, and behind the same-origin guard, because answering it is a WRITE. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage refreshes it first, and the refresh ROTATES the stored credentials - Anthropic retires the refresh token it was handed. The new pair is written straight back into the profile file, because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A still-valid token is used as-is and nothing is rewritten; a token that looks valid but is refused is retried once through the refresh. Nothing calls this on a timer - it is one profile per user request. The OAuth token is sent to Anthropic and nowhere else, and never appears in the response.
operationId: ccConfigProfileUsage
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
properties:
name:
type: string
example: work
responses:
'200':
description: Percentages and reset times for the two quota windows.
content:
application/json:
schema:
type: object
properties:
name:
type: string
refreshed:
type: boolean
description: True when the stored token was expired and had to be rotated.
fiveHour:
$ref: '#/components/schemas/CcConfigUsageWindow'
sevenDay:
$ref: '#/components/schemas/CcConfigUsageWindow'
checkedAt:
type: string
format: date-time
'401':
description: '`EUNAUTHORIZED` - the account refused the token even after a refresh.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: '`ENOTFOUND` - no profile by that name.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: '`ENOREFRESH`/`ENOAUTH` - the profile holds no usable token; re-save it from a login.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'502':
description: '`EUPSTREAM` - Anthropic''s token or usage endpoint did not cooperate.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/codex-config/overview: /api/codex-config/overview:
get: get:
tags: tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "1.6.8", "version": "1.6.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "1.6.8", "version": "1.6.14",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "1.6.8", "version": "1.6.14",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
+8
View File
@@ -160,7 +160,15 @@ function reloadPanes() {
// a shell command instead. // a shell command instead.
if (SHELL_COMMANDS.has(paneCmd)) continue; if (SHELL_COMMANDS.has(paneCmd)) continue;
try { try {
let first = true;
for (const command of ["/reload-plugins", "/reload-skills"]) { for (const command of ["/reload-plugins", "/reload-skills"]) {
// Let the first command actually submit before typing the second, or
// both land on one composer line and Claude Code runs the pair as
// `/reload-plugins /reload-skills` — see SETTLE_MS in
// server/lib/cc-reload.js. Blocking is fine here: this worker is
// detached and serves no requests.
if (!first) execFileSync("sleep", ["1"], { timeout: 15_000 });
first = false;
execFileSync("tmux", ["send-keys", "-t", name, "-l", command], { timeout: 15_000 }); execFileSync("tmux", ["send-keys", "-t", name, "-l", command], { timeout: 15_000 });
execFileSync("tmux", ["send-keys", "-t", name, "Enter"], { timeout: 15_000 }); execFileSync("tmux", ["send-keys", "-t", name, "Enter"], { timeout: 15_000 });
} }
+1 -1
View File
@@ -507,7 +507,7 @@ function autoInstallSkillPlugins(rt, { force = false } = {}) {
/* ------------------------------------------------------ plugin-cache pruning */ /* ------------------------------------------------------ plugin-cache pruning */
/** How many retired builds to leave behind, newest first, as a rollback cushion. */ /** How many retired builds to leave behind, newest first, as a rollback cushion. */
const KEEP_RETIRED_ROOTS = 2; const KEEP_RETIRED_ROOTS = 1;
/** /**
* Delete the cache directories earlier updates left behind. * Delete the cache directories earlier updates left behind.
@@ -606,6 +606,24 @@ describe("patchSettings — backup and atomicity", () => {
assert.deepEqual(backup, original); assert.deepEqual(backup, original);
}); });
it("writes and backs up nothing when the patch changes nothing", () => {
// Re-attaching the same AI Router profile sends the same eight env keys
// every time. With an unconditional backup that left a `.bak` per apply
// piling up inside the user's own project — a dozen untracked files in a
// minute of switching lanes.
const file = path.join(FAKE_HOME, "settings.json");
ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } });
const before = fs.readFileSync(file, "utf8");
const result = ccMutate.patchSettings({ scope: "user", set: { model: "sonnet" } });
assert.equal(result.backupPath, null);
assert.deepEqual(result.changed, ["model"], "the request was still satisfied");
assert.equal(fs.readFileSync(file, "utf8"), before);
const backupDir = path.join(FAKE_HOME, ".cc-config-backups", "settings");
assert.deepEqual(fs.existsSync(backupDir) ? fs.readdirSync(backupDir) : [], []);
});
it("returns null backupPath for a fresh file", () => { it("returns null backupPath for a fresh file", () => {
const result = ccMutate.patchSettings({ const result = ccMutate.patchSettings({
scope: "user", scope: "user",
+30 -12
View File
@@ -103,11 +103,11 @@ describe("reloadableLanes", () => {
describe("reloadOpenSessions", () => { describe("reloadOpenSessions", () => {
beforeEach(() => tmux.__reset()); beforeEach(() => tmux.__reset());
it("types both reload commands, plugins before skills", () => { it("types both reload commands, plugins before skills", async () => {
// Order matters: a skill shipped BY a plugin only exists on disk once the // Order matters: a skill shipped BY a plugin only exists on disk once the
// plugin itself has been reloaded. // plugin itself has been reloaded.
const calls = stubTmux(["agenthub-lane-7"]); const calls = stubTmux(["agenthub-lane-7"]);
const out = reloadOpenSessions(); const out = await reloadOpenSessions({ settleMs: 0 });
assert.deepEqual(out.commands, ["/reload-plugins", "/reload-skills"]); assert.deepEqual(out.commands, ["/reload-plugins", "/reload-skills"]);
assert.equal(out.lanes_total, 1); assert.equal(out.lanes_total, 1);
@@ -119,27 +119,45 @@ describe("reloadOpenSessions", () => {
assert.equal(calls.filter((c) => c[0] === "send-keys" && c[3] === "Enter").length, 2); assert.equal(calls.filter((c) => c[0] === "send-keys" && c[3] === "Enter").length, 2);
}); });
it("honours a narrowed kinds list", () => { it("waits between the two commands so they cannot share one composer line", async () => {
// Without the pause the first Enter lands on Claude Code's slash-command
// menu, the second command is appended to the line still in the composer,
// and the pane submits `/reload-plugins/reload-skills` as a prompt — the
// reported bug. Timing is the behaviour, so timing is what is asserted.
const calls = stubTmux(["agenthub-lane-1"]); const calls = stubTmux(["agenthub-lane-1"]);
const out = reloadOpenSessions({ kinds: ["skills"] }); const at = [];
const typed = () => calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").length;
const before = typed();
const started = Date.now();
const done = reloadOpenSessions({ settleMs: 40 });
at.push(typed() - before);
await done;
assert.equal(at[0], 1, "only the first command is typed before the wait");
assert.ok(Date.now() - started >= 35, "the second command waited for the first to submit");
});
it("honours a narrowed kinds list", async () => {
const calls = stubTmux(["agenthub-lane-1"]);
const out = await reloadOpenSessions({ kinds: ["skills"], settleMs: 0 });
assert.deepEqual(out.commands, ["/reload-skills"]); assert.deepEqual(out.commands, ["/reload-skills"]);
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]); const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
assert.deepEqual(typed, ["/reload-skills"]); assert.deepEqual(typed, ["/reload-skills"]);
}); });
it("ignores an unknown kind rather than typing it into a REPL", () => { it("ignores an unknown kind rather than typing it into a REPL", async () => {
// The command set is fixed in the module; a pane's stdin is not a place to // The command set is fixed in the module; a pane's stdin is not a place to
// forward caller-supplied strings. // forward caller-supplied strings.
const calls = stubTmux(["agenthub-lane-1"]); const calls = stubTmux(["agenthub-lane-1"]);
const out = reloadOpenSessions({ kinds: ["plugins", "rm -rf /"] }); const out = await reloadOpenSessions({ kinds: ["plugins", "rm -rf /"], settleMs: 0 });
assert.deepEqual(out.commands, ["/reload-plugins"]); assert.deepEqual(out.commands, ["/reload-plugins"]);
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]); const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
assert.deepEqual(typed, ["/reload-plugins"]); assert.deepEqual(typed, ["/reload-plugins"]);
}); });
it("types into several lanes in one call", () => { it("types into several lanes in one call", async () => {
stubTmux(["agenthub-lane-1", "agenthub-lane-2"]); stubTmux(["agenthub-lane-1", "agenthub-lane-2"]);
const out = reloadOpenSessions(); const out = await reloadOpenSessions({ settleMs: 0 });
assert.equal(out.lanes_total, 2); assert.equal(out.lanes_total, 2);
assert.deepEqual( assert.deepEqual(
out.lanes.map((l) => l.laneId).sort((a, b) => a - b), out.lanes.map((l) => l.laneId).sort((a, b) => a - b),
@@ -147,9 +165,9 @@ describe("reloadOpenSessions", () => {
); );
}); });
it("records a pane that dies mid-send without failing the others", () => { it("records a pane that dies mid-send without failing the others", async () => {
stubTmux(["agenthub-lane-1", "agenthub-lane-2"], { "agenthub-lane-1": "__send-fails__" }); stubTmux(["agenthub-lane-1", "agenthub-lane-2"], { "agenthub-lane-1": "__send-fails__" });
const out = reloadOpenSessions(); const out = await reloadOpenSessions({ settleMs: 0 });
const failed = out.lanes.find((l) => l.id === "agenthub-lane-1"); const failed = out.lanes.find((l) => l.id === "agenthub-lane-1");
const ok = out.lanes.find((l) => l.id === "agenthub-lane-2"); const ok = out.lanes.find((l) => l.id === "agenthub-lane-2");
assert.match(failed.error, /died mid-send/); assert.match(failed.error, /died mid-send/);
@@ -158,9 +176,9 @@ describe("reloadOpenSessions", () => {
assert.equal(ok.error, undefined); assert.equal(ok.error, undefined);
}); });
it("reports an empty result instead of throwing when nothing is open", () => { it("reports an empty result instead of throwing when nothing is open", async () => {
stubTmux([]); stubTmux([]);
const out = reloadOpenSessions(); const out = await reloadOpenSessions({ settleMs: 0 });
assert.equal(out.lanes_total, 0); assert.equal(out.lanes_total, 0);
assert.deepEqual(out.lanes, []); assert.deepEqual(out.lanes, []);
}); });
+267
View File
@@ -0,0 +1,267 @@
/**
* @file cc-usage.test.js
* @description Tests for per-profile quota lookup.
*
* Two properties carry the whole feature. A profile's refreshed OAuth tokens
* must be written back — the exchange retires the refresh token it was given,
* so dropping the result leaves an account the user has to log into again. And
* a still-valid token must NOT be refreshed, because every refresh rewrites
* credentials on disk and the point of a read is to read.
*
* `fetch` is stubbed throughout: nothing here talks to Anthropic.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, afterEach, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-usage-"));
process.env.CLAUDE_HOME = TMP;
const profiles = require("../lib/cc-profiles");
const usage = require("../lib/cc-usage");
const realFetch = global.fetch;
let calls = [];
after(() => {
global.fetch = realFetch;
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5 });
} catch {
/* best-effort temp cleanup */
}
});
function writeProfileFile(name, credentials) {
const dir = profiles.profilesDir();
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, `${name}.json`),
JSON.stringify({
name,
label: "Label kept",
savedAt: 1,
account: { emailAddress: "a@b.c" },
credentials,
})
);
}
/** Answer the token endpoint and the usage endpoint, recording both. */
function stubFetch({ usageStatus = 200, usageBody } = {}) {
global.fetch = async (url, init) => {
calls.push({ url: String(url), init });
if (String(url).includes("/v1/oauth/token")) {
return {
ok: true,
status: 200,
text: async () =>
JSON.stringify({
access_token: "fresh-access",
refresh_token: "rotated-refresh",
expires_in: 28800,
}),
};
}
return {
ok: usageStatus === 200,
status: usageStatus,
json: async () =>
usageBody || {
five_hour: { utilization: 48.0, resets_at: "2026-09-17T13:00:00Z" },
seven_day: { utilization: 66.0, resets_at: "2026-09-21T08:00:00Z" },
seven_day_opus: null,
},
};
};
}
beforeEach(() => {
calls = [];
fs.rmSync(profiles.profilesDir(), { recursive: true, force: true });
});
afterEach(() => {
global.fetch = realFetch;
});
describe("profileUsage", () => {
it("reports the two windows, and only those", async () => {
writeProfileFile("live", {
accessToken: "still-good",
refreshToken: "r",
expiresAt: Date.now() + 3_600_000,
});
stubFetch();
const result = await usage.profileUsage("live");
assert.deepEqual(result.fiveHour, { utilization: 48, resetsAt: "2026-09-17T13:00:00Z" });
assert.deepEqual(result.sevenDay, { utilization: 66, resetsAt: "2026-09-21T08:00:00Z" });
// Nothing token-shaped may be in what a route is about to serialise.
assert.equal(JSON.stringify(result).includes("still-good"), false);
});
it("does not refresh a token that is still valid", async () => {
writeProfileFile("live", {
accessToken: "still-good",
refreshToken: "r",
expiresAt: Date.now() + 3_600_000,
});
stubFetch();
const result = await usage.profileUsage("live");
assert.equal(result.refreshed, false);
assert.equal(
calls.some((c) => c.url.includes("/v1/oauth/token")),
false,
"a read must not rewrite credentials on disk"
);
assert.match(calls[0].init.headers.Authorization, /still-good/);
});
it("refreshes an expired token and WRITES THE ROTATED PAIR BACK", async () => {
// The exchange retires the refresh token it was handed. A refresh whose
// result is not persisted leaves the profile holding a dead credential —
// the account could never be activated again without a fresh login.
writeProfileFile("stale", {
accessToken: "expired",
refreshToken: "original-refresh",
expiresAt: Date.now() - 1000,
subscriptionType: "team",
});
stubFetch();
const result = await usage.profileUsage("stale");
assert.equal(result.refreshed, true);
const stored = profiles.readProfile("stale");
assert.equal(stored.credentials.accessToken, "fresh-access");
assert.equal(stored.credentials.refreshToken, "rotated-refresh");
assert.ok(stored.credentials.expiresAt > Date.now());
// Everything else about the profile survives the rewrite.
assert.equal(stored.label, "Label kept");
assert.equal(stored.credentials.subscriptionType, "team");
assert.equal(stored.account.emailAddress, "a@b.c");
});
it("retries once through a refresh when a token that looked valid is refused", async () => {
writeProfileFile("revoked", {
accessToken: "revoked-but-unexpired",
refreshToken: "original-refresh",
expiresAt: Date.now() + 3_600_000,
});
let first = true;
global.fetch = async (url) => {
calls.push({ url: String(url) });
if (String(url).includes("/v1/oauth/token")) {
return {
ok: true,
status: 200,
text: async () =>
JSON.stringify({ access_token: "fresh", refresh_token: "rot", expires_in: 100 }),
};
}
if (first) {
first = false;
return { ok: false, status: 401, json: async () => ({}) };
}
return {
ok: true,
status: 200,
json: async () => ({ five_hour: { utilization: 1, resets_at: null }, seven_day: null }),
};
};
const result = await usage.profileUsage("revoked");
assert.equal(result.refreshed, true);
assert.deepEqual(result.fiveHour, { utilization: 1, resetsAt: null });
});
it("refuses a profile with no refresh token instead of half-answering", async () => {
writeProfileFile("tokenless", { accessToken: "expired", expiresAt: Date.now() - 1000 });
stubFetch();
await assert.rejects(
() => usage.profileUsage("tokenless"),
(e) => e.code === "ENOREFRESH"
);
});
it("uses the CLI's own token for the account that is logged in, not the saved copy", async () => {
// The saved copy of the LIVE account is the one set of credentials certain
// to fail: the CLI has refreshed since, and that retired the refresh token
// sitting in the profile file. Refreshing it here would also rotate the
// token the user's terminal is using.
const account = { accountUuid: "uuid-live", emailAddress: "a@b.c" };
fs.mkdirSync(profiles.profilesDir(), { recursive: true });
fs.writeFileSync(
path.join(profiles.profilesDir(), "mine.json"),
JSON.stringify({
name: "mine",
account,
credentials: { accessToken: "retired", refreshToken: "retired-r", expiresAt: 1 },
})
);
fs.writeFileSync(
path.join(TMP, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "live-token",
refreshToken: "live-r",
expiresAt: Date.now() + 3_600_000,
},
})
);
fs.writeFileSync(path.join(TMP, ".claude.json"), JSON.stringify({ oauthAccount: account }));
stubFetch();
const result = await usage.profileUsage("mine");
assert.equal(result.refreshed, false);
assert.match(calls[0].init.headers.Authorization, /live-token/);
assert.equal(
calls.some((c) => c.url.includes("/v1/oauth/token")),
false,
"refreshing the live account would log the user's own terminal out"
);
fs.rmSync(path.join(TMP, ".credentials.json"));
fs.rmSync(path.join(TMP, ".claude.json"));
});
it("names a spent refresh token instead of echoing the upstream 400", async () => {
writeProfileFile("superseded", {
accessToken: "expired",
refreshToken: "already-rotated",
expiresAt: Date.now() - 1000,
});
global.fetch = async (url) => {
calls.push({ url: String(url) });
return {
ok: false,
status: 400,
text: async () => '{"error": "invalid_grant", "error_description": "not found"}',
};
};
await assert.rejects(
() => usage.profileUsage("superseded"),
(e) => e.code === "ESTALE" && /save the profile again/.test(e.message)
);
});
it("reports an unknown profile as ENOTFOUND", async () => {
stubFetch();
await assert.rejects(
() => usage.profileUsage("nope"),
(e) => e.code === "ENOTFOUND"
);
});
});
+39 -21
View File
@@ -673,18 +673,18 @@ describe("destructive lane lifecycle actions", () => {
assert.equal(g(lane.cwd, "log", "-1", "--format=%s").trim(), "move head"); assert.equal(g(lane.cwd, "log", "-1", "--format=%s").trim(), "move head");
}); });
it("remove tears down the worktree and the branch, and deletes the lane row", async () => { it("remove deletes the lane row only, leaving a managed worktree and its branch on disk", async () => {
const lane = await createManagedLane("remove-managed"); const lane = await createManagedLane("remove-managed");
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=remove`); const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=remove`);
const removed = await request("POST", `/api/lanes/${lane.id}/remove`, { const removed = await request("POST", `/api/lanes/${lane.id}/remove`, {
confirm: true, confirm: true,
force: true,
expect: destructiveExpect(preflight.body), expect: destructiveExpect(preflight.body),
}); });
assert.equal(removed.status, 200); assert.equal(removed.status, 200);
assert.equal(removed.body.ok, true); assert.equal(removed.body.ok, true);
assert.equal(fs.existsSync(lane.cwd), false); // The user's code is the user's: forgetting a lane may never cost a branch.
assert.equal(g(SRC, "branch", "--list", lane.branch).trim(), ""); assert.equal(fs.existsSync(lane.cwd), true);
assert.equal(g(SRC, "branch", "--list", lane.branch).trim().endsWith(lane.branch), true);
const missing = await request("GET", `/api/lanes/${lane.id}`); const missing = await request("GET", `/api/lanes/${lane.id}`);
assert.equal(missing.status, 404); assert.equal(missing.status, 404);
}); });
@@ -719,7 +719,7 @@ describe("destructive lane lifecycle actions", () => {
assert.equal(fs.readFileSync(adoptedFile, "utf8"), "real project contents\n"); assert.equal(fs.readFileSync(adoptedFile, "utf8"), "real project contents\n");
}); });
it("forgets an adopted lane with unpushed commits without force, while managed removal still requires it", async () => { it("forgets a lane with unpushed commits without force, adopted or managed — removal destroys nothing", async () => {
const adoptedDir = path.join(ROOT, "adopted-unpushed"); const adoptedDir = path.join(ROOT, "adopted-unpushed");
fs.mkdirSync(adoptedDir, { recursive: true }); fs.mkdirSync(adoptedDir, { recursive: true });
g(adoptedDir, "init"); g(adoptedDir, "init");
@@ -750,13 +750,14 @@ describe("destructive lane lifecycle actions", () => {
`/api/lanes/${managed.id}/preflight?action=remove` `/api/lanes/${managed.id}/preflight?action=remove`
); );
assert.ok(managedPreflight.body.unpushed > 0); assert.ok(managedPreflight.body.unpushed > 0);
const refused = await request("POST", `/api/lanes/${managed.id}/remove`, { const forgottenManaged = await request("POST", `/api/lanes/${managed.id}/remove`, {
confirm: true, confirm: true,
expect: destructiveExpect(managedPreflight.body), expect: destructiveExpect(managedPreflight.body),
}); });
assert.equal(refused.status, 409); assert.equal(forgottenManaged.status, 200);
assert.equal(refused.body.error.code, "EUNPUSHED"); // The unpushed commit is still there, on a branch nothing deleted.
await request("DELETE", `/api/lanes/${managed.id}`); assert.equal(fs.existsSync(managed.cwd), true);
assert.equal(g(managed.cwd, "log", "-1", "--format=%s").trim(), "unpushed managed work");
}); });
it("requires a complete expect object and returns stale diagnostics", async () => { it("requires a complete expect object and returns stale diagnostics", async () => {
@@ -885,11 +886,33 @@ describe("destructive lane lifecycle actions", () => {
await request("DELETE", `/api/lanes/${lane.id}`); await request("DELETE", `/api/lanes/${lane.id}`);
}); });
it("removes a lane whose worktree was deleted by hand, taking the prune path", async () => { it("removes a managed lane whose directory git no longer lists as a worktree (issue #28)", async () => {
// The design promises "the lane reports `missing` and only `remove` is // Reported as "Lane action failed: lane is not listed as a worktree in …":
// offered, taking the prune path". Before this, `remove` hit check 2, which // a managed lane whose directory is a plain repo git never registered (or no
// maps a non-existent path to EOUTSIDEROOT → 400, so the lane row could // longer registers) as a worktree used to hit assertDestroyable's check 3 and
// never be removed through the destructive action at all. // become unremovable. Removal no longer asks git anything.
const lane = await createManagedLane("remove-not-a-worktree");
g(SRC, "worktree", "remove", "--force", lane.cwd);
fs.mkdirSync(lane.cwd, { recursive: true });
g(lane.cwd, "init");
g(lane.cwd, "config", "user.email", "test@example.com");
g(lane.cwd, "config", "user.name", "Test User");
fs.writeFileSync(path.join(lane.cwd, "keep.txt"), "user code\n");
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=remove`);
const removed = await request("POST", `/api/lanes/${lane.id}/remove`, {
confirm: true,
expect: destructiveExpect(preflight.body),
});
assert.equal(removed.status, 200, JSON.stringify(removed.body));
assert.equal((await request("GET", `/api/lanes/${lane.id}`)).status, 404);
assert.equal(fs.readFileSync(path.join(lane.cwd, "keep.txt"), "utf8"), "user code\n");
});
it("removes a lane whose worktree was deleted by hand, leaving git's own records alone", async () => {
// A missing directory changes nothing about `remove` any more: it drops the
// dashboard's row and never touches git. Cleaning up git's stale worktree
// entry is the user's own `git worktree prune`.
const lane = await createManagedLane("remove-hand-deleted"); const lane = await createManagedLane("remove-hand-deleted");
fs.rmSync(lane.cwd, { recursive: true, force: true }); fs.rmSync(lane.cwd, { recursive: true, force: true });
@@ -905,13 +928,8 @@ describe("destructive lane lifecycle actions", () => {
assert.equal(removed.status, 200, JSON.stringify(removed.body)); assert.equal(removed.status, 200, JSON.stringify(removed.body));
assert.equal(removed.body.ok, true); assert.equal(removed.body.ok, true);
assert.equal((await request("GET", `/api/lanes/${lane.id}`)).status, 404); assert.equal((await request("GET", `/api/lanes/${lane.id}`)).status, 404);
// git's stale record and the lane's branch are both gone. // The branch survives — it is where the deleted directory's commits live.
const wt = require("../lib/worktree"); assert.equal(g(SRC, "branch", "--list", lane.branch).trim().endsWith(lane.branch), true);
assert.equal(
(await wt.listWorktrees(SRC)).some((w) => w.path === lane.cwd),
false
);
assert.equal(g(SRC, "branch", "--list", lane.branch).trim(), "");
}); });
it("refuses a second start while the first run is still live, so no tmux session is orphaned", async () => { it("refuses a second start while the first run is still live, so no tmux session is orphaned", async () => {
@@ -93,6 +93,26 @@ describe("lane_stage_events", () => {
); );
}); });
it("keeps the run in flight when a DETECTION moves backwards", () => {
// Detection bounces by design: one feature reads implement → tests →
// implement as the session edits, runs the suite and edits again. Counting
// each bounce as a new run recorded this repo's own lane as 26 features
// when 5 had shipped, and `runs` is what every pass rate is divided by.
lanesLib.recordDetection(laneId, { nodeId: "implement", signal: "`edit`" });
lanesLib.recordDetection(laneId, { nodeId: "tests", signal: "`npm test`" });
// The bounce back only reaches the log once the standing detection has aged
// past the TTL — forward-only holds while it is fresh (see recordDetection).
db.prepare("UPDATE lanes SET detected_at = '2020-01-01T00:00:00.000Z' WHERE id = ?").run(
laneId
);
lanesLib.recordDetection(laneId, { nodeId: "implement", signal: "`edit`" });
assert.deepEqual(
events().map((r) => `${r.node_id}#${r.run_seq}`),
["implement#1", "tests#1", "implement#1"]
);
});
it("starts a new run when the pipeline's last node is left behind", () => { it("starts a new run when the pipeline's last node is left behind", () => {
lanesLib.setStage(laneId, { stage: "done" }); lanesLib.setStage(laneId, { stage: "done" });
lanesLib.setStage(laneId, { stage: "intake" }); lanesLib.setStage(laneId, { stage: "intake" });
+44
View File
@@ -277,6 +277,50 @@ describe("/api/lanes", () => {
ws.close(); ws.close();
}); });
it("broadcasts lane_update on router apply", async () => {
// Without this the client's cached row keeps the OLD router_profile: the
// picker looks right until the user switches lanes and back, at which
// point the effect re-reads the stale row and flips the selection.
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-bcast-"));
const created = await request("POST", "/api/lanes", { cwd, title: "Router Lane" });
const id = created.body.lane.id;
// Start somewhere other than native, so the broadcast has to carry a
// CHANGED value rather than the one the row already held.
require("../lib/lanes").setRouterProfile(id, "some-router");
const ws = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`);
const messages = [];
await new Promise((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
});
ws.on("message", (msg) => {
try {
messages.push(JSON.parse(msg));
} catch {
/* parse error */
}
});
try {
const applied = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
assert.equal(applied.status, 200);
await new Promise((resolve) => setTimeout(resolve, 100));
const msg = messages.find((m) => m.type === "lane_update" && m.data.lane?.id === id);
assert.ok(msg, "router apply should broadcast lane_update");
// `native` is stored as null — what matters is that the pushed row is the
// post-apply one, not the "some-router" the client still has cached.
assert.equal(msg.data.lane.router_profile, null);
} finally {
// A socket left open by a failed assertion keeps the whole run alive.
ws.close();
}
await request("DELETE", `/api/lanes/${id}`);
fs.rmSync(cwd, { recursive: true, force: true });
});
}); });
describe("hook → lane binding", () => { describe("hook → lane binding", () => {
+3 -2
View File
@@ -546,13 +546,14 @@ describe("agenthub lanes destructive lifecycle", () => {
assert.match(loser.stdout, /Current preflight:/); assert.match(loser.stdout, /Current preflight:/);
}); });
it("removes a managed lane's worktree and directory with --yes", async () => { it("removes a managed lane's record with --yes, leaving its directory on disk", async () => {
const lane = await ownLane("CLI remove managed"); const lane = await ownLane("CLI remove managed");
assert.equal(fs.existsSync(lane.cwd), true); assert.equal(fs.existsSync(lane.cwd), true);
const r = await cli(["lanes", "remove", String(lane.id), "--yes"], SOURCE_REPO); const r = await cli(["lanes", "remove", String(lane.id), "--yes"], SOURCE_REPO);
assert.equal(r.status, 0, r.stderr); assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`Removed lane #${lane.id}`)); assert.match(r.stdout, new RegExp(`Removed lane #${lane.id}`));
assert.equal(fs.existsSync(lane.cwd), false); assert.match(r.stdout, /left for you to delete yourself/);
assert.equal(fs.existsSync(lane.cwd), true);
assert.equal((await get(`/api/lanes/${lane.id}`)).status, 404); assert.equal((await get(`/api/lanes/${lane.id}`)).status, 404);
}); });
+12
View File
@@ -157,6 +157,18 @@ describe("per-node driver command", () => {
// `agenthub lanes node <id>` runnable for every node in the template. // `agenthub lanes node <id>` runnable for every node in the template.
assert.equal(byId.done, "agenthub stage done"); assert.equal(byId.done, "agenthub stage done");
assert.equal(byId["e2e-feature-passed"], "agenthub stage e2e-feature-passed"); assert.equal(byId["e2e-feature-passed"], "agenthub stage e2e-feature-passed");
// The 8-node `default` template drives the same way. `intake` in particular
// runs the brainstorm — it used to declare only `agenthub stage intake`,
// which made "intake" a label the pipeline walked past without ever asking
// the user a question.
const def = getPipeline("default");
const defById = Object.fromEntries(def.nodes.map((n) => [n.id, n.command]));
assert.equal(defById.intake, "/superpowers:brainstorming");
assert.equal(defById.plan, "/superpowers:writing-plans");
assert.equal(defById.implement, "/superpowers:test-driven-development");
assert.equal(defById.gate, "/superpowers:verification-before-completion");
assert.equal(defById.done, "agenthub stage done");
}); });
it("defaults a template node that declares no command to the empty string", () => { it("defaults a template node that declares no command to the empty string", () => {
+3 -3
View File
@@ -571,12 +571,12 @@ describe("pruneRetiredPluginRoots", () => {
fs.writeFileSync(path.join(pluginsDir, "installed_plugins.json"), "{}"); fs.writeFileSync(path.join(pluginsDir, "installed_plugins.json"), "{}");
}); });
it("keeps the current build and the two newest retired ones", () => { it("keeps the current build and the single newest retired one", () => {
const removed = boot.pruneRetiredPluginRoots(RT, dir("current")); const removed = boot.pruneRetiredPluginRoots(RT, dir("current"));
assert.equal(removed, 3); // v1 v2 v3 go; v4 v5 are the cushion assert.equal(removed, 4); // v1..v4 go; v5 is the whole cushion
assert.ok(fs.existsSync(dir("current"))); assert.ok(fs.existsSync(dir("current")));
assert.ok(fs.existsSync(dir("v5"))); assert.ok(fs.existsSync(dir("v5")));
assert.ok(fs.existsSync(dir("v4"))); assert.ok(!fs.existsSync(dir("v4")));
assert.ok(!fs.existsSync(dir("v1"))); assert.ok(!fs.existsSync(dir("v1")));
}); });
+2 -1
View File
@@ -176,7 +176,8 @@ test("detects stage through the real default pipeline template, end-to-end via g
test("every shipped default.json rule fires end-to-end via getPipeline", () => { test("every shipped default.json rule fires end-to-end via getPipeline", () => {
const pipeline = getPipeline("default"); const pipeline = getPipeline("default");
const cases = [ const cases = [
["plan", "Skill", { skill: "brainstorming" }], // The brainstorm is intake's own work now, so it paints intake, not plan.
["intake", "Skill", { skill: "brainstorming" }],
["plan", "Skill", { skill: "writing-plans" }], ["plan", "Skill", { skill: "writing-plans" }],
["implement", "Edit", { file_path: "server/lib/app.js" }], ["implement", "Edit", { file_path: "server/lib/app.js" }],
["implement", "Write", { file_path: "server/lib/app.js" }], ["implement", "Write", { file_path: "server/lib/app.js" }],
+7 -3
View File
@@ -8,7 +8,7 @@
"label": "Intake", "label": "Intake",
"icon": "📝", "icon": "📝",
"gate": false, "gate": false,
"command": "agenthub stage intake", "command": "/superpowers:brainstorming",
"aliases": [ "aliases": [
"assigned", "assigned",
"claimed", "claimed",
@@ -18,6 +18,10 @@
{ {
"tool": "Agent", "tool": "Agent",
"match": "^Explore$" "match": "^Explore$"
},
{
"tool": "Skill",
"match": "brainstorming"
} }
] ]
}, },
@@ -35,7 +39,7 @@
"detect": [ "detect": [
{ {
"tool": "Skill", "tool": "Skill",
"match": "brainstorming|writing-plans" "match": "writing-plans"
}, },
{ {
"tool": "Write", "tool": "Write",
@@ -103,7 +107,7 @@
"label": "Review", "label": "Review",
"icon": "👀", "icon": "👀",
"gate": true, "gate": true,
"command": "/superpowers:requesting-code-review", "command": "/code-review",
"aliases": [ "aliases": [
"reviewing", "reviewing",
"code-review", "code-review",
+1 -1
View File
@@ -142,7 +142,7 @@
"label": "Code review", "label": "Code review",
"icon": "👀", "icon": "👀",
"gate": true, "gate": true,
"command": "/superpowers:requesting-code-review", "command": "/code-review",
"aliases": [ "aliases": [
"reviewing", "reviewing",
"code-review", "code-review",
+11
View File
@@ -571,6 +571,17 @@ function patchSettings(args) {
throw makeError("ETOOLARGE", "the resulting settings file would be too large"); throw makeError("ETOOLARGE", "the resulting settings file would be too large");
} }
// A patch that changes nothing writes nothing — and therefore backs up
// nothing. Re-applying the same AI Router profile to a lane is the common
// case (every re-attach sends the same eight env keys), and an unconditional
// backup turned that into a `.bak` per apply piling up inside the user's
// project. `changed` still lists the keys the caller asked for: they ARE the
// file's content now, the request was satisfied, there was just nothing to
// move to get there.
if (existed && output === raw) {
return { path: file, backupPath: null, created: false, changed };
}
let backupPath = null; let backupPath = null;
if (existed) { if (existed) {
const root = settingsBackupRoot(scope, opts); const root = settingsBackupRoot(scope, opts);
+38
View File
@@ -156,6 +156,29 @@ function freeName(base) {
return name; return name;
} }
/**
* The LIVE credentials when `stored` is the account logged in right now, else
* null.
*
* A saved profile is a copy, and an OAuth refresh token is single use: every
* time Claude Code refreshes the account it is logged into, the copy sitting in
* the profile file is retired. So for the active account the saved credentials
* are the one set guaranteed NOT to work, and `.credentials.json` — which the
* CLI keeps current — is the only usable source. Nothing here writes: rotating
* the token the live session is holding would log the user out of their own
* terminal.
*
* @param stored a profile as returned by `readProfile`
* @returns the live `claudeAiOauth` block, or null when this is not that account
*/
function liveCredentialsFor(stored) {
const live = readActiveAuth();
if (!live.oauth) return null;
const mine = identityOf(live).accountUuid;
const theirs = identityOf({ oauth: stored.credentials, account: stored.account }).accountUuid;
return mine && mine === theirs ? live.oauth : null;
}
/** Read one stored profile, or null when it does not exist. */ /** Read one stored profile, or null when it does not exist. */
function readProfile(name) { function readProfile(name) {
const stored = readJson(profileFile(name)); const stored = readJson(profileFile(name));
@@ -368,12 +391,27 @@ function deleteProfile(name) {
return { ok: true, name }; return { ok: true, name };
} }
/**
* Overwrite a stored profile wholesale. The one caller is `cc-usage.js`, which
* has to write a rotated OAuth token pair straight back: the exchange retires
* the refresh token it was given, so a profile whose refreshed credentials are
* not persisted is a profile that can no longer be activated.
*/
function writeProfile(name, value) {
writeJson(profileFile(name), value);
return { ok: true, name };
}
module.exports = { module.exports = {
listProfiles, listProfiles,
saveProfile, saveProfile,
renameProfile, renameProfile,
activateProfile, activateProfile,
deleteProfile, deleteProfile,
// Token-bearing reads/writes: for cc-usage.js, never for a route.
readProfile,
writeProfile,
liveCredentialsFor,
// exported for tests // exported for tests
profilesDir, profilesDir,
configPaths, configPaths,
+41 -12
View File
@@ -29,6 +29,20 @@ const { listRuns } = require("./pty-run");
/** Shell process names — a pane sitting at one of these has no REPL to reload. */ /** Shell process names — a pane sitting at one of these has no REPL to reload. */
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]); const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
/**
* Pause between two commands typed into the SAME pane.
*
* Claude Code's REPL opens a slash-command menu as soon as `/` is typed, and
* the Enter that follows lands on that menu rather than submitting the line.
* With no pause the next command's text is appended to the line still sitting
* in the composer, and what gets submitted is the single string
* `/reload-plugins/reload-skills` — which Claude Code runs as `/reload-plugins`
* with `/reload-skills` as its argument, so the second reload never happens and
* the pane shows a stray prompt turn. Measured against a live pane: 0.3s is
* already enough on an idle one, 1s leaves headroom for a busy REPL.
*/
const SETTLE_MS = 1000;
/** The only commands this module is ever allowed to type. Not caller-supplied: /** The only commands this module is ever allowed to type. Not caller-supplied:
* a pane's stdin is an arbitrary-code boundary, so the set is fixed here. */ * a pane's stdin is an arbitrary-code boundary, so the set is fixed here. */
const RELOAD_COMMANDS = { const RELOAD_COMMANDS = {
@@ -61,30 +75,39 @@ function reloadableLanes() {
/** /**
* Type the requested reload commands into every live `claude` pane. * Type the requested reload commands into every live `claude` pane.
* *
* Sends to a pane even when its agent is mid-turn: tmux queues the keystrokes * Sends to a pane even when its agent is mid-turn: the keystrokes land in the
* into the REPL's input buffer, so the command lands when the turn ends rather * REPL's composer, so the command runs when the turn ends rather than being
* than being lost. That is a deliberate choice — a reload that arrives late is * lost. That is a deliberate choice — a reload that arrives late is still a
* still a reload, whereas skipping busy panes silently leaves exactly the * reload, whereas skipping busy panes silently leaves exactly the sessions the
* sessions the user is actually working in running stale code. * user is actually working in running stale code. It is also why SETTLE_MS
* exists: a composer that has not submitted yet is a line the next command
* would be appended to.
* *
* Never throws: one unreachable pane (killed between the listing and the send) * Never throws: one unreachable pane (killed between the listing and the send)
* must not fail a batch that updated everything else successfully. * must not fail a batch that updated everything else successfully.
* *
* @param {{kinds?: ("plugins"|"skills")[]}} [options] which commands to send; * Async because of SETTLE_MS above: the two commands must reach a pane as two
* defaults to both, plugins first (a skill shipped BY a plugin only exists on * separate submissions, and waiting for that without blocking the event loop is
* disk once the plugin has been reloaded). * the only way a live dashboard keeps broadcasting while it types.
* @returns {{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[], *
* commands: string[], lanes_total: number}} * @param {{kinds?: ("plugins"|"skills")[], settleMs?: number}} [options] which
* commands to send; defaults to both, plugins first (a skill shipped BY a
* plugin only exists on disk once the plugin has been reloaded). `settleMs`
* is a test seam — no route passes it.
* @returns {Promise<{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[],
* commands: string[], lanes_total: number}>}
*/ */
function reloadOpenSessions(options = {}) { async function reloadOpenSessions(options = {}) {
const kinds = (options.kinds || ["plugins", "skills"]).filter((k) => k in RELOAD_COMMANDS); const kinds = (options.kinds || ["plugins", "skills"]).filter((k) => k in RELOAD_COMMANDS);
const commands = kinds.map((k) => RELOAD_COMMANDS[k]); const commands = kinds.map((k) => RELOAD_COMMANDS[k]);
const settleMs = options.settleMs ?? SETTLE_MS;
const lanes = []; const lanes = [];
for (const lane of reloadableLanes()) { for (const lane of reloadableLanes()) {
const sent = []; const sent = [];
let error; let error;
for (const command of commands) { for (const command of commands) {
if (sent.length && settleMs > 0) await new Promise((r) => setTimeout(r, settleMs));
try { try {
tmux.sendText(lane.id, command); tmux.sendText(lane.id, command);
sent.push(command); sent.push(command);
@@ -99,4 +122,10 @@ function reloadOpenSessions(options = {}) {
return { commands, lanes, lanes_total: lanes.length }; return { commands, lanes, lanes_total: lanes.length };
} }
module.exports = { reloadOpenSessions, reloadableLanes, RELOAD_COMMANDS, SHELL_COMMANDS }; module.exports = {
reloadOpenSessions,
reloadableLanes,
RELOAD_COMMANDS,
SHELL_COMMANDS,
SETTLE_MS,
};
+203
View File
@@ -0,0 +1,203 @@
/**
* @file cc-usage.js
* @description How much of an Anthropic account's quota is already spent —
* the number that decides which saved auth profile (`cc-profiles.js`) is worth
* switching to.
*
* There is no local source for it. Nothing under CLAUDE_HOME records a
* utilization or a reset time; the credentials file carries only
* `subscriptionType` and `rateLimitTier`. The figure comes from the same
* endpoint the CLI's own `/usage` reads, `GET /api/oauth/usage`, answered for
* whichever account's OAuth token is presented.
*
* Two rules this module exists to hold:
*
* - **A profile's token is sent to exactly one place: Anthropic.** It is read
* from the profile file, put in an `Authorization` header, and never
* returned, logged or echoed. What leaves this module is two percentages
* and two reset timestamps.
*
* - **The active account is read from the CLI, never from its saved copy.**
* A refresh token is single use, so every refresh Claude Code performs on
* the account it is logged into retires the copy stored in that profile:
* the saved credentials of the LIVE account are the one set certain to be
* rejected. `cc-profiles.liveCredentialsFor` hands back what the CLI is
* actually holding, and that token is never refreshed here — rotating it
* would log the user out of their own terminal.
*
* - **Nothing here runs on a poll, and no refresh happens that a token's own
* expiry did not force.** Opening the Profiles modal asks for every saved
* account at once — comparing them is the point — so the guard against
* rewriting a user's credentials over and over is not "one row at a time",
* it is here: the live account never refreshes at all, and any other profile
* refreshes only once its token has actually expired, which then leaves it
* valid for hours of further opens. What a refresh does return is written
* straight back (`cc-profiles.writeProfile`), because Anthropic retires the
* refresh token it was handed and dropping the replacement would leave an
* account the user has to log into again.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const profiles = require("./cc-profiles");
/** Claude Code's own public OAuth client. A refresh is only accepted for the
* client the token was issued to, so this is not a configurable value. */
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
/** Treat a token expiring within this window as already expired: a refresh is
* cheaper than a 401 that costs the whole call. */
const EXPIRY_MARGIN_MS = 60_000;
const TIMEOUT_MS = 15_000;
function err(code, message) {
const e = new Error(message);
e.code = code;
return e;
}
async function postJson(url, body, headers) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(body),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
const text = await res.text();
if (!res.ok) {
// The one upstream failure a user can act on: this profile's refresh token
// has already been spent, which happens whenever a NEWER login or refresh
// for the same account rotated it. Saying so beats echoing a raw 400.
if (text.includes("invalid_grant")) {
throw err(
"ESTALE",
"this profile's saved login has been superseded - activate the account once and save the profile again"
);
}
throw err("EUPSTREAM", `${url} answered ${res.status}: ${text.slice(0, 200)}`);
}
try {
return JSON.parse(text);
} catch {
throw err("EUPSTREAM", `${url} did not answer JSON`);
}
}
/**
* Exchange a profile's refresh token for a fresh access token AND persist what
* comes back.
*
* The persist is not an optimisation. Anthropic returns a new refresh token and
* retires the one just used, so a refresh that is not written back leaves the
* profile file holding a dead credential — the account would have to be logged
* into again before it could ever be activated.
*
* @param {string} name a saved profile's identifier
* @returns {Promise<string>} the fresh access token (never returned to a route)
*/
async function refreshProfile(name) {
const stored = profiles.readProfile(name);
if (!stored) throw err("ENOTFOUND", `no saved profile named ${name}`);
const creds = stored.credentials || {};
if (!creds.refreshToken) {
throw err("ENOREFRESH", `profile ${name} has no refresh token — re-save it from a login`);
}
const data = await postJson(TOKEN_URL, {
grant_type: "refresh_token",
refresh_token: creds.refreshToken,
client_id: CLIENT_ID,
});
if (!data.access_token) throw err("EUPSTREAM", "the token endpoint returned no access_token");
profiles.writeProfile(name, {
...stored,
credentials: {
...creds,
accessToken: data.access_token,
// Both are rotated by the exchange; keeping the old refresh token here
// would pin the profile to a credential that no longer works.
refreshToken: data.refresh_token || creds.refreshToken,
expiresAt: Date.now() + (Number(data.expires_in) || 0) * 1000,
},
});
return data.access_token;
}
/** The two windows the UI reports, flattened out of a response that also
* carries a dozen experiment-named buckets this has no use for. */
function windowOf(value) {
if (!value || typeof value !== "object") return null;
const utilization = Number(value.utilization);
return {
utilization: Number.isFinite(utilization) ? utilization : null,
resetsAt: typeof value.resets_at === "string" ? value.resets_at : null,
};
}
/** Ask Anthropic what this token's account has spent. */
async function fetchUsage(accessToken) {
const res = await fetch(USAGE_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
"anthropic-beta": "oauth-2025-04-20",
},
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (res.status === 401) throw err("EUNAUTHORIZED", "the account rejected this token");
if (!res.ok) throw err("EUPSTREAM", `the usage endpoint answered ${res.status}`);
const data = await res.json();
return { fiveHour: windowOf(data.five_hour), sevenDay: windowOf(data.seven_day) };
}
/**
* Usage for one saved profile, refreshing its token first when that token has
* expired (which, for any profile that is not the active one, it usually has).
*
* @param {string} name a saved profile's identifier
* @returns {Promise<{name:string, refreshed:boolean, fiveHour:object|null,
* sevenDay:object|null, checkedAt:string}>} percentages and reset times only
*/
async function profileUsage(name) {
const stored = profiles.readProfile(name);
if (!stored) throw err("ENOTFOUND", `no saved profile named ${name}`);
// For the account that is logged in RIGHT NOW the saved copy is the one set
// of credentials guaranteed to fail — the CLI has rotated its refresh token
// since, retiring the profile's. Use what the CLI holds, and never refresh
// it: that would retire the token the user's own terminal is using, and this
// module does not write `.credentials.json` back.
const live = profiles.liveCredentialsFor(stored);
const creds = live || stored.credentials || {};
const fresh = Number(creds.expiresAt) > Date.now() + EXPIRY_MARGIN_MS;
if (live && !fresh) {
throw err(
"ELIVEEXPIRED",
"the active account's token has expired - use Claude Code once so it refreshes, then check again"
);
}
let token = fresh ? creds.accessToken : await refreshProfile(name);
let refreshed = !fresh;
if (!token) throw err("ENOAUTH", `profile ${name} holds no access token`);
try {
const usage = await fetchUsage(token);
return { name, refreshed, ...usage, checkedAt: new Date().toISOString() };
} catch (e) {
// A token that looked fresh but was revoked server-side is worth one
// retry through the refresh path; a second 401 is a real answer. Not for
// the live account, for the same reason its token is never refreshed here.
if (e.code !== "EUNAUTHORIZED" || refreshed || live) throw e;
token = await refreshProfile(name);
refreshed = true;
const usage = await fetchUsage(token);
return { name, refreshed, ...usage, checkedAt: new Date().toISOString() };
}
}
module.exports = { profileUsage, fetchUsage, refreshProfile, CLIENT_ID, USAGE_URL, TOKEN_URL };
+3 -2
View File
@@ -76,8 +76,9 @@ async function preflight(lane, action) {
} }
} }
// The database name this action would drop (reset unless --keep-db, // The database name this action would drop (reset unless --keep-db; never
// remove always) — echoed the same way `head`/`dirty` are, so the // for remove, which forgets the lane and leaves its data in place — see the
// remove branch in routes/lanes.js) — echoed the same way `head`/`dirty` are, so the
// confirmation dialog names the destructive fact rather than leaving it a // confirmation dialog names the destructive fact rather than leaving it a
// surprise. Null when the lane has no slot yet or the profile declares no // surprise. Null when the lane has no slot yet or the profile declares no
// DB_PREFIX — nothing has been derived to drop. // DB_PREFIX — nothing has been derived to drop.
+13 -4
View File
@@ -398,9 +398,17 @@ function reservedPorts(excludeLaneId = null) {
* *
* The run a row belongs to is decided here rather than stored on the lane, so * The run a row belongs to is decided here rather than stored on the lane, so
* there is one rule and no column to fall out of sync. A new run starts when * there is one rule and no column to fall out of sync. A new run starts when
* the lane moves BACKWARDS (the next feature begins at plan) or when the last * the lane is DECLARED backwards (the next feature begins at plan) or when the
* event sat on the pipeline's final node (the previous feature finished). Any * last event sat on the pipeline's final node (the previous feature finished).
* other move continues the run in flight. * Any other move continues the run in flight.
*
* Only a declaration may open a run by going backwards. Detection bounces by
* design — one feature reads `implement → tests → implement` as the session
* edits, runs the suite and edits again — so counting every backwards detection
* as a new run recorded a single feature as five or six of them, and `runs` is
* the denominator the Workflows page reads every pass rate against. A pass ends
* where the session says it ends. Reaching the final node still closes the run
* for a detection too: work after `done` is new work however it was observed.
* *
* Never throws: a lane's stage must still advance if this log cannot be * Never throws: a lane's stage must still advance if this log cannot be
* written — the dashboard losing a statistic beats an agent's pipeline dying. * written — the dashboard losing a statistic beats an agent's pipeline dying.
@@ -415,7 +423,8 @@ function appendStageEvent(lane, nodeId, { source, result, evidence } = {}) {
if (last) { if (last) {
const lastIdx = phaseIdx(pipeline, last.node_id); const lastIdx = phaseIdx(pipeline, last.node_id);
const nextIdx = phaseIdx(pipeline, nodeId); const nextIdx = phaseIdx(pipeline, nodeId);
const wentBack = lastIdx > -1 && nextIdx > -1 && nextIdx < lastIdx; const declared = source !== "detected";
const wentBack = declared && lastIdx > -1 && nextIdx > -1 && nextIdx < lastIdx;
const finished = lastIdx > -1 && lastIdx === pipeline.nodes.length - 1 && nextIdx !== lastIdx; const finished = lastIdx > -1 && lastIdx === pipeline.nodes.length - 1 && nextIdx !== lastIdx;
runSeq = last.run_seq + (wentBack || finished ? 1 : 0); runSeq = last.run_seq + (wentBack || finished ? 1 : 0);
} }
+10 -7
View File
@@ -105,18 +105,21 @@ function gitDirOf(laneDir) {
* must not fail an otherwise-successful attach. * must not fail an otherwise-successful attach.
*/ */
function excludeSettingsFromGit(laneDir) { function excludeSettingsFromGit(laneDir) {
const line = ".claude/settings.local.json"; // The backup directory beside it, for the same reason plus one more: those
// copies hold the API key too, and they are what a user sees as a dozen
// untracked files appearing in their project while they switch routers.
const lines = [".claude/settings.local.json", ".claude/.cc-config-backups/"];
try { try {
const gitDir = gitDirOf(laneDir); const gitDir = gitDirOf(laneDir);
if (!gitDir) return; if (!gitDir) return;
const excludePath = path.join(gitDir, "info", "exclude"); const excludePath = path.join(gitDir, "info", "exclude");
fs.mkdirSync(path.dirname(excludePath), { recursive: true }); fs.mkdirSync(path.dirname(excludePath), { recursive: true });
const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : ""; let existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
if (existing.split("\n").includes(line)) return; const present = new Set(existing.split("\n"));
fs.writeFileSync( const missing = lines.filter((line) => !present.has(line));
excludePath, if (missing.length === 0) return;
existing && !existing.endsWith("\n") ? `${existing}\n${line}\n` : `${existing}${line}\n` if (existing && !existing.endsWith("\n")) existing += "\n";
); fs.writeFileSync(excludePath, `${existing}${missing.join("\n")}\n`);
} catch { } catch {
/* best effort — see above */ /* best effort — see above */
} }
+88 -1
View File
@@ -1297,6 +1297,25 @@ const schemas = {
}, },
}, },
}, },
CcConfigUsageWindow: {
type: "object",
nullable: true,
description: "One quota window as the account reports it.",
properties: {
utilization: {
type: "number",
nullable: true,
description: "Percent of the window's allowance already spent, 0-100.",
example: 48,
},
resetsAt: {
type: "string",
format: "date-time",
nullable: true,
description: "When the window rolls over, when the account reports one.",
},
},
},
}; };
// ── Reusable parameters (inlined per-operation to match base style) ──── // ── Reusable parameters (inlined per-operation to match base style) ────
@@ -1634,7 +1653,7 @@ const paths = {
tags: ["CcConfig"], tags: ["CcConfig"],
summary: "Tell the open Claude Code sessions to re-read plugins and skills", summary: "Tell the open Claude Code sessions to re-read plugins and skills",
description: description:
'Types `/reload-plugins` and `/reload-skills` into the dashboard\'s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.', 'Types `/reload-plugins` and `/reload-skills` into the dashboard\'s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. The two commands are typed one second apart (`SETTLE_MS`): Claude Code opens its slash-command menu as soon as `/` is typed and swallows the Enter that follows, so back-to-back sends merged into the single line `/reload-plugins/reload-skills`, which runs as `/reload-plugins` with an argument and never reloads skills. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.',
operationId: "ccConfigReload", operationId: "ccConfigReload",
responses: { responses: {
200: { 200: {
@@ -2649,6 +2668,74 @@ const paths = {
}, },
}, },
}, },
"/api/cc-config/profiles/usage": {
post: {
tags: ["CcConfig"],
summary: "How much of a saved profile's account quota is already spent",
description:
"Answers `{ name, refreshed, fiveHour, sevenDay, checkedAt }`, each window being `{ utilization, resetsAt }` - a percentage and a rollover time. There is no local source for it: nothing under `CLAUDE_HOME` records a utilization and the credentials file carries only `subscriptionType`/`rateLimitTier`, so the server asks the endpoint the CLI's own `/usage` reads (`GET https://api.anthropic.com/api/oauth/usage`) with that profile's OAuth token.\n\nA POST, and behind the same-origin guard, because answering it is a WRITE. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage refreshes it first, and the refresh ROTATES the stored credentials - Anthropic retires the refresh token it was handed. The new pair is written straight back into the profile file, because a refresh whose result is dropped leaves an account that can never be activated again without a fresh `claude auth login`. A still-valid token is used as-is and nothing is rewritten; a token that looks valid but is refused is retried once through the refresh. Nothing calls this on a timer - it is one profile per user request. The OAuth token is sent to Anthropic and nowhere else, and never appears in the response.",
operationId: "ccConfigProfileUsage",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["name"],
properties: { name: { type: "string", example: "work" } },
},
},
},
},
responses: {
200: {
description: "Percentages and reset times for the two quota windows.",
content: {
"application/json": {
schema: {
type: "object",
properties: {
name: { type: "string" },
refreshed: {
type: "boolean",
description: "True when the stored token was expired and had to be rotated.",
},
fiveHour: { $ref: "#/components/schemas/CcConfigUsageWindow" },
sevenDay: { $ref: "#/components/schemas/CcConfigUsageWindow" },
checkedAt: { type: "string", format: "date-time" },
},
},
},
},
},
401: {
description: "`EUNAUTHORIZED` - the account refused the token even after a refresh.",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
404: {
description: "`ENOTFOUND` - no profile by that name.",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
409: {
description:
"`ENOREFRESH`/`ENOAUTH` - the profile holds no usable token; re-save it from a login.",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
502: {
description: "`EUPSTREAM` - Anthropic's token or usage endpoint did not cooperate.",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
},
},
},
}; };
module.exports = { tags, schemas, paths }; module.exports = { tags, schemas, paths };
+35 -3
View File
@@ -30,6 +30,7 @@ const ccMutate = require("../lib/cc-mutate");
const ccCli = require("../lib/cc-plugin-cli"); const ccCli = require("../lib/cc-plugin-cli");
const ccMcpCli = require("../lib/cc-mcp-cli"); const ccMcpCli = require("../lib/cc-mcp-cli");
const ccProfiles = require("../lib/cc-profiles"); const ccProfiles = require("../lib/cc-profiles");
const ccUsage = require("../lib/cc-usage");
const projectMemory = require("../lib/project-memory"); const projectMemory = require("../lib/project-memory");
const { sameOriginGuard } = require("../lib/same-origin"); const { sameOriginGuard } = require("../lib/same-origin");
const { broadcast } = require("../websocket"); const { broadcast } = require("../websocket");
@@ -334,7 +335,7 @@ router.post("/plugins/action", async (req, res) => {
let reloaded; let reloaded;
if (reload && result.ok) { if (reload && result.ok) {
try { try {
reloaded = reloadOpenSessions(); reloaded = await reloadOpenSessions();
} catch { } catch {
/* a reload that cannot be delivered must not fail the update itself */ /* a reload that cannot be delivered must not fail the update itself */
} }
@@ -351,7 +352,7 @@ router.post("/plugins/action", async (req, res) => {
* sessions to re-read what is already there — so it neither queues on the CLI * sessions to re-read what is already there — so it neither queues on the CLI
* mutation chain nor emits `cc_config_changed`. * mutation chain nor emits `cc_config_changed`.
*/ */
router.post("/reload", (req, res) => { router.post("/reload", async (req, res) => {
const { kinds } = req.body || {}; const { kinds } = req.body || {};
if (kinds !== undefined && !Array.isArray(kinds)) { if (kinds !== undefined && !Array.isArray(kinds)) {
return res.status(400).json({ return res.status(400).json({
@@ -359,7 +360,7 @@ router.post("/reload", (req, res) => {
}); });
} }
try { try {
res.json({ ok: true, ...reloadOpenSessions({ kinds }) }); res.json({ ok: true, ...(await reloadOpenSessions({ kinds })) });
} catch (err) { } catch (err) {
res.status(500).json({ res.status(500).json({
error: { code: "ERELOAD", message: err.message || String(err) }, error: { code: "ERELOAD", message: err.message || String(err) },
@@ -577,6 +578,37 @@ router.post("/profiles/activate", (req, res) => {
} }
}); });
/**
* POST /profiles/usage — how much of this profile's account quota is spent.
*
* A POST, and behind the same-origin guard, because it is not a read: a saved
* profile's access token has almost always expired, so answering means
* refreshing it, and the refresh rotates the stored credentials on disk (see
* `cc-usage.js`). That is also why nothing calls this on a timer — the UI asks
* per profile, when the user asks.
*
* The response is two percentages and two reset timestamps. No token material
* crosses this boundary, in either direction: the name is all the caller sends.
*/
router.post("/profiles/usage", sameOriginGuard, async (req, res) => {
const name = (req.body || {}).name;
try {
res.json(await ccUsage.profileUsage(name));
} catch (e) {
const status =
{
ENOTFOUND: 404,
EBADNAME: 400,
ENOREFRESH: 409,
ENOAUTH: 409,
ESTALE: 409,
ELIVEEXPIRED: 409,
EUNAUTHORIZED: 401,
}[e.code] || 502;
res.status(status).json({ error: { code: e.code || "EUPSTREAM", message: e.message } });
}
});
// Identifier travels in the body, not the URL — same reason as DELETE /file. // Identifier travels in the body, not the URL — same reason as DELETE /file.
router.delete("/profiles", (req, res) => { router.delete("/profiles", (req, res) => {
try { try {
+21 -20
View File
@@ -35,7 +35,6 @@ const {
isGitRepo, isGitRepo,
isValidBranchName, isValidBranchName,
listBranches, listBranches,
removeWorktree,
resetWorktree, resetWorktree,
resolveBase, resolveBase,
slugify, slugify,
@@ -53,7 +52,6 @@ const {
requireProfile, requireProfile,
provisionLane, provisionLane,
resetLaneData, resetLaneData,
removeLaneData,
} = require("../lib/lane-runtime"); } = require("../lib/lane-runtime");
const { reapOrphanMcp, capOversizedLogs } = require("../lib/lane-gc"); const { reapOrphanMcp, capOversizedLogs } = require("../lib/lane-gc");
const { detectNode, scaffoldProfile } = require("../lib/lane-detect"); const { detectNode, scaffoldProfile } = require("../lib/lane-detect");
@@ -649,6 +647,10 @@ router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
try { try {
const result = applyRouterToLane(lane, profile); const result = applyRouterToLane(lane, profile);
lanesLib.setRouterProfile(lane.id, profile); lanesLib.setRouterProfile(lane.id, profile);
// Every other mutating lane route broadcasts; without it the client's
// cached row keeps the OLD router_profile, and the next lane switch
// re-reads that stale row and flips the picker back.
broadcastLane(lane.id);
res.json(result); res.json(result);
} catch (err) { } catch (err) {
const status = { ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400 }[err.code] || 500; const status = { ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400 }[err.code] || 500;
@@ -1246,21 +1248,20 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
if (!lockedLane) throw lifecycleError("ENOLANE", "lane not found"); if (!lockedLane) throw lifecycleError("ENOLANE", "lane not found");
await stopLaneRun(lockedLane); await stopLaneRun(lockedLane);
// A running stack holds files open in the very directory reset and remove // A running stack holds files open in the very directory `reset` is about
// are about to rewrite or delete, and its processes would outlive the lane // to rewrite, and either way its processes would outlive the lane still
// still bound to its ports. Stop it before touching git. Idempotent and a // bound to its ports. Stop it before touching git or dropping the row.
// no-op for a lane that was never brought up. // Idempotent and a no-op for a lane that was never brought up.
if (action === "reset" || action === "remove") { if (action === "reset" || action === "remove") {
await downLane(lanesLib.getLane(lane.id)); await downLane(lanesLib.getLane(lane.id));
} }
const current = lanesLib.getLane(lane.id); const current = lanesLib.getLane(lane.id);
const facts = await preflight(current, action); const facts = await preflight(current, action);
assertExpectedPreflight(action, facts, body.expect); assertExpectedPreflight(action, facts, body.expect);
if ( // Only `reset` can lose committed work now — `remove` forgets the row and
(action === "reset" || (action === "remove" && current.kind === "managed")) && // touches neither the worktree nor the branch, so unpushed commits are
facts.unpushed > 0 && // nothing it can discard.
body.force !== true if (action === "reset" && facts.unpushed > 0 && body.force !== true) {
) {
throw lifecycleError( throw lifecycleError(
"EUNPUSHED", "EUNPUSHED",
`${action} requires force: true when commits are unpushed` `${action} requires force: true when commits are unpushed`
@@ -1282,15 +1283,15 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
return { lane: lanesLib.getLane(current.id) }; return { lane: lanesLib.getLane(current.id) };
} }
if (action === "remove") { if (action === "remove") {
// Drop the lane's own database(s) before anything else — its state // `remove` forgets the lane, it does not destroy it. The worktree
// directory (the drop-created marker) is about to be deleted too. // directory, its branch and the lane's own database(s) are the USER's
const profile = resolveProfile(current); // data and outlive the dashboard's record of them — one mis-click here
if (profile && current.slot) await removeLaneData(current, profile); // used to cost a whole branch's unpushed code, and no dashboard row is
// Forgetting an adopted lane only removes dashboard metadata. The // worth that. Deleting any of it is the user's own call, by hand
// filesystem destroy guard is deliberately reached only for managed // (`git worktree remove` / `git branch -d` / dropping the db).
// worktrees, where removal can actually touch a directory. //
if (current.kind === "managed") await removeWorktree(current); // Runtime bookkeeping is the exception, because it is AgentHub's and
// Runtime bookkeeping outlives the row otherwise: pid files and hook // not the user's: pid files and hook
// logs under .state/lane<slot>/ would be inherited by whichever lane // logs under .state/lane<slot>/ would be inherited by whichever lane
// claims that slot next. Deleting the row is what frees the slot — // claims that slot next. Deleting the row is what frees the slot —
// usedSlots() reads the table, so there is nothing else to release. // usedSlots() reads the table, so there is nothing else to release.
+20 -11
View File
@@ -1,11 +1,11 @@
--- ---
name: ship-feature-lane name: ship-feature-lane
description: "Autonomous end-to-end feature pipeline for ONE AgentHub lane. Invoke inside a lane's working directory with a requirement: `/ship-feature-lane <requirement>`. Frontloads ALL clarifying questions once, then runs unattended: implement (TDD) → independent unit-test agent → pre-push CI gates + dev preflight → e2e on the feature branch (agent-run) → code review (fresh-context agent) → local QC → senior GO/NO-GO gate → push branch + open PR (base `development`) → CI watch → report, then watches the PR (new comments → gated fix-loop; base conflicts → sync development into the branch) until a HUMAN merges it. The PR is only published after all local gates pass and the senior gate says GO — it's finalized when reviewers see it. Declares lane stage at every step via `agenthub stage` for the dashboard. Use when the user wants to build/ship/implement a feature in a AgentHub lane. NOTE: ticket-filing (Stage 9) and post-merge dev-site QC (Stage 13's second half) are currently OFF (F's integrations aren't built yet) — Stage 13's CI-wait half now runs via agenthub lanes ci status." description: "Autonomous end-to-end feature pipeline for ONE AgentHub lane. Invoke inside a lane's working directory with a requirement: `/ship-feature-lane <requirement>`. Brainstorms with the user and frontloads ALL clarifying questions once (`AskUserQuestion`), stops for their approval of the plan, then runs unattended: implement (TDD) → independent unit-test agent → pre-push CI gates + dev preflight → e2e on the feature branch (agent-run) → code review (fresh-context agent) → local QC → senior GO/NO-GO gate → the user's acceptance → push branch + open PR (base `development`) → CI watch → report, then watches the PR (new comments → gated fix-loop; base conflicts → sync development into the branch) until a HUMAN merges it. The PR is only published after all local gates pass, the senior gate says GO and the user accepts — it's finalized when reviewers see it. A NO-GO loops back to the gates, or to replanning when the approach itself was wrong. Declares lane stage at every step via `agenthub stage` for the dashboard. Use when the user wants to build/ship/implement a feature in a AgentHub lane. NOTE: ticket-filing (Stage 9) and post-merge dev-site QC (Stage 13's second half) are currently OFF (F's integrations aren't built yet) — Stage 13's CI-wait half now runs via agenthub lanes ci status."
--- ---
# Ship Feature Lane (AgentHub lane pipeline) # Ship Feature Lane (AgentHub lane pipeline)
You are running the autonomous feature pipeline for **one AgentHub lane**. The human's only interactive touchpoints are **Stage 0 (frontloaded Q&A)** and **merging the PR on GitHub**; everything else runs to completion or to a `blocked` escalation, reporting progress through `agenthub stage` (which the dashboard renders). You are running the autonomous feature pipeline for **one AgentHub lane**. The human's interactive touchpoints are exactly three — **Stage 0 (frontloaded Q&A)**, **Stage 0b (approving the plan)** and **Stage 7 (accepting the work before the PR is published)** — plus **merging the PR on GitHub**; everything between them runs to completion or to a `blocked` escalation, reporting progress through `agenthub stage` (which the dashboard renders). Those three are not optional politeness: they are the points where being wrong is cheapest to fix (before a plan), and where being wrong becomes public (before a PR).
## Current status ## Current status
@@ -48,7 +48,7 @@ Resume from the stage shown. If state says `stage=X status=running`, you were mi
## Hard rules ## Hard rules
- **Publish ONLY after the senior-gate-reviewer returns `VERDICT: GO`.** "Publish" = push the feature branch + open/update the PR (Stage 8). Nothing reviewer-visible exists before GO, and nothing else authorizes it. - **Publish ONLY after the senior-gate-reviewer returns `VERDICT: GO` AND the human accepts it (Stage 7).** "Publish" = push the feature branch + open/update the PR (Stage 8). Nothing reviewer-visible exists before both, and nothing else authorizes it.
- **Testing and reviewing run in a FRESH-CONTEXT agent — never in this session.** The session that wrote the code is its worst judge: it already believes the code is correct and it remembers the reasoning the tests are supposed to challenge. Every stage whose job is to *judge* the work — Stage 0b (plan debate), Stage 1b (unit tests), Stage 3 (e2e run + triage), Stage 4 (code review), Stage 6 (browser QC), Stage 7 (senior gate) — is dispatched with the Agent tool to a subagent that starts with an EMPTY context and receives only artifacts it can verify itself: the lane's working directory, the lane spec file, the diff range, the commands to run, and the other agents' reports verbatim. The stages you keep are the ones that *do* rather than judge: intake, implementing, the mechanical gates (Stage 2), publishing (Stage 8), the CI/PR watch (Stages 10–12), and applying every fix the judges ask for. Rules: **never `subagent_type: fork`** for these (a fork inherits this session's context, which defeats the whole point); never "save a round-trip" by doing the pass yourself; never paste your own conclusions ("I already checked X", "this is safe because…") into the brief — hand facts and paths, not verdicts. You implement and you apply fixes; you do not grade your own work. - **Testing and reviewing run in a FRESH-CONTEXT agent — never in this session.** The session that wrote the code is its worst judge: it already believes the code is correct and it remembers the reasoning the tests are supposed to challenge. Every stage whose job is to *judge* the work — Stage 0b (plan debate), Stage 1b (unit tests), Stage 3 (e2e run + triage), Stage 4 (code review), Stage 6 (browser QC), Stage 7 (senior gate) — is dispatched with the Agent tool to a subagent that starts with an EMPTY context and receives only artifacts it can verify itself: the lane's working directory, the lane spec file, the diff range, the commands to run, and the other agents' reports verbatim. The stages you keep are the ones that *do* rather than judge: intake, implementing, the mechanical gates (Stage 2), publishing (Stage 8), the CI/PR watch (Stages 10–12), and applying every fix the judges ask for. Rules: **never `subagent_type: fork`** for these (a fork inherits this session's context, which defeats the whole point); never "save a round-trip" by doing the pass yourself; never paste your own conclusions ("I already checked X", "this is safe because…") into the brief — hand facts and paths, not verdicts. You implement and you apply fixes; you do not grade your own work.
- Launch them **FOREGROUND** (they gate the pipeline, and their completion re-invokes you, which satisfies the turn-liveness rule). - Launch them **FOREGROUND** (they gate the pipeline, and their completion re-invokes you, which satisfies the turn-liveness rule).
- Each returns ONE parseable final line (`UNIT-TESTS:` / `E2E:` / `REVIEW:` / `LOCAL-QC:` / `VERDICT:`). Parse that line; never infer a pass from prose. - Each returns ONE parseable final line (`UNIT-TESTS:` / `E2E:` / `REVIEW:` / `LOCAL-QC:` / `VERDICT:`). Parse that line; never infer a pass from prose.
@@ -63,7 +63,7 @@ Resume from the stage shown. If state says `stage=X status=running`, you were mi
- **Run long helpers so they can't be killed mid-flight or hang your turn.** `agenthub lanes hook ci-gate`, `agenthub lanes up`, `agenthub lanes hook e2e` legitimately run 3–20+ minutes (builds, tests, Playwright, lock waits). NEVER invoke them with the default Bash timeout (2 min kills them mid-flight and strands the lane half-done): use `run_in_background: true` and poll the output file until done, or set `timeout: 600000` for the shorter gates. If a helper does die mid-run, don't panic: every hook is idempotent — re-run the step (e.g. re-run `agenthub lanes up --no-build` to revive a stack). - **Run long helpers so they can't be killed mid-flight or hang your turn.** `agenthub lanes hook ci-gate`, `agenthub lanes up`, `agenthub lanes hook e2e` legitimately run 3–20+ minutes (builds, tests, Playwright, lock waits). NEVER invoke them with the default Bash timeout (2 min kills them mid-flight and strands the lane half-done): use `run_in_background: true` and poll the output file until done, or set `timeout: 600000` for the shorter gates. If a helper does die mid-run, don't panic: every hook is idempotent — re-run the step (e.g. re-run `agenthub lanes up --no-build` to revive a stack).
- **Waiting + polling NEVER use a foreground `sleep`** (the Bash tool blocks it) or `ScheduleWakeup` (that's a `/loop`-only primitive — this pipeline is not a `/loop` session, so it won't sustain your watch). To pace a poll loop or wait out a timer, **background the wait**: run `sleep <secs>` with `run_in_background: true` — you're re-invoked when it exits, and re-invoked the moment a backgrounded Agent or helper finishes, so you never busy-poll for background work. **If you ever can't sustain a wait/loop in this session, set `agenthub stage <current> --note "<honest note>"` and STOP — never narrate a watch or loop you are not actually running.** - **Waiting + polling NEVER use a foreground `sleep`** (the Bash tool blocks it) or `ScheduleWakeup` (that's a `/loop`-only primitive — this pipeline is not a `/loop` session, so it won't sustain your watch). To pace a poll loop or wait out a timer, **background the wait**: run `sleep <secs>` with `run_in_background: true` — you're re-invoked when it exits, and re-invoked the moment a backgrounded Agent or helper finishes, so you never busy-poll for background work. **If you ever can't sustain a wait/loop in this session, set `agenthub stage <current> --note "<honest note>"` and STOP — never narrate a watch or loop you are not actually running.**
- **e2e: actively poll — never wait on the completion re-invoke alone.** This binds whoever actually runs the hook — normally Stage 3's e2e agent, so pass this paragraph into its brief verbatim; you only follow it yourself for an ad-hoc run. A hung suite never fires the completion re-invoke, stranding the lane at `stage=e2e-feature`. When running `agenthub lanes hook e2e`: start it `run_in_background: true` AND background a `sleep 90` beside it. Each wake — finished → parse PASS/FAIL; still running → read the e2e log tail (`agenthub lanes logs <id> e2e`), bump the heartbeat, and re-background `sleep 90`, UNLESS it's erroring or has run past ~22 min, in which case kill the e2e task and treat it as FAIL → re-enter Stage 2. - **e2e: actively poll — never wait on the completion re-invoke alone.** This binds whoever actually runs the hook — normally Stage 3's e2e agent, so pass this paragraph into its brief verbatim; you only follow it yourself for an ad-hoc run. A hung suite never fires the completion re-invoke, stranding the lane at `stage=e2e-feature`. When running `agenthub lanes hook e2e`: start it `run_in_background: true` AND background a `sleep 90` beside it. Each wake — finished → parse PASS/FAIL; still running → read the e2e log tail (`agenthub lanes logs <id> e2e`), bump the heartbeat, and re-background `sleep 90`, UNLESS it's erroring or has run past ~22 min, in which case kill the e2e task and treat it as FAIL → re-enter Stage 2.
- **Turn-liveness: never let the pipeline die silently.** A "stalled" lane usually died one of two ways: (a) a turn ended with NOTHING pending — no backgrounded wait, no running helper, no background agent — so nothing ever re-invoked the session; or (b) a transient API error (rate-limit, 529/overload, connection refused) killed the turn mid-stage. Rules: while the pipeline is anywhere between Stage 1 and Stage 14 (done), every turn you end MUST leave at least one re-invoker pending (a `run_in_background` helper/sleep or a background agent) — check before ending the turn. And on ANY resume after an error or a human nudge ("continue"), do not ask questions: re-derive position from lane state (Context recovery above) and continue the stage. If you truly cannot leave a re-invoker, set `--note "watch needs re-trigger: <what to do>"` so the dashboard shows it honestly. - **Turn-liveness: never let the pipeline die silently.** A "stalled" lane usually died one of two ways: (a) a turn ended with NOTHING pending — no backgrounded wait, no running helper, no background agent — so nothing ever re-invoked the session; or (b) a transient API error (rate-limit, 529/overload, connection refused) killed the turn mid-stage. Rules: while the pipeline is anywhere between Stage 1 and Stage 14 (done), every turn you end MUST leave at least one re-invoker pending (a `run_in_background` helper/sleep or a background agent) — check before ending the turn. The ONE legitimate exception is a turn that ends on an `AskUserQuestion` at a scheduled touchpoint (Stage 0, 0b, 7): the human's answer is the re-invoker, and a pipeline waiting on a question it actually asked is not a stalled one. And on ANY resume after an error or a human nudge ("continue"), do not ask questions: re-derive position from lane state (Context recovery above) and continue the stage. If you truly cannot leave a re-invoker, set `--note "watch needs re-trigger: <what to do>"` so the dashboard shows it honestly.
- **No retry cap — the phase clock is the signal.** A failing gate/QC/CI/e2e just re-enters the loop (fix on the feature branch, re-run from Stage 2); there is NO automatic block after N attempts. The dashboard shows how long the lane has sat in its current stage, so the human can spot a stuck or endlessly-looping lane and step in. Reserve `--status blocked` for GENUINE blockers you cannot resolve (an ambiguous merge conflict, a hard/unrecoverable error). - **No retry cap — the phase clock is the signal.** A failing gate/QC/CI/e2e just re-enters the loop (fix on the feature branch, re-run from Stage 2); there is NO automatic block after N attempts. The dashboard shows how long the lane has sat in its current stage, so the human can spot a stuck or endlessly-looping lane and step in. Reserve `--status blocked` for GENUINE blockers you cannot resolve (an ambiguous merge conflict, a hard/unrecoverable error).
- **Commit only on the feature branch.** Never commit on `development`/`main`. Stage only intended files (never `git add .` blindly — this repo collects stray build/QA artifacts). - **Commit only on the feature branch.** Never commit on `development`/`main`. Stage only intended files (never `git add .` blindly — this repo collects stray build/QA artifacts).
- Keep the lane's state truthful: on any stop, set an accurate `agenthub stage <stage> --status <status> --note "<why>"`. - Keep the lane's state truthful: on any stop, set an accurate `agenthub stage <stage> --status <status> --note "<why>"`.
@@ -76,8 +76,10 @@ Resume from the stage shown. If state says `stage=X status=running`, you were mi
### 0 — Intake & frontloaded Q&A *(the only interactive part)* ### 0 — Intake & frontloaded Q&A *(the only interactive part)*
Do NOT jump to code. Understand the requirement first. Do NOT jump to code. Understand the requirement first.
- **Restate + quick scan.** Restate the requirement. Do a fast targeted scan of the relevant code (use the `Explore` agent for breadth; the **brainstorming** skill if the requirement is fuzzy) so your questions are grounded in what actually exists. - **Restate + quick scan.** Restate the requirement. Do a fast targeted scan of the relevant code (use the `Explore` agent for breadth) so your questions are grounded in what actually exists.
- **Frontloaded Q&A.** Ask the human **every** clarifying question in ONE batch: acceptance criteria, scope / non-goals, UI/UX specifics, data shapes, edge cases, which existing flows it touches. **Sibling-surface check (mandatory):** if your scan shows the app has N parallel surfaces of the pattern the requirement touches (e.g. several foldered areas, several list pages sharing a component) and the requirement names fewer than N, explicitly ask "this exists in [all N places] — apply to all, or only [the named ones]?" A missed sibling here costs a full second pipeline pass when a reviewer catches it on the PR. Then activate a clean feature slot for this run: `agenthub feature activate <slug> --title "<short title>"` — Task B's `activate` archives whatever feature was previously active on this lane automatically, so this run's dashboard state starts clean without a separate "init" step. Then mark intake: `agenthub stage intake --status running`. Announce "Questions answered — going autonomous now." After this, don't ask the human anything unless you hit a `blocked` escalation. - **The brainstorm is mandatory, not conditional.** Run the **superpowers:brainstorming** skill here on every run — not only "if the requirement is fuzzy". A one-line requirement always leaves scope, edge cases and acceptance criteria unstated, and this stage is the only place left to find that out cheaply.
- **Ask only what the repo cannot answer.** Read first, ask second: a question whose answer is in the code, the tests, `CLAUDE.md` or the existing UI spends the human's one interactive round on something you could have looked up. What belongs to them is intent — scope, priorities, trade-offs, what "done" means — never facts.
- **Frontloaded Q&A — asked with `AskUserQuestion`, and you WAIT for the answers.** Put the questions to the human with the `AskUserQuestion` tool (batched, up to 4 per round, each with real options — the way plan mode asks), not as prose they may not answer. Ask **every** clarifying question in ONE batch: acceptance criteria, scope / non-goals, UI/UX specifics, data shapes, edge cases, which existing flows it touches. You may not answer them on the human's behalf and you may not go on to Stage 0b until they are answered — deciding for them here is what makes a whole autonomous pipeline build the wrong feature. Only if the human explicitly says to decide yourself do you proceed, and then you write the assumptions into the lane spec. **Sibling-surface check (mandatory):** if your scan shows the app has N parallel surfaces of the pattern the requirement touches (e.g. several foldered areas, several list pages sharing a component) and the requirement names fewer than N, explicitly ask "this exists in [all N places] — apply to all, or only [the named ones]?" A missed sibling here costs a full second pipeline pass when a reviewer catches it on the PR. Then activate a clean feature slot for this run: `agenthub feature activate <slug> --title "<short title>"` — Task B's `activate` archives whatever feature was previously active on this lane automatically, so this run's dashboard state starts clean without a separate "init" step. Then mark intake: `agenthub stage intake --status running`. After this the human is asked exactly twice more — to approve the plan (Stage 0b) and to accept the work before the PR is published (Stage 7) — plus any `blocked` escalation. Nothing else is interactive.
### 0b — Investigate & plan *(autonomous)* ### 0b — Investigate & plan *(autonomous)*
- `agenthub stage plan` — now design a real plan and have it independently challenged before you implement. - `agenthub stage plan` — now design a real plan and have it independently challenged before you implement.
@@ -85,6 +87,8 @@ Do NOT jump to code. Understand the requirement first.
- **Plan.** Produce a concrete implementation plan (the **writing-plans** skill): approach, files to change, the test strategy (which behaviors/edge cases each test will pin), migration/contract impact, and how each acceptance criterion is met. - **Plan.** Produce a concrete implementation plan (the **writing-plans** skill): approach, files to change, the test strategy (which behaviors/edge cases each test will pin), migration/contract impact, and how each acceptance criterion is met.
- **Debate the plan (adversarial review) — fresh context, never `fork`.** Spawn a SEPARATE sub-agent (Agent tool — `Plan` or `general-purpose`, FOREGROUND) to critique the plan + investigation. Hand it the requirement, the Stage-0 answers and the plan as text plus the paths to read; do NOT hand it your reasoning for why the plan is right — that is exactly what it is there to test. Ask for: missed requirements, wrong assumptions, a simpler approach, unhandled edge cases, acceptance-criteria gaps. Apply the worthwhile critiques (use **receiving-code-review** judgment — verify each point, don't blindly accept or reject). Iterate once or twice until the plan holds up. - **Debate the plan (adversarial review) — fresh context, never `fork`.** Spawn a SEPARATE sub-agent (Agent tool — `Plan` or `general-purpose`, FOREGROUND) to critique the plan + investigation. Hand it the requirement, the Stage-0 answers and the plan as text plus the paths to read; do NOT hand it your reasoning for why the plan is right — that is exactly what it is there to test. Ask for: missed requirements, wrong assumptions, a simpler approach, unhandled edge cases, acceptance-criteria gaps. Apply the worthwhile critiques (use **receiving-code-review** judgment — verify each point, don't blindly accept or reject). Iterate once or twice until the plan holds up.
- Write the Q&A answers **and the agreed plan** to a lane spec file `docs/superpowers/specs/lane-<slug>.md` (gitignored, or add it to `.gitignore` if this is the first one) — the acceptance contract the senior gate checks against. - Write the Q&A answers **and the agreed plan** to a lane spec file `docs/superpowers/specs/lane-<slug>.md` (gitignored, or add it to `.gitignore` if this is the first one) — the acceptance contract the senior gate checks against.
- **STOP — the human approves the plan before any code exists.** This is the second of the three scheduled interactive touchpoints. Show them the plan: what changes, which files, what is deliberately out of scope, the test strategy, and how each acceptance criterion is met — plus the lane spec path. Ask with `AskUserQuestion`: approve / revise / change approach. "Revise" means edit the plan and the lane spec and ask again. **Stage 1 does not start without an approval in this turn** — an adversarial agent agreeing with your plan is not the human agreeing with it. Announce "Plan approved — going autonomous now" and only then continue.
- A later stage that turns out to need a materially different approach comes BACK here for a fresh plan and a fresh approval, rather than drifting under the old one.
### 1 — Implement (TDD, to the plan) ### 1 — Implement (TDD, to the plan)
- Choose a **single-segment slug** for the feature — lowercase, hyphens, NO slashes. Cut the feature branch from **development** (the PR base): `git fetch origin && git checkout -b feat/<slug> origin/development`. - Choose a **single-segment slug** for the feature — lowercase, hyphens, NO slashes. Cut the feature branch from **development** (the PR base): `git fetch origin && git checkout -b feat/<slug> origin/development`.
@@ -125,15 +129,16 @@ The e2e hook doesn't run migrations itself — it tests the already-running stac
### 4 — Code review *(no open PR yet — use local diff)* ### 4 — Code review *(no open PR yet — use local diff)*
- `agenthub stage review --status running`, then launch a fresh-context reviewer agent (Agent tool, `subagent_type: Code Reviewer` — FOREGROUND, **never `fork`**; a reviewer who already watched you write the code is not a reviewer). Hand it ONLY: the lane's working directory, the lane spec path (requirement + acceptance criteria), the diff commands, and the brief below. Do NOT summarize your own implementation for it, and do NOT pre-empt findings — let it read the diff cold. - `agenthub stage review --status running`, then launch a fresh-context reviewer agent (Agent tool, `subagent_type: Code Reviewer` — FOREGROUND, **never `fork`**; a reviewer who already watched you write the code is not a reviewer). Hand it ONLY: the lane's working directory, the lane spec path (requirement + acceptance criteria), the diff commands, and the brief below. Do NOT summarize your own implementation for it, and do NOT pre-empt findings — let it read the diff cold.
- Run the **`code-review` skill at effort `high`** on the feature diff vs `origin/development` — the deterministic gate, not an ad-hoc read. No PR is open yet, so point it at the local diff: `git diff origin/development...feat/<slug>` (and `git log origin/development..feat/<slug>` for commits). ONLY if the `code-review` skill is unavailable in its session, fall back to a manual review of that diff (correctness, security, tests, migration/contract safety, this repo's `CLAUDE.md` + `.claude/rules/` boundaries). - Run **Claude Code's own `code-review` skill** (the official plugin's `/code-review`) **at effort `high`** on the feature diff vs `origin/development` — the deterministic gate, not an ad-hoc read, and not a reviewer summarizing the diff in its own words. No PR is open yet, so point it at the local diff: `git diff origin/development...feat/<slug>` (and `git log origin/development..feat/<slug>` for commits). ONLY if the `code-review` skill is unavailable in its session, fall back to a manual review of that diff (correctness, security, tests, migration/contract safety, this repo's `CLAUDE.md` + `.claude/rules/` boundaries).
- Final line: `REVIEW: CLEAN` or `REVIEW: FINDINGS — <n>`, with the findings listed above it as `path:line — severity — problem — suggested fix`. - Final line: `REVIEW: CLEAN` or `REVIEW: FINDINGS — <n>`, with the findings listed above it as `path:line — severity — problem — suggested fix`.
- **Scale the pass to the diff.** A handful of lines in one layer gets the `code-review` pass alone — three agents re-reading a 20-line change buys one opinion at three times the tokens, and this stage re-runs on every fix-loop lap. Add the domain reviewer(s) once the change has seams: multiple files in a layer, a route/schema/migration/hook touched, or anything you would want a second reader on.
- **Domain reviewers, in parallel with the above (same message, separate Agent calls).** Check `git diff --name-only origin/development...feat/<slug>` for touched layers and launch every matching one — FOREGROUND, fresh context, **never `fork`**, addressed as `agenthub:backend-reviewer` / `agenthub:frontend-reviewer` (plugin-qualified — no lane install needed, unlike `qc-local`/`senior-gate-reviewer`): - **Domain reviewers, in parallel with the above (same message, separate Agent calls).** Check `git diff --name-only origin/development...feat/<slug>` for touched layers and launch every matching one — FOREGROUND, fresh context, **never `fork`**, addressed as `agenthub:backend-reviewer` / `agenthub:frontend-reviewer` (plugin-qualified — no lane install needed, unlike `qc-local`/`senior-gate-reviewer`):
- Any path under `server/` → `agenthub:backend-reviewer`. - Any path under `server/` → `agenthub:backend-reviewer`.
- Any path under `client/` → `agenthub:frontend-reviewer`. - Any path under `client/` → `agenthub:frontend-reviewer`.
- Neither touched (root/`skills/`/`agents/`/`scripts/`/docs-only change) → skip both; the generic Code Reviewer above is the only gate. - Neither touched (root/`skills/`/`agents/`/`scripts/`/docs-only change) → skip both; the generic Code Reviewer above is the only gate.
- Hand each ONLY the lane's working directory and the diff range `git diff origin/development...feat/<slug>` — each scopes itself to its own domain's files even though the diff range is the same for both. - Hand each ONLY the lane's working directory and the diff range `git diff origin/development...feat/<slug>` — each scopes itself to its own domain's files even though the diff range is the same for both.
- Each ends with its own parseable line: `BACKEND-REVIEW:` / `FRONTEND-REVIEW:` — `CLEAN` or `FINDINGS — <n>`. - Each ends with its own parseable line: `BACKEND-REVIEW:` / `FRONTEND-REVIEW:` — `CLEAN` or `FINDINGS — <n>`.
- Triage ALL findings — the generic review plus every domain reviewer that ran — together with **receiving-code-review** judgment (verify each, neither blind agreement nor blind rejection) and apply what's worth applying on the feature branch. Changed source → re-run **from Stage 1b**; changed only tests → **from Stage 2**. Keep the findings + your resolutions — the senior gate reads them. - Triage ALL findings — the generic review plus every domain reviewer that ran — together with **receiving-code-review** judgment (verify each, neither blind agreement nor blind rejection) and apply what's worth applying on the feature branch. A finding you reject is answered with a reason, never with silence. Changed source → re-run **from Stage 1b**; changed only tests → **from Stage 2**. Either way the re-entry passes through THIS stage again: review ends when a pass over the current diff comes back clean (or with only findings you explicitly and defensibly rejected), not when one pass has been performed. Keep the findings + your resolutions — the senior gate reads them.
- The Stage-6 `qc-local` report covers the user-flow review for the senior gate. - The Stage-6 `qc-local` report covers the user-flow review for the senior gate.
- `agenthub stage review --evidence "<total findings count across all reviewers that ran> findings, resolved"` - `agenthub stage review --evidence "<total findings count across all reviewers that ran> findings, resolved"`
@@ -154,9 +159,13 @@ The e2e hook doesn't run migrations itself — it tests the already-running stac
### 7 — Senior GO/NO-GO gate *(authorizes the publish)* ### 7 — Senior GO/NO-GO gate *(authorizes the publish)*
- Launch the **senior-gate-reviewer** agent (Agent tool, `subagent_type: senior-gate-reviewer`, or `agenthub:senior-gate-reviewer` if the lane copy isn't installed — FOREGROUND, fresh context, **never `fork`**; installed the same way as `qc-local`, see Stage 6). Give it: the lane's working directory, the requirement + Stage-0 answers (the lane spec file), the feature branch, the Stage-4 code-review findings + resolutions **as the other agents wrote them** (verbatim/by path — never your paraphrase of why each was fine), the Stage-6 `qc-local` report (the user-flow review), and the raw results of gates/e2e/QC. The agent inspects the local diff with `git diff origin/development...feat/<slug>` — no open PR is required (and none exists yet). - Launch the **senior-gate-reviewer** agent (Agent tool, `subagent_type: senior-gate-reviewer`, or `agenthub:senior-gate-reviewer` if the lane copy isn't installed — FOREGROUND, fresh context, **never `fork`**; installed the same way as `qc-local`, see Stage 6). Give it: the lane's working directory, the requirement + Stage-0 answers (the lane spec file), the feature branch, the Stage-4 code-review findings + resolutions **as the other agents wrote them** (verbatim/by path — never your paraphrase of why each was fine), the Stage-6 `qc-local` report (the user-flow review), and the raw results of gates/e2e/QC. The agent inspects the local diff with `git diff origin/development...feat/<slug>` — no open PR is required (and none exists yet).
- Parse its final line: - Parse its final line:
- `VERDICT: GO` → proceed to Stage 8. - `VERDICT: GO` → the agent gate is satisfied; now the human's.
- `VERDICT: NO-GO — <fixes>` → `agenthub stage gate-blocked --evidence "NO-GO — <reason>"` (an alias of `gate`, so the map holds while the lane's stage name says the gate refused), then fix on the feature branch and re-run **from Stage 2**. No attempt cap — the loop re-enters; the dashboard's time-on-stage surfaces a lane stuck cycling so the human can step in. Set `--status blocked` only for a genuine blocker you can't resolve. - `VERDICT: NO-GO — <fixes>` → `agenthub stage gate-blocked --evidence "NO-GO — <reason>"` (an alias of `gate`, so the map holds while the lane's stage name says the gate refused), then **re-enter by the SIZE of what is wrong**, not always at the same place:
- `agenthub stage gate --evidence "GO"` (or `NO-GO — <reason>`) - A defect, a missed case, a wrong detail → fix on the feature branch and re-run **from Stage 2** (or Stage 1b if non-test source changed).
- Wrong approach, wrong scope, or a requirement that turns out to mean something else → back to **Stage 0b**: replan, and take the new plan through its approval stop again before any further code.
- No attempt cap — the loop re-enters; the dashboard's time-on-stage surfaces a lane stuck cycling so the human can step in. Set `--status blocked` only for a genuine blocker you can't resolve. Two NO-GOs on the same root issue means the loop is not converging: stop and ask the human instead of a third lap.
- **Then ask the human to accept, before anything reviewer-visible exists.** Publishing is outward-facing and irreversible in the social sense — a PR reviewers have seen cannot be un-seen. Present: what was built, what was verified and how (gates/e2e/QC with their real outcomes), what the reviews found and what you did about it, and anything you could not run. Ask with `AskUserQuestion`: publish the PR / send back for fixes / rethink the approach. `VERDICT: GO` is the agent's opinion about evidence; it is not the human's decision to publish. Not accepted → re-enter exactly as for a NO-GO above.
- `agenthub stage gate --evidence "GO; human accepted"` (or `NO-GO — <reason>`)
### 8 — Publish: push branch + open/update PR *(GATED — only on GO)* ### 8 — Publish: push branch + open/update PR *(GATED — only on GO)*
- `agenthub stage publishing --status running` - `agenthub stage publishing --status running`
+220 -47
View File
@@ -1,6 +1,6 @@
--- ---
name: ship-feature name: ship-feature
description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates. Drives the Superpowers workflow skills and declares each phase with `agenthub stage` so the dashboard's pipeline map follows along. description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates. Drives the Superpowers workflow skills and declares each phase with `agenthub stage` so the dashboard's pipeline map follows along. Brainstorms WITH the user at intake, stops for their approval of the plan before any code, reviews with Claude Code's own `code-review` plus only the domain reviewers the diff actually needs, and stops again at the gate for their acceptance — a NO-GO loops back to implement or plan rather than shipping.
--- ---
# Ship Feature # Ship Feature
@@ -37,17 +37,117 @@ never overrides a declaration — and detection cannot move a lane BACKWARD past
a declared stage, so a rework loop is invisible unless you re-declare the phase a declared stage, so a rework loop is invisible unless you re-declare the phase
you dropped back to. you dropped back to.
## Waiting for an agent or a long command
A phase that dispatches a foreground agent (tests, review, gate) or runs a long
command is **already** wired to wake you: you are re-invoked the moment a
backgrounded Agent or helper finishes. So:
- Do not poll for it, and do not invent a timer. **`ScheduleWakeup` is a
`/loop`-only primitive** — this pipeline is not a `/loop` session, so it
neither paces nor sustains anything here, and in `auto` permission mode it can
simply be refused (the safety classifier is a model call, which fails when
that model is briefly unavailable). Never let a phase's progress depend on it.
- Need to pace a wait anyway (a CI run, an external queue)? Background the wait:
`sleep <secs>` with `run_in_background: true`.
- Never narrate a wait you are not actually running. If you cannot sustain one,
say so plainly and stop.
## The shape of a run
Forward: `intake → plan → implement → tests → review → gate → ship → done`.
Two back-edges, and they are the point of the pipeline, not its failure mode:
- **`tests`/`review` → `implement`** — a failure or a finding sends you to the
code, then forward through `tests` and `review` again.
- **`gate` → `plan`** — a NO-GO about the APPROACH (wrong scope, wrong shape,
the requirement meant something else) replans and re-earns the user's
approval. A NO-GO about a DEFECT goes to `implement` instead.
Going back is a declaration: re-run `agenthub stage <node>` for the node you
dropped to. Detection cannot move a lane backward on its own, so a silent
rework leaves the dashboard showing a stage you already left, and the nodes
ahead keep the previous pass's evidence.
## Right-size the run before you start it
This pipeline is not free: every phase that dispatches an agent costs a model
call and minutes of wall clock, and the fix-loop pays it again on each lap.
Match its weight to the work, and say in one line which weight you picked.
| Work | What runs |
|---|---|
| **Trivial** — typo, rename, one-line fix, a question | Not this skill. Do it, run the touched suite, say what you did. |
| **Small** — one layer, a few files, no new interface | `intake` questions → `implement` → `tests` → `review` with `code-review` alone → `gate` (the user's acceptance IS the gate). Skip the plan-debate agent, the independent-test agent and the gate agent. |
| **Medium / large** — multi-file, new route/schema/interface, cross-layer, or anything you would defend in a review | Every phase below, in full. |
Sizing is a claim you make out loud, not a shortcut you take quietly. When
torn, take the heavier one. A small task that grows re-sizes upward mid-run —
re-declare the phase you re-enter and pick up the skipped agents from there.
## Dispatching a judgement (used by `tests`, `review`, `gate`)
One contract, written once, so the phases below don't each re-argue it:
- **Fresh context, FOREGROUND, never `fork`.** A fork inherits your context —
precisely the bias the dispatch exists to escape. Foreground because the phase
gates on the answer, and the agent's completion is what re-invokes you.
- **Hand only what the agent can verify for itself**: the repo path, the
requirement/plan, the `git diff` range, the commands to run. Never your
conclusions — "I already checked X", "this is safe because…" — those are the
very claims it is there to test.
- **One parseable final line** is the verdict (`UNIT-TESTS:`, `REVIEW:`,
`BACKEND-REVIEW:`, `FRONTEND-REVIEW:`, `GATE:`). Read that line; never infer a
pass from prose around it.
- **Never dispatch what a command answers.** Running a suite, reading a log,
grepping a path — do it yourself. An agent is for judgement, not for typing.
- Agents for the same phase go out in **one message** so they run concurrently.
## Resuming after a compaction
A long run outlives the context window. Re-derive your position, don't guess it:
- `agenthub lanes` — the lane's declared stage, status and notes.
- `git status` + `git diff --stat <base>...` — what is actually built.
- The plan file — what was actually approved.
Resume at the declared stage and re-run it from the top; every `agenthub`
command is idempotent. **A phase whose evidence you cannot find did not
happen** — re-run it rather than assuming a past self did.
## Phases ## Phases
### intake — understand before touching anything ### intake — brainstorm WITH the user, always
- `agenthub stage intake --status running` - `agenthub stage intake --status running`
- Restate the requirement and its success criteria. - Restate the requirement and its success criteria.
- Explore the impacted modules: `Explore` agent for breadth, then read the key - Explore the impacted modules: `Explore` agent for breadth, then read the key
files yourself. Identify which layers are hit (server / client / docs / files yourself. Identify which layers are hit (server / client / docs /
scripts) — `repo-onboarding` if the area is unfamiliar. scripts) — `repo-onboarding` if the area is unfamiliar.
- Requirement fuzzy or open to more than one reading? **superpowers:brainstorming**. - **superpowers:brainstorming is MANDATORY here, not a fallback for a fuzzy
requirement.** Run it before any plan exists. Skipping it because the ask
"seems clear" is the single most common way this pipeline builds the wrong
thing: a requirement typed in one line always leaves scope, edge cases and
acceptance criteria unstated.
- **Ask only what the repo cannot answer.** Read first, ask second: a question
whose answer is in the code, the tests, `CLAUDE.md` or the existing UI costs
the user's time and earns you a worse answer than reading would. What belongs
to the user is intent — scope, priorities, trade-offs, what "done" means —
never facts you could have looked up.
- **Ask the user, and wait for the answer.** Put the open questions to them with
`AskUserQuestion` — batched in one round (up to 4 at a time), each with real
options, exactly the way plan mode asks. Questions that change what you build:
scope boundaries, which surfaces are in/out, behavior on the edge cases you
found, what "done" means. You may not answer them on the user's behalf, and
you may not move to `plan` until they are answered. Answering yourself and
proceeding IS the bug this phase exists to prevent.
- The one exception: the user explicitly said to skip the questions and decide
yourself ("cứ làm đi", "no questions", "you decide"). Then state the
assumptions you are proceeding under, in writing, and go on.
- **Leave when:** the user's answers are in hand (or their explicit "decide
yourself" is), and you can state the scope, the non-goals and what "done"
means in your own words without a guess in any of them.
### plan — a written plan, challenged before it is code ### plan — a written plan, challenged, then APPROVED by the user
- `agenthub stage plan` - `agenthub stage plan`
- **superpowers:writing-plans**: approach, files to change, the test strategy - **superpowers:writing-plans**: approach, files to change, the test strategy
(which behavior each test pins), and how each success criterion is met. (which behavior each test pins), and how each success criterion is met.
@@ -59,72 +159,143 @@ you dropped back to.
simpler approach, unhandled edge cases. Give it the requirement and the plan, simpler approach, unhandled edge cases. Give it the requirement and the plan,
not your reasons for believing the plan. Apply what survives not your reasons for believing the plan. Apply what survives
**superpowers:receiving-code-review** scrutiny. **superpowers:receiving-code-review** scrutiny.
- **STOP. The plan is presented to the user and implementation does not start
until they approve it in this turn.** Show the plan (or its path plus a short
summary: what changes, which files, what is deliberately out of scope, how it
will be verified) and ask with `AskUserQuestion` — approve / revise / change
approach. "Revise" means edit the plan and ask again; the loop is the point.
Writing a plan and immediately implementing it is exactly the behavior this
phase forbids, however obvious the plan looks to you.
- Approval covers the plan as presented. Work that turns out to need a
materially different approach comes BACK here (`agenthub stage plan` again)
for a fresh approval, rather than drifting under the old one.
- **Leave when:** a written plan exists, it survived the challenge, and the user
approved THIS version of it in this turn.
### implement — smallest coherent diff ### implement — smallest coherent diff
- `agenthub stage implementing` - `agenthub stage implementing`
- Entered only from an **approved** plan (or from a gate NO-GO sending work
back here). No approval in this turn → you are still in `plan`.
- **superpowers:test-driven-development**: failing test → minimal code → green. - **superpowers:test-driven-development**: failing test → minimal code → green.
- Smallest diff that satisfies the requirement. Every changed line traces to it. - Smallest diff that satisfies the requirement. Every changed line traces to it.
- **file-headers** applies to every source file you create or edit. - **file-headers** applies to every source file you create or edit.
- Work the approved plan task by task, in its order. Before leaving this phase,
re-read the plan's acceptance criteria and check each one against what you
actually built — that list, not your memory of it, is what `gate` will ask
about.
- **The plan turning out wrong is a result, not a detour.** If implementing
shows the approach cannot work, or the requirement means something else, stop
and go back to `plan` (re-declare it, present the new plan, get it approved
again). Quietly building a different thing than the one approved is the
failure this phase has to avoid.
- Long implementation? Re-declare `agenthub stage implementing` as you go — it
doubles as the heartbeat, so the dashboard doesn't read a working lane as
stalled.
- **Leave when:** every plan task is done, every acceptance criterion has
something concrete behind it, and the diff contains nothing that traces to
neither.
### tests — this repo's verification, not a claim ### tests — this repo's verification, not a claim
- `agenthub stage tests --status running` - `agenthub stage tests --status running`
- **Dispatch, don't self-grade.** Both halves below run in a **fresh-context - **Dispatch the judgement, run the commands yourself.** Only work that needs an
agent** (Agent tool, `subagent_type: general-purpose` — FOREGROUND, and unbiased pair of eyes is worth an agent; a deterministic command is not.
**never `fork`**: a fork inherits your context, which is the bias you are - **Independent unit tests → a fresh-context agent** (`general-purpose`; see
trying to escape). Hand it only what it can verify itself — repo path, the *Dispatching a judgement*). It reads the requirement
spec/requirement, `git diff` range, the commands — never your own conclusions. and the diff cold and writes the tests the implementation is *missing* —
- **Independent unit tests.** It reads the requirement and the diff cold and happy, negative and boundary, in the repo's existing style. It may not touch
writes the tests the implementation is *missing* — happy, negative and non-test files nor weaken existing tests. Final line:
boundary, in the repo's existing style. It may not touch non-test files nor `UNIT-TESTS: PASS — <n> added` or `UNIT-TESTS: FAIL — <test> :: <defect>`.
weaken existing tests. Final line: `UNIT-TESTS: PASS — <n> added` or - **Suite run → plain Bash, here.** Run the suite(s) the diff actually needs:
`UNIT-TESTS: FAIL — <test> :: <defect>`. backend changed → `npm run test:server`; frontend changed →
- **Suite run.** Backend changed → `npm run test:server`. Frontend → `npm run test:client`; both → both. A docs-only change needs neither, and
`npm run test:client`. running the other layer's suite proves nothing about this diff. Nothing is judged, so nothing is
A UI snapshot diff is reported for review, **never blindly regenerated** delegated: an agent for this buys a second model's tokens to read output you
(`cd client && npx vitest run -u` is yours to run, only after you read the can read for free. A UI snapshot diff is reported for review, **never
diff). blindly regenerated** (`cd client && npx vitest run -u` is yours to run,
only after you read the diff).
- `FAIL` → fix the **source** (root cause), not the agent's test — then re-run - `FAIL` → fix the **source** (root cause), not the agent's test — then re-run
this phase. Rewriting a failing independent test to pass is the one move that this phase. Rewriting a failing independent test to pass is the one move that
makes this whole phase worthless. makes this whole phase worthless.
- Record the outcome: `agenthub stage tests --evidence "<what passed>"`, or - Record the outcome: `agenthub stage tests --evidence "<what passed>"`, or
`--result fail` with what failed. A step you could not run is reported as not `--result fail` with what failed. A step you could not run is reported as not
run, never as passed. run, never as passed.
- **Leave when:** the suites the diff needs are green in output you actually
read, and the independent-test agent (when the size called for one) returned
`PASS`.
### review — a real review pass, not a re-read ### review — the native review, plus the domains this diff actually touched
- `agenthub stage review --status running` - `agenthub stage review --status running`
- Hand the review to a **fresh-context agent** (`subagent_type: Code Reviewer`, - **Read the diff first, then decide who reviews it.** `git diff --name-only`
FOREGROUND, never `fork`) — a reviewer that watched you write the code is not against the base this task started from is what picks the reviewers. Spawning
a reviewer. It runs the **code-review** skill on the working diff a fixed set regardless of what changed wastes a pass on files nobody touched,
(see also **superpowers:requesting-code-review** for what to hand over). and worse, reads as coverage that never happened.
- **Scale the pass to the diff.** A handful of lines in one layer gets the
`code-review` pass alone — three agents re-reading a 20-line change costs
three times the tokens for one opinion. Add the domain reviewer(s) once the
change is big enough to have seams: multiple files in a layer, a route or
schema or hook touched, or anything you would want a second reader on.
- **Always: Claude Code's own `code-review` skill** (`/code-review`, on the
working diff — it is also what the `review` node declares). Not optional, and
not replaced by an agent reviewing "in its own words": run it in a
fresh-context `Code Reviewer` agent whose job is to invoke that skill and
report what it found (a reviewer that watched you write the code is not a
reviewer). See **superpowers:requesting-code-review** for what to hand over.
Final line: `REVIEW: CLEAN` or `REVIEW: FINDINGS — <n>`. Final line: `REVIEW: CLEAN` or `REVIEW: FINDINGS — <n>`.
- **Domain reviewers, in parallel with the above (same message, separate - **Domain reviewers, conditional on the touched layers**, in the same message
Agent calls).** Check `git diff --name-only` (against the base this task as the above so they run concurrently:
started from) for touched layers and launch every matching one — - Any path under `server/` (routes, lib, hooks, db, CLI-facing code) →
FOREGROUND, fresh context, never `fork`, addressed as `agenthub:backend-reviewer` `agenthub:backend-reviewer`. No backend path changed → do not launch it.
/ `agenthub:frontend-reviewer`: - Any path under `client/` (components, pages, i18n, styles) →
- Any path under `server/` → `agenthub:backend-reviewer`. `agenthub:frontend-reviewer`. No frontend path changed → do not launch it.
- Any path under `client/` → `agenthub:frontend-reviewer`. - Both touched → both, in the same message so they run concurrently.
- Neither touched → skip both; the generic Code Reviewer - Neither touched (docs-only, scripts-only, config-only) → neither; the
above is the only gate. `code-review` pass above is the whole review.
- Hand each ONLY the repo path and the working diff — each scopes itself to - Hand each ONLY the repo path and the working diff — each scopes itself to
its own domain's files. Each ends with its own parseable line: its own domain's files. Each ends with its own parseable line:
`BACKEND-REVIEW:` / `FRONTEND-REVIEW:` — `CLEAN` or `BACKEND-REVIEW:` / `FRONTEND-REVIEW:` — `CLEAN` or `FINDINGS — <n>`.
`FINDINGS — <n>`. - Apply what is worth applying — `code-review` plus every domain reviewer that
- Apply what is worth applying — the generic review plus every domain ran — with **superpowers:receiving-code-review** judgment: verify each point,
reviewer that ran — with **superpowers:receiving-code-review** judgment: neither blind agreement nor blind rejection. A point you reject is answered
verify each point, neither blind agreement nor blind rejection. with a reason, not silence.
- Changed code in response? Re-run the `tests` phase. - **Changed code in response? Re-run `tests`, then re-run THIS phase.** Review
is a loop, not a checkpoint: it ends when a pass over the current diff comes
back clean (or with only findings you have explicitly and defensibly
rejected), not when one pass has been performed.
- Record it: `agenthub stage review --evidence "<n> findings across <who ran>,
resolved"`.
- **Leave when:** the latest pass over the CURRENT diff is clean or
argued-through — not when a pass has merely happened.
### gate — evidence before the completion claim ### gate — evidence, then the user's acceptance
- **superpowers:verification-before-completion**. Commands actually run, output - **superpowers:verification-before-completion**. Commands actually run, output
actually read. This is the phase that stops "should work" from shipping. actually read. This is the phase that stops "should work" from shipping.
- For a change of any real size, the gate is a **fresh-context agent** too - At medium/large size the gate is a **fresh-context agent** too
(`general-purpose`, FOREGROUND, never `fork`): hand it the requirement, the (`general-purpose`): hand it the requirement, the diff range and the
diff range and the verification commands — not your evidence — and let it verification commands — not your evidence — and let it re-run and re-check
re-run and re-check them. Final line: `GATE: GO` or `GATE: NO-GO — <what is them. Final line: `GATE: GO` or `GATE: NO-GO — <what is missing>`. At small
missing>`. A one-line fix does not need it; anything you would defend in a size skip it; the user's acceptance below is the gate.
review does. - **The gate is not yours to wave through. Ask the user.** Present, in a few
- `agenthub stage gate --evidence "<what was verified>"` lines: what was built, what was verified and how (the commands and their real
outcome), what review found and what you did about it, and anything you could
not run. Then ask with `AskUserQuestion`: accept / send back for fixes /
rethink the approach. Silence is not acceptance, and neither is `GATE: GO` —
the agent gate checks the evidence, the user decides whether this is what they
wanted.
- **NO-GO, or the user does not accept → go BACK, do not proceed.** Pick the
node by the size of what is wrong, and re-declare it so the dashboard follows
the rework (a declaration on an earlier node clears the nodes ahead of it —
that is what makes a loop visible instead of leaving stale `done` evidence):
- A defect, a missed case, a wrong detail → `agenthub stage implementing`, fix
at the root cause, then forward through `tests` and `review` again.
- Wrong approach, wrong scope, a requirement that turns out to mean something
else → `agenthub stage plan`, replan, and get the plan approved again (the
`plan` phase's approval step applies in full, every time through).
- Either way the phases after it run again. Never carry a NO-GO forward into
`ship` with a note about it.
- Only once the user has accepted: `agenthub stage gate --evidence "<what was
verified, and that the user accepted it>"`
- **Leave when:** the verification really ran, its output was really read, and
the user said yes. Two of three is a NO-GO.
### ship — docs, then the user's call ### ship — docs, then the user's call
- `agenthub stage ship` - `agenthub stage ship`
@@ -133,6 +304,8 @@ you dropped back to.
deferred, not "if asked". deferred, not "if asked".
- Commit / push / PR **only when the user asks in this turn**. Finishing an - Commit / push / PR **only when the user asks in this turn**. Finishing an
implementation is not authorization to commit. implementation is not authorization to commit.
- **Leave when:** the docs that the change made wrong are right again, and you
have reported plainly what shipped, what was verified, and what you skipped.
- `agenthub stage done --status passed --evidence "<what shipped>"` - `agenthub stage done --status passed --evidence "<what shipped>"`
## This repository's own rules ## This repository's own rules
+9 -9
View File
@@ -30,11 +30,11 @@ Update docs **in the same change-set (PR/commit) as the code**, before claiming
| Change type | Docs to update | | Change type | Docs to update |
|---|---| |---|---|
| **Env var** | `README.md` (env table), `ARCHITECTURE.md` (inline), `server/README.md`, `.env.example` | | **Env var** | `README.md` (env table), `ARCHITECTURE.md` (inline), `server/README.md`, `.env.example` |
| **Event type** | `README.md`+VN+CN (hook-event table), `ARCHITECTURE.md` (Event types line), `docs/PLUGINS.md`, + i18n, `docs/DATABASE.md` (if it enumerates types) | | **Event type** | `README.md` (hook-event table), `ARCHITECTURE.md` (Event types line), `docs/PLUGINS.md`, `docs/DATABASE.md` (if it enumerates types), plus the `en`/`vi` strings under `client/src/i18n/locales/` if the type is rendered |
| **Hook behavior / state transition** | `docs/HOOKS.md`, state-machine **mermaid** diagrams in `README.md`+VN+CN + `server/README.md` + `docs/DATABASE.md` + , `ARCHITECTURE.md` (hooks.js row) | | **Hook behavior / state transition** | `docs/HOOKS.md`, the state-machine **mermaid** diagrams in `README.md`, `server/README.md` and `docs/DATABASE.md`, `ARCHITECTURE.md` (hooks.js row) |
| **API route / response** | `docs/API.md`, `server/README.md` (routes), `ARCHITECTURE.md` (routes row), `server/openapi*.js` (code) | | **API route / response** | `docs/API.md`, `server/README.md` (routes), `ARCHITECTURE.md` (routes row), `server/openapi.js` + `server/openapi-extra/*.js` (code), then regenerate `openapi.yaml` with `npm run openapi:yaml` |
| **DB schema** | `docs/DATABASE.md`, `ARCHITECTURE.md` (ERD/schema) | | **DB schema** | `docs/DATABASE.md`, `ARCHITECTURE.md` (ERD/schema) |
| **WebSocket message** | `client/README.md` (Event Types), `server/README.md`, | | **WebSocket message** | `client/README.md` (Event Types), `server/README.md` |
| **Feature / page / background service** | `README.md` (feature table + data-flow list), `ARCHITECTURE.md` (module table), `server/README.md` or `client/README.md` | | **Feature / page / background service** | `README.md` (feature table + data-flow list), `ARCHITECTURE.md` (module table), `server/README.md` or `client/README.md` |
| **CLI command / script** | `README.md` commands, `CLAUDE.md` / `AGENTS.md`, `INSTALL.md` / `SETUP.md` | | **CLI command / script** | `README.md` commands, `CLAUDE.md` / `AGENTS.md`, `INSTALL.md` / `SETUP.md` |
| **New language** | `docs/I18N.md`, `client/src/i18n/locales/<xx>/*`, `client/src/i18n/index.ts` (add to `supportedLngs` AND the `resources` map) | | **New language** | `docs/I18N.md`, `client/src/i18n/locales/<xx>/*`, `client/src/i18n/index.ts` (add to `supportedLngs` AND the `resources` map) |
@@ -42,13 +42,13 @@ Update docs **in the same change-set (PR/commit) as the code**, before claiming
## Procedure ## Procedure
1. **Classify** the change against the table above. A change can hit multiple rows (a new feature with a new env var hits both). 1. **Classify** the change against the table above. A change can hit multiple rows (a new feature with a new env var hits both).
2. **Write the canonical English version first** — usually `README.md` and/or `ARCHITECTURE.md`. Get the wording right there; it anchors everything else. 2. **Write the canonical version first** — usually `README.md` and/or `ARCHITECTURE.md`. Get the wording right there; it anchors everything else.
6. **Area READMEs / docs/**: update `server/README.md`, `client/README.md`, and the relevant `docs/*.md` per the mapping. 3. **Area READMEs / docs/**: update `server/README.md`, `client/README.md`, and the relevant `docs/*.md` per the mapping.
7. **Diagrams**: when a state transition changes, edit every mermaid `stateDiagram-v2` block that models it (they are duplicated across README, server/README and docs/DATABASE). Keep transition labels consistent. 4. **Diagrams**: when a state transition changes, edit every mermaid `stateDiagram-v2` block that models it (they are duplicated across README, server/README and docs/DATABASE). Keep transition labels consistent.
## Verify (do not skip) ## Verify (do not skip)
- **Coverage**: run `scripts/doc-coverage.sh <new-term> [...]` (e.g. the new env var / event type / identifier) and confirm every doc the mapping flags shows a HIT. The matrix is advisory — not every term belongs in every file — but a flagged doc reading `0` is a miss to fix. - **Coverage**: grep the new term (env var / event type / identifier) across the docs the mapping flags — `grep -rn "<new-term>" README.md ARCHITECTURE.md server/README.md client/README.md docs/` — and confirm each flagged doc actually shows a hit. Advisory, not mechanical: not every term belongs in every file, but a flagged doc with zero hits is a miss to fix.
- **Tables**: markdown tables stay pipe-balanced (header column count == every row). - **Tables**: markdown tables stay pipe-balanced (header column count == every row).
- **Mermaid**: each edited block still parses (valid `source --> target: label`). - **Mermaid**: each edited block still parses (valid `source --> target: label`).
- **i18n**: every new English string has a `vi` entry in `client/src/i18n/locales/vi/`. - **i18n**: every new English string has a `vi` entry in `client/src/i18n/locales/vi/`.
@@ -58,5 +58,5 @@ Update docs **in the same change-set (PR/commit) as the code**, before claiming
## Tips ## Tips
- The fastest way to find where something already lives: `grep -n "<existing-neighbor-term>" <doc>` (e.g. grep an adjacent env var to find the env table). `references/doc-map.md` lists the stable anchors per file. - The fastest way to find where something already lives: `grep -n "<existing-neighbor-term>" <doc>` (e.g. grep an adjacent env var to find the env table). `references/doc-map.md` lists the stable anchors per file.
- Parallelize translations + HTML across subagents when the change is large, but write the canonical English edit yourself first so the translations have a faithful source. - Parallelize independent doc edits across subagents when the change is large, but write the canonical `README.md`/`ARCHITECTURE.md` edit yourself first so every other file has a faithful source.
- One language/area per subagent keeps edits reviewable and tables un-corrupted. - One language/area per subagent keeps edits reviewable and tables un-corrupted.
@@ -14,10 +14,7 @@ The source of truth most other docs mirror. Key sections:
### Translations ### Translations
This build ships English only. `README-VN.md`, `README-CN.md` and `README-KO.md` were removed, as were the `zh` and `ko` UI locales — do not recreate them. This build ships English only. `README-VN.md`, `README-CN.md` and `README-KO.md` were removed, as were the `zh` and `ko` UI locales — do not recreate them, and do not mirror a README change into a translation file. (The UI itself still ships `en` and `vi` locales under `client/src/i18n/locales/` — those are strings, not docs, and are covered by the frontend section below.)
Standalone full translations of `README.md`. **Every** README change must be mirrored here at the corresponding section. Conventions:
- Keep in English/code: identifiers, env-var names, event-type names, `awaiting_input_since`, `pendingInterrupt`, "watchdog", `fs.watch`, model IDs, mermaid transition labels.
- Translate prose. "Waiting" → **Đang chờ** (vi) / **等待中** (zh) / **대기 중** (ko). "watchdog" often kept; in zh sometimes 看门狗.
### `ARCHITECTURE.md` ### `ARCHITECTURE.md`
- **Module responsibility table** — one row per source file (`scripts/import-history.js`, `lib/transcript-cache.js`, `routes/hooks.js`, `server/index.js`, …). Update the row whose file you changed. Grep the file path. - **Module responsibility table** — one row per source file (`scripts/import-history.js`, `lib/transcript-cache.js`, `routes/hooks.js`, `server/index.js`, …). Update the row whose file you changed. Grep the file path.