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.
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.
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.
The lane strip's tiles are now drag-reorderable (Alt+←/→ is the keyboard
path), and a lane's name can be set in place from the detail header. The
"Lane 23 ·" prefix is gone from both — the bare `#23` stays on the tile,
because that number is how the CLI addresses a lane.
Card order is a per-viewer preference, not a lane fact: it lives in
localStorage (`agenthub.workspace.laneOrder`) beside the split-view layout
and writes nothing server-side, so `GET /api/lanes` and `agenthub lanes`
keep answering in id order. Renaming is the opposite — it is PATCH
/api/lanes/:id with {title}, the one lane column a user now types into
directly.
That made a patch value reachable by hand for the first time, so
`bindable` now rejects any non-scalar for every PATCHABLE column with
EBADFIELD, and the route answers 400 for that and for the
SQLITE_CONSTRAINT family, rather than handing better-sqlite3 a datatype
throw and reporting 500 on plain bad input.
The uncommitted-files tooltip became a real panel. `GET /api/lanes/:id/git`
now returns `files` as {path, status, added, deleted} records — deltas from
one `git diff --numstat HEAD`, null where git has none to report (untracked,
binary, renamed) — and the card renders them portalled to the body, so the
Workspace's own scroll box cannot clip the list.
Two gaps left by the AI Router feature: a profile could be saved without ever
being reachable, and a lane's router could only be chosen at creation.
**POST /api/ai-routers/test** answers the question saving cannot. It sends one
Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages`
with a 15s timeout — the wire shape a claude lane's eight env keys point at, so
what is tested is what will actually run. A router that answers is a 200 here
whatever it replies: `{ ok, status, message }` carries the router's own words,
and `status` is null when nothing answered at all. Only bad input is a 4xx.
The stored key is only ever sent to the endpoint it was stored against. A blank
`apiKey` falls back to the key of the profile named by `name`, but only while
the submitted `baseUrl` still equals the saved one — so testing a retyped
endpoint needs a retyped key (`EBADKEY`). Without that rule this route would be
a way to make the server POST a saved API key to any URL a browser names.
Network failures report `error.cause`, not `error.message`: node's fetch reports
every transport failure as the same useless "fetch failed" and hides the part
the user can act on (ECONNREFUSED, ENOTFOUND, a TLS error) one level down.
**The lane card carries an AI Router select.** The Add-lane modal was the only
place a router was chosen, and a lane outlives its creation. Switching goes
through `POST /api/lanes/:id/router/apply` — never a lane PATCH — because the
DB column and the lane's own config on disk may only move together. A failed
apply puts the select back rather than leaving the card claiming a router the
lane did not get. Hidden when `native` is the only profile: a select with one
option is a control that cannot do anything.
Adds `Lane.router_profile` to the client type (the payload already carried it).
A new **AI Router** tab in Settings, next to Codex, stores named router
endpoints (9Router, OpenRouter, a self-hosted gateway) as profiles: a base
URL, an API key, a default model and an optional per-tier override. The tab
only stores them; a lane CHOOSES one in the Add-lane modal, via a Native /
AI Router radio, and that choice is written into the lane's own config.
The two CLIs are configured differently, and the difference is theirs:
- a claude lane gets eight `env` keys merged into its own
`.claude/settings.local.json` (`.local` because the value holds a key);
- a codex lane gets `$CODEX_HOME/<name>.config.toml` — the layer
`codex -p <name>` loads — because Codex has NO project-level config.
Its base `config.toml` is never touched.
Native is a built-in profile rather than a code branch, so "go back to
native" is the same write path. It blanks the two connection variables
instead of removing them (an absent key at project scope lets a USER-level
router apply) and removes the model names instead of writing them (writing
them would override the user's own global model choice).
Key material never leaves the server: profiles live one file each under
`<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers`
reports only `hasKey`, and there is deliberately no route returning one
profile in full.
Three things this had to get right, each found by review and verified
against the real tools rather than assumed:
- `wire_api = "chat"` is rejected outright by codex-cli 0.149 ("no longer
supported"), so every codex lane on a router would have failed to start.
The layer writes "responses", and writes `env_key` only when the profile
actually has a key — naming an unset variable is itself a startup error.
- The key must never be TYPED into a pane. `tmux.sendCommand` used
`send-keys` with a `KEY='…' cmd` prefix, which the dashboard streams to
every attached browser and bash appends to history; it now takes over the
idle pane with `respawn-pane -k -e`, passing env through argv the way
`newSession` always has. Verified on tmux 3.4: multi-element argv arrives
intact and the pane renders no trace of the value.
- Attaching COPIES a profile, so editing or deleting one in Settings has to
reach the lanes already using it. An edit re-applies it (a rotated key
would otherwise never reach the lane still holding the revoked one); a
delete resets those lanes to native and removes the Codex layer file.
Both report per-lane failures, which the tab surfaces.
`.claude/settings.local.json` is added to the lane's git exclude, for both
native and router lanes: `agenthub lanes reset` runs `git clean -fd` without
`-x`, so an un-excluded settings file is deleted on reset — and untracked
meant the key was committable.
DB: `lanes.router_profile`, nullable, additive. Written only by
`setRouterProfile` beside the disk write, never through `PATCHABLE`.
Starting a run on a machine without tmux surfaced `spawnSync tmux ENOENT`
as a 400 — blaming the request for a missing host package, naming no fix,
and reaching the user as a raw errno in the console pane.
The guard goes in spawnRun, which both spawn paths (POST /api/run and
POST /api/lanes/:id/start) already route through, so one check covers
them; the message carries the install command. Both routes map the code to
503: the request was fine, the dependency is not installed. The client
already renders `error.message` from the body verbatim, so no UI change.
Also: README's node advice said "stay on 20/22/24"; 1712 server + 488
client tests are green on v26.8.1, so only 25 is actually the bad one.
`dashboard_runs.mode` is NOT NULL and `recordRun` swallows insert errors by
design, so pty-run.js passing `mode: null` meant NO tmux-backed run was ever
persisted — the newest row in a live database dated from before pty-run.js
replaced run-spawner.js. `publicRun` reads cwd, model, prompt preview,
started_at and session_id off that row and the handle itself is recomputed
from tmux, so the runs modal rendered a live row with no cwd, no model, no
prompt and a start date of Jan 1 1970. The same missing row is why a resumed
session kept its separate `external` badge: the client dedupes an external
session against the session ids carried by run rows, and no run row ever
carried one.
- pty-run.js records `mode: "interactive"` — every run here is one pane.
- dashboard-runs.js still never fails a live run on a db error, but warns
instead of swallowing silently; that silence hid this for six weeks.
- RunHistory renders `startedAt: 0` as "-" rather than dating a row with no
known start time to the epoch.
Also closes the other half of the same lie: adopting a pane that is already
running an agent now throws ERUNLIVE (409 from both the lane start action and
POST /api/run) when the request carried a prompt or a resumeSessionId. That
argv cannot be typed over a live agent, so answering 200 reported a resume
that never spawned. A start asking for nothing but a pane still adopts.
An archive-sourced marketplace entry ("source": "url" — this project's own)
makes Claude Code unpack the plugin rather than clone it, so the cache dir
has no .git. getUpdatesStatus probed for .git first and returned "not a git
clone; check for updates manually", skipping the entire release layer:
update_available stayed false however many releases were published, and
plugin_marketplace/plugin_name were never set, so POST /api/updates/apply's
plugin branch could never fire (it answered 409, claiming nothing newer was
published). The bug reads as "up to date", so it stayed invisible for as
long as the install happened to be current.
The plugin branch now runs BEFORE the .git probe and answers without git at
all: the repository URL comes from the marketplace manifest on disk
(known_marketplaces.json -> .claude-plugin/marketplace.json -> source.url,
null rather than a guessed host for a github owner/repo shorthand), and
package.json is the only local comparison signal. A failed release lookup
is reported as release_check, never as "nothing newer".
UI/CLI: the "not a git clone" note is gated on install_kind !== "plugin", so
the Update now button is offered where the dashboard can actually check and
apply.
The plugins tab could already update one plugin or one marketplace at a time. It gains an install-wide batch and, separately, the reload step that actually makes an update take effect.
Update all runs in a DETACHED worker rather than the request handler, for a structural reason: the batch ends with the plugin the dashboard ITSELF runs from, and that step's plugin-bootstrap.js --force SIGTERMs this very server. An in-process loop would die partway through with the remaining plugins unrun and nothing left to report. Order is marketplaces (one call, no name = all) -> every other installed plugin -> AgentHub last -> one reload. Each transition is journalled to cc-update-all.json in the runtime dir, outside the plugin cache the update replaces, so whichever server is alive serves progress from it; the POST's response is only an acknowledgement.
readUpdateAll DERIVES an abandoned status for a running journal older than an hour, because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would stay wedged forever. isRunning gates a concurrent batch: two claude plugin update chains over one config dir race.
Reload is its own capability (server/lib/cc-reload.js, POST /api/cc-config/reload). /reload-plugins and /reload-skills are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from outside - typing into a session already running is the only real mechanism, and that is what this does, over tmux send-keys into the dashboard's live lane panes. It spawns NO claude process on purpose: 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 - a headless run gets a full sessions row and there is no filter anywhere to suppress it. Reusing live panes means no session is created at all. Panes at a shell prompt are skipped (the text would run as a shell command) and only claude lanes are addressed, since Codex has no such command.
Update-all reloads once at the end; updating a single plugin passes reload:true and reloads immediately, and never after a failed action.
Fixes a real pre-existing bug on the way: pluginUpdate omitted -y while runClaude closes stdin, and the CLI requires it whenever stdin is not a TTY - so the per-plugin Update button failed for any plugin whose marketplace declares an install command. Also completes CcConfigChangedPayload's action union, which claimed write|delete while nine other values were already being emitted.
Verified: server 1683/1683, client 486/486 (27 new tests), build, prettier, file-header audit, openapi.yaml regenerated (117 paths). Update all was NOT executed against this machine - it would update 7 real plugins and restart the running dashboard.
Update detection was a commit count against the default branch, which says nothing about whether a *version* shipped and is pure noise on the plugin cache (a depth-1 clone, where every distance is a graft artefact). It now reads the forge's own releases API and compares the published tag to this install's package.json version, falling back to the commit count only when no release can be read — with the reason in release_check, so "up to date" never silently means "we could not look".
Two states a commit count cannot express: dev_build (ahead of the newest release, missing nothing from it) now reads "Dev build - N ahead" instead of a false "Up to date"; and on a shallow clone every commits_*_release field is null and dev_build is never asserted, because the numbers would be meaningless.
POST /api/updates/apply gains a second mode, chosen by install kind and never by the request body. A checkout still fast-forwards onto the release tag, rebuilds and self-restarts. A ~/.claude/plugins/cache/ install is refused by applyUpdate (that directory is Claude Code's to replace) and handed instead to a detached chain: claude plugin marketplace update -> claude plugin update -y -> plugin-bootstrap.js --force from the NEW cache dir, which retires this server and starts its replacement. Both write the HTTP response before anything stops the process.
The chain records every outcome to plugin-update.json in the runtime dir, outside the cache the update replaces, so the next server reports it as last_plugin_update: a failed chain is otherwise indistinguishable from a slow one, and the tab would poll a server that never returns. /reload-plugins is deliberately not in the chain - it is REPL-scoped to the session that runs it, so no background process can perform it, and plugin-bootstrap derives PLUGIN_ROOT from its own __dirname, which is what makes it unnecessary for the dashboard.
Also rebrands the sidebar subtitle, splash and page title to SmartGift Solution, and corrects three ARCHITECTURE.md statements that claimed /apply did not exist.
Verified: server 1683/1683, client 486/486, build, prettier, file-header audit, openapi.yaml regenerated.
Lane Pipeline Pass Rates read `lanes.stages`, which is a snapshot of the
pass in flight: re-entering a node overwrites its record, and moving
backwards deletes every record ahead of the new stage. The report treated
one lane row as one run, so shipping ten features through a lane still
counted as one, and no node could ever exceed 1/1. History survived only
via `lane_features`, which is written when a feature is activated — a
path most users never take. The panel was not wrong, it was structurally
incapable of counting.
Add `lane_stage_events`, an append-only log written by every declaration
and every accepted detection, and aggregate from that. A run is one pass
through the template: the log starts a new one when the lane moves
backwards or leaves the template's last node. Existing snapshots are
seeded as one run each on first boot, so upgrading does not read as
"never run". Templates a lane is configured with are still listed at
zero — a pipeline going unused has to be visible as such.
Count inferred reaches too, in their own column and their own colour.
Detection stays out of the pass rate ("inference is never evidence"), but
reporting a stage the hooks demonstrably saw as "never reached" is what
made the panel look broken to anyone who works without running the
pipeline commands.
Also reject a stage naming no node of the lane's pipeline (400 EBADSTAGE,
listing the ones that exist) instead of storing it with a warning nobody
reads. A real lane had accumulated five such keys, one of them `test` on
a ship-feature lane whose CI node is `gates` — which is why that row read
never-reached while the stage had in fact been declared. Aliases still
resolve, so shorthand is unaffected.
The Memory tab could only show this machine's user memory and the
dashboard's own project file. Setting up a project you had not run
through the dashboard meant opening a terminal — `PUT /file`'s
"project" scope always resolves against the server's cwd, on purpose.
Add one narrow route per tool that writes into the directory you name:
CLAUDE.md for Claude Code, AGENTS.md for Codex, both through the same
`project-memory.js`. Being the only write path that honours a
caller-supplied directory, it pays for that:
- the directory must be absolute and already exist — nothing is
created for you;
- the filename comes from the route, never from the body;
- an existing file, symlink included, is a 409 — this can only
create, never overwrite;
- it sits behind the loopback-Origin guard /api/run uses, so a page
in the user's browser cannot plant agent instructions in a project
directory. That guard moves to lib/same-origin.js to be shared.
The modal takes the dashboard's known project dirs as suggestions on a
free-text input rather than a closed list: a project it has never seen
is exactly the case this exists for.
Mutation-checked: dropping the origin guard, and swapping lstat for
stat, each fail a test.
Mutation goes through `claude mcp add|remove`, never a direct edit of
~/.claude.json — the running CLI rewrites that file concurrently, so a
dashboard write would race it. Reads stay on readMcpServers.
The plan specified a --url flag; the real CLI (v2.1.266) has none —
commandOrUrl is positional, and --header must trail it or the variadic
flag swallows the url. Built against the verified shape.
The Claude writer's containment was lexical only: a symlink at
~/.claude/<type>/<name>, or at any ancestor of it, is a string inside the
root while the write lands wherever it points. The backup destination had
the same exposure, and delete copies through the link before unlinking it.
PUT/DELETE /file also honoured ?cwd=, so a project-scope write could be
aimed at any directory on disk. PATCH /settings already refused that, with
the reasoning in a comment; the two other write routes never adopted it.
The guard moves into config-write.js so both writers run one copy.
Plugins/marketplaces GET routes were silently empty because codex-cli.js
never unwrapped the CLI's {installed,available}/{marketplaces} objects
into arrays; backup writes bypassed the symlink-escape guard entirely;
readFileSafe was stricter than every other Codex reader, showing a
symlinked skill/rule in the list and then refusing to open it;
marketplaceAdd only accepted http(s) URLs, rejecting owner/repo sources
the CLI itself takes. Also: docs drift, a dead i18n key, and a scope-cast
hazard in the shared Editor/ConfirmDelete modals.
Fixes, one per finding: unwrap installed/marketplaces in codex-cli.js so
every list route keeps returning a bare list; guard the backup path in
codex-mutate.js with the same assertNotSymlinkEscape the write path
already used (Claude's cc-mutate.js has no such guard on its primary
write path either, so left untouched there); relax readFileSafe's
containment to lexical-only, matching the other four Codex readers,
while leaving the write side's realpath check untouched; reuse
cc-plugin-cli's assertSource for marketplaceAdd instead of a third
validator (ssh remains out of scope, no credential story for it yet).
Server: 1557/1557 (was 1548). Client: tsc -b clean, 469/469 tests.
Four defects, all from one design mistake: a profile's display name and its
filename were the same string.
- Saving the logged-in account made a SECOND profile of an account another
profile already held. Both then reported themselves active, so there was
nothing to switch between. `POST /profiles` now refuses `EDUPLICATE` and
names the profile that already holds the account. Re-saving into the
account's own profile stays allowed - that is the token-refresh path.
- Worse, an accidental duplicate could not be cleaned up: two profiles of one
account are both active, and the delete guard refused any active profile, so
neither was ever deletable. The guard now blocks only the LAST saved copy of
the live account, which is the actual credential-loss condition it exists
for. The trash button had to follow, or the server would allow what the UI
hid.
- A name could not contain spaces or diacritics, because it had to be
filename-safe. A profile now carries `name` (internal identifier, still the
filename, still what every route takes) and `label` (what the user reads and
edits: 1-60 free-form characters, control characters rejected, stored
trimmed, unique case-insensitively). A label never touches a path, which is
what makes "Tài khoản chính" a legal name.
- Naming happened up front in a "save current account as" field. That field is
gone. The server derives the identifier, the save button lives on the live
account's card and appears only while it is unsaved, and the label is edited
by clicking it (Enter saves, Escape cancels).
Renaming rewrites the label in place and deliberately 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`, so nothing needs migrating.
Adds `PATCH /api/cc-config/profiles`; `POST` now takes an optional `name`.
Verified: server 1431/1431 (11 new), client 459/459, tsc clean. Also exercised
end to end against a COPY of the real profile store on this machine: the
duplicate deleted, the last copy still refused, a Vietnamese label accepted,
and the stored access/refresh tokens and 0600 mode intact after a rename.
Adds a "Switch profile" button left of Backups on /settings?tool=claude-code.
A *profile* is one Anthropic account - one quota - saved under a name, so the
local `claude` CLI can be pointed at a different account without disturbing
anything else.
A switch swaps exactly two identity artifacts and nothing more: `claudeAiOauth`
inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside
the `.claude.json` config file(s). Those files are key-merged in place, and only
where they already exist, 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: those are
the machine's MCP server tokens, not account identity.
Profiles are one JSON file each under `<CLAUDE_HOME>/agenthub-profiles/` (dir
0700, files 0600, atomic temp+rename) and hold live OAuth tokens, so no route
ever returns token material - `GET /profiles` answers identity metadata only.
The dashboard cannot log an account in; only `claude auth login` mints tokens.
Adding a profile is therefore always "log in from a terminal, then snapshot what
the CLI wrote". `activateProfile` re-snapshots the outgoing account first so
tokens Claude Code refreshed since its last save 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. A switch applies to new sessions only,
since a running session rewrites the config on exit.
Routes: GET/POST/DELETE /api/cc-config/profiles, POST /profiles/activate.
Verified: server 1420/1420 (14 new, incl. token-free responses and 0600/0700
permissions), client 459/459.
Clean break, no compatibility alias. Renames the product name, the CLI
binary (`ccam` -> `agenthub`), the env var prefix (`CCAM_` ->
`AGENTHUB_`), the Prometheus metric namespace (`ccam_*` ->
`agenthub_*`), the tmux session prefix (`ccam-lane-<id>` ->
`agenthub-lane-<id>`), the per-repo runtime profile directory
(`.ccam/profile` -> `.agenthub/profile`), the lanes state root
(`~/.claude/ccam-lanes` -> `~/.claude/agenthub-lanes`), the bundled
plugins (`plugins/ccam*` -> `plugins/agenthub*`) and their slash
commands (`/ccam-doctor` -> `/agenthub-doctor`, `/ccam:*` ->
`/agenthub:*`).
Because there is no alias, an existing install has to be updated by
hand: rename its `.ccam/profile` directory and any `CCAM_*` variables,
and reinstall the plugin under its new name.
Two other name families are deliberately left alone here, since they are
not just strings: the `Claude-Code-Monitor` repository/marketplace name,
and the `agent-dashboard` npm package plus its `~/.claude/agent-dashboard`
runtime directory, which holds the live database and needs a migration
rather than a rename.
Verified: server 1406/1406, client 459/459, `grep -i ccam` clean across
tracked files, `openapi.yaml` regenerated (95 paths, unchanged count).
A pipeline template named ONE command for the whole run (`skill`), so the
only way to know what actually moves a given node was to read that node's
`detect` regexes. Each node now carries its own `command`: a slash command
(`/superpowers:writing-plans`) or a shell one (`ccam lanes hook ci-gate`),
whichever its detect rules already watch for.
The values are derived from each node's own detect rules, not invented —
every `ccam …` string was checked against bin/ccam.js and every
`/superpowers:*` against the installed plugin. Nodes reaching which is a
declaration rather than an action (`done`, `merged`, `reported`,
`e2e-feature-passed`, `qc-plan`) are deliberately left empty.
`pipelines.js` normalizes a missing or non-string value to "", so a
custom template written before this field keeps loading rather than
rendering "undefined". `nodeStates()` and GET /api/lanes/pipeline-stats
pass it through. Never executed by the dashboard — display text, the same
contract `skill` already had.
Surfaced where each view has room for it: the Workflows pass-rate panel
shows it under the node label and copies it on click; PipelineMap folds
it into the node tooltip, because a 16-node pipeline has to fit across
one card and there is no width for a second line.
`command?: string` is optional in the client type only to spare four
unrelated test fixtures — the server always sends the key, and the UI has
to branch on the empty string regardless.
Verified: npm run test:server (1373, +3), npm run test:client (459, +2),
tsc --noEmit, prettier and check-headers clean. docs/LANES.md § Custom
template format documents the field. openapi.yaml needs no change: the
lane payload and pipeline-stats were never schematized there.
Repo/Adopt mode (POST /api/lanes/ensure) treated lanes.cwd as globally
unique, so adopting a path already driven by one CLI silently ignored a
different tool choice. Uniqueness moves to the composite (cwd, tool):
one directory can now back a claude lane and a codex lane side by side.
- server/db.js: drop the column-level UNIQUE on cwd, migrate existing
DBs via table rebuild, add idx_lanes_cwd_tool.
- resolveLaneByCwd gains an optional tool filter; hook ingestion always
pins to "claude" since Codex has no hook mechanism here, so a hook
event never touches a codex lane sharing the path.
- POST /ensure resolves the existing owner by (cwd, tool) instead of
cwd alone, so a different tool creates a sibling lane.
- LaneConsolePane's cwd-only lane lookups now also match tool, so a
shared cwd doesn't attach a run to the wrong sibling lane.
Worktree mode is unaffected (already allocates a unique directory per
lane). docs/LANES.md and CLAUDE.md updated to describe the new
composite uniqueness.
The panel aggregated every lane at once, which answers "does this stage get
proved across the project" but never "does THIS lane prove it". A lane picker
now narrows the same aggregation through ?lane=<id> on
GET /api/lanes/pipeline-stats, counting that lane plus its own archived
features — dropping the features would hide most of a lane's history the moment
it starts its next one.
The response carries a `lanes` array that the filter never narrows: it
populates the picker, so it has to keep offering every other lane even while
the counts show one. For the same reason the picker renders above the empty
branch rather than inside the populated one — narrowing to a lane that has
recorded nothing would otherwise be a one-way trip, with no control left to
select anything else. A lane filter that is not a positive integer is rejected
with 400 EBADLANE rather than silently aggregating everything.
Restores the five analytics sections that turned out to be worth keeping
(patterns, model delegation, error propagation, session complexity, compaction
impact) and reworks what the page opens on.
The lane panel no longer mirrors the Workspace's live lane strip. It now
answers the question the strip cannot: how often each pipeline node actually
passes. GET /api/lanes/pipeline-stats aggregates every stage record of every
lane AND every archived feature — a lane's own `stages` only holds its current
feature, so without the archive the history resets each time a lane starts new
work. Evidence-backed passes are counted apart from bare claims, the same line
the pipeline map draws between `done` and `passed-no-evidence`.
Beside it, GET /api/workflows/skill-usage reports which skills and plugins the
sessions actually reach for. Only PreToolUse rows are counted: every call also
emits a PostToolUse, and counting both would double every number. A plugin's
total includes calls to the MCP tools it ships, so one used purely through MCP
does not read as unused.
Still dropped from issue #6: the agent collaboration network and the subagent
effectiveness scorecards.
The Claude Config page was read-only for these four surfaces. It can now:
- register a marketplace from a pasted GitHub `owner/repo`, http(s) URL or
absolute path, and install / enable / disable / update / uninstall plugins,
all delegated to the `claude plugin …` CLI so its registry stays consistent;
- edit hooks and settings.json keys in the user, project and project-local
layers.
Server:
- `server/lib/cc-plugin-cli.js` wraps the CLI with `execFile` and an explicit
argv array — never a shell string. Every argument is validated before the
spawn (shape allowlist per kind, no control characters, no leading `-`, no
`..`), `CLAUDE_CONFIG_DIR` is pinned to the dashboard's CLAUDE_HOME, stdin is
closed so a prompt errors instead of hanging, and mutating calls are
serialized in-process.
- `cc-mutate.patchSettings` merges individual dotted keys into one settings
layer. It re-reads the file unredacted, so keys the UI never displayed —
including secret-valued ones — survive byte-for-byte. A payload carrying the
`<redacted>` placeholder is refused (EREDACTED), as are `__proto__` /
`prototype` / `constructor` segments; a settings file that does not parse is
reported (EBADJSON) rather than clobbered. Backup first, then atomic write.
- Six additive routes on /api/cc-config; a non-zero CLI exit is a 422 carrying
the CLI's own message, a missing binary a 503.
Client: install/add bars, per-card plugin and marketplace actions behind a
two-click confirm, and edit modes for hooks and settings that send only the
keys that changed. New i18n keys in en + vi; the explainer banners that said
these surfaces were read-only now describe what the writes actually do.
Tests: 61 new server tests (CLI argv construction and validation, the settings
key-merge, and the six routes against a fake `claude` on PATH).
Unmatched Ask questions now adopt a dedicated "Sagi" lane (POST
/api/lanes/ensure, cwd defaults to $HOME) and spawn a tmux-backed claude
run there instead of just prefilling the Run page composer.
Adds POST /api/updates/apply (pull, rebuild, restart) plus an "Update now"
button and hourly auto-check in the UI, for checkouts that are safely
fast-forwardable. Reintroduces self-restart (previously removed in edc25ca
for cross-environment reliability concerns) per explicit user request.
Replace run-spawner imports and APIs with pty-run:
- Import pty-run instead of run-spawner
- Delete setRunExitHandler registration, replace with read-time self-heal in payload()
- Remove mode validation (mode no longer exists in pty-run)
- Update spawnRun call to use new parameter names (initialPrompt, not prompt/mode)
- Replace "message" action with explicit 400 EUNSUPPORTED response
- Fix stopLaneRun to poll on status !== "gone" instead of !actualExitedAt
Adapt tests to tmux-based run model:
- Delete tests about mode-specific behavior (removed feature)
- Rewrite lane release tests using tmux.__setExecImpl mocks instead of withFakeClaude
- Update assertions to check status === "gone" instead of specific exit codes
- Update ERUNTIMEOUT test to mock tmux sessions instead of child processes
All lane-related tests pass; only pre-existing port conflicts in lane-detect.test.js remain.
Deletes desktop/ (Electron wrapper), deployments/ (Helm/Kustomize/
Terraform/CI for cloud deploy), and monitoring/ (Prometheus + Grafana
stack) along with DESKTOP.md, DEPLOYMENT.md, docker-compose.full.yml,
their npm scripts, and every dangling reference across README,
ARCHITECTURE, INSTALL, SETUP, docs/, and the repeated per-file
MODULE_GUIDE "Observability" boilerplate comment. The GET /api/metrics
endpoint itself is untouched — it's the dashboard's own route, not
part of the removed monitoring stack.
Creation is the only point the UI could ever set a lane's template, and it
never offered the choice — so every lane added from "+ Add lane" was born
on `default` and rendered an 8-node map for a 16-node workflow, with no
screen able to change it afterwards. That is the defect that made the
ship-feature template unreachable from the browser.
The modal now shows a *Pipeline template* select fed by
`GET /api/lanes/pipelines`, labelled with each template's node count so the
consequence of the choice is visible. A failed fetch degrades to a `default`
option rather than blocking lane creation.
`pipeline` was already accepted by `POST /api/lanes` but silently dropped by
`/ensure` and `/worktree`, which build their own createLane payloads; both
now pass it through, and both map `EBADPIPELINE` to 400 like `EBADCWD`.
A lane's pipeline map only ever moved when a skill remembered to call
`ccam stage`, and the ship-feature template shipped with no detection rules
at all — so a lane driven by Superpowers skills sat at whatever stage it
last declared, and the `gates` node was never declared by anything.
Detection (`detect` rules on each node) now covers the Superpowers skill
invocations and the `ccam`/`gh` commands the ship-feature-lane skill
actually runs. It stays a safety net, not the mechanism: forward-only,
never `done`, never overriding a declaration. Two rules were deliberately
left out — `git diff` on `review` (this repo's own tests record it pinning
a lane at `review` on a real session) and anything on `merged`/`done`.
Stage vocabulary grows to 50 names over the same 16 nodes, following
Shipyard's PHASES shape: sub-states like `migration-collision`,
`e2e-scoped` and `gate-blocked` say WHY a lane sits on a node without the
map growing a node per reason. Every alias has a source — the skill
declares it, `default.json` uses it, or Shipyard's PHASES lists it.
Two silent failures fixed along the way:
- `lane.stages` is keyed by the raw declared string, so a stage declared
under an alias lost its `--evidence` and rendered amber instead of
green. `stageRecords` resolves each key onto its node.
- `ccam stage <typo>` stored fine and then rendered nowhere. It now warns
on stderr while still exiting 0.
`ccam lanes pipeline` closes the gap that made all of this invisible: a
lane could only be assigned a template at creation, and no screen in the
web UI offers the choice, so every lane added from "+ Add lane" was stuck
on `default`'s 8 nodes. An unknown template id is now refused rather than
silently falling back to `default` on read.
Also merges the repo's own `ship-feature` skill into the Superpowers
workflow: it delegates planning/TDD/review/verification instead of
restating them, and declares a stage at each phase.
- Repo mode adopts a directory as-is via /lanes/ensure (no worktree, no
branch fields) - the right choice for a main repo you want stage
detection on. Worktree mode (default) keeps the existing provisioning
flow but now requires a manually-typed branch name instead of deriving
one from the title.
- POST /lanes/worktree accepts an optional `branch`, validated via
`git check-ref-format --branch`; omitting it preserves the CLI's
existing auto-derived-branch behavior.
- New GET /lanes/browse lists a directory's immediate subdirectories,
backing a small folder-browse modal on both path fields - browsers
cannot expose an absolute path from a native picker, so this is
server-backed instead, consistent with the tool's local-first model.
archiveActiveFeature was overwriting a feature's own title with the
LIVE lane's title on every clear/switch — a --title set on activation
silently disappeared. activateFeature restored stage/status/gate/CI/
stages/notes onto the live lane when switching back to a past feature,
but never links, so they vanished on reactivation. Both are fixed, with
a regression test for each (full activate/archive/reactivate round trip
for links).
Also fixes server/routes/lanes.js: the doc comment explaining GET
/:id/git's rationale had been left sitting above the newly-inserted
/:id/features routes instead of its own route.
docs/API.md's Lane features section described fields and behavior that
don't exist in the real routes (an "active" boolean, a POST response
containing "archivedPrevious", a "409 ESTALE" concurrency response) —
rewritten to match the actual request/response shapes exactly.