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
@@ -79,8 +79,8 @@ describe("rankSettingsEntries", () => {
expect(tos).toContain(`agents/claude-code?tab=${t}`);
expect(tos).toContain("data#cleanup");
expect(tos).toContain("data#prefs");
expect(SETTINGS_INDEX.find((e) => e.section === "worktrees")?.keywords).not.toContain(
"sidebar"
);
expect(
SETTINGS_INDEX.find((e) => e.titleKey === "worktrees.showUntracked")?.keywords
).not.toContain("sidebar");
});
});
+77 -11
View File
@@ -397,6 +397,7 @@ import type {
Lane,
LaneChangeRange,
LaneChanges,
LaneBlame,
LaneCommit,
LaneFileContent,
LaneTreeEntry,
@@ -673,8 +674,42 @@ export const api = {
users: {
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () => request<{ users: ServerUserUsage[] }>("/users"),
/** GET /api/users/audit — staff reads of members' sessions, newest first. */
/** GET /api/users/audit — staff reads of members' sessions and admins'
* account changes, newest first. */
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
// Account changes (admin only). Each carries the admin's OWN password
// again (`currentPassword`): the server refuses without it (EREAUTH).
/** POST /api/users — a new account. */
create: (
body: { username: string; password: string; role: AuthUser["role"] },
currentPassword: string
) =>
request<{ user: AuthUser & { disabled: boolean } }>("/users", {
method: "POST",
body: JSON.stringify({ ...body, currentPassword }),
}),
/** PATCH /api/users/:id — role and/or disabled (never your own). */
update: (
id: number,
body: { role?: AuthUser["role"]; disabled?: boolean },
currentPassword: string
) =>
request<{ user: AuthUser & { disabled: boolean } }>(`/users/${id}`, {
method: "PATCH",
body: JSON.stringify({ ...body, currentPassword }),
}),
/** POST /api/users/:id/password — a new password; that user's logins end. */
setPassword: (id: number, password: string, currentPassword: string) =>
request<{ user: AuthUser }>(`/users/${id}/password`, {
method: "POST",
body: JSON.stringify({ password, currentPassword }),
}),
/** POST /api/users/:id/workspaces — give that user a workspace. */
assignWorkspace: (id: number, laneId: number, currentPassword: string) =>
request<{ user: AuthUser }>(`/users/${id}/workspaces`, {
method: "POST",
body: JSON.stringify({ laneId, currentPassword }),
}),
},
// ───────────────────────── Updates / self-update API ─────────────────────────
/** Self-update status: whether this install is a git clone and, if so,
@@ -1113,7 +1148,13 @@ export const api = {
};
load_stats: { m5: number; m15: number; h1: number };
};
hooks: { installed: boolean; path: string; hooks: Record<string, boolean> };
hooks: {
installed: boolean;
path: string;
hooks: Record<string, boolean>;
/** "plugin" when the enabled agenthub plugin provides the hooks. */
source?: "plugin";
};
server: {
version: string;
uptime: number;
@@ -1197,6 +1238,21 @@ export const api = {
body: JSON.stringify({ enabled }),
}),
},
/** AgentHub's icon in the OS status area — on by default; a change starts
* or stops the icon process, never the server. */
tray: {
/** GET /api/settings/tray */
get: () => request<TrayStatus>("/settings/tray"),
/** PUT /api/settings/tray */
set: (enabled: boolean) =>
request<TrayStatus>("/settings/tray", {
method: "PUT",
body: JSON.stringify({ enabled }),
}),
},
/** POST /api/settings/quit — close every workspace terminal and stop the
* server until `agenthub start` (or a boot/login with autostart on). */
quit: () => request<{ ok: true; killed: number }>("/settings/quit", { method: "POST" }),
/** Opt-in secret redaction of stored payloads and the folders whose hooks
* are dropped (`server/lib/privacy.js`). */
privacy: {
@@ -2891,6 +2947,9 @@ export const api = {
* @param id The lane id.
* @param opts `file` narrows the list to the commits touching one path.
*/
/** GET /api/lanes/:id/git/blame — Annotate: each line's last commit. */
gitBlame: (id: GitTarget, path: string) =>
request<LaneBlame>(gitUrl(id, "/blame", new URLSearchParams({ path }))),
gitLog: (id: GitTarget, opts: { limit?: number; file?: string; ref?: string } = {}) => {
const q = new URLSearchParams();
if (opts.limit) q.set("limit", String(opts.limit));
@@ -2978,8 +3037,11 @@ export const api = {
* POST /api/lanes/:id/git/commit-message — a message for what is staged,
* written by the user's `claude` CLI (no tools, nothing persisted).
*/
suggestCommitMessage: (id: GitTarget) =>
request<{ message: string }>(gitUrl(id, "/commit-message"), { method: "POST" }),
suggestCommitMessage: (id: GitTarget, paths?: string[]) =>
request<{ message: string }>(gitUrl(id, "/commit-message"), {
method: "POST",
...(paths ? { body: JSON.stringify({ paths }) } : {}),
}),
/**
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
* @param id The lane id.
@@ -3016,7 +3078,8 @@ export const api = {
| "cherry-pick"
| "revert"
| "undo-commit"
| "amend",
| "amend"
| "ignore",
body: {
/** cherry-pick / revert: the commit. */
sha?: string;
@@ -3278,12 +3341,7 @@ function requestCodexBackupsHelper(params?: {
/** Kind of Claude Code config artifact manageable via `api.ccConfig.write`/
* `delete` - each maps to a distinct on-disk location under `.claude/`. */
export type CcArtifactType =
| "skills"
| "agents"
| "commands"
| "output-styles"
| "memory"
| "auto-memory";
"skills" | "agents" | "commands" | "output-styles" | "memory" | "auto-memory";
/** Body for PUT /api/cc-config/file - create or overwrite one artifact. */
export interface CcWriteArgs {
@@ -4436,6 +4494,14 @@ export interface AutostartStatus {
foreign: boolean;
}
/** GET /api/settings/tray. `available` false: server mode, or no graphical
* session on this Linux machine. */
export interface TrayStatus {
available: boolean;
enabled: boolean;
running: boolean;
}
/** GET /api/settings/privacy. A field the environment set is `env_owned`
* and cannot be saved from the dashboard. */
export interface PrivacySettings {
+2 -1
View File
@@ -2,7 +2,8 @@
* @file panePaste.ts — lets another part of the Workspace page paste text into
* a workspace's terminal (a diff-line comment from the Changes panel). Every
* mounted TerminalView registers its run id; the text goes to the pane of that
* workspace the user focused last, else any of its panes. It is a paste —
* workspace focused last — clicked into, or made the Workspace's focused pane
* (a tab, Alt+1–4, the sidebar) — else any of its panes. It is a paste —
* bracketed, so `claude` takes it as one block in its composer — and never an
* Enter: the user reads it and sends it. Only a pane on SCREEN takes it (one
* hidden behind a full-width Changes panel or a replay would swallow it
+33 -10
View File
@@ -39,6 +39,13 @@ export const SETTINGS_INDEX: {
to: "#language",
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
},
{
section: "appearance",
titleKey: "terminalFont.title",
descKey: "terminalFont.description",
keywords: ["font", "phông", "maple", "jetbrains", "monospace", "rounded", "bo tròn"],
to: "#terminal-font",
},
{
section: "data",
titleKey: "pushIngest.title",
@@ -100,16 +107,28 @@ export const SETTINGS_INDEX: {
to: "#danger",
},
{
section: "about",
section: "system",
titleKey: "about.title",
keywords: ["version", "server", "database", "uptime"],
keywords: ["about", "version", "server", "database", "uptime", "memory", "cpu", "install"],
},
{
section: "about",
section: "system",
titleKey: "autostart.label",
keywords: ["autostart", "boot", "login", "systemd", "launchd", "service"],
to: "#autostart",
},
{
section: "system",
titleKey: "quit.label",
keywords: ["quit", "stop", "kill", "shutdown", "exit", "background"],
to: "#autostart",
},
{
section: "system",
titleKey: "tray.label",
keywords: ["tray", "icon", "taskbar", "menu bar", "status area", "notification area"],
to: "#autostart",
},
{
section: "appearance",
titleKey: "sagi.title",
@@ -126,10 +145,11 @@ export const SETTINGS_INDEX: {
to: "#pet",
},
{
section: "snapshots",
section: "reports",
titleKey: "snapshots.title",
descKey: "snapshots.description",
keywords: ["share", "freeze", "link", "redact"],
to: "#snapshots",
},
{
section: "reports",
@@ -178,7 +198,6 @@ export const SETTINGS_INDEX: {
descKey: "aiTools.description",
keywords: ["install", "update", "default"],
},
{ section: "agents/claude-code", titleKey: "pricing.title", keywords: ["cost", "price"] },
{ section: "agents/claude-code", titleKey: "hooks.title" },
{ section: "agents/claude-code", titleKey: "claudeHome.title" },
{ section: "agents/claude-code", titleKey: "ccConfig:tabs.skills", to: "?tab=skills" },
@@ -237,7 +256,6 @@ export const SETTINGS_INDEX: {
keywords: ["account", "login", "quota", "switch"],
},
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
{ section: "agents/codex", titleKey: "pricing.title", keywords: ["cost", "price"] },
{ section: "agents/codex", titleKey: "codexHooks.title" },
{ section: "agents/codex", titleKey: "codexHome.title" },
{ section: "agents/codex", titleKey: "codexConfig:tabs.skills", to: "?tab=skills" },
@@ -261,10 +279,15 @@ export const SETTINGS_INDEX: {
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
{ section: "git-credentials", titleKey: "gitCreds.title", keywords: ["login", "token"] },
{ section: "worktrees", titleKey: "worktrees.showUntracked", keywords: ["hide", "suggestions"] },
{ section: "worktrees", titleKey: "worktrees.claude" },
{ section: "worktrees", titleKey: "worktrees.agenthub" },
{ section: "git", titleKey: "gitCreds.title", keywords: ["login", "token"] },
{
section: "git",
titleKey: "worktrees.showUntracked",
keywords: ["hide", "suggestions"],
to: "#worktrees",
},
{ section: "git", titleKey: "worktrees.claude", to: "#worktrees" },
{ section: "git", titleKey: "worktrees.agenthub", to: "#worktrees" },
];
function tier(hay: string | undefined, q: string, exact: number): number {
+37
View File
@@ -0,0 +1,37 @@
/**
* @file terminalFont — the lane terminal's font, a viewer preference kept in
* `localStorage` (`agenthub.terminal.font`) and read by `TerminalView` when a
* terminal opens. `jetbrains` (the default) and `maple` (rounded) are bundled
* webfonts that cover every Vietnamese letter; `system` is the browser's own
* fixed-width font, which may not (DejaVu Sans Mono on Linux lacks ấ, ế, ố…,
* and those glyphs then fall back to a face of another width).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const TERMINAL_FONT_KEY = "agenthub.terminal.font";
export const TERMINAL_FONTS = {
jetbrains: '"JetBrains Mono", monospace',
maple: '"Maple Mono", monospace',
system: "monospace",
} as const;
export type TerminalFont = keyof typeof TERMINAL_FONTS;
export function readTerminalFont(): TerminalFont {
try {
const v = window.localStorage.getItem(TERMINAL_FONT_KEY);
if (v && v in TERMINAL_FONTS) return v as TerminalFont;
} catch {
/* storage blocked — the default */
}
return "jetbrains";
}
export function writeTerminalFont(font: TerminalFont) {
try {
window.localStorage.setItem(TERMINAL_FONT_KEY, font);
} catch {
/* storage blocked — terminals keep the default */
}
}
+24 -3
View File
@@ -1211,9 +1211,15 @@ export interface CostResult {
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
export interface ToolQuota {
id: "claude" | "codex";
/** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100. */
windows: Array<{ label: string; usedPercent: number | null; resetsAt: string | null }>;
id: "claude" | "codex" | "antigravity" | "cursor" | "grok" | "kimi" | "opencode";
/** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100.
* `group` names the model pool a window belongs to (Antigravity's). */
windows: Array<{
label: string;
usedPercent: number | null;
resetsAt: string | null;
group?: string;
}>;
/** Why there is no figure, e.g. `ELIVEEXPIRED` (Claude's token needs a CLI refresh). */
error?: string;
/** False for a saved Claude auth profile that is not the live login. */
@@ -1222,6 +1228,8 @@ export interface ToolQuota {
label?: string | null;
/** The signed-in account's email, when the tool's login names one. */
account?: string | null;
/** The subscription's name as the tool reports it (`pro`, `max`, `plus`, `team`…). */
plan?: string | null;
/** When the stored access token expires (ISO). */
expiresAt?: string | null;
/** When the figure was read, if not just now (Codex's journal, an expired profile). */
@@ -2648,6 +2656,8 @@ export interface Lane {
id: number;
title: string;
cwd: string;
/** Server mode: the account that owns this workspace (null = nobody yet). */
owner_id?: number | null;
branch: string | null;
kind: "managed" | "adopted";
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
@@ -2927,6 +2937,17 @@ export interface LaneDiffLine {
text: string;
}
/** `GET /api/lanes/:id/git/blame` — Annotate: who last changed each line of
* the working-tree file (`null` = not committed yet). */
export type LaneBlame =
| {
available: true;
path: string;
commits: Record<string, { short: string; author: string; date: string; summary: string }>;
lines: (string | null)[];
}
| { available: false };
/** One `@@` block of a file's diff. */
export interface LaneDiffHunk {
header: string;