aff79a787c6bace89419a54f6adf552769eb4988
154 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
aff79a787c |
feat(lanes,git): publish a repo with no remote, and read a repo with no commits
What broke: a lane on a fresh `git init` repo had no working git surface.
`statusCounts` ran `rev-parse --short HEAD` unguarded, so `gitFacts` threw
and `GET /api/lanes/:id/git` answered `{available:false}` — the Changes
panel showed no header, and `commit`/`switch` 500'd through `isDirty`.
Add lane in Worktree mode accepted such a repo and left a `failed` row
behind, and a repo with commits but no remote had a permanently disabled
Push with no way to give it one short of a terminal.
What changed:
- worktree.js: an unborn HEAD is a normal state. `statusCounts` reports
`head: null`; `gitFacts` reads the branch via `symbolic-ref`, an empty
subject, and a new `unborn` field.
- git-ops.js: an 11th write, `publish` — `remote add origin -- <url>` then
`push -u origin HEAD`, only with a commit (ENOCOMMITS) and NO remote at
all (EREMOTEEXISTS, so it never re-points one), the url allowlisted by
`validateRemoteUrl` (https without userinfo, ssh://, scp-like; refuses
local paths, file://, ext::, fd::, git://, http://, leading `-`:
EBADURL), and `origin` removed again when the push fails.
- POST /api/lanes/worktree answers 400 EEMPTYREPO for a repo with no
commits instead of creating a row that can only fail.
- LaneGitBar: unborn repos show "no commits yet" and a commit-first hint;
no remote offers Publish… with an inline URL box; a remote but no
upstream relabels Push as "Publish branch". AddLaneModal disables Add
on an empty repo and offers "Use as a Repo lane".
- The two tests that used an unborn repo to force a provisioning failure
now use a `feat` ref clash, since that repo is refused up front.
- CLAUDE.md rule reworded to "the boring eleven" with publish's guards;
docs/API.md, docs/LANES.md, ARCHITECTURE.md, server/README.md, openapi.
|
||
|
|
f514602697 |
feat(lanes): git login from the dashboard, merge and conflict resolution (#45)
Part A — a fetch/pull/push against a remote that needs a login no longer dies on "terminal prompts disabled": the server answers 409 EGITAUTH with the host, the git bar asks for a username + password/access token and resends the same action. AgentHub keeps its OWN credential store (<CLAUDE_HOME>/agenthub-git-credentials/, 0700/0600), one login per host, shared by every user of the install, managed from Settings -> Git credentials. A login is only sent to one of the repo's own remote hosts, saved only after git accepted it, never returned by any route, and reaches git through an exclusive credential helper (never argv). Part B — merge a branch (or the diverged upstream) from the git bar; a merge that stops on conflicts lists them in the Changes panel with Keep mine / Take theirs / Mark resolved, then Complete merge or Abort merge (confirm gate). Clean tree, repo-root lane and an existing ref only; pull/switch/hunk actions are blocked mid-merge; staging a conflicted file with markers left is refused. Rebase/reset/stash stay out. |
||
|
|
158783d5d5 |
feat(lanes,run,pricing): Changes sidebar editor, staged groups, image drop, auto pricing
- Changes panel at full screen: the whole Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab is dropped; a working-copy file opens straight into the editor (Save/Cancel live once the text differs, Cancel restores it, Whole file toggles back to the diff), and the page is height-locked so the editor no longer collapses with no live run. - Overview splits into Staged Changes / Changes groups in the work range. - A changed file's whole tree row takes its status tint. - Drag an image onto the lane terminal: POST /api/run/drop-image saves it (png/jpeg/gif/webp, random name, 0600, pruned after a day) and the path is pasted into the pane, which is how claude attaches it. - Claude Opus 5.5 gets its own pricing row (4/20/0.2/5/8, fast 8/40); claude-opus-5% had been pricing it as Opus 5. - server/lib/pricing-sync.js: a model family used in token_usage with no row of its own is inserted from LiteLLM's public price list, never updated; off with AGENTHUB_PRICING_SYNC=0. repo-links allows exactly that one third-party GitHub raw URL. |
||
|
|
d397d3a1f6 |
feat(lanes): split panes on one lane, in-place file editing, hunk review bar
- Split view: every pane shows the selected lane, each in its own tmux session (`slot` 0-3 on POST /api/lanes/:id/start; slot 0 stays the lane's `run_id`, slots 1-3 are `agenthub-lane-<id>-<slot>`). A pane's Active runs hides what the other panes show; stop/reset/remove kill every slot. No per-pane lane picker any more. - Whole-file editor: PUT /api/lanes/:id/git/file (lane-file-write.js) with the read path's guards, existing text files only, and a baseSha stale check (409 ESTALE). A file it cannot take says why. - Per-hunk review bar under each hunk's last changed line: prev/next, "n of N", Undo, Keep. Review diffs no longer nest their own scroll box (unless windowed), so navigation moves the one real scroller. - Files tree lists untracked-not-ignored files and colours entries by git status (M/U/A/D/R), folders holding changes get a dot. - Changes panel: Unstage all on the single commit/Stage-all row; sizes Narrow (30%) / Medium / Full screen, no 70/30. - Lane card: the needs-you banner is gone (the strip tile keeps it). |
||
|
|
cfe832383c |
feat(lanes): stage/undo one hunk at a time, sticky review file headers
Review and the single-file view gain per-hunk Stage and Undo (stage-hunk / discard-hunk: the hunk is named by its @@ line and re-matched against a fresh diff, then git apply --cached / -R; undo needs confirm: true and two clicks). Each review file header stays pinned while its diff scrolls and carries the file's own stage/unstage/discard. |
||
|
|
0737786faf |
feat(lanes): full-width Changes, Stage all, one-read expand-all
- The Changes panel's width control gains a full-width step (narrow → 50/50 → 70/30 → full → closed); the console beside it is hidden with CSS, never unmounted. - "Expand all folders" is one GET /api/lanes/:id/git/tree-all (one `git ls-files`, every level shaped like /git/tree) instead of one request and one git process per folder. - Overview: a Stage all button and the staged-only commit box sit above the file list and stay pinned while it scrolls; the Files finder is pinned too. |
||
|
|
428519335d |
fix(run,lanes): prompt autocomplete, per-lane Active runs, compact Changes header
- The run form's prompt box offers suggestions again: `/` lists the run
cwd's project and user commands and skills plus the CLI built-ins
(claude lanes only), `@` lists files under the cwd; Enter picks while
the list is open and sends otherwise. The
|
||
|
|
faa56f5c75 |
feat(lanes): move the lane's git section into the Changes panel
- The branch line, the uncommitted-files hover panel and the git action row (fetch/pull/push/branch) move from the lane card to the top of the Changes panel (new LaneGitHeader.tsx); the card has no git row now. - Header and panel share one git-facts read: stage/unstage/discard/commit and Refresh re-read it, and a git bar action re-reads the diff, the history and the file tree. - The git bar no longer commits: the panel's staged-only box is the one commit path. Orphaned gitBar.commit*/cancel strings removed (en, vi). - "New lane here" is wired from Workspace to the panel. |
||
|
|
7bc4594b94 |
fix(lanes,git): harden the lane git surface and fix client races in the Changes panel
Server (git-diff.js, git-ops.js, worktree.js, routes/lanes.js): - Every lane git call runs with GIT_LITERAL_PATHSPECS=1 and -z porcelain, so a file name is a file name, never a glob, and odd names parse correctly. - A lane that is a subdirectory of its repo sees and acts on ONLY its own paths (reads, stage/unstage/discard/commit, and gitFacts.files). - Any file named settings.local.json (the lane's router API key) is refused as 403 ESECRET on disk (symlinks resolved) and at every committed rev. - commit refuses with 400 ESTAGEDOUTSIDE while anything outside the lane is staged (checked with --no-renames, so a move into the lane is caught). - push resolves its remote (upstream → only remote → origin), else 400 ENOREMOTE; unstaging a rename unstages both halves. - Git writes serialize on the repo's git dir, not the lane id, so two lanes on one index cannot collide on index.lock. - git refusing an action (a numeric exit status) answers 409 EGIT with git's stderr instead of a 500 carrying a numeric code; one shared parseNumstat. Client: - Changes panel: no stale answer from a previous lane or an older quick-open query repaints the panel; discard disarms on lane switch; Enter on a file path opens it instead of folding; bounded tree expand-all. - Large diffs (> 500 rows) render only the visible rows, with stable width and in-sync split columns; mirrored split scroll is clamped. - Git bar: no "in sync" without counts, a clear no-remote message, branch list re-read after a switch, and no previous-lane facts after a lane switch. - Tooltips no longer flicker or close on unrelated scrolls. |
||
|
|
6cf8a82889 |
feat(ui): Appearance settings — dark/light/system mode and named themes; fix Add lane silently doing nothing (#42)
- Settings → Appearance: mode Dark / Light / System (follows the OS live) and a named theme remembered per mode — dark: Default, Darcula, Apple Dark; light: Default, Apple Light, IntelliJ Light, Canvas. Each theme is a CSS-variable override block keyed by `data-theme` on <html>; the sidebar quick toggle gains System and swaps between the two remembered themes. - An inline boot script in index.html applies the stored mode and theme before first paint, so a reload no longer flashes the default colours. - Diff +/- rows are easier to tell apart on every theme: a /20 status tint, a 3px status bar and a bold coloured marker; the changed-span emphasis no longer vanishes on light themes. - #42: the Add-lane modal (Repo mode / "Current branch") asks POST /api/lanes/ensure with `exact: true`. Without it a folder nested inside an existing lane resolved to that parent lane with created:false, so the modal closed and no new card appeared. When the folder really already has a lane, the modal now says which one instead of closing. Closes #42 |
||
|
|
3f50e162e6 |
feat(run): Enter sends the prompt, and a busy lane attaches instead of failing
- The prompt box sends on Enter (Ctrl/Cmd+Enter still works); Shift+Enter inserts a newline, and IME composition (Vietnamese/CJK) never sends. - Starting or resuming into a lane that still holds a live run answered a raw 409 ERUNLIVE; the console now attaches to that run with a notice. Checked by error code, since the server's message is prose. - Active runs gains a two-click Kill for live dashboard-spawned rows, the way to free a lane when the previous session really should go. |
||
|
|
92cee94431 |
fix(hooks): store a hook delivered twice only once
A doubled hook registration (plugin + leftover checkout install) POSTs every event twice with a byte-identical payload, so the timeline showed each row twice and token/alert/lane side effects ran twice. POST /api/hooks/event now skips a payload identical to the session's LATEST event from the last 10s (findRecentDuplicateEvent). Latest-only keeps two legitimately identical Stop events of consecutive turns, which always have other events between. |
||
|
|
1b561c22d9 |
feat(lanes,ui): per-lane git surface — Changes panel, VS Code-style diff, branch-aware Add lane, redesigned tooltips (#34, #35)
- Changes card beside lane detail with five modes: Files tree, overview, Review (whole diff, per-file collapse), single file, commit history. - LaneDiffView: unified / side-by-side with independent scroll on both axes and mirrored scrollLeft, expand/collapse context, intra-line emphasis. - Staged-only commit box; git actions (fetch/pull/push/stage/unstage/ discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js. - Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind. - Add lane: new/existing branch, current branch, worktree list, prefill from the git bar's "New lane here"; /api/run/cwds gains a repo kind. - GlobalTooltip upgrades every native title into a themed anchored bubble; chart/info popovers use theme tokens. |
||
|
|
f77049c238 |
feat(run,ui): make the lane console usable — terminal focus, live-exit recovery, auto permission mode, model aliases
Terminal (issue #33): - `convertEol` is gone from xterm. It rewrote every bare LF as CR+LF, so a tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the column it was on) had the rest of that draw land at column 0, stamping a line's first characters into the left margin of the rows below — the torn left edge that only showed up once content scrolled. - The attach carries the client's real size (`?cols=&rows=`, `sizeFromQuery` in `server/websocket.js`, bounded because it reaches a TIOCSWINSZ ioctl). `attach()` spawned `tmux attach-session` at a hardcoded 80x24 on every attach, and tmux sizes the window to the newest client, so `claude` redrew its whole UI at 80 columns until the first `resize` message landed. - The terminal takes the keyboard on mount, so typing reaches tmux without clicking the pane first. - A pane that ends (double Ctrl+C, `exit`, a crash) reports itself through a new `onExit` prop and `LaneConsolePane` runs `newRun()`, so the console returns to the setup form instead of leaving a dead pane behind a "New run" button. Pane environment: - `server/lib/desktop-env.js` forwards the graphical session's DISPLAY / WAYLAND_DISPLAY / XAUTHORITY / XDG_RUNTIME_DIR (from `systemctl --user show-environment`, this process's own values winning) into the pane. The server normally runs as a systemd user service with none of them, so `claude` there could not read the clipboard and answered "no image found in clipboard" on every image paste. Run setup: - `auto` is a permission mode and is now the default everywhere — allowlists, the `--permission-mode` fallback, the select, and the pane's initial state. - The model picker sends the CLI's aliases (`fable`/`opus`/`sonnet`/`haiku`) instead of pinned ids. `claude --model` resolves an alias to the latest model itself, so a release like Fable 5.1 needs no edit here; pricing is unaffected because it keys off the resolved id the transcript reports. - **Create/update readme file** moved from the Add-lane modal into the lane's Tools menu: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first. - The Workspace's no-lane state is a real banner; the lane strip (add tile included) renders only once a lane exists. Refs #32, #33. Tests: npm run test:server (1828 pass), client vitest (558 pass). |
||
|
|
64017c6ca5 |
feat(run,notifications,profiles): stop sending prompts nobody typed, and fix notification + profile delivery
Run console:
- Picking a pipeline no longer pre-writes anything into the prompt box
(`promptPrefill` in `LaneConsolePane.tsx` is gone — it wrote the driving
slash command on a claude lane and a stage checklist on a codex one).
Whatever sits in that box is sent as a real prompt the moment the run
starts, so the box is the user's alone now.
- Resuming a session sends NO prompt at all (`handleStart` in `RunSetup.tsx`):
the resumed session carries its own transcript, and leftover text in the box
fired an extra turn into it.
Notifications:
- `client/src/lib/notify.ts` is now the ONE sender, used by both
`useNotifications` and Settings' test button, and it reports where the
notification landed: `POST /api/push/send` answering `{native:false,pushed:0}`
means it went NOWHERE, so the client falls back to a local notification
instead of counting the 200 as delivered.
- A notification carries the lane that raised it (`/workspace?lane=<id>`), and
Workspace selects that lane on arrival.
Profiles:
- `server/lib/cc-profiles.js` reads and writes the macOS login Keychain item
`Claude Code-credentials`, where the `claude` CLI actually keeps
`claudeAiOauth` on darwin — a switch used to rewrite a file nothing reads.
The file stays the fallback, is kept in step only when it already exists, and
`AGENTHUB_CC_KEYCHAIN=0` pins it (tests set it).
Tests: npm run test:server (1820 pass), client vitest (553 pass).
|
||
|
|
8d48a038c7 |
fix(ingest,ui): count each assistant message once, each turn once, and each event once
Three defects that all showed up as "the dashboard's numbers are wrong", plus the notification and brand work asked for alongside them. Tokens: Claude Code writes ONE JSONL line per content block of an assistant message, and every one of those lines repeats the same message-level `usage`. Both parsers summed them, so a tool-heavy session's tokens — and its cost — ran 1.5-2x high (one 4h session: $45.82 stored against $27.72 real). `lastUsageMsgId` counts a message once, carried across the transcript cache's incremental chunk boundaries. `replaceTokenUsage`'s high-water baselines mean the fixed parser cannot correct what is already stored, so `setTokenUsage` (exact, baselines cleared) is added for the deliberate re-parse path and `reconcileTokens` now covers every session with a transcript on disk rather than imported ones only. Turns: every `TurnDuration` row was stamped NOW while its dedup guard matched on the transcript's timestamp, so each hook re-inserted the session's whole turn history — 587k rows, 75% of a real events table, one session holding 68,747 copies of the same turns. The row now carries the transcript timestamp, which fixes the guard and the feed's ordering together. Same class of bug in the session metadata: `thinking_blocks` / `turn_count` / `total_turn_duration_ms` are assigned from the full parse instead of `+=`, since `extract()` returns whole-transcript totals on every call. Events: leftover checkout hook entries in `~/.claude/settings.json` double every event, every subagent row and every token figure. `stripLegacyHooks` has always removed them — but only inside `bootstrap()`, which `fastPathOk` skips on exactly the healthy installs where nothing else forces a full pass, so they survived indefinitely. `hasLegacyHooks` (pure fs) now blocks the fast path. A one-time repair at the first start after upgrading (`user_version` 3) deletes the duplicated `TurnDuration` rows and re-parses every transcript with exact writes. Marker 3 and not 2: an install that ran the earlier pass had its corrected rows re-inflated by whatever old build was still ingesting hooks. Router: switching a lane's AI Router while a pane is live only half-applies — Claude Code re-applies `env` to a running session but keeps the model it resolved at startup, so the old router's model name reaches the new endpoint and the next prompt fails with "model not found". The apply route reports `restart_required` / `run_id`, and the picker offers a Restart that respawns the pane from the lane id alone (replaying the old `--model` would re-create the mismatch). Notifications (#29): the two moments Claude is BLOCKED on the user now have their own switches and default to on — `onAwaitingInput` (a permission prompt or a question) and `onTurnComplete` (end of turn). Before, the first was unswitchable and the second hung off a preference that defaults to off, so the one thing a user walks away from went unannounced. Prefs shape/defaults/storage move to `client/src/lib/notifPrefs.ts`; Settings and the hook had private copies. Brand: the sidebar shows the SmartGift wordmark with the AgentHub caption under it, the collapsed rail shows the mark, and the favicon is vendored to match. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
5bd1c0323f |
feat(lanes): reorder and rename lane cards, and show each file's line delta
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.
|
||
|
|
af2ad63a18 |
feat(lanes): name the files behind a lane's uncommitted counts
GET /api/lanes/:id/git now returns `files`, the raw `git status --porcelain` lines the dirty/untracked counts were already parsed from, capped at 20 so a lane mid-`npm install` cannot ship thousands of paths through a polled endpoint. The lane card hovers them as the tooltip on its "N modified · N untracked" text, appending "… +N" when the cap bit. No new subprocess and no new endpoint — statusCounts was counting those lines and throwing them away. |
||
|
|
7fae2f48c0 |
refactor(run): drop the lane progress bar, iconify pane layout, and one-row the run setup
Four more spots that spent height saying what was already visible or saying too much of it. **LaneCard** drops the progress bar and percent entirely — the pipeline map right below the card already draws the same number as named, coloured nodes, and a bar beside it was a worse second telling of it. **SplitLayoutToggle** (Workspace) swaps "1 pane" / "2 pane" / "4 pane" text buttons for the conventional square / two-column / 2x2-grid icons (`Square`, `Columns2`, `Grid2x2`) most apps use for this. The words move to `aria-label` and `title` rather than disappearing. **RunSetup** merges the fresh/resume source picker onto the same row as the Working directory / Model / Permission mode / Thinking effort fields instead of a second row above them — settings left, New/Resume session right. The session picker for an active resume still gets its own row below, since it only appears in that one state. **LaneConsolePane**'s "Run Claude" header goes from a two-row title + wrapped blurb to one row (smaller icon and title), and the blurb itself is trimmed to one short sentence, hidden below `sm` where it would wrap again anyway. `docs/LANES.md` updated to match (split-view description, lane-card field list, and `agenthub stage`'s progress-bar wording). Verification: `tsc --noEmit` clean, `npx vitest run` 508/508 (2 stale progress-bar assertions in LaneCard.test.tsx replaced with one asserting the bar is gone), `npm run build` clean, screens.snapshot diff reviewed before regenerating (icon buttons + new blurb + reordered DOM only), file headers audit clean. |
||
|
|
27923db4e0 |
refactor(lanes): compact the lane strip tile and the lane-detail header
The Workspace lane panel spent four rows saying what fits in two. Strip tile: the stage badges move onto the id row and the progress bar and percent go away — the detail panel's pipeline map right below shows the same progress with the stage names attached. Lane detail: the pipeline picker, stage badges, feature picker, router select and the remove button merge into the single header row instead of a second row under it, and the pipeline's name is no longer interpolated into `laneHeader` — the picker beside it already says it. The git block collapses from four lines to one and drops the cwd, which the header above it already spells out. The router select is relabelled Model, moves out of LaneCard into that header row (`LaneRouterRow` is now exported), and reports its apply outcome as a ✓/⚠ marker with the message in a tooltip rather than a wrapped sentence that pushes every row below it down. Drops the tracker / dev QC / CI wait badge row and everything feeding it (`useLaneIntegrations`, `api.lanes.integration`, the `integrations.*` strings): two of the three toggles have no consumer at all, and a badge reporting a toggle nothing acts on costs a row of height to say nothing. `agenthub lanes integration` and the API route are untouched. |
||
|
|
00a2aedb09 |
feat(ai-router): test an endpoint before saving it, and re-point a live lane
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).
|
||
|
|
9128352d1b |
fix(pricing): price Claude Opus 5, correct Sonnet 5, drop retired models
Claude Opus 5 had no pricing row at all, so every token it burned was costed at $0 — the pattern list stopped at the 4.x Opus family and nothing matched `claude-opus-5`. Add it (5/25, fast mode 10/50, same shape as 4.8). Sonnet 5's launch discount expired 2026-08-31 and the discounted rate became the published standard, so the row's 3/15 standard rate started overcharging the day after. Fold 2/10 into DEFAULT_PRICING and empty DEFAULT_INTRO_PRICING; no model ships a promo today. Fable/Mythos 5.1 fell through to the `claude-fable-5%` row, which caches at $1/MTok against 5.1's $0.25 — give them their own rows. That overlap is only resolvable if the lookup prefers the more specific pattern, and `matchPricing` took `LIMIT 1` with no ORDER BY, i.e. whichever row was inserted first. Order by pattern length descending, which is what docs/DATABASE.md already claimed. (The nested REPLACE(model_pattern, '%', '%') it dropped was a no-op.) Retire Claude 3.x, Opus 4/4.1 and Sonnet 4 from the defaults. The startup top-up only ever ADDS missing rows, so removing them from DEFAULT_PRICING would leave every existing install untouched — prune them once, guarded by PRAGMA user_version (0 -> 1) so a model deliberately re-added in Settings survives later restarts. The same one-shot migration corrects the existing Sonnet 5 row. Only rates are deleted; token_usage is never touched, and Reset Defaults still restores the full current set. |
||
|
|
29a5da3e4e |
fix(bootstrap): stop a retired plugin build from downgrading the dashboard
`claude plugin update` unpacks the new version into a new cache directory and says "Restart to apply changes". A Claude Code process that was already running does not: it keeps loading the OLD directory, hooks and all. Every session such a process opened then ran the OLD plugin-bootstrap, which saw a state.json naming a different root, concluded the server had to be replaced, repointed agenthub.service at itself and restarted the dashboard onto the version the user had just upgraded away from. The dashboard reported the old version and offered the update again — once per session, forever, while installed_plugins.json and the cache both said the new version was installed. A /agenthub-update from that session "succeeded" and changed nothing. - `supersededBy()` reads Claude Code's installed_plugins.json. When a SIBLING cache directory is the recorded install, the bootstrap logs one line and returns "superseded" — no deps, no unit, no server. `--force` does not override it: the self-update chain forces from the NEW directory, which is never superseded, so the only --force refused is a deliberate downgrade. It cannot hand over either — the process it would replace is the one loading it — so the log line says to restart Claude Code. - Matched by sibling directory, not by the <plugin>@<marketplace> key: correct without parsing a cache path or trusting a name. A phantom entry (installPath that no longer exists) is ignored rather than wedging the bootstrap shut. Also adds `pruneRetiredPluginRoots()`, since the same updates leave a full copy of the repo — node_modules and all — per release; twelve of them measured 1.7 GB on one machine. It runs after writeState, so a bad prune cannot cost the record of a good bootstrap, and it protects four independent readings of "in use": this build, every installPath Claude Code records, the directory agenthub.service names (`unitRoot`, foreign unit included), and any directory still named in the user's settings.json hooks — that last one is the un-restarted process above, seen from the other side. Plus the 2 newest survivors as a rollback cushion, and nothing at all outside ~/.claude/plugins/cache/. |
||
|
|
0a86dfb8c5 |
feat(settings): add AI Router profiles and attach one per lane
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`.
|
||
|
|
baa355bee4 |
feat(lanes): offer a create/update readme run when adding a lane
An optional checkbox in the Add-lane modal starts the new lane's terminal run right after creation with `/sg-all:sg-md-writing` as its first message, so the lane opens already writing or refreshing its CLAUDE.md/AGENTS.md set. Claude lanes only — codex has no slash commands. |
||
|
|
c90dd7b147 |
fix(bootstrap): self-heal a systemd unit left on a retired plugin root
The residue an interrupted update leaves behind is invisible: the server
runs, every doctor row reads OK, and the unit quietly still names a cache
directory Claude Code deleted during the update. Nothing surfaces it until
a reboot, when the dashboard does not come back and nothing recorded why.
- fastPathOk now rejects a stale unit, so the next SessionStart repairs it
instead of short-circuiting. `unitIsStale` is pure fs on purpose: that
path runs on every session and may not spawn a process.
- Repairing the unit is not restarting the server. A current, healthy
server keeps running and the corrected unit takes effect at the next
start (`pendingSystemdRestart = systemdOwned && needsServer`), and the
detached-spawn fallback is guarded by needsServer so a stale unit alone
cannot start a second server onto a bound port.
- systemdOwned now also requires the unit to be ACTIVE. Installed-but-
stopped owns nothing: skipping the stop there and asking systemd to start
would bind an occupied port and crash-loop under Restart=always.
- /agenthub-doctor gains a systemd row naming the consequence ("will NOT
come back after a reboot") and the fix. WARN, never FAIL — the dashboard
in front of you works, and having no unit at all is a normal setup.
The fast-path test pins the regression against an otherwise fully healthy
install, live server included; without a live server it would have passed
against the unfixed code either way.
|
||
|
|
de8d1079e0 |
fix(bootstrap): never stop the dashboard from inside its own systemd cgroup
A plugin self-update took the dashboard down and left it down. The chain
ran correctly up to the point where the bootstrap SIGTERMs the server it is
replacing — and that server was systemd-managed, with the bootstrap running
as its descendant, i.e. inside its cgroup. Two failures fired at once:
* the server exits 0, and `Restart=on-failure` reads a clean exit as
"meant to stop", so systemd did not bring it back;
* `KillMode=control-group` reaped the bootstrap itself, mid-run, before it
could repoint the unit at the new plugin root or write state.json.
Evidence from the incident: bootstrap.log ends at "stopped the server
started from the previous plugin version" with no systemd line after it,
.bootstrap.lock survived with a dead pid, state.json still named the
previous version, and the unit's ExecStart still pointed at the old cache
dir. The same truncation is visible on three earlier updates — those only
recovered because a later SessionStart bootstrapped again.
systemd-service.js now splits install into writeUserService (repoints the
unit, starts nothing) and restartUserService (--no-block). The bootstrap
skips the manual stop whenever a unit of ours owns the server, and defers
the restart past writeState and releaseLock, so being killed by its own
request costs nothing. The unit is Restart=always: a graceful SIGTERM from
anything must no longer read as "leave it stopped".
installUserService keeps its old semantics for `agenthub service install`,
which runs from a user's shell and is not in the cgroup.
|
||
|
|
63ed90ba05 |
docs(plugins): stop promising an MCP server the plugin no longer ships
|
||
|
|
27313d4413 |
fix(run): refuse a run with ETMUXMISSING when the host has no tmux
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. |
||
|
|
d4b949a6f4 |
docs(deployment): list git and tmux beside node in the prerequisites
The only prerequisites block that still said node+npm alone. |
||
|
|
29858ddccb |
docs: document the system prerequisites, and report tmux in both doctors
The READMEs never said tmux was needed, so a fresh install only found out when Start Run failed with nothing explaining why. Both doctors were silent about it too. - README/README.vi: a prerequisites table (node, npm, git, tmux, claude, codex, gh, systemd) and per-OS tmux install + verify steps; the feature list is regrouped into observe/drive/configure and now covers analytics, alerts, import, remote sources, the config explorer and Update all. - INSTALL: Step 0 for the system packages, plus a troubleshooting entry separating "tmux missing" from "the server has a different PATH". - SETUP/docs/README: the same troubleshooting entry and a pointer to Step 0. - plugin-doctor: a tmux row, WARN and never FAIL - the run console and `lanes shell` break without it, nothing else does. Reuses tmux.js's own isTmuxAvailable(). - agenthub doctor: asks GET /api/run/tmux instead, because the PATH that decides whether a run starts is the server's, not the caller's shell's. |
||
|
|
99e718ad91 |
fix(plugin): build the UI with devDependencies so a self-update ships its own bundle
After the 1.5.2 -> 1.5.3 self-update the server ran 1.5.3 while the dashboard still served the 1.5.2 UI. The client build had failed, and `runtime/client-dist` still held the previous version's bundle, so nothing looked broken — the only signal was the Settings page's "UI build v1.5.2 (rebuild client to match)". Cause: the whole client build toolchain (vite, typescript, @vitejs/plugin-react, tailwindcss, @types/*) is a devDependency, and npm reads NODE_ENV=production as --omit=dev (`NODE_ENV=production npm config get omit` -> `dev`). The dashboard server runs with NODE_ENV=production, and every bootstrap descended from it inherits that: the plugin-update chain, and any SessionStart fired by a `claude` running in a tmux pane the server started. So `npm ci` dropped vite, `tsc -b` died on missing type declarations, and buildClient returned "failed" with the stale bundle left in place. `--include=dev` on both install paths — a CLI flag outranks the env, which is the point. Verified by running buildClient with NODE_ENV=production against the installed plugin root: previously "failed", now "built", and the served bundle carries 1.5.3. Also: plugin-update-helper writes its verdict BEFORE the bootstrap handover as well as after. That step retires the process tree the helper hangs off, and the observed run reached the new server with no verdict file at all — the dashboard reported `last_plugin_update: null` for an update that had actually succeeded. |
||
|
|
8b8582f231 |
fix(runs): persist tmux-backed runs so a resumed session shows its own info
`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. |
||
|
|
30bc6debde |
fix(updates): check releases for a plugin install with no .git
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.
|
||
|
|
aca35dd4cd |
feat(cc-config): update every plugin and marketplace at once, then reload the open sessions
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. |
||
|
|
cc85e35439 |
feat(updates): detect the newest release and self-apply it, including plugin installs
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. |
||
|
|
61cc37ba32 |
chore: remove unused surfaces (VS Code extension, statusline, landing assets, Docker)
The repo shipped five install/publish surfaces that nothing in the project builds, tests or documents as supported any more. Each one carried its own stale docs, and the Docker/Podman path additionally described a Dockerfile whose COPY of statusline/ would have broken the build the moment that directory went away. Removed: vscode-extension/ (16 files, marketplace extension for an ID we do not own); statusline/ (3 files, standalone CLI statusline); the repo-root landing/wiki PWA assets (sw.js, manifest.json, robots.txt, sitemap.xml, og-image.svg, favicon.svg, fonts/ - 17 files for pages that no longer exist); .superpowers/ (4 files of scratch brainstorm state, already covered by .gitignore:55); and the container publish path (Dockerfile, docker-compose.yml, .dockerignore, 5 Makefile targets, 2 npm scripts). Kept deliberately - these are about the USER's containers, not ours: scripts/install-hooks.js container detection and AGENTHUB_ALLOW_CONTAINER_HOOKS (issue #193), server/lib/lane-detect.js docker-compose service detection, server/data/profile-templates/postgres-compose/, server/lib/stage-detect.js 'docker push' detection, the host.docker.internal Prometheus notes, the Config Explorer's reads of the user's own ~/.claude/statusline.py, and .devcontainer/ (its docker-in-docker feature is dropped since it existed only to build the removed Dockerfile). Docs follow the code: ARCHITECTURE.md loses the VS Code, statusline and container sections plus two dead TOC anchors, and its PWA section is rewritten from three PWAs to one; INSTALL.md, SETUP.md, docs/DEPLOYMENT.md, server/README.md, client/README.md, docs/README.md, docs/HOOKS.md and docs/DATABASE.md drop the same. The Makefile target count in ARCHITECTURE.md is corrected 25 -> 17 (it was already stale). |
||
|
|
e972267a42 |
fix(plugins): make the 10 focused plugins installable, finish the AgentHub rename
The ten `agenthub-*` plugins were listed by the marketplace but could never be
installed — two schema mistakes present since the entries were first authored
(
|
||
|
|
876c6de6d2 |
fix(lanes): count every pipeline run, not one per lane
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.
|
||
|
|
2942ced3ec |
style(config): give Codex the same tab bar as Claude Code, drop the Cursor tab
The Codex tab strip wrapped onto a second row while Claude Code's stayed one scrollable row, so two panels that are meant to be twins read as different components. Both now render the same ConfigTabBar — the scroller lifted out of CcConfig, with the fade edges and chevrons that only appear when there is somewhere to scroll. The deferral note in CodexConfig that predicted this is gone with it. The bar also carries role="tablist"/"tab" and aria-selected now: Codex had them, Claude Code did not, and one of the two had to win. Cursor's placeholder tab goes away with the machinery that served only it — the optional Panel, the "soon" badge, the coming-soon card, and their four i18n keys. Element.scrollBy and friends move to test-setup: jsdom has none of them, and the tab bar now calls them from both pages. |
||
|
|
85187dfc78 |
feat(config): create a new project's memory file from Settings
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.
|
||
|
|
c34931c3f5 |
fix(cc-config): .claude.json sits BESIDE ~/.claude by default, not inside it
The previous commit routed the read through getClaudeHome(), which appends /.claude when CLAUDE_HOME is unset — pointing the dashboard at ~/.claude/.claude.json, a file the CLI never writes by default. Verified against the real binary: with CLAUDE_CONFIG_DIR unset the CLI writes $HOME/.claude.json; with it set, .claude.json sits flat inside that dir. Branch on CLAUDE_HOME accordingly. The test now drives a fake claude that writes where the real one does and asserts getClaudeJsonPath() names that exact file, under both branches — the previous test only restated the implementation. |
||
|
|
6d27c115d8 |
fix(cc-config): MCP scope was inverted — expose user/local, not user/project
readMcpServers only ever parses ~/.claude.json (top-level = user scope, projects[<root>] = the panel's "projectScoped" bucket, which the CLI itself calls `local`). The previous user/project scope choice was the exact inverse: `--scope project` writes .mcp.json, which readMcpServers never reads (a successful add would vanish), and every "projectScoped" row was actually `local`, so Remove sent a scope the CLI could never match. - cc-mcp-cli.js: MCP_SCOPES -> ["user", "local"]; drop the local mutationQueue/serialize in favor of cc-plugin-cli.js's exported one, so a plugin install and an MCP add can't spawn `claude` concurrently; drop the String(v) coercion in env/header flags (a wrong-typed value is now a 400, not a written "[object Object]"); assertEmbeddedValue allows empty strings (a legitimate empty stdio arg was previously a 400). - cc-plugin-cli.js: export `serialize` for reuse. - CcConfig.tsx: CcMcpScope narrowed to user/local; the projectScoped bucket's McpCard now sends scope="local"; rows sourced from ~/.claude/settings.json (no CLI scope addresses them) no longer render a Remove button. Cleaned up i18n keys my own earlier change made dead (explain.mcp.addCmd/remove/removeButton) and gave the scope <select> its own short option labels instead of reusing full-sentence section headings. |
||
|
|
fd4edaac98 |
feat(cc-config): add and remove MCP servers through the claude CLI
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. |