feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)

Usage panel (status bar):
- every account shows its plan (Claude subscriptionType, Codex
  chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier)
- new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity
  (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs
  agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's
  own file, sent only to its own host while valid, never refreshed
- in-flight quota reads are shared between tabs

AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added;
Gemini CLI, Hermes and OpenClaw removed.

Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat
on an editor selection, Settings in three groups, JetBrains-style Changes
panel (ticked files/hunks, ignore, blame, file history), terminal font.

--no-verify: :3000 is taken by another dev server (lane-detect tests);
both suites run by hand — server 2636/2639 (the 3 are :3000), client
1131/1131.
This commit is contained in:
2026-10-08 12:22:14 +07:00
parent 6ef9a5d4b7
commit a9c71cd905
133 changed files with 8007 additions and 1830 deletions
+80 -24
View File
@@ -409,7 +409,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=1`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+ — one column, since tmux 3.7c reserves `width` columns but paints only the first and a wider bar left a column of stale cells that read as a second scrollbar). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane under `"tui": "fullscreen"` (`~/.claude/settings.json`) scrolls inside Claude's own UI instead. AgentHub no longer forces it inline (`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`): inline, every time the pane narrowed tmux re-wrapped Claude's full-width rules and pushed lines into history that its redraw could not clear, duplicating the banner and transcript. A pane started before this change keeps inline mode until it is restarted. Every pane's CLI (and a Terminal tab's shell) also starts only once a client is attached — 2 s at most — so nothing is drawn at tmux's 80×24 birth size and re-wrapped on the attach (`WAIT_FOR_CLIENT` in `server/lib/pty-run.js`, issue #55).
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, split to the right (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2); a drop that would pass four panes reorders instead (`joinGroup`). Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.)
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen, to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, and **×** takes the pane out of this tab into a tab of its own (its session keeps running). A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen, to the right of the focused pane (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/<runId>` and AI-tool confirm as a tab's **×** — without taking it out first. A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
@@ -560,8 +560,8 @@ to spawn a sibling worktree workspace on it, and moving this pane's HEAD under a
working agent is not. **New workspace here** goes through the normal Add-workspace flow
with the fields prefilled — one provisioning path for every workspace, not a second
one hidden in the git bar. There is no **Commit** here: committing is the
overview's staged-only box below, the one commit path, and it only ever takes
what was staged.
overview's commit box below, the one commit path, and it only ever takes the
files ticked there.
**Publish…** is the one button whose destination the user types, so the server
keeps it narrow: it only gives a remote to a repo that has none (it never
@@ -687,10 +687,16 @@ reads the workspace's git surface in five modes:
that block alone. The single-file view offers the same bar.
A new line's NUMBER is a button too: it opens a comment box under the diff,
and **Paste into pane** pastes `<path>:<line> — <text>` into this
workspace's pane focused last (`client/src/lib/panePaste.ts`, the same
workspace's pane focused last — clicked into, or focused through a tab,
Alt+1–4 or the sidebar (`client/src/lib/panePaste.ts`, the same
bracketed `term.paste()` an image drop uses) — nothing is sent until the
user presses Enter in the pane; with no pane of the workspace open the box
says so and keeps the text.
In the file editor, a text selection shows Cursor's **Add to Chat** chip
above it, which pastes `@<path>#L<from>-<to>` (Claude Code's line-range
mention; `#L<n>` for one line — without the `L` Claude Code attaches the
whole file) the same way. Other tools (Codex, …) get the same text as a
plain hint.
- **One file** — clicking a file's path in any list opens just that file.
**All files** returns to the list it was opened from — the same mode, at the
same scroll offset — because reading a change-set is a walk down a long list
@@ -743,27 +749,67 @@ reason — *where you are* (mode, refresh) above, *how you are looking at it*
(range, layout, context, whole file) below — because one row wrapped mid-group
in every panel narrow enough to sit beside a console.
**Per-file writes are the panel's, per-repo writes are the git bar's.** Staging
one file means nothing unless a commit can take the index as it stands, so the
overview grows a commit box the moment anything is staged — above the list and
pinned while it scrolls: the message (and its suggest button), the commit button, a **Stage all** that stages every listed file
the index lacks in one call and an **Unstage all** that unstages every staged one — one row when the
panel has room, the buttons wrapping under the message when it is narrow. The commit sends
`only: "staged"`, never `add -A`, and the box is absent with a clean index because
there is nothing it could mean. A file you never want committed (a local note, a scratch folder) can be marked **Never stage** (its row's eye-off button, or right-click): **Stage all** and a folder's stage button then skip it, the row is struck through, and the file is drawn orange in the Files tree (`--kept`, apart from the amber of a modified file). The list is kept in this browser per workspace (`agenthub.changes.neverStage.<lane id>`) and git is never told — nothing is written to the repo, and the file's own Stage still works. It is the dashboard's ONLY commit: the git bar
above it has none, so no click can sweep the rest of the tree in with `add -A`. **Discard** is the one
irreversible action in the panel: it asks twice in the UI, and the server
refuses it without `confirm: true` and accepts only paths `git status` is
already reporting as changed. In the `work` range the overview splits like VS
Code's Source Control: **Staged Changes** (what the next commit takes, each row
offering Unstage) above **Changes** (unstaged and untracked, each offering
Stage); a partly staged file appears in both. Each group is a folder tree like
**Per-file writes are the panel's, per-repo writes are the git bar's.** The
commit is JetBrains' (PhpStorm's Commit window without its staging area): in
the `work` range the overview lists **Changes** (every tracked change, staged
or not) above **Unversioned Files** (untracked), and every file, folder and
group header has a checkbox. **Commit** takes exactly the ticked files'
working-tree content — `paths`, `git commit --only` on the server — whatever
the index holds, so a staged but unticked file stays staged and uncommitted. A
tracked change arrives ticked, an unversioned one unticked, and a file the
user unticked stays unticked across polls. The commit box sits above the list
and stays pinned while it scrolls: the message (and its ✨ suggest button,
which reads the ticked files), **Amend** (folds the ticked files into the last
commit; nothing ticked rewords it), **Commit (n)** and **Commit and Push**
(the same commit, then the git bar's `push`; a failed push leaves the commit
made and says so). It is hidden while a merge, cherry-pick or revert is open —
a partial commit cannot finish one, **Complete** does. It is the dashboard's
ONLY commit: the git bar above it has none, so no click can sweep the rest of
the tree in with `add -A`.
**A commit can take part of a file**, like JetBrains' diff viewer: in Review
(and in the editor's bar under each block) every change block of a tracked
file has its own checkbox beside its Undo. Unticking one makes the file's box
partial (indeterminate) everywhere it is drawn; ticking a block of an unticked
file takes just that block; unticking every block unticks the file. A block
is remembered by its content (its -/+ lines and how many identical blocks come
before it), so a re-read or an edit elsewhere in the file keeps the choice. At
Commit the panel re-reads each partial file at the default context and sends
its ticked blocks as `hunks` (`{"<path>": [{hunk, blocks}]}`); the server builds
the commit in a temporary index from git's own fresh diff and resets the real
index entries of those paths to the new HEAD, so what was left out stays an
unstaged change in the working tree. The review header of each file carries
the same checkbox and **Rollback** (discard) instead of stage/unstage.
Files are coloured like JetBrains in the Changes list and the Files tree:
unversioned orange (`--kept`), added green, modified and renamed blue
(`--diff-mod`), deleted grey (struck through in the tree), conflicted red,
ignored dimmed.
An unversioned row offers **Add to VCS** (`stage`, and the file is ticked).
Right-click on a file adds **Commit File…** (that file alone ticked, the
message box focused), **Jump to Source** (the whole file), **Copy relative
path**, **Copy as Patch to Clipboard** (the file's diff as `git apply` takes
it), and for an unversioned file **Add to .gitignore** (`ignore`: one anchored,
escaped `/path` line appended to the workspace's own `.gitignore`), **Unstage**
for a file something else left staged in the index, **Show History** (also on
a Files-tree file: `/git/log?file=`, parents rewritten so the graph joins up;
a commit opens that file as that commit changed it, and **Whole branch** goes
back to the branch's history), and **Refresh**. In Review and in an open file
the toolbar's ▲/▼ (and **F7** / **Shift+F7**) jump to the previous / next
difference — the editor's own changed blocks, else each block's first diff
row — and in an open file ◀/▶ open the previous / next file of the list it
was opened from. **Annotate** on an open working file adds a gutter naming
each line's last commit (`GET /git/blame`; date and author, the subject on
hover, a click opens that commit; a line not committed yet says so).
**Discard** is the one irreversible action in the panel: it asks twice in the
UI, and the server refuses it without `confirm: true` and accepts only paths
`git status` is already reporting as changed. Each group is a folder tree like
JetBrains' commit view (`client/src/lib/changeTree.ts`): folders first, a
chain of folders holding only one sub-folder compacted into one row
(`src/lib`), each folder row showing its file count, folding on a click and —
in Staged Changes / Changes — carrying one button that unstages / stages every
file under it; the group headers fold too. The fold state is not stored. In the tree, a changed file's
whole row takes its status tint, not only its letter.
(`src/lib`), each folder row showing its file count and folding on a click;
the group headers fold too. The fold state is not stored. In the tree, a
changed file's whole row takes its status tint, not only its letter.
Review, single-file, history and browse do **not** poll: re-fetching under the
reader's scroll position is worse than a slightly stale view, and those are the
@@ -1499,6 +1545,16 @@ curl -X POST http://localhost:4820/api/lanes/5/git/commit \
-H "Content-Type: application/json" \
-d '{"message": "wip: staged only", "only": "staged"}'
# Exactly these files (the Changes panel's ticked ones), untracked included
curl -X POST http://localhost:4820/api/lanes/5/git/commit \
-H "Content-Type: application/json" \
-d '{"message": "fix: two files", "paths": ["src/a.ts", "notes.md"]}'
# Add an untracked file to the workspace's .gitignore
curl -X POST http://localhost:4820/api/lanes/5/git/ignore \
-H "Content-Type: application/json" \
-d '{"paths": ["scratch.log"]}'
# Irreversible, so `confirm` is required and the path must be one git
# already reports as changed
curl -X POST http://localhost:4820/api/lanes/5/git/discard \
@@ -1753,7 +1809,7 @@ agenthub stage <stage> [--evidence "..."] # Report the current stage
## Workspaces on any AI tool
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — ten built in: Claude Code, Codex, Gemini CLI, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Oh My Pi, Hermes and OpenClaw —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A workspace can run any AI coding CLI in the AI Tools catalog (`server/data/ai-tools/*.json` — nine built in: Claude Code, Codex, Antigravity, GitHub Copilot CLI, OpenCode, Cursor CLI (`cursor-agent`), Grok Build (`grok`), Kimi Code (`kimi`) and Oh My Pi —, plus any `*.json` in `DASHBOARD_AI_TOOLS_DIR`, which replaces a built-in file with the same `id` but may not set `launcher`). Settings → Agents → **AI tools** (`/settings/agents`; the old `/settings#ai-tools` redirects there) lists what is installed (found on PATH; **Refresh** re-reads the login shell's PATH first) with an enable switch, **Set default** and **Update** (for every tool whose catalog file names an updater — no version lookup, the updater itself reports "up to date"), and, folded behind a disclosure, what is available to install with a homepage link. The Add-workspace modal shows the chosen tool as one button (the default until changed); clicking it lists every installed, enabled tool as buttons to pick from; `agenthub workspace add --tool <id>` does the same from a terminal. The run form's **Model** picker lists the workspace tool's own models (`GET /api/ai-tools/:id/models`; Claude, Codex, and any catalog tool whose file declares `model: { flag }` — the id is validated and passed as `<flag> <id>`), plus **Custom…**, and starts on the tool's default model when one is set.
A catalog file: