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:
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user